Skip to content

Commit ccf3490

Browse files
authored
chore: add templates search query to a filter (#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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy