github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/shared/params.go (about) 1 // Copyright 2017 The WPT Dashboard Project. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package shared 6 7 import ( 8 "bytes" 9 "encoding/base64" 10 "encoding/json" 11 "errors" 12 "fmt" 13 "io/ioutil" 14 "net/http" 15 "net/url" 16 "regexp" 17 "strconv" 18 "strings" 19 "time" 20 21 mapset "github.com/deckarep/golang-set" 22 ) 23 24 // QueryFilter represents the ways search results can be filtered in the webapp 25 // search API. 26 type QueryFilter struct { 27 RunIDs []int64 28 Q string 29 } 30 31 // MaxCountMaxValue is the maximum allowed value for the max-count param. 32 const MaxCountMaxValue = 500 33 34 // MaxCountMinValue is the minimum allowed value for the max-count param. 35 const MaxCountMinValue = 1 36 37 // SHARegex is the pattern for a valid SHA1 hash that's at least 7 characters long. 38 var SHARegex = regexp.MustCompile(`^[0-9a-fA-F]{7,40}$`) 39 40 // ParseSHAParam parses and validates any 'sha' param(s) for the request. 41 func ParseSHAParam(v url.Values) (SHAs, error) { 42 shas := ParseRepeatedParam(v, "sha", "shas") 43 var err error 44 for i := range shas { 45 shas[i], err = ParseSHA(shas[i]) 46 if err != nil { 47 return nil, err 48 } 49 } 50 return shas, nil 51 } 52 53 // ParseSHA parses and validates the given 'sha'. 54 // It returns "latest" by default (and in error cases). 55 func ParseSHA(shaParam string) (sha string, err error) { 56 // Get the SHA for the run being loaded (the first part of the path.) 57 sha = "latest" 58 if shaParam != "" && shaParam != "latest" { 59 sha = shaParam 60 if !SHARegex.MatchString(shaParam) { 61 return "latest", fmt.Errorf("Invalid sha param value: %s", shaParam) 62 } 63 } 64 return sha, err 65 } 66 67 // ParseProductSpecs parses multiple product specs 68 func ParseProductSpecs(specs ...string) (products ProductSpecs, err error) { 69 products = make(ProductSpecs, len(specs)) 70 for i, p := range specs { 71 product, err := ParseProductSpec(p) 72 if err != nil { 73 return nil, err 74 } 75 products[i] = product 76 } 77 return products, nil 78 } 79 80 // ParseProductSpec parses a test-run spec into a ProductAtRevision struct. 81 func ParseProductSpec(spec string) (productSpec ProductSpec, err error) { 82 errMsg := "invalid product spec: " + spec 83 productSpec.Revision = "latest" 84 name := spec 85 // @sha (optional) 86 atSHAPieces := strings.Split(spec, "@") 87 if len(atSHAPieces) > 2 { 88 return productSpec, errors.New(errMsg) 89 } else if len(atSHAPieces) == 2 { 90 name = atSHAPieces[0] 91 if productSpec.Revision, err = ParseSHA(atSHAPieces[1]); err != nil { 92 return productSpec, errors.New(errMsg) 93 } 94 } 95 // [foo,bar] labels syntax (optional) 96 labelPieces := strings.Split(name, "[") 97 if len(labelPieces) > 2 { 98 return productSpec, errors.New(errMsg) 99 } else if len(labelPieces) == 2 { 100 name = labelPieces[0] 101 labels := labelPieces[1] 102 if labels == "" { 103 return productSpec, errors.New(errMsg) 104 } 105 if labels[len(labels)-1:] != "]" || strings.Index(labels, "]") < len(labels)-1 { 106 return productSpec, errors.New(errMsg) 107 } 108 labels = labels[:len(labels)-1] 109 productSpec.Labels = mapset.NewSet() 110 for _, label := range strings.Split(labels, ",") { 111 if label != "" { 112 productSpec.Labels.Add(label) 113 } 114 } 115 } 116 // Product (required) 117 if productSpec.Product, err = ParseProduct(name); err != nil { 118 return productSpec, err 119 } 120 return productSpec, nil 121 } 122 123 // ParseProductSpecUnsafe ignores any potential error parsing the given product spec. 124 func ParseProductSpecUnsafe(s string) ProductSpec { 125 parsed, _ := ParseProductSpec(s) 126 return parsed 127 } 128 129 // ParseProduct parses the `browser-version-os-version` input as a Product struct. 130 func ParseProduct(product string) (result Product, err error) { 131 pieces := strings.Split(product, "-") 132 if len(pieces) > 4 { 133 return result, fmt.Errorf("invalid product: %s", product) 134 } 135 result = Product{ 136 BrowserName: strings.ToLower(pieces[0]), 137 } 138 if !IsBrowserName(result.BrowserName) { 139 return result, fmt.Errorf("invalid browser name: %s", result.BrowserName) 140 } 141 if len(pieces) > 1 { 142 if _, err := ParseVersion(pieces[1]); err != nil { 143 return result, fmt.Errorf("invalid browser version: %s", pieces[1]) 144 } 145 result.BrowserVersion = pieces[1] 146 } 147 if len(pieces) > 2 { 148 result.OSName = pieces[2] 149 } 150 if len(pieces) > 3 { 151 if _, err := ParseVersion(pieces[3]); err != nil { 152 return result, fmt.Errorf("invalid OS version: %s", pieces[3]) 153 } 154 result.OSVersion = pieces[3] 155 } 156 return result, nil 157 } 158 159 // ParseVersion parses the given version as a semantically versioned string. 160 func ParseVersion(version string) (result *Version, err error) { 161 pieces := strings.Split(version, " ") 162 channel := "" 163 if len(pieces) > 2 { 164 return nil, fmt.Errorf("Invalid version: %s", version) 165 } else if len(pieces) > 1 { 166 channel = " " + pieces[1] 167 version = pieces[0] 168 } 169 170 // Special case ff's "a1" suffix 171 ffSuffix := regexp.MustCompile(`^.*([ab]\d+)$`) 172 if match := ffSuffix.FindStringSubmatch(version); match != nil { 173 channel = match[1] 174 version = version[:len(version)-len(channel)] 175 } 176 177 pieces = strings.Split(version, ".") 178 if len(pieces) > 4 { 179 return nil, fmt.Errorf("Invalid version: %s", version) 180 } 181 numbers := make([]int, len(pieces)) 182 for i, piece := range pieces { 183 n, err := strconv.ParseInt(piece, 10, 0) 184 if err != nil { 185 return nil, fmt.Errorf("Invalid version: %s", version) 186 } 187 numbers[i] = int(n) 188 } 189 result = &Version{ 190 Major: numbers[0], 191 Channel: channel, 192 } 193 if len(numbers) > 1 { 194 result.Minor = &numbers[1] 195 } 196 if len(numbers) > 2 { 197 result.Build = &numbers[2] 198 } 199 if len(numbers) > 3 { 200 result.Revision = &numbers[3] 201 } 202 return result, nil 203 } 204 205 // ParseBrowserParam parses and validates the 'browser' param for the request. 206 // It returns "" by default (and in error cases). 207 func ParseBrowserParam(v url.Values) (product *Product, err error) { 208 browser := v.Get("browser") 209 if "" == browser { 210 return nil, nil 211 } 212 if IsBrowserName(browser) { 213 return &Product{ 214 BrowserName: browser, 215 }, nil 216 } 217 return nil, fmt.Errorf("Invalid browser param value: %s", browser) 218 } 219 220 // ParseBrowsersParam returns a list of browser params for the request. 221 // It parses the 'browsers' parameter, split on commas, and also checks for the (repeatable) 222 // 'browser' params. 223 func ParseBrowsersParam(v url.Values) (browsers []string, err error) { 224 browserParams := ParseRepeatedParam(v, "browser", "browsers") 225 if browserParams == nil { 226 return nil, nil 227 } 228 for _, b := range browserParams { 229 if !IsBrowserName(b) { 230 return nil, fmt.Errorf("Invalid browser param value %s", b) 231 } 232 browsers = append(browsers, b) 233 } 234 return browsers, nil 235 } 236 237 // ParseProductParam parses and validates the 'product' param for the request. 238 func ParseProductParam(v url.Values) (product *ProductSpec, err error) { 239 productParam := v.Get("product") 240 if "" == productParam { 241 return nil, nil 242 } 243 parsed, err := ParseProductSpec(productParam) 244 if err != nil { 245 return nil, err 246 } 247 return &parsed, nil 248 } 249 250 // ParseProductsParam returns a list of product params for the request. 251 // It parses the 'products' parameter, split on commas, and also checks for the (repeatable) 252 // 'product' params. 253 func ParseProductsParam(v url.Values) (ProductSpecs, error) { 254 repeatedParam := v["product"] 255 pluralParam := v.Get("products") 256 // Replace nested ',' in the label part with a placeholder 257 nestedCommas := regexp.MustCompile(`(\[[^\]]*),`) 258 const comma = `%COMMA%` 259 for nestedCommas.MatchString(pluralParam) { 260 pluralParam = nestedCommas.ReplaceAllString(pluralParam, "$1"+comma) 261 } 262 productParams := parseRepeatedParamValues(repeatedParam, pluralParam) 263 if productParams == nil { 264 return nil, nil 265 } 266 // Revert placeholder to ',' and parse. 267 for i := range productParams { 268 productParams[i] = strings.Replace(productParams[i], comma, ",", -1) 269 } 270 return ParseProductSpecs(productParams...) 271 } 272 273 // ParseProductOrBrowserParams parses the product (or, browser) params present in the given 274 // request. 275 func ParseProductOrBrowserParams(v url.Values) (products ProductSpecs, err error) { 276 if products, err = ParseProductsParam(v); err != nil { 277 return nil, err 278 } 279 // Handle legacy browser param. 280 browserParams, err := ParseBrowsersParam(v) 281 if err != nil { 282 return nil, err 283 } 284 for _, browser := range browserParams { 285 spec := ProductSpec{} 286 spec.BrowserName = browser 287 products = append(products, spec) 288 } 289 return products, nil 290 } 291 292 // ParseMaxCountParam parses the 'max-count' parameter as an integer 293 func ParseMaxCountParam(v url.Values) (*int, error) { 294 if maxCountParam := v.Get("max-count"); maxCountParam != "" { 295 count, err := strconv.Atoi(maxCountParam) 296 if err != nil { 297 return nil, err 298 } 299 if count < MaxCountMinValue { 300 count = MaxCountMinValue 301 } 302 if count > MaxCountMaxValue { 303 count = MaxCountMaxValue 304 } 305 return &count, nil 306 } 307 return nil, nil 308 } 309 310 // ParseOffsetParam parses the 'offset' parameter as an integer 311 func ParseOffsetParam(v url.Values) (*int, error) { 312 if offsetParam := v.Get("offset"); offsetParam != "" { 313 offset, err := strconv.Atoi(offsetParam) 314 if err != nil { 315 return nil, err 316 } 317 return &offset, nil 318 } 319 return nil, nil 320 } 321 322 // ParseMaxCountParamWithDefault parses the 'max-count' parameter as an integer, or returns the 323 // default when no param is present, or on error. 324 func ParseMaxCountParamWithDefault(v url.Values, defaultValue int) (count int, err error) { 325 if maxCountParam, err := ParseMaxCountParam(v); maxCountParam != nil { 326 return *maxCountParam, err 327 } else if err != nil { 328 return defaultValue, err 329 } 330 return defaultValue, nil 331 } 332 333 // ParseViewParam parses the 'view' parameter and ensures it is a valid value. 334 func ParseViewParam(v url.Values) (*string, error) { 335 viewParam := v.Get("view") 336 if viewParam == "subtest" || viewParam == "interop" || viewParam == "test" { 337 return &viewParam, nil 338 } 339 return nil, nil 340 } 341 342 // ParseDateTimeParam flexibly parses a date/time param with the given name as a time.Time. 343 func ParseDateTimeParam(v url.Values, name string) (*time.Time, error) { 344 if fromParam := v.Get(name); fromParam != "" { 345 format := time.RFC3339 346 if len(fromParam) < strings.Index(time.RFC3339, "Z") { 347 format = format[:len(fromParam)] 348 } 349 parsed, err := time.Parse(format, fromParam) 350 if err != nil { 351 return nil, err 352 } 353 return &parsed, nil 354 } 355 return nil, nil 356 } 357 358 // DiffFilterParam represents the types of changed test paths to include. 359 type DiffFilterParam struct { 360 // Added tests are present in the 'after' state of the diff, but not present 361 // in the 'before' state of the diff. 362 Added bool 363 364 // Deleted tests are present in the 'before' state of the diff, but not present 365 // in the 'after' state of the diff. 366 Deleted bool 367 368 // Changed tests are present in both the 'before' and 'after' states of the diff, 369 // but the number of passes, failures, or total tests has changed. 370 Changed bool 371 372 // Unchanged tests are present in both the 'before' and 'after' states of the diff, 373 // and the number of passes, failures, or total tests is unchanged. 374 Unchanged bool 375 } 376 377 func (d DiffFilterParam) String() string { 378 s := "" 379 if d.Added { 380 s += "A" 381 } 382 if d.Deleted { 383 s += "D" 384 } 385 if d.Changed { 386 s += "C" 387 } 388 if d.Unchanged { 389 s += "U" 390 } 391 return s 392 } 393 394 // ParseDiffFilterParams collects the diff filtering params for the given request. 395 // It splits the filter param into the differences to include. The filter param is inspired by Git's --diff-filter flag. 396 // It also adds the set of test paths to include; see ParsePathsParam below. 397 func ParseDiffFilterParams(v url.Values) (param DiffFilterParam, paths mapset.Set, err error) { 398 param = DiffFilterParam{ 399 Added: true, 400 Deleted: true, 401 Changed: true, 402 } 403 if filter := v.Get("filter"); filter != "" { 404 param = DiffFilterParam{} 405 for _, char := range filter { 406 switch char { 407 case 'A': 408 param.Added = true 409 case 'D': 410 param.Deleted = true 411 case 'C': 412 param.Changed = true 413 case 'U': 414 param.Unchanged = true 415 default: 416 return param, nil, fmt.Errorf("invalid filter character %c", char) 417 } 418 } 419 } 420 return param, NewSetFromStringSlice(ParsePathsParam(v)), nil 421 } 422 423 // ParsePathsParam returns a set list of test paths to include, or nil if no 424 // filter is provided (and all tests should be included). It parses the 'paths' 425 // parameter, split on commas, and also checks for the (repeatable) 'path' params 426 func ParsePathsParam(v url.Values) []string { 427 return ParseRepeatedParam(v, "path", "paths") 428 } 429 430 // ParseLabelsParam returns a set list of test-run labels to include, or nil if 431 // no labels are provided. 432 func ParseLabelsParam(v url.Values) []string { 433 return ParseRepeatedParam(v, "label", "labels") 434 } 435 436 // ParseRepeatedParam parses a param that may be a plural name, with all values 437 // comma-separated, or a repeated singular param. 438 // e.g. ?label=foo&label=bar vs ?labels=foo,bar 439 func ParseRepeatedParam(v url.Values, singular string, plural string) (params []string) { 440 repeatedParam := v[singular] 441 pluralParam := v.Get(plural) 442 return parseRepeatedParamValues(repeatedParam, pluralParam) 443 } 444 445 func parseRepeatedParamValues(repeatedParam []string, pluralParam string) (params []string) { 446 if len(repeatedParam) == 0 && pluralParam == "" { 447 return nil 448 } 449 allValues := repeatedParam 450 if pluralParam != "" { 451 allValues = append(allValues, strings.Split(pluralParam, ",")...) 452 } 453 454 seen := mapset.NewSet() 455 for _, value := range allValues { 456 if value == "" { 457 continue 458 } 459 if !seen.Contains(value) { 460 params = append(params, value) 461 seen.Add(value) 462 } 463 } 464 return params 465 } 466 467 // ParseIntParam parses the result of ParseParam as int64. 468 func ParseIntParam(v url.Values, param string) (*int, error) { 469 strVal := v.Get(param) 470 if strVal == "" { 471 return nil, nil 472 } 473 parsed, err := strconv.Atoi(strVal) 474 if err != nil { 475 return nil, err 476 } 477 return &parsed, nil 478 } 479 480 // ParseRepeatedInt64Param parses the result of ParseRepeatedParam as int64. 481 func ParseRepeatedInt64Param(v url.Values, singular, plural string) (params []int64, err error) { 482 strs := ParseRepeatedParam(v, singular, plural) 483 if len(strs) < 1 { 484 return nil, nil 485 } 486 ints := make([]int64, len(strs)) 487 for i, idStr := range strs { 488 ints[i], err = strconv.ParseInt(idStr, 10, 64) 489 if err != nil { 490 return nil, err 491 } 492 } 493 return ints, err 494 } 495 496 // ParseQueryParamInt parses the URL query parameter at key. If the parameter is 497 // empty or missing, nil is returned. 498 func ParseQueryParamInt(v url.Values, key string) (*int, error) { 499 value := v.Get(key) 500 if value == "" { 501 return nil, nil 502 } 503 i, err := strconv.Atoi(value) 504 if err != nil { 505 return &i, fmt.Errorf("Invalid %s value: %s", key, value) 506 } 507 return &i, err 508 } 509 510 // ParseAlignedParam parses the "aligned" param. See ParseBooleanParam. 511 func ParseAlignedParam(v url.Values) (aligned *bool, err error) { 512 if aligned, err := ParseBooleanParam(v, "aligned"); aligned != nil || err != nil { 513 return aligned, err 514 } 515 // Legacy param name: complete 516 return ParseBooleanParam(v, "complete") 517 } 518 519 // ParseBooleanParam parses the given param name as a bool. 520 // Return nil if the param is missing, true if if it's present with no value, 521 // otherwise the parsed boolean value of the param's value. 522 func ParseBooleanParam(v url.Values, name string) (result *bool, err error) { 523 q := v 524 b := false 525 if _, ok := q[name]; !ok { 526 return nil, nil 527 } else if val := q.Get(name); val == "" { 528 b = true 529 } else { 530 b, err = strconv.ParseBool(val) 531 } 532 return &b, err 533 } 534 535 // ParseRunIDsParam parses the "run_ids" parameter. If the ID is not a valid 536 // int64, an error will be returned. 537 func ParseRunIDsParam(v url.Values) (ids TestRunIDs, err error) { 538 return ParseRepeatedInt64Param(v, "run_id", "run_ids") 539 } 540 541 // ParsePRParam parses the "pr" parameter. If it's not a valid int64, an error 542 // will be returned. 543 func ParsePRParam(v url.Values) (*int, error) { 544 return ParseIntParam(v, "pr") 545 } 546 547 // ParseQueryFilterParams parses shared params for the search APIs. 548 func ParseQueryFilterParams(v url.Values) (filter QueryFilter, err error) { 549 keys, err := ParseRunIDsParam(v) 550 if err != nil { 551 return filter, err 552 } 553 filter.RunIDs = keys 554 555 filter.Q = v.Get("q") 556 557 return filter, nil 558 } 559 560 // ParseTestRunFilterParams parses all of the filter params for a TestRun query. 561 func ParseTestRunFilterParams(v url.Values) (filter TestRunFilter, err error) { 562 if page, err := ParsePageToken(v); page != nil { 563 return *page, err 564 } else if err != nil { 565 return filter, err 566 } 567 568 runSHA, err := ParseSHAParam(v) 569 if err != nil { 570 return filter, err 571 } 572 filter.SHAs = runSHA 573 filter.Labels = NewSetFromStringSlice(ParseLabelsParam(v)) 574 if user := v.Get("user"); user != "" { 575 filter.Labels.Add(GetUserLabel(user)) 576 } 577 if filter.Aligned, err = ParseAlignedParam(v); err != nil { 578 return filter, err 579 } 580 if filter.Products, err = ParseProductOrBrowserParams(v); err != nil { 581 return filter, err 582 } 583 if filter.MaxCount, err = ParseMaxCountParam(v); err != nil { 584 return filter, err 585 } 586 if filter.Offset, err = ParseOffsetParam(v); err != nil { 587 return filter, err 588 } 589 if filter.From, err = ParseDateTimeParam(v, "from"); err != nil { 590 return filter, err 591 } 592 if filter.To, err = ParseDateTimeParam(v, "to"); err != nil { 593 return filter, err 594 } 595 if filter.View, err = ParseViewParam(v); err != nil { 596 return filter, err 597 } 598 return filter, nil 599 } 600 601 // ParseBeforeAndAfterParams parses the before and after params used when 602 // intending to diff two test runs. Either both or neither of the params 603 // must be present. 604 func ParseBeforeAndAfterParams(v url.Values) (ProductSpecs, error) { 605 before := v.Get("before") 606 after := v.Get("after") 607 if before == "" && after == "" { 608 return nil, nil 609 } 610 if before == "" { 611 return nil, errors.New("after param provided, but before param missing") 612 } else if after == "" { 613 return nil, errors.New("before param provided, but after param missing") 614 } 615 616 specs := make(ProductSpecs, 2) 617 beforeSpec, err := ParseProductSpec(before) 618 if err != nil { 619 return nil, fmt.Errorf("invalid before param: %s", err.Error()) 620 } 621 specs[0] = beforeSpec 622 623 afterSpec, err := ParseProductSpec(after) 624 if err != nil { 625 return nil, fmt.Errorf("invalid after param: %s", err.Error()) 626 } 627 specs[1] = afterSpec 628 return specs, nil 629 } 630 631 // ParsePageToken decodes a base64 encoding of a TestRunFilter struct. 632 func ParsePageToken(v url.Values) (*TestRunFilter, error) { 633 token := v.Get("page") 634 if token == "" { 635 return nil, nil 636 } 637 decoded, err := base64.URLEncoding.DecodeString(token) 638 if err != nil { 639 return nil, err 640 } 641 var filter TestRunFilter 642 if err := json.Unmarshal([]byte(decoded), &filter); err != nil { 643 return nil, err 644 } 645 return &filter, nil 646 } 647 648 // ExtractRunIDsBodyParam extracts {"run_ids": <run ids>} from a request JSON 649 // body. Optionally replace r.Body so that it can be replayed by subsequent 650 // request handling code can process it. 651 func ExtractRunIDsBodyParam(r *http.Request, replay bool) (TestRunIDs, error) { 652 raw := make([]byte, 0) 653 body := r.Body 654 raw, err := ioutil.ReadAll(body) 655 if err != nil { 656 return nil, err 657 } 658 defer body.Close() 659 660 // If requested, allow subsequent request handling code to re-read body. 661 if replay { 662 r.Body = ioutil.NopCloser(bytes.NewBuffer(raw)) 663 } 664 665 var data map[string]*json.RawMessage 666 err = json.Unmarshal(raw, &data) 667 if err != nil { 668 return nil, err 669 } 670 671 msg, ok := data["run_ids"] 672 if !ok { 673 return nil, fmt.Errorf(`JSON request body is missing "run_ids" key; body: %s`, string(raw)) 674 } 675 var runIDs []int64 676 err = json.Unmarshal(*msg, &runIDs) 677 return TestRunIDs(runIDs), err 678 }