Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b4492ff

Browse files
authored
chore: support multiple key:value search query params (coder#12690)
This more closely aligns with GitHub's label search style. Actual search params need to be converted to allow this format, by default they will throw an error if they do not support listing.
1 parent 8499eac commit b4492ff

File tree

3 files changed

+112
-25
lines changed

3 files changed

+112
-25
lines changed

coderd/httpapi/queryparams.go

+46-11
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func (p *QueryParamParser) UInt(vals url.Values, def uint64, queryParam string)
6161
if err != nil {
6262
p.Errors = append(p.Errors, codersdk.ValidationError{
6363
Field: queryParam,
64-
Detail: fmt.Sprintf("Query param %q must be a valid positive integer (%s)", queryParam, err.Error()),
64+
Detail: fmt.Sprintf("Query param %q must be a valid positive integer: %s", queryParam, err.Error()),
6565
})
6666
return 0
6767
}
@@ -73,7 +73,7 @@ func (p *QueryParamParser) Int(vals url.Values, def int, queryParam string) int
7373
if err != nil {
7474
p.Errors = append(p.Errors, codersdk.ValidationError{
7575
Field: queryParam,
76-
Detail: fmt.Sprintf("Query param %q must be a valid integer (%s)", queryParam, err.Error()),
76+
Detail: fmt.Sprintf("Query param %q must be a valid integer: %s", queryParam, err.Error()),
7777
})
7878
}
7979
return v
@@ -97,7 +97,7 @@ func (p *QueryParamParser) PositiveInt32(vals url.Values, def int32, queryParam
9797
if err != nil {
9898
p.Errors = append(p.Errors, codersdk.ValidationError{
9999
Field: queryParam,
100-
Detail: fmt.Sprintf("Query param %q must be a valid 32-bit positive integer (%s)", queryParam, err.Error()),
100+
Detail: fmt.Sprintf("Query param %q must be a valid 32-bit positive integer: %s", queryParam, err.Error()),
101101
})
102102
}
103103
return v
@@ -108,7 +108,7 @@ func (p *QueryParamParser) Boolean(vals url.Values, def bool, queryParam string)
108108
if err != nil {
109109
p.Errors = append(p.Errors, codersdk.ValidationError{
110110
Field: queryParam,
111-
Detail: fmt.Sprintf("Query param %q must be a valid boolean (%s)", queryParam, err.Error()),
111+
Detail: fmt.Sprintf("Query param %q must be a valid boolean: %s", queryParam, err.Error()),
112112
})
113113
}
114114
return v
@@ -203,9 +203,15 @@ func (p *QueryParamParser) timeWithMutate(vals url.Values, def time.Time, queryP
203203
}
204204

205205
func (p *QueryParamParser) String(vals url.Values, def string, queryParam string) string {
206-
v, _ := parseQueryParam(p, vals, func(v string) (string, error) {
206+
v, err := parseQueryParam(p, vals, func(v string) (string, error) {
207207
return v, nil
208208
}, def, queryParam)
209+
if err != nil {
210+
p.Errors = append(p.Errors, codersdk.ValidationError{
211+
Field: queryParam,
212+
Detail: fmt.Sprintf("Query param %q must be a valid string: %s", queryParam, err.Error()),
213+
})
214+
}
209215
return v
210216
}
211217

@@ -248,13 +254,23 @@ func ParseCustom[T any](parser *QueryParamParser, vals url.Values, def T, queryP
248254
return v
249255
}
250256

251-
// ParseCustomList is a function that handles csv query params.
257+
// ParseCustomList is a function that handles csv query params or multiple values
258+
// for a query param.
259+
// Csv is supported as it is a common way to pass multiple values in a query param.
260+
// Multiple values is supported (key=value&key=value2) for feature parity with GitHub issue search.
252261
func ParseCustomList[T any](parser *QueryParamParser, vals url.Values, def []T, queryParam string, parseFunc func(v string) (T, error)) []T {
253-
v, err := parseQueryParam(parser, vals, func(v string) ([]T, error) {
254-
terms := strings.Split(v, ",")
262+
v, err := parseQueryParamSet(parser, vals, func(set []string) ([]T, error) {
263+
// Gather all terms.
264+
allTerms := make([]string, 0, len(set))
265+
for _, s := range set {
266+
// If a term is a csv, break it out into individual terms.
267+
terms := strings.Split(s, ",")
268+
allTerms = append(allTerms, terms...)
269+
}
270+
255271
var badValues []string
256272
var output []T
257-
for _, s := range terms {
273+
for _, s := range allTerms {
258274
good, err := parseFunc(s)
259275
if err != nil {
260276
badValues = append(badValues, s)
@@ -277,7 +293,27 @@ func ParseCustomList[T any](parser *QueryParamParser, vals url.Values, def []T,
277293
return v
278294
}
279295

296+
// parseQueryParam expects just 1 value set for the given query param.
280297
func parseQueryParam[T any](parser *QueryParamParser, vals url.Values, parse func(v string) (T, error), def T, queryParam string) (T, error) {
298+
setParse := func(set []string) (T, error) {
299+
if len(set) > 1 {
300+
// Set as a parser.Error rather than return an error.
301+
// Returned errors are errors from the passed in `parse` function, and
302+
// imply the query param value had attempted to be parsed.
303+
// By raising the error this way, we can also more easily control how it
304+
// is presented to the user. A returned error is wrapped with more text.
305+
parser.Errors = append(parser.Errors, codersdk.ValidationError{
306+
Field: queryParam,
307+
Detail: fmt.Sprintf("Query param %q provided more than once, found %d times. Only provide 1 instance of this query param.", queryParam, len(set)),
308+
})
309+
return def, nil
310+
}
311+
return parse(set[0])
312+
}
313+
return parseQueryParamSet(parser, vals, setParse, def, queryParam)
314+
}
315+
316+
func parseQueryParamSet[T any](parser *QueryParamParser, vals url.Values, parse func(set []string) (T, error), def T, queryParam string) (T, error) {
281317
parser.addParsed(queryParam)
282318
// If the query param is required and not present, return an error.
283319
if parser.RequiredNotEmptyParams[queryParam] && (!vals.Has(queryParam) || vals.Get(queryParam) == "") {
@@ -293,6 +329,5 @@ func parseQueryParam[T any](parser *QueryParamParser, vals url.Values, parse fun
293329
return def, nil
294330
}
295331

296-
str := vals.Get(queryParam)
297-
return parse(str)
332+
return parse(vals[queryParam])
298333
}

coderd/httpapi/queryparams_test.go

+66-3
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ import (
1717
type queryParamTestCase[T any] struct {
1818
QueryParam string
1919
// No set does not set the query param, rather than setting the empty value
20-
NoSet bool
21-
Value string
20+
NoSet bool
21+
// Value vs values is the difference between a single query param and multiple
22+
// to the same key.
23+
// -> key=value
24+
Value string
25+
// -> key=value1 key=value2
26+
Values []string
2227
Default T
2328
Expected T
2429
ExpectedErrorContains string
@@ -27,6 +32,7 @@ type queryParamTestCase[T any] struct {
2732

2833
func TestParseQueryParams(t *testing.T) {
2934
t.Parallel()
35+
const multipleValuesError = "provided more than once"
3036

3137
t.Run("Enum", func(t *testing.T) {
3238
t.Parallel()
@@ -59,6 +65,11 @@ func TestParseQueryParams(t *testing.T) {
5965
Value: fmt.Sprintf("%s,%s", database.ResourceTypeWorkspace, database.ResourceTypeApiKey),
6066
Expected: []database.ResourceType{database.ResourceTypeWorkspace, database.ResourceTypeApiKey},
6167
},
68+
{
69+
QueryParam: "resource_type_as_list",
70+
Values: []string{string(database.ResourceTypeWorkspace), string(database.ResourceTypeApiKey)},
71+
Expected: []database.ResourceType{database.ResourceTypeWorkspace, database.ResourceTypeApiKey},
72+
},
6273
}
6374

6475
parser := httpapi.NewQueryParamParser()
@@ -151,6 +162,11 @@ func TestParseQueryParams(t *testing.T) {
151162
Default: "default",
152163
Expected: "default",
153164
},
165+
{
166+
QueryParam: "unexpected_list",
167+
Values: []string{"one", "two"},
168+
ExpectedErrorContains: multipleValuesError,
169+
},
154170
}
155171

156172
parser := httpapi.NewQueryParamParser()
@@ -193,6 +209,11 @@ func TestParseQueryParams(t *testing.T) {
193209
Expected: false,
194210
ExpectedErrorContains: "must be a valid boolean",
195211
},
212+
{
213+
QueryParam: "unexpected_list",
214+
Values: []string{"true", "false"},
215+
ExpectedErrorContains: multipleValuesError,
216+
},
196217
}
197218

198219
parser := httpapi.NewQueryParamParser()
@@ -230,6 +251,11 @@ func TestParseQueryParams(t *testing.T) {
230251
Expected: 0,
231252
ExpectedErrorContains: "must be a valid integer",
232253
},
254+
{
255+
QueryParam: "unexpected_list",
256+
Values: []string{"5", "10"},
257+
ExpectedErrorContains: multipleValuesError,
258+
},
233259
}
234260

235261
parser := httpapi.NewQueryParamParser()
@@ -274,6 +300,11 @@ func TestParseQueryParams(t *testing.T) {
274300
Expected: 0,
275301
ExpectedErrorContains: "must be a valid 32-bit positive integer",
276302
},
303+
{
304+
QueryParam: "unexpected_list",
305+
Values: []string{"5", "10"},
306+
ExpectedErrorContains: multipleValuesError,
307+
},
277308
}
278309

279310
parser := httpapi.NewQueryParamParser()
@@ -311,6 +342,11 @@ func TestParseQueryParams(t *testing.T) {
311342
Expected: 0,
312343
ExpectedErrorContains: "must be a valid positive integer",
313344
},
345+
{
346+
QueryParam: "unexpected_list",
347+
Values: []string{"5", "10"},
348+
ExpectedErrorContains: multipleValuesError,
349+
},
314350
}
315351

316352
parser := httpapi.NewQueryParamParser()
@@ -354,6 +390,23 @@ func TestParseQueryParams(t *testing.T) {
354390
Default: []uuid.UUID{},
355391
ExpectedErrorContains: "bogus",
356392
},
393+
{
394+
QueryParam: "multiple_keys",
395+
Values: []string{"6c8ef17d-5dd8-4b92-bac9-41944f90f237", "65fb05f3-12c8-4a0a-801f-40439cf9e681"},
396+
Expected: []uuid.UUID{
397+
uuid.MustParse("6c8ef17d-5dd8-4b92-bac9-41944f90f237"),
398+
uuid.MustParse("65fb05f3-12c8-4a0a-801f-40439cf9e681"),
399+
},
400+
},
401+
{
402+
QueryParam: "multiple_and_csv",
403+
Values: []string{"6c8ef17d-5dd8-4b92-bac9-41944f90f237", "65fb05f3-12c8-4a0a-801f-40439cf9e681, 01b94888-1eab-4bbf-aed0-dc7a8010da97"},
404+
Expected: []uuid.UUID{
405+
uuid.MustParse("6c8ef17d-5dd8-4b92-bac9-41944f90f237"),
406+
uuid.MustParse("65fb05f3-12c8-4a0a-801f-40439cf9e681"),
407+
uuid.MustParse("01b94888-1eab-4bbf-aed0-dc7a8010da97"),
408+
},
409+
},
357410
}
358411

359412
parser := httpapi.NewQueryParamParser()
@@ -381,7 +434,17 @@ func testQueryParams[T any](t *testing.T, testCases []queryParamTestCase[T], par
381434
if c.NoSet {
382435
continue
383436
}
384-
v.Set(c.QueryParam, c.Value)
437+
if len(c.Values) > 0 && c.Value != "" {
438+
t.Errorf("test case %q has both value and values, choose one, not both!", c.QueryParam)
439+
t.FailNow()
440+
}
441+
if c.Value != "" {
442+
c.Values = append(c.Values, c.Value)
443+
}
444+
445+
for _, value := range c.Values {
446+
v.Add(c.QueryParam, value)
447+
}
385448
}
386449

387450
for _, c := range testCases {

coderd/searchquery/search.go

-11
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,6 @@ func searchTerms(query string, defaultKey func(term string, values url.Values) e
163163
}
164164
}
165165

166-
for k := range searchValues {
167-
if len(searchValues[k]) > 1 {
168-
return nil, []codersdk.ValidationError{
169-
{
170-
Field: "q",
171-
Detail: fmt.Sprintf("Query parameter %q provided more than once, found %d times", k, len(searchValues[k])),
172-
},
173-
}
174-
}
175-
}
176-
177166
return searchValues, nil
178167
}
179168

0 commit comments

Comments
 (0)