Skip to content

Commit ccf3490

Browse files
authored
chore: add templates search query to a filter (coder#13772)
* chore: add templates search query to a filter
1 parent 8778aa0 commit ccf3490

File tree

8 files changed

+246
-23
lines changed

8 files changed

+246
-23
lines changed

coderd/httpapi/queryparams.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package httpapi
22

33
import (
4+
"database/sql"
45
"errors"
56
"fmt"
67
"net/url"
@@ -104,6 +105,27 @@ func (p *QueryParamParser) PositiveInt32(vals url.Values, def int32, queryParam
104105
return v
105106
}
106107

108+
// NullableBoolean will return a null sql value if no input is provided.
109+
// SQLc still uses sql.NullBool rather than the generic type. So converting from
110+
// the generic type is required.
111+
func (p *QueryParamParser) NullableBoolean(vals url.Values, def sql.NullBool, queryParam string) sql.NullBool {
112+
v, err := parseNullableQueryParam[bool](p, vals, strconv.ParseBool, sql.Null[bool]{
113+
V: def.Bool,
114+
Valid: def.Valid,
115+
}, queryParam)
116+
if err != nil {
117+
p.Errors = append(p.Errors, codersdk.ValidationError{
118+
Field: queryParam,
119+
Detail: fmt.Sprintf("Query param %q must be a valid boolean: %s", queryParam, err.Error()),
120+
})
121+
}
122+
123+
return sql.NullBool{
124+
Bool: v.V,
125+
Valid: v.Valid,
126+
}
127+
}
128+
107129
func (p *QueryParamParser) Boolean(vals url.Values, def bool, queryParam string) bool {
108130
v, err := parseQueryParam(p, vals, strconv.ParseBool, def, queryParam)
109131
if err != nil {
@@ -294,9 +316,34 @@ func ParseCustomList[T any](parser *QueryParamParser, vals url.Values, def []T,
294316
return v
295317
}
296318

319+
func parseNullableQueryParam[T any](parser *QueryParamParser, vals url.Values, parse func(v string) (T, error), def sql.Null[T], queryParam string) (sql.Null[T], error) {
320+
setParse := parseSingle(parser, parse, def.V, queryParam)
321+
return parseQueryParamSet[sql.Null[T]](parser, vals, func(set []string) (sql.Null[T], error) {
322+
if len(set) == 0 {
323+
return sql.Null[T]{
324+
Valid: false,
325+
}, nil
326+
}
327+
328+
value, err := setParse(set)
329+
if err != nil {
330+
return sql.Null[T]{}, err
331+
}
332+
return sql.Null[T]{
333+
V: value,
334+
Valid: true,
335+
}, nil
336+
}, def, queryParam)
337+
}
338+
297339
// parseQueryParam expects just 1 value set for the given query param.
298340
func parseQueryParam[T any](parser *QueryParamParser, vals url.Values, parse func(v string) (T, error), def T, queryParam string) (T, error) {
299-
setParse := func(set []string) (T, error) {
341+
setParse := parseSingle(parser, parse, def, queryParam)
342+
return parseQueryParamSet(parser, vals, setParse, def, queryParam)
343+
}
344+
345+
func parseSingle[T any](parser *QueryParamParser, parse func(v string) (T, error), def T, queryParam string) func(set []string) (T, error) {
346+
return func(set []string) (T, error) {
300347
if len(set) > 1 {
301348
// Set as a parser.Error rather than return an error.
302349
// Returned errors are errors from the passed in `parse` function, and
@@ -311,7 +358,6 @@ func parseQueryParam[T any](parser *QueryParamParser, vals url.Values, parse fun
311358
}
312359
return parse(set[0])
313360
}
314-
return parseQueryParamSet(parser, vals, setParse, def, queryParam)
315361
}
316362

317363
func parseQueryParamSet[T any](parser *QueryParamParser, vals url.Values, parse func(set []string) (T, error), def T, queryParam string) (T, error) {

coderd/httpapi/queryparams_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package httpapi_test
22

33
import (
4+
"database/sql"
45
"fmt"
56
"net/http"
67
"net/url"
@@ -220,6 +221,65 @@ func TestParseQueryParams(t *testing.T) {
220221
testQueryParams(t, expParams, parser, parser.Boolean)
221222
})
222223

224+
t.Run("NullableBoolean", func(t *testing.T) {
225+
t.Parallel()
226+
expParams := []queryParamTestCase[sql.NullBool]{
227+
{
228+
QueryParam: "valid_true",
229+
Value: "true",
230+
Expected: sql.NullBool{
231+
Bool: true,
232+
Valid: true,
233+
},
234+
},
235+
{
236+
QueryParam: "no_value_true_def",
237+
NoSet: true,
238+
Default: sql.NullBool{
239+
Bool: true,
240+
Valid: true,
241+
},
242+
Expected: sql.NullBool{
243+
Bool: true,
244+
Valid: true,
245+
},
246+
},
247+
{
248+
QueryParam: "no_value",
249+
NoSet: true,
250+
Expected: sql.NullBool{
251+
Bool: false,
252+
Valid: false,
253+
},
254+
},
255+
256+
{
257+
QueryParam: "invalid_boolean",
258+
Value: "yes",
259+
Expected: sql.NullBool{
260+
Bool: false,
261+
Valid: false,
262+
},
263+
ExpectedErrorContains: "must be a valid boolean",
264+
},
265+
{
266+
QueryParam: "unexpected_list",
267+
Values: []string{"true", "false"},
268+
ExpectedErrorContains: multipleValuesError,
269+
// Expected value is a bit strange, but the error is raised
270+
// in the parser, not as a parse failure. Maybe this should be
271+
// fixed, but is how it is done atm.
272+
Expected: sql.NullBool{
273+
Bool: false,
274+
Valid: true,
275+
},
276+
},
277+
}
278+
279+
parser := httpapi.NewQueryParamParser()
280+
testQueryParams(t, expParams, parser, parser.NullableBoolean)
281+
})
282+
223283
t.Run("Int", func(t *testing.T) {
224284
t.Parallel()
225285
expParams := []queryParamTestCase[int]{

coderd/searchquery/search.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,52 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
184184
return filter, parser.Errors
185185
}
186186

187+
func Templates(ctx context.Context, db database.Store, query string) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) {
188+
// Always lowercase for all searches.
189+
query = strings.ToLower(query)
190+
values, errors := searchTerms(query, func(term string, values url.Values) error {
191+
// Default to the template name
192+
values.Add("name", term)
193+
return nil
194+
})
195+
if len(errors) > 0 {
196+
return database.GetTemplatesWithFilterParams{}, errors
197+
}
198+
199+
parser := httpapi.NewQueryParamParser()
200+
filter := database.GetTemplatesWithFilterParams{
201+
Deleted: parser.Boolean(values, false, "deleted"),
202+
// TODO: Should name be a fuzzy search?
203+
ExactName: parser.String(values, "", "name"),
204+
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
205+
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
206+
}
207+
208+
// Convert the "organization" parameter to an organization uuid. This can require
209+
// a database lookup.
210+
organizationArg := parser.String(values, "", "organization")
211+
if organizationArg != "" {
212+
organizationID, err := uuid.Parse(organizationArg)
213+
if err == nil {
214+
filter.OrganizationID = organizationID
215+
} else {
216+
// Organization could be a name
217+
organization, err := db.GetOrganizationByName(ctx, organizationArg)
218+
if err != nil {
219+
parser.Errors = append(parser.Errors, codersdk.ValidationError{
220+
Field: "organization",
221+
Detail: fmt.Sprintf("Organization %q either does not exist, or you are unauthorized to view it", organizationArg),
222+
})
223+
} else {
224+
filter.OrganizationID = organization.ID
225+
}
226+
}
227+
}
228+
229+
parser.ErrorExcessParams(values)
230+
return filter, parser.Errors
231+
}
232+
187233
func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {
188234
searchValues := make(url.Values)
189235

coderd/searchquery/search_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99
"time"
1010

11+
"github.com/google/uuid"
1112
"github.com/stretchr/testify/assert"
1213
"github.com/stretchr/testify/require"
1314

@@ -454,3 +455,45 @@ func TestSearchUsers(t *testing.T) {
454455
})
455456
}
456457
}
458+
459+
func TestSearchTemplates(t *testing.T) {
460+
t.Parallel()
461+
testCases := []struct {
462+
Name string
463+
Query string
464+
Expected database.GetTemplatesWithFilterParams
465+
ExpectedErrorContains string
466+
}{
467+
{
468+
Name: "Empty",
469+
Query: "",
470+
Expected: database.GetTemplatesWithFilterParams{},
471+
},
472+
}
473+
474+
for _, c := range testCases {
475+
c := c
476+
t.Run(c.Name, func(t *testing.T) {
477+
t.Parallel()
478+
// Do not use a real database, this is only used for an
479+
// organization lookup.
480+
db := dbmem.New()
481+
values, errs := searchquery.Templates(context.Background(), db, c.Query)
482+
if c.ExpectedErrorContains != "" {
483+
require.True(t, len(errs) > 0, "expect some errors")
484+
var s strings.Builder
485+
for _, err := range errs {
486+
_, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail))
487+
}
488+
require.Contains(t, s.String(), c.ExpectedErrorContains)
489+
} else {
490+
require.Len(t, errs, 0, "expected no error")
491+
if c.Expected.IDs == nil {
492+
// Nil and length 0 are the same
493+
c.Expected.IDs = []uuid.UUID{}
494+
}
495+
require.Equal(t, c.Expected, values, "expected values")
496+
}
497+
})
498+
}
499+
}

coderd/templates.go

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/coder/coder/v2/coderd/rbac"
2222
"github.com/coder/coder/v2/coderd/rbac/policy"
2323
"github.com/coder/coder/v2/coderd/schedule"
24+
"github.com/coder/coder/v2/coderd/searchquery"
2425
"github.com/coder/coder/v2/coderd/telemetry"
2526
"github.com/coder/coder/v2/coderd/util/ptr"
2627
"github.com/coder/coder/v2/coderd/workspacestats"
@@ -457,20 +458,12 @@ func (api *API) fetchTemplates(mutate func(r *http.Request, arg *database.GetTem
457458
return func(rw http.ResponseWriter, r *http.Request) {
458459
ctx := r.Context()
459460

460-
p := httpapi.NewQueryParamParser()
461-
values := r.URL.Query()
462-
463-
deprecated := sql.NullBool{}
464-
if values.Has("deprecated") {
465-
deprecated = sql.NullBool{
466-
Bool: p.Boolean(values, false, "deprecated"),
467-
Valid: true,
468-
}
469-
}
470-
if len(p.Errors) > 0 {
461+
queryStr := r.URL.Query().Get("q")
462+
filter, errs := searchquery.Templates(ctx, api.Database, queryStr)
463+
if len(errs) > 0 {
471464
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
472-
Message: "Invalid query params.",
473-
Validations: p.Errors,
465+
Message: "Invalid template search query.",
466+
Validations: errs,
474467
})
475468
return
476469
}
@@ -484,9 +477,7 @@ func (api *API) fetchTemplates(mutate func(r *http.Request, arg *database.GetTem
484477
return
485478
}
486479

487-
args := database.GetTemplatesWithFilterParams{
488-
Deprecated: deprecated,
489-
}
480+
args := filter
490481
if mutate != nil {
491482
mutate(r, &args)
492483
}

coderd/templates_test.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,9 @@ func TestTemplatesByOrganization(t *testing.T) {
420420

421421
ctx := testutil.Context(t, testutil.WaitLong)
422422

423-
templates, err := client.TemplatesByOrganization(ctx, user.OrganizationID)
423+
templates, err := client.Templates(ctx, codersdk.TemplateFilter{
424+
OrganizationID: user.OrganizationID,
425+
})
424426
require.NoError(t, err)
425427
require.Len(t, templates, 1)
426428
})
@@ -440,7 +442,7 @@ func TestTemplatesByOrganization(t *testing.T) {
440442
require.Len(t, templates, 2)
441443

442444
// Listing all should match
443-
templates, err = client.Templates(ctx)
445+
templates, err = client.Templates(ctx, codersdk.TemplateFilter{})
444446
require.NoError(t, err)
445447
require.Len(t, templates, 2)
446448

@@ -473,12 +475,19 @@ func TestTemplatesByOrganization(t *testing.T) {
473475
ctx := testutil.Context(t, testutil.WaitLong)
474476

475477
// All 4 are viewable by the owner
476-
templates, err := client.Templates(ctx)
478+
templates, err := client.Templates(ctx, codersdk.TemplateFilter{})
477479
require.NoError(t, err)
478480
require.Len(t, templates, 4)
479481

482+
// View a single organization from the owner
483+
templates, err = client.Templates(ctx, codersdk.TemplateFilter{
484+
OrganizationID: owner.OrganizationID,
485+
})
486+
require.NoError(t, err)
487+
require.Len(t, templates, 2)
488+
480489
// Only 2 are viewable by the org user
481-
templates, err = user.Templates(ctx)
490+
templates, err = user.Templates(ctx, codersdk.TemplateFilter{})
482491
require.NoError(t, err)
483492
require.Len(t, templates, 2)
484493
for _, tmpl := range templates {

codersdk/organizations.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"net/http"
8+
"strings"
89
"time"
910

1011
"github.com/google/uuid"
@@ -362,11 +363,33 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui
362363
return templates, json.NewDecoder(res.Body).Decode(&templates)
363364
}
364365

366+
type TemplateFilter struct {
367+
OrganizationID uuid.UUID
368+
}
369+
370+
// asRequestOption returns a function that can be used in (*Client).Request.
371+
// It modifies the request query parameters.
372+
func (f TemplateFilter) asRequestOption() RequestOption {
373+
return func(r *http.Request) {
374+
var params []string
375+
// Make sure all user input is quoted to ensure it's parsed as a single
376+
// string.
377+
if f.OrganizationID != uuid.Nil {
378+
params = append(params, fmt.Sprintf("organization:%q", f.OrganizationID.String()))
379+
}
380+
381+
q := r.URL.Query()
382+
q.Set("q", strings.Join(params, " "))
383+
r.URL.RawQuery = q.Encode()
384+
}
385+
}
386+
365387
// Templates lists all viewable templates
366-
func (c *Client) Templates(ctx context.Context) ([]Template, error) {
388+
func (c *Client) Templates(ctx context.Context, filter TemplateFilter) ([]Template, error) {
367389
res, err := c.Request(ctx, http.MethodGet,
368390
"/api/v2/templates",
369391
nil,
392+
filter.asRequestOption(),
370393
)
371394
if err != nil {
372395
return nil, xerrors.Errorf("execute request: %w", err)

0 commit comments

Comments
 (0)