Skip to content

Commit 2941b58

Browse files
committed
feat: Implement workspace search filter to support names
1 parent 91d7d84 commit 2941b58

File tree

5 files changed

+264
-47
lines changed

5 files changed

+264
-47
lines changed

coderd/database/queries.sql.go

Lines changed: 27 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/workspaces.sql

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ FROM
1515
workspaces
1616
WHERE
1717
-- Optionally include deleted workspaces
18-
deleted = @deleted
18+
workspaces.deleted = @deleted
1919
-- Filter by organization_id
2020
AND CASE
2121
WHEN @organization_id :: uuid != '00000000-00000000-00000000-00000000' THEN
@@ -24,21 +24,35 @@ WHERE
2424
END
2525
-- Filter by owner_id
2626
AND CASE
27-
WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN
28-
owner_id = @owner_id
29-
ELSE true
27+
WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN
28+
owner_id = @owner_id
29+
ELSE true
30+
END
31+
-- Filter by owner_name
32+
AND CASE
33+
WHEN @owner_username :: text != '' THEN
34+
owner_id = (SELECT id FROM users WHERE username = @owner_username)
35+
ELSE true
36+
END
37+
-- Filter by template_name
38+
-- There can be more than 1 template with the same name across organizations.
39+
-- Use the organization filter to restrict to 1 org if needed.
40+
AND CASE
41+
WHEN @template_name :: text != '' THEN
42+
template_id = (SELECT id FROM templates WHERE name = @template_name)
43+
ELSE true
3044
END
3145
-- Filter by template_ids
3246
AND CASE
33-
WHEN array_length(@template_ids :: uuid[], 1) > 0 THEN
34-
template_id = ANY(@template_ids)
35-
ELSE true
47+
WHEN array_length(@template_ids :: uuid[], 1) > 0 THEN
48+
template_id = ANY(@template_ids)
49+
ELSE true
3650
END
3751
-- Filter by name, matching on substring
3852
AND CASE
39-
WHEN @name :: text != '' THEN
40-
LOWER(name) LIKE '%' || LOWER(@name) || '%'
41-
ELSE true
53+
WHEN @name :: text != '' THEN
54+
LOWER(name) LIKE '%' || LOWER(@name) || '%'
55+
ELSE true
4256
END
4357
;
4458

coderd/httpapi/queryparams.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package httpapi
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strings"
7+
8+
"github.com/google/uuid"
9+
10+
"golang.org/x/xerrors"
11+
)
12+
13+
// QueryParamParser is a helper for parsing all query params and gathering all
14+
// errors in 1 sweep. This means all invalid fields are returned at once,
15+
// rather than only returning the first error
16+
type QueryParamParser struct {
17+
errors []Error
18+
}
19+
20+
func NewQueryParamParser() *QueryParamParser {
21+
return &QueryParamParser{
22+
errors: []Error{},
23+
}
24+
}
25+
26+
// ValidationErrors is the set of errors to return via the API. If the length
27+
// of this set is 0, there was no errors.
28+
func (p QueryParamParser) ValidationErrors() []Error {
29+
return p.errors
30+
}
31+
32+
func (p *QueryParamParser) ParseUUIDorMe(r *http.Request, def uuid.UUID, me uuid.UUID, queryParam string) uuid.UUID {
33+
if r.URL.Query().Get(queryParam) == "me" {
34+
return me
35+
}
36+
37+
v, err := parse(r, uuid.Parse, def, queryParam)
38+
if err != nil {
39+
p.errors = append(p.errors, Error{
40+
Field: queryParam,
41+
Detail: fmt.Sprintf("Query param %q must be a valid uuid", queryParam),
42+
})
43+
}
44+
return v
45+
}
46+
47+
func (p *QueryParamParser) ParseUUID(r *http.Request, def uuid.UUID, queryParam string) uuid.UUID {
48+
v, err := parse(r, uuid.Parse, def, queryParam)
49+
if err != nil {
50+
p.errors = append(p.errors, Error{
51+
Field: queryParam,
52+
Detail: fmt.Sprintf("Query param %q must be a valid uuid", queryParam),
53+
})
54+
}
55+
return v
56+
}
57+
58+
func (p *QueryParamParser) ParseUUIDArray(r *http.Request, def []uuid.UUID, queryParam string) []uuid.UUID {
59+
v, err := parse(r, func(v string) ([]uuid.UUID, error) {
60+
var badValues []string
61+
strs := strings.Split(v, ",")
62+
ids := make([]uuid.UUID, 0, len(strs))
63+
for _, s := range strs {
64+
id, err := uuid.Parse(s)
65+
if err != nil {
66+
badValues = append(badValues, v)
67+
continue
68+
}
69+
ids = append(ids, id)
70+
}
71+
72+
if len(badValues) > 0 {
73+
return nil, xerrors.Errorf("%s", strings.Join(badValues, ","))
74+
}
75+
return ids, nil
76+
}, def, queryParam)
77+
if err != nil {
78+
p.errors = append(p.errors, Error{
79+
Field: queryParam,
80+
Detail: fmt.Sprintf("Query param %q has invalid uuids: %q", err.Error()),
81+
})
82+
}
83+
return v
84+
}
85+
86+
func (p *QueryParamParser) ParseString(r *http.Request, def string, queryParam string) string {
87+
v, err := parse(r, func(v string) (string, error) {
88+
return v, nil
89+
}, def, queryParam)
90+
if err != nil {
91+
p.errors = append(p.errors, Error{
92+
Field: queryParam,
93+
Detail: fmt.Sprintf("Query param %q must be a valid string", queryParam),
94+
})
95+
}
96+
return v
97+
}
98+
99+
func parse[T any](r *http.Request, parse func(v string) (T, error), def T, queryParam string) (T, error) {
100+
if !r.URL.Query().Has(queryParam) {
101+
return def, nil
102+
}
103+
str := r.URL.Query().Get(queryParam)
104+
return parse(str)
105+
}

coderd/httpapi/search.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package httpapi
2+
3+
import (
4+
"strings"
5+
6+
"golang.org/x/xerrors"
7+
)
8+
9+
// WorkspaceSearchQuery takes a query string and breaks it into it's query
10+
// params as a set of key=value.
11+
func WorkspaceSearchQuery(query string) (map[string]string, error) {
12+
searchParams := make(map[string]string)
13+
elements := queryElements(query)
14+
for _, element := range elements {
15+
parts := strings.Split(element, ":")
16+
switch len(parts) {
17+
case 1:
18+
// No key:value pair. It is a workspace name, and maybe includes an owner
19+
parts = strings.Split(element, "/")
20+
switch len(parts) {
21+
case 1:
22+
searchParams["name"] = parts[0]
23+
case 2:
24+
searchParams["owner"] = parts[0]
25+
searchParams["name"] = parts[1]
26+
default:
27+
return nil, xerrors.Errorf("Query element %q can only contain 1 '/'", element)
28+
}
29+
case 2:
30+
searchParams[parts[0]] = parts[1]
31+
default:
32+
return nil, xerrors.Errorf("Query element %q can only contain 1 ':'", element)
33+
}
34+
}
35+
36+
return searchParams, nil
37+
}
38+
39+
// queryElements takes a query string and splits it into the individual elements
40+
// of the query. Each element is separated by a space. All quoted strings are
41+
// kept as a single element.
42+
func queryElements(query string) []string {
43+
var parts []string
44+
45+
quoted := false
46+
var current strings.Builder
47+
for _, c := range query {
48+
switch c {
49+
case '"':
50+
quoted = !quoted
51+
case ' ':
52+
if quoted {
53+
current.WriteRune(c)
54+
} else {
55+
parts = append(parts, current.String())
56+
current = strings.Builder{}
57+
}
58+
default:
59+
current.WriteRune(c)
60+
}
61+
}
62+
parts = append(parts, current.String())
63+
return parts
64+
}

coderd/workspaces.go

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -103,43 +103,59 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
103103
// Optional filters with query params
104104
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
105105
apiKey := httpmw.APIKey(r)
106-
filter := database.GetWorkspacesWithFilterParams{Deleted: false}
107106

108-
orgFilter := r.URL.Query().Get("organization_id")
109-
if orgFilter != "" {
110-
orgID, err := uuid.Parse(orgFilter)
111-
if err == nil {
112-
filter.OrganizationID = orgID
113-
}
107+
queryStr := r.URL.Query().Get("q")
108+
values, err := httpapi.WorkspaceSearchQuery(queryStr)
109+
if err != nil {
110+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
111+
Message: "Invalid workspace search query",
112+
Validations: []httpapi.Error{
113+
{Field: "q", Detail: err.Error()},
114+
},
115+
})
116+
return
114117
}
115118

116-
ownerFilter := r.URL.Query().Get("owner")
117-
if ownerFilter == "me" {
118-
filter.OwnerID = apiKey.UserID
119-
} else if ownerFilter != "" {
120-
user, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
121-
Username: ownerFilter,
122-
})
123-
if err == nil {
124-
filter.OwnerID = user.ID
119+
// Set all the query params from the "q" field.
120+
for k, v := range values {
121+
// Do not allow overriding if the user also set query param fields
122+
// outside the query string.
123+
if r.URL.Query().Has(k) {
124+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
125+
Message: fmt.Sprintf("Workspace filter %q cannot be set twice. In query params %q and %q", k, k, "q"),
126+
})
127+
return
125128
}
129+
r.URL.Query().Set(k, v)
126130
}
127131

128-
nameFilter := r.URL.Query().Get("name")
129-
if nameFilter != "" {
130-
filter.Name = nameFilter
132+
parser := httpapi.NewQueryParamParser()
133+
filter := database.GetWorkspacesWithFilterParams{
134+
Deleted: false,
135+
OrganizationID: parser.ParseUUID(r, uuid.Nil, "organization_id"),
136+
OwnerID: parser.ParseUUIDorMe(r, uuid.Nil, apiKey.UserID, "owner_id"),
137+
OwnerUsername: parser.ParseString(r, "", "owner"),
138+
TemplateName: parser.ParseString(r, "", "template"),
139+
TemplateIds: parser.ParseUUIDArray(r, []uuid.UUID{}, "template_ids"),
140+
Name: parser.ParseString(r, "", "name"),
131141
}
132-
133-
templateFilter := r.URL.Query().Get("template")
134-
if templateFilter != "" {
135-
ts, err := api.Database.GetTemplatesByName(r.Context(), database.GetTemplatesByNameParams{
136-
Name: templateFilter,
142+
if len(parser.ValidationErrors()) > 0 {
143+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
144+
Message: fmt.Sprintf("Query parameters have invalid values"),
145+
Validations: parser.ValidationErrors(),
137146
})
138-
if err == nil {
139-
for _, t := range ts {
140-
filter.TemplateIds = append(filter.TemplateIds, t.ID)
141-
}
147+
return
148+
}
149+
150+
if filter.OwnerUsername == "me" {
151+
if !(filter.OwnerID == uuid.Nil || filter.OwnerID == apiKey.UserID) {
152+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
153+
Message: fmt.Sprintf("Cannot set both \"me\" in \"owner_name\" and use \"owner_id\""),
154+
})
155+
return
142156
}
157+
filter.OwnerID = apiKey.UserID
158+
filter.OwnerUsername = ""
143159
}
144160

145161
workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), filter)

0 commit comments

Comments
 (0)