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  }