feat: workspace filter query supported in backend (#2232)

* feat: add support for template in workspace filter
* feat: Implement workspace search filter to support names
* Use new query param parser for pagination fields
* Remove excessive calls, use filters on a single query

Co-authored-by: Garrett <garrett@coder.com>
This commit is contained in:
Steven Masley 2022-06-14 08:46:33 -05:00 committed by GitHub
parent 5be52de593
commit dc1de58857
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1068 additions and 464 deletions

View File

@ -321,41 +321,47 @@ func (q *fakeQuerier) GetWorkspacesWithFilter(_ context.Context, arg database.Ge
workspaces := make([]database.Workspace, 0)
for _, workspace := range q.workspaces {
if arg.OrganizationID != uuid.Nil && workspace.OrganizationID != arg.OrganizationID {
continue
}
if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID {
continue
}
if arg.OwnerUsername != "" {
owner, err := q.GetUserByID(context.Background(), workspace.OwnerID)
if err == nil && arg.OwnerUsername != owner.Username {
continue
}
}
if arg.TemplateName != "" {
templates, err := q.GetTemplatesWithFilter(context.Background(), database.GetTemplatesWithFilterParams{
ExactName: arg.TemplateName,
})
// Add to later param
if err == nil {
for _, t := range templates {
arg.TemplateIds = append(arg.TemplateIds, t.ID)
}
}
}
if !arg.Deleted && workspace.Deleted {
continue
}
if arg.Name != "" && !strings.Contains(workspace.Name, arg.Name) {
continue
}
workspaces = append(workspaces, workspace)
}
return workspaces, nil
}
func (q *fakeQuerier) GetWorkspacesByTemplateID(_ context.Context, arg database.GetWorkspacesByTemplateIDParams) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
workspaces := make([]database.Workspace, 0)
for _, workspace := range q.workspaces {
if workspace.TemplateID.String() != arg.TemplateID.String() {
continue
}
if workspace.Deleted != arg.Deleted {
continue
if len(arg.TemplateIds) > 0 {
match := false
for _, id := range arg.TemplateIds {
if workspace.TemplateID == id {
match = true
break
}
}
if !match {
continue
}
}
workspaces = append(workspaces, workspace)
}
if len(workspaces) == 0 {
return nil, sql.ErrNoRows
}
return workspaces, nil
}
@ -641,25 +647,6 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Con
return database.WorkspaceBuild{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspacesByOrganizationIDs(_ context.Context, req database.GetWorkspacesByOrganizationIDsParams) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
workspaces := make([]database.Workspace, 0)
for _, workspace := range q.workspaces {
for _, id := range req.Ids {
if workspace.OrganizationID != id {
continue
}
if workspace.Deleted != req.Deleted {
continue
}
workspaces = append(workspaces, workspace)
}
}
return workspaces, nil
}
func (q *fakeQuerier) GetOrganizations(_ context.Context) ([]database.Organization, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -786,6 +773,44 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd
return sql.ErrNoRows
}
func (q *fakeQuerier) GetTemplatesWithFilter(_ context.Context, arg database.GetTemplatesWithFilterParams) ([]database.Template, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
var templates []database.Template
for _, template := range q.templates {
if template.Deleted != arg.Deleted {
continue
}
if arg.OrganizationID != uuid.Nil && template.OrganizationID != arg.OrganizationID {
continue
}
if arg.ExactName != "" && !strings.EqualFold(template.Name, arg.ExactName) {
continue
}
if len(arg.Ids) > 0 {
match := false
for _, id := range arg.Ids {
if template.ID == id {
match = true
break
}
}
if !match {
continue
}
}
templates = append(templates, template)
}
if len(templates) > 0 {
return templates, nil
}
return nil, sql.ErrNoRows
}
func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg database.GetTemplateVersionsByTemplateIDParams) (version []database.TemplateVersion, err error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -923,45 +948,6 @@ func (q *fakeQuerier) GetParameterValueByScopeAndName(_ context.Context, arg dat
return database.ParameterValue{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetTemplatesByOrganization(_ context.Context, arg database.GetTemplatesByOrganizationParams) ([]database.Template, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
templates := make([]database.Template, 0)
for _, template := range q.templates {
if template.Deleted != arg.Deleted {
continue
}
if template.OrganizationID != arg.OrganizationID {
continue
}
templates = append(templates, template)
}
if len(templates) == 0 {
return nil, sql.ErrNoRows
}
return templates, nil
}
func (q *fakeQuerier) GetTemplatesByIDs(_ context.Context, ids []uuid.UUID) ([]database.Template, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
templates := make([]database.Template, 0)
for _, template := range q.templates {
for _, id := range ids {
if template.ID.String() != id.String() {
continue
}
templates = append(templates, template)
}
}
if len(templates) == 0 {
return nil, sql.ErrNoRows
}
return templates, nil
}
func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

View File

@ -0,0 +1,61 @@
package databasefake_test
import (
"fmt"
"reflect"
"testing"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/databasefake"
)
// TestExactMethods will ensure the fake database does not hold onto excessive
// functions. The fake database is a manual implementation, so it is possible
// we forget to delete functions that we remove. This unit test just ensures
// we remove the extra methods.
func TestExactMethods(t *testing.T) {
t.Parallel()
// extraFakeMethods contains the extra allowed methods that are not a part
// of the database.Store interface.
extraFakeMethods := map[string]string{
// Example
// "SortFakeLists": "Helper function used",
}
fake := reflect.TypeOf(databasefake.New())
fakeMethods := methods(fake)
store := reflect.TypeOf((*database.Store)(nil)).Elem()
storeMethods := methods(store)
// Store should be a subset
for k := range storeMethods {
_, ok := fakeMethods[k]
if !ok {
panic(fmt.Sprintf("This should never happen. FakeDB missing method %s, so doesn't fit the interface", k))
}
delete(storeMethods, k)
delete(fakeMethods, k)
}
for k := range fakeMethods {
_, ok := extraFakeMethods[k]
if ok {
continue
}
// If you are seeing this error, you have an extra function not required
// for the database.Store. If you still want to keep it, add it to
// 'extraFakeMethods' to allow it.
t.Errorf("Fake method '%s()' is excessive and not needed to fit interface, delete it", k)
}
}
func methods(rt reflect.Type) map[string]bool {
methods := make(map[string]bool)
for i := 0; i < rt.NumMethod(); i++ {
methods[rt.Method(i).Name] = true
}
return methods
}

View File

@ -53,8 +53,7 @@ type querier interface {
GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error)
GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error)
GetTemplateVersionsByTemplateID(ctx context.Context, arg GetTemplateVersionsByTemplateIDParams) ([]TemplateVersion, error)
GetTemplatesByIDs(ctx context.Context, ids []uuid.UUID) ([]Template, error)
GetTemplatesByOrganization(ctx context.Context, arg GetTemplatesByOrganizationParams) ([]Template, error)
GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error)
GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetUserCount(ctx context.Context) (int64, error)
@ -78,8 +77,6 @@ type querier interface {
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error)
GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error)
GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error)
GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error)
GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error)
InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error)
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error)

View File

@ -1671,68 +1671,48 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G
return i, err
}
const getTemplatesByIDs = `-- name: GetTemplatesByIDs :many
const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many
SELECT
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by
FROM
templates
WHERE
id = ANY($1 :: uuid [ ])
-- Optionally include deleted templates
templates.deleted = $1
-- Filter by organization_id
AND CASE
WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN
organization_id = $2
ELSE true
END
-- Filter by exact name
AND CASE
WHEN $3 :: text != '' THEN
LOWER("name") = LOWER($3)
ELSE true
END
-- Filter by ids
AND CASE
WHEN array_length($4 :: uuid[], 1) > 0 THEN
id = ANY($4)
ELSE true
END
`
func (q *sqlQuerier) GetTemplatesByIDs(ctx context.Context, ids []uuid.UUID) ([]Template, error) {
rows, err := q.db.QueryContext(ctx, getTemplatesByIDs, pq.Array(ids))
if err != nil {
return nil, err
}
defer rows.Close()
var items []Template
for rows.Next() {
var i Template
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.Deleted,
&i.Name,
&i.Provisioner,
&i.ActiveVersionID,
&i.Description,
&i.MaxTtl,
&i.MinAutostartInterval,
&i.CreatedBy,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
type GetTemplatesWithFilterParams struct {
Deleted bool `db:"deleted" json:"deleted"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
ExactName string `db:"exact_name" json:"exact_name"`
Ids []uuid.UUID `db:"ids" json:"ids"`
}
const getTemplatesByOrganization = `-- name: GetTemplatesByOrganization :many
SELECT
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by
FROM
templates
WHERE
organization_id = $1
AND deleted = $2
`
type GetTemplatesByOrganizationParams struct {
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetTemplatesByOrganization(ctx context.Context, arg GetTemplatesByOrganizationParams) ([]Template, error) {
rows, err := q.db.QueryContext(ctx, getTemplatesByOrganization, arg.OrganizationID, arg.Deleted)
func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) {
rows, err := q.db.QueryContext(ctx, getTemplatesWithFilter,
arg.Deleted,
arg.OrganizationID,
arg.ExactName,
pq.Array(arg.Ids),
)
if err != nil {
return nil, err
}
@ -3639,98 +3619,6 @@ func (q *sqlQuerier) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, e
return items, nil
}
const getWorkspacesByOrganizationIDs = `-- name: GetWorkspacesByOrganizationIDs :many
SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2
`
type GetWorkspacesByOrganizationIDsParams struct {
Ids []uuid.UUID `db:"ids" json:"ids"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesByOrganizationIDs, pq.Array(arg.Ids), arg.Deleted)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Workspace
for rows.Next() {
var i Workspace
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
&i.OrganizationID,
&i.TemplateID,
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
template_id = $1
AND deleted = $2
`
type GetWorkspacesByTemplateIDParams struct {
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesByTemplateID, arg.TemplateID, arg.Deleted)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Workspace
for rows.Next() {
var i Workspace
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
&i.OrganizationID,
&i.TemplateID,
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWorkspacesWithFilter = `-- name: GetWorkspacesWithFilter :many
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
@ -3738,39 +3626,57 @@ FROM
workspaces
WHERE
-- Optionally include deleted workspaces
deleted = $1
-- Filter by organization_id
AND CASE
WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN
organization_id = $2
ELSE true
END
workspaces.deleted = $1
-- Filter by owner_id
AND CASE
WHEN $3 :: uuid != '00000000-00000000-00000000-00000000' THEN
owner_id = $3
ELSE true
WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN
owner_id = $2
ELSE true
END
-- Filter by owner_name
AND CASE
WHEN $3 :: text != '' THEN
owner_id = (SELECT id FROM users WHERE username = $3)
ELSE true
END
-- Filter by template_name
-- There can be more than 1 template with the same name across organizations.
-- Use the organization filter to restrict to 1 org if needed.
AND CASE
WHEN $4 :: text != '' THEN
template_id = ANY(SELECT id FROM templates WHERE name = $4)
ELSE true
END
-- Filter by template_ids
AND CASE
WHEN array_length($5 :: uuid[], 1) > 0 THEN
template_id = ANY($5)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN $4 :: text != '' THEN
LOWER(name) LIKE '%' || LOWER($4) || '%'
ELSE true
WHEN $6 :: text != '' THEN
LOWER(name) LIKE '%' || LOWER($6) || '%'
ELSE true
END
`
type GetWorkspacesWithFilterParams struct {
Deleted bool `db:"deleted" json:"deleted"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
Name string `db:"name" json:"name"`
Deleted bool `db:"deleted" json:"deleted"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
OwnerUsername string `db:"owner_username" json:"owner_username"`
TemplateName string `db:"template_name" json:"template_name"`
TemplateIds []uuid.UUID `db:"template_ids" json:"template_ids"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesWithFilter,
arg.Deleted,
arg.OrganizationID,
arg.OwnerID,
arg.OwnerUsername,
arg.TemplateName,
pq.Array(arg.TemplateIds),
arg.Name,
)
if err != nil {

View File

@ -8,13 +8,33 @@ WHERE
LIMIT
1;
-- name: GetTemplatesByIDs :many
-- name: GetTemplatesWithFilter :many
SELECT
*
FROM
templates
WHERE
id = ANY(@ids :: uuid [ ]);
-- Optionally include deleted templates
templates.deleted = @deleted
-- Filter by organization_id
AND CASE
WHEN @organization_id :: uuid != '00000000-00000000-00000000-00000000' THEN
organization_id = @organization_id
ELSE true
END
-- Filter by exact name
AND CASE
WHEN @exact_name :: text != '' THEN
LOWER("name") = LOWER(@exact_name)
ELSE true
END
-- Filter by ids
AND CASE
WHEN array_length(@ids :: uuid[], 1) > 0 THEN
id = ANY(@ids)
ELSE true
END
;
-- name: GetTemplateByOrganizationAndName :one
SELECT
@ -28,15 +48,6 @@ WHERE
LIMIT
1;
-- name: GetTemplatesByOrganization :many
SELECT
*
FROM
templates
WHERE
organization_id = $1
AND deleted = $2;
-- name: InsertTemplate :one
INSERT INTO
templates (

View File

@ -15,30 +15,41 @@ FROM
workspaces
WHERE
-- Optionally include deleted workspaces
deleted = @deleted
-- Filter by organization_id
AND CASE
WHEN @organization_id :: uuid != '00000000-00000000-00000000-00000000' THEN
organization_id = @organization_id
ELSE true
END
workspaces.deleted = @deleted
-- Filter by owner_id
AND CASE
WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN
owner_id = @owner_id
ELSE true
WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN
owner_id = @owner_id
ELSE true
END
-- Filter by owner_name
AND CASE
WHEN @owner_username :: text != '' THEN
owner_id = (SELECT id FROM users WHERE username = @owner_username)
ELSE true
END
-- Filter by template_name
-- There can be more than 1 template with the same name across organizations.
-- Use the organization filter to restrict to 1 org if needed.
AND CASE
WHEN @template_name :: text != '' THEN
template_id = ANY(SELECT id FROM templates WHERE name = @template_name)
ELSE true
END
-- Filter by template_ids
AND CASE
WHEN array_length(@template_ids :: uuid[], 1) > 0 THEN
template_id = ANY(@template_ids)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN @name :: text != '' THEN
LOWER(name) LIKE '%' || LOWER(@name) || '%'
ELSE true
WHEN @name :: text != '' THEN
LOWER(name) LIKE '%' || LOWER(@name) || '%'
ELSE true
END
;
-- name: GetWorkspacesByOrganizationIDs :many
SELECT * FROM workspaces WHERE organization_id = ANY(@ids :: uuid [ ]) AND deleted = @deleted;
-- name: GetWorkspacesAutostart :many
SELECT
*
@ -53,15 +64,6 @@ AND
(ttl IS NOT NULL AND ttl > 0)
);
-- name: GetWorkspacesByTemplateID :many
SELECT
*
FROM
workspaces
WHERE
template_id = $1
AND deleted = $2;
-- name: GetWorkspaceByOwnerIDAndName :one
SELECT
*

View File

@ -0,0 +1,105 @@
package httpapi
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
)
// QueryParamParser is a helper for parsing all query params and gathering all
// errors in 1 sweep. This means all invalid fields are returned at once,
// rather than only returning the first error
type QueryParamParser struct {
// Errors is the set of errors to return via the API. If the length
// of this set is 0, there are no errors!.
Errors []Error
}
func NewQueryParamParser() *QueryParamParser {
return &QueryParamParser{
Errors: []Error{},
}
}
func (p *QueryParamParser) Int(vals url.Values, def int, queryParam string) int {
v, err := parseQueryParam(vals, strconv.Atoi, def, queryParam)
if err != nil {
p.Errors = append(p.Errors, Error{
Field: queryParam,
Detail: fmt.Sprintf("Query param %q must be a valid integer (%s)", queryParam, err.Error()),
})
}
return v
}
func (p *QueryParamParser) UUIDorMe(vals url.Values, def uuid.UUID, me uuid.UUID, queryParam string) uuid.UUID {
if vals.Get(queryParam) == "me" {
return me
}
return p.UUID(vals, def, queryParam)
}
func (p *QueryParamParser) UUID(vals url.Values, def uuid.UUID, queryParam string) uuid.UUID {
v, err := parseQueryParam(vals, uuid.Parse, def, queryParam)
if err != nil {
p.Errors = append(p.Errors, Error{
Field: queryParam,
Detail: fmt.Sprintf("Query param %q must be a valid uuid", queryParam),
})
}
return v
}
func (p *QueryParamParser) UUIDs(vals url.Values, def []uuid.UUID, queryParam string) []uuid.UUID {
v, err := parseQueryParam(vals, func(v string) ([]uuid.UUID, error) {
var badValues []string
strs := strings.Split(v, ",")
ids := make([]uuid.UUID, 0, len(strs))
for _, s := range strs {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
badValues = append(badValues, v)
continue
}
ids = append(ids, id)
}
if len(badValues) > 0 {
return []uuid.UUID{}, xerrors.Errorf("%s", strings.Join(badValues, ","))
}
return ids, nil
}, def, queryParam)
if err != nil {
p.Errors = append(p.Errors, Error{
Field: queryParam,
Detail: fmt.Sprintf("Query param %q has invalid uuids: %q", queryParam, err.Error()),
})
}
return v
}
func (p *QueryParamParser) String(vals url.Values, def string, queryParam string) string {
v, err := parseQueryParam(vals, func(v string) (string, error) {
return v, nil
}, def, queryParam)
if err != nil {
p.Errors = append(p.Errors, Error{
Field: queryParam,
Detail: fmt.Sprintf("Query param %q must be a valid string", queryParam),
})
}
return v
}
func parseQueryParam[T any](vals url.Values, parse func(v string) (T, error), def T, queryParam string) (T, error) {
if !vals.Has(queryParam) || vals.Get(queryParam) == "" {
return def, nil
}
str := vals.Get(queryParam)
return parse(str)
}

View File

@ -0,0 +1,201 @@
package httpapi_test
import (
"fmt"
"net/http"
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/httpapi"
)
type queryParamTestCase[T any] struct {
QueryParam string
// No set does not set the query param, rather than setting the empty value
NoSet bool
Value string
Default T
Expected T
ExpectedErrorContains string
Parse func(r *http.Request, def T, queryParam string) T
}
func TestParseQueryParams(t *testing.T) {
t.Parallel()
t.Run("UUID", func(t *testing.T) {
t.Parallel()
me := uuid.New()
expParams := []queryParamTestCase[uuid.UUID]{
{
QueryParam: "valid_id",
Value: "afe39fbf-0f52-4a62-b0cc-58670145d773",
Expected: uuid.MustParse("afe39fbf-0f52-4a62-b0cc-58670145d773"),
},
{
QueryParam: "me",
Value: "me",
Expected: me,
},
{
QueryParam: "invalid_id",
Value: "bogus",
ExpectedErrorContains: "must be a valid uuid",
},
{
QueryParam: "long_id",
Value: "afe39fbf-0f52-4a62-b0cc-58670145d773-123",
ExpectedErrorContains: "must be a valid uuid",
},
{
QueryParam: "no_value",
NoSet: true,
Default: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Expected: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
},
{
QueryParam: "empty",
Value: "",
Expected: uuid.Nil,
},
}
parser := httpapi.NewQueryParamParser()
testQueryParams(t, expParams, parser, func(vals url.Values, def uuid.UUID, queryParam string) uuid.UUID {
return parser.UUIDorMe(vals, def, me, queryParam)
})
})
t.Run("String", func(t *testing.T) {
t.Parallel()
expParams := []queryParamTestCase[string]{
{
QueryParam: "valid_string",
Value: "random",
Expected: "random",
},
{
QueryParam: "empty",
Value: "",
Expected: "",
},
{
QueryParam: "no_value",
NoSet: true,
Default: "default",
Expected: "default",
},
}
parser := httpapi.NewQueryParamParser()
testQueryParams(t, expParams, parser, parser.String)
})
t.Run("Int", func(t *testing.T) {
t.Parallel()
expParams := []queryParamTestCase[int]{
{
QueryParam: "valid_integer",
Value: "100",
Expected: 100,
},
{
QueryParam: "empty",
Value: "",
Expected: 0,
},
{
QueryParam: "no_value",
NoSet: true,
Default: 5,
Expected: 5,
},
{
QueryParam: "negative",
Value: "-10",
Expected: -10,
Default: 5,
},
{
QueryParam: "invalid_integer",
Value: "bogus",
Expected: 0,
ExpectedErrorContains: "must be a valid integer",
},
}
parser := httpapi.NewQueryParamParser()
testQueryParams(t, expParams, parser, parser.Int)
})
t.Run("UUIDs", func(t *testing.T) {
t.Parallel()
expParams := []queryParamTestCase[[]uuid.UUID]{
{
QueryParam: "valid_ids_with_spaces",
Value: "6c8ef17d-5dd8-4b92-bac9-41944f90f237, 65fb05f3-12c8-4a0a-801f-40439cf9e681 , 01b94888-1eab-4bbf-aed0-dc7a8010da97",
Expected: []uuid.UUID{
uuid.MustParse("6c8ef17d-5dd8-4b92-bac9-41944f90f237"),
uuid.MustParse("65fb05f3-12c8-4a0a-801f-40439cf9e681"),
uuid.MustParse("01b94888-1eab-4bbf-aed0-dc7a8010da97"),
},
},
{
QueryParam: "empty",
Value: "",
Default: []uuid.UUID{},
Expected: []uuid.UUID{},
},
{
QueryParam: "no_value",
NoSet: true,
Default: []uuid.UUID{},
Expected: []uuid.UUID{},
},
{
QueryParam: "default",
NoSet: true,
Default: []uuid.UUID{uuid.Nil},
Expected: []uuid.UUID{uuid.Nil},
},
{
QueryParam: "invalid_id_in_set",
Value: "6c8ef17d-5dd8-4b92-bac9-41944f90f237,bogus",
Expected: []uuid.UUID{},
Default: []uuid.UUID{},
ExpectedErrorContains: "bogus",
},
}
parser := httpapi.NewQueryParamParser()
testQueryParams(t, expParams, parser, parser.UUIDs)
})
}
func testQueryParams[T any](t *testing.T, testCases []queryParamTestCase[T], parser *httpapi.QueryParamParser, parse func(vals url.Values, def T, queryParam string) T) {
v := url.Values{}
for _, c := range testCases {
if c.NoSet {
continue
}
v.Set(c.QueryParam, c.Value)
}
for _, c := range testCases {
// !! Do not run these in parallel !!
t.Run(c.QueryParam, func(t *testing.T) {
v := parse(v, c.Default, c.QueryParam)
require.Equal(t, c.Expected, v, fmt.Sprintf("param=%q value=%q", c.QueryParam, c.Value))
if c.ExpectedErrorContains != "" {
errors := parser.Errors
require.True(t, len(errors) > 0, "error exist")
last := errors[len(errors)-1]
require.True(t, last.Field == c.QueryParam, fmt.Sprintf("query param %q did not fail", c.QueryParam))
require.Contains(t, last.Detail, c.ExpectedErrorContains, "correct error")
}
})
}
}

View File

@ -2,7 +2,6 @@ package coderd
import (
"net/http"
"strconv"
"github.com/google/uuid"
@ -13,53 +12,21 @@ import (
// parsePagination extracts pagination query params from the http request.
// If an error is encountered, the error is written to w and ok is set to false.
func parsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Pagination, ok bool) {
var (
afterID = uuid.Nil
limit = -1 // Default to no limit and return all results.
offset = 0
)
var err error
if s := r.URL.Query().Get("after_id"); s != "" {
afterID, err = uuid.Parse(r.URL.Query().Get("after_id"))
if err != nil {
httpapi.Write(w, http.StatusBadRequest, httpapi.Response{
Message: "Query param 'after_id' must be a valid UUID.",
Validations: []httpapi.Error{
{Field: "after_id", Detail: err.Error()},
},
})
return p, false
}
queryParams := r.URL.Query()
parser := httpapi.NewQueryParamParser()
params := codersdk.Pagination{
AfterID: parser.UUID(queryParams, uuid.Nil, "after_id"),
// Limit default to "-1" which returns all results
Limit: parser.Int(queryParams, -1, "limit"),
Offset: parser.Int(queryParams, 0, "offset"),
}
if s := r.URL.Query().Get("limit"); s != "" {
limit, err = strconv.Atoi(s)
if err != nil {
httpapi.Write(w, http.StatusBadRequest, httpapi.Response{
Message: "Query param 'limit' must be a valid integer.",
Validations: []httpapi.Error{
{Field: "limit", Detail: err.Error()},
},
})
return p, false
}
}
if s := r.URL.Query().Get("offset"); s != "" {
offset, err = strconv.Atoi(s)
if err != nil {
httpapi.Write(w, http.StatusBadRequest, httpapi.Response{
Message: "Query param 'offset' must be a valid integer.",
Validations: []httpapi.Error{
{Field: "offset", Detail: err.Error()},
},
})
return p, false
}
if len(parser.Errors) > 0 {
httpapi.Write(w, http.StatusBadRequest, httpapi.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return params, false
}
return codersdk.Pagination{
AfterID: afterID,
Limit: limit,
Offset: offset,
}, true
return params, true
}

View File

@ -14,6 +14,7 @@ import (
func TestPagination(t *testing.T) {
t.Parallel()
const invalidValues = "Query parameters have invalid values"
testCases := []struct {
Name string
@ -27,27 +28,27 @@ func TestPagination(t *testing.T) {
{
Name: "BadAfterID",
AfterID: "bogus",
ExpectedError: "Query param 'after_id' must be a valid UUID",
ExpectedError: invalidValues,
},
{
Name: "ShortAfterID",
AfterID: "ff22a7b-bb6f-43d8-83e1-eefe0a1f5197",
ExpectedError: "Query param 'after_id' must be a valid UUID",
ExpectedError: invalidValues,
},
{
Name: "LongAfterID",
AfterID: "cff22a7b-bb6f-43d8-83e1-eefe0a1f51972",
ExpectedError: "Query param 'after_id' must be a valid UUID",
ExpectedError: invalidValues,
},
{
Name: "BadLimit",
Limit: "bogus",
ExpectedError: "Query param 'limit' must be a valid integer",
ExpectedError: invalidValues,
},
{
Name: "BadOffset",
Offset: "bogus",
ExpectedError: "Query param 'offset' must be a valid integer",
ExpectedError: invalidValues,
},
// Valid values

View File

@ -68,8 +68,8 @@ func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
return
}
workspaces, err := api.Database.GetWorkspacesByTemplateID(r.Context(), database.GetWorkspacesByTemplateIDParams{
TemplateID: template.ID,
workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), database.GetWorkspacesWithFilterParams{
TemplateIds: []uuid.UUID{template.ID},
})
if errors.Is(err, sql.ErrNoRows) {
err = nil
@ -241,7 +241,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request) {
organization := httpmw.OrganizationParam(r)
templates, err := api.Database.GetTemplatesByOrganization(r.Context(), database.GetTemplatesByOrganizationParams{
templates, err := api.Database.GetTemplatesWithFilter(r.Context(), database.GetTemplatesWithFilterParams{
OrganizationID: organization.ID,
})
if errors.Is(err, sql.ErrNoRows) {

View File

@ -7,7 +7,9 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
@ -103,38 +105,19 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
// Empty strings mean no filter
orgFilter := r.URL.Query().Get("organization_id")
ownerFilter := r.URL.Query().Get("owner")
nameFilter := r.URL.Query().Get("name")
queryStr := r.URL.Query().Get("q")
filter, errs := workspaceSearchQuery(queryStr)
if len(errs) > 0 {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "Invalid workspace search query.",
Validations: errs,
})
return
}
filter := database.GetWorkspacesWithFilterParams{Deleted: false}
if orgFilter != "" {
orgID, err := uuid.Parse(orgFilter)
if err == nil {
filter.OrganizationID = orgID
}
}
if ownerFilter == "me" {
if filter.OwnerUsername == "me" {
filter.OwnerID = apiKey.UserID
} else if ownerFilter != "" {
userID, err := uuid.Parse(ownerFilter)
if err != nil {
// Maybe it's a username
user, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
// Why not just accept 1 arg and use it for both in the sql?
Username: ownerFilter,
Email: ownerFilter,
})
if err == nil {
filter.OwnerID = user.ID
}
} else {
filter.OwnerID = userID
}
}
if nameFilter != "" {
filter.Name = nameFilter
filter.OwnerUsername = ""
}
workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), filter)
@ -276,29 +259,16 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
return
}
if !api.Authorize(rw, r, rbac.ActionRead, template) {
return
}
if organization.ID != template.OrganizationID {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: fmt.Sprintf("Template is not in organization %q.", organization.Name),
})
return
}
_, err = api.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{
OrganizationID: template.OrganizationID,
UserID: apiKey.UserID,
})
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: "You aren't allowed to access templates in that organization.",
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: "Internal error fetching organization member.",
Detail: err.Error(),
})
return
}
dbAutostartSchedule, err := validWorkspaceSchedule(createWorkspace.AutostartSchedule, time.Duration(template.MinAutostartInterval))
if err != nil {
@ -791,7 +761,9 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data
if err != nil {
return nil, xerrors.Errorf("get workspace builds: %w", err)
}
templates, err := db.GetTemplatesByIDs(ctx, templateIDs)
templates, err := db.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
Ids: templateIDs,
})
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
@ -974,3 +946,81 @@ func validWorkspaceSchedule(s *string, min time.Duration) (sql.NullString, error
String: *s,
}, nil
}
// workspaceSearchQuery takes a query string and returns the workspace filter.
// It also can return the list of validation errors to return to the api.
func workspaceSearchQuery(query string) (database.GetWorkspacesWithFilterParams, []httpapi.Error) {
searchParams := make(url.Values)
if query == "" {
// No filter
return database.GetWorkspacesWithFilterParams{}, nil
}
// Because we do this in 2 passes, we want to maintain quotes on the first
// pass.Further splitting occurs on the second pass and quotes will be
// dropped.
elements := splitQueryParameterByDelimiter(query, ' ', true)
for _, element := range elements {
parts := splitQueryParameterByDelimiter(element, ':', false)
switch len(parts) {
case 1:
// No key:value pair. It is a workspace name, and maybe includes an owner
parts = splitQueryParameterByDelimiter(element, '/', false)
switch len(parts) {
case 1:
searchParams.Set("name", parts[0])
case 2:
searchParams.Set("owner", parts[0])
searchParams.Set("name", parts[1])
default:
return database.GetWorkspacesWithFilterParams{}, []httpapi.Error{
{Field: "q", Detail: fmt.Sprintf("Query element %q can only contain 1 '/'", element)},
}
}
case 2:
searchParams.Set(parts[0], parts[1])
default:
return database.GetWorkspacesWithFilterParams{}, []httpapi.Error{
{Field: "q", Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element)},
}
}
}
// Using the query param parser here just returns consistent errors with
// other parsing.
parser := httpapi.NewQueryParamParser()
filter := database.GetWorkspacesWithFilterParams{
Deleted: false,
OwnerUsername: parser.String(searchParams, "", "owner"),
TemplateName: parser.String(searchParams, "", "template"),
Name: parser.String(searchParams, "", "name"),
}
return filter, parser.Errors
}
// splitQueryParameterByDelimiter takes a query string and splits it into the individual elements
// of the query. Each element is separated by a delimiter. All quoted strings are
// kept as a single element.
//
// Although all our names cannot have spaces, that is a validation error.
// We should still parse the quoted string as a single value so that validation
// can properly fail on the space. If we do not, a value of `template:"my name"`
// will search `template:"my name:name"`, which produces an empty list instead of
// an error.
// nolint:revive
func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes bool) []string {
quoted := false
parts := strings.FieldsFunc(query, func(r rune) bool {
if r == '"' {
quoted = !quoted
}
return !quoted && r == delimiter
})
if !maintainQuotes {
for i, part := range parts {
parts[i] = strings.Trim(part, "\"")
}
}
return parts
}

View File

@ -0,0 +1,144 @@
package coderd
import (
"fmt"
"strings"
"testing"
"github.com/coder/coder/coderd/database"
"github.com/stretchr/testify/require"
)
func TestSearchWorkspace(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Query string
Expected database.GetWorkspacesWithFilterParams
ExpectedErrorContains string
}{
{
Name: "Empty",
Query: "",
Expected: database.GetWorkspacesWithFilterParams{},
},
{
Name: "Owner/Name",
Query: "Foo/Bar",
Expected: database.GetWorkspacesWithFilterParams{
OwnerUsername: "Foo",
Name: "Bar",
},
},
{
Name: "Name",
Query: "workspace-name",
Expected: database.GetWorkspacesWithFilterParams{
Name: "workspace-name",
},
},
{
Name: "Name+Param",
Query: "workspace-name template:docker",
Expected: database.GetWorkspacesWithFilterParams{
Name: "workspace-name",
TemplateName: "docker",
},
},
{
Name: "OnlyParams",
Query: "name:workspace-name template:docker owner:alice",
Expected: database.GetWorkspacesWithFilterParams{
Name: "workspace-name",
TemplateName: "docker",
OwnerUsername: "alice",
},
},
{
Name: "QuotedParam",
Query: `name:workspace-name template:"docker template" owner:alice`,
Expected: database.GetWorkspacesWithFilterParams{
Name: "workspace-name",
TemplateName: "docker template",
OwnerUsername: "alice",
},
},
{
Name: "QuotedKey",
Query: `"name":baz "template":foo "owner":bar`,
Expected: database.GetWorkspacesWithFilterParams{
Name: "baz",
TemplateName: "foo",
OwnerUsername: "bar",
},
},
{
// This will not return an error
Name: "ExtraKeys",
Query: `foo:bar`,
Expected: database.GetWorkspacesWithFilterParams{},
},
{
// Quotes keep elements together
Name: "QuotedSpecial",
Query: `name:"workspace:name"`,
Expected: database.GetWorkspacesWithFilterParams{
Name: "workspace:name",
},
},
{
Name: "QuotedMadness",
Query: `"name":"foo:bar:baz/baz/zoo:zonk"`,
Expected: database.GetWorkspacesWithFilterParams{
Name: "foo:bar:baz/baz/zoo:zonk",
},
},
{
Name: "QuotedName",
Query: `"foo/bar"`,
Expected: database.GetWorkspacesWithFilterParams{
Name: "foo/bar",
},
},
{
Name: "QuotedOwner/Name",
Query: `"foo"/"bar"`,
Expected: database.GetWorkspacesWithFilterParams{
Name: "bar",
OwnerUsername: "foo",
},
},
// Failures
{
Name: "ExtraSlashes",
Query: `foo/bar/baz`,
ExpectedErrorContains: "can only contain 1 '/'",
},
{
Name: "ExtraColon",
Query: `owner:name:extra`,
ExpectedErrorContains: "can only contain 1 ':'",
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
values, errs := workspaceSearchQuery(c.Query)
if c.ExpectedErrorContains != "" {
require.True(t, len(errs) > 0, "expect some errors")
var s strings.Builder
for _, err := range errs {
_, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail))
}
require.Contains(t, s.String(), c.ExpectedErrorContains)
} else {
require.Len(t, errs, 0, "expected no error")
require.Equal(t, c.Expected, values, "expected values")
}
})
}
}

View File

@ -4,18 +4,19 @@ import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/util/ptr"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/autobuild/schedule"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
)
@ -336,8 +337,164 @@ func TestWorkspaceByOwnerAndName(t *testing.T) {
})
}
// TestWorkspaceFilter creates a set of workspaces, users, and organizations
// to run various filters against for testing.
func TestWorkspaceFilter(t *testing.T) {
t.Parallel()
type coderUser struct {
*codersdk.Client
User codersdk.User
Org codersdk.Organization
}
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
first := coderdtest.CreateFirstUser(t, client)
users := make([]coderUser, 0)
for i := 0; i < 10; i++ {
userClient := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleAdmin())
user, err := userClient.User(context.Background(), codersdk.Me)
require.NoError(t, err, "fetch me")
org, err := userClient.CreateOrganization(context.Background(), codersdk.CreateOrganizationRequest{
Name: user.Username + "-org",
})
require.NoError(t, err, "create org")
users = append(users, coderUser{
Client: userClient,
User: user,
Org: org,
})
}
type madeWorkspace struct {
Owner codersdk.User
Workspace codersdk.Workspace
Template codersdk.Template
}
availTemplates := make([]codersdk.Template, 0)
allWorkspaces := make([]madeWorkspace, 0)
// Create some random workspaces
for _, user := range users {
version := coderdtest.CreateTemplateVersion(t, client, user.Org.ID, nil)
// Create a template & workspace in the user's org
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.Org.ID, version.ID)
availTemplates = append(availTemplates, template)
workspace := coderdtest.CreateWorkspace(t, user.Client, template.OrganizationID, template.ID)
allWorkspaces = append(allWorkspaces, madeWorkspace{
Workspace: workspace,
Template: template,
Owner: user.User,
})
// Make a workspace with a random template
idx, _ := cryptorand.Intn(len(availTemplates))
randTemplate := availTemplates[idx]
randWorkspace := coderdtest.CreateWorkspace(t, user.Client, randTemplate.OrganizationID, randTemplate.ID)
allWorkspaces = append(allWorkspaces, madeWorkspace{
Workspace: randWorkspace,
Template: randTemplate,
Owner: user.User,
})
}
// Make sure all workspaces are done. Do it after all are made
for i, w := range allWorkspaces {
latest := coderdtest.AwaitWorkspaceBuildJob(t, client, w.Workspace.LatestBuild.ID)
allWorkspaces[i].Workspace.LatestBuild = latest
}
// --- Setup done ---
testCases := []struct {
Name string
Filter codersdk.WorkspaceFilter
// If FilterF is true, we include it in the expected results
FilterF func(f codersdk.WorkspaceFilter, workspace madeWorkspace) bool
}{
{
Name: "All",
Filter: codersdk.WorkspaceFilter{},
FilterF: func(_ codersdk.WorkspaceFilter, _ madeWorkspace) bool {
return true
},
},
{
Name: "Owner",
Filter: codersdk.WorkspaceFilter{
Owner: users[2].User.Username,
},
FilterF: func(f codersdk.WorkspaceFilter, workspace madeWorkspace) bool {
return workspace.Owner.Username == f.Owner
},
},
{
Name: "TemplateName",
Filter: codersdk.WorkspaceFilter{
Template: allWorkspaces[5].Template.Name,
},
FilterF: func(f codersdk.WorkspaceFilter, workspace madeWorkspace) bool {
return workspace.Template.Name == f.Template
},
},
{
Name: "Name",
Filter: codersdk.WorkspaceFilter{
// Use a common letter... one has to have this letter in it
Name: "a",
},
FilterF: func(f codersdk.WorkspaceFilter, workspace madeWorkspace) bool {
return strings.Contains(workspace.Workspace.Name, f.Name)
},
},
{
Name: "Q-Owner/Name",
Filter: codersdk.WorkspaceFilter{
FilterQuery: allWorkspaces[5].Owner.Username + "/" + allWorkspaces[5].Workspace.Name,
},
FilterF: func(_ codersdk.WorkspaceFilter, workspace madeWorkspace) bool {
return workspace.Workspace.ID == allWorkspaces[5].Workspace.ID
},
},
{
Name: "Many filters",
Filter: codersdk.WorkspaceFilter{
Owner: allWorkspaces[3].Owner.Username,
Template: allWorkspaces[3].Template.Name,
Name: allWorkspaces[3].Workspace.Name,
},
FilterF: func(f codersdk.WorkspaceFilter, workspace madeWorkspace) bool {
return workspace.Workspace.ID == allWorkspaces[3].Workspace.ID
},
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
workspaces, err := client.Workspaces(context.Background(), c.Filter)
require.NoError(t, err, "fetch workspaces")
exp := make([]codersdk.Workspace, 0)
for _, made := range allWorkspaces {
if c.FilterF(c.Filter, made) {
exp = append(exp, made.Workspace)
}
}
require.ElementsMatch(t, exp, workspaces, "expected workspaces returned")
})
}
}
// TestWorkspaceFilterManual runs some specific setups with basic checks.
func TestWorkspaceFilterManual(t *testing.T) {
t.Parallel()
t.Run("Name", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
@ -370,6 +527,49 @@ func TestWorkspaceFilter(t *testing.T) {
require.NoError(t, err)
require.Len(t, ws, 0)
})
t.Run("Template", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
template2 := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template2.ID)
// empty
ws, err := client.Workspaces(context.Background(), codersdk.WorkspaceFilter{})
require.NoError(t, err)
require.Len(t, ws, 2)
// single template
ws, err = client.Workspaces(context.Background(), codersdk.WorkspaceFilter{
Template: template.Name,
})
require.NoError(t, err)
require.Len(t, ws, 1)
require.Equal(t, workspace.ID, ws[0].ID)
})
t.Run("FilterQuery", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
template2 := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template2.ID)
// single workspace
ws, err := client.Workspaces(context.Background(), codersdk.WorkspaceFilter{
FilterQuery: fmt.Sprintf("template:%s %s/%s", template.Name, workspace.OwnerName, workspace.Name),
})
require.NoError(t, err)
require.Len(t, ws, 1)
require.Equal(t, workspace.ID, ws[0].ID)
})
}
func TestPostWorkspaceBuild(t *testing.T) {

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
@ -217,26 +218,39 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx
}
type WorkspaceFilter struct {
OrganizationID uuid.UUID `json:"organization_id,omitempty"`
// Owner can be a user_id (uuid), "me", or a username
Owner string `json:"owner,omitempty"`
Name string `json:"name,omitempty"`
// Owner can be "me" or a username
Owner string `json:"owner,omitempty" typescript:"-"`
// Template is a template name
Template string `json:"template,omitempty" typescript:"-"`
// Name will return partial matches
Name string `json:"name,omitempty" typescript:"-"`
// FilterQuery supports a raw filter query string
FilterQuery string `json:"q,omitempty"`
}
// asRequestOption returns a function that can be used in (*Client).Request.
// It modifies the request query parameters.
func (f WorkspaceFilter) asRequestOption() requestOption {
return func(r *http.Request) {
q := r.URL.Query()
if f.OrganizationID != uuid.Nil {
q.Set("organization_id", f.OrganizationID.String())
}
var params []string
// Make sure all user input is quoted to ensure it's parsed as a single
// string.
if f.Owner != "" {
q.Set("owner", f.Owner)
params = append(params, fmt.Sprintf("owner:%q", f.Owner))
}
if f.Name != "" {
q.Set("name", f.Name)
params = append(params, fmt.Sprintf("name:%q", f.Name))
}
if f.Template != "" {
params = append(params, fmt.Sprintf("template:%q", f.Template))
}
if f.FilterQuery != "" {
// If custom stuff is added, just add it on here.
params = append(params, f.FilterQuery)
}
q := r.URL.Query()
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
}
}

View File

@ -118,10 +118,10 @@ describe("api.ts", () => {
it.each<[TypesGen.WorkspaceFilter | undefined, string]>([
[undefined, "/api/v2/workspaces"],
[{ organization_id: "1", owner: "" }, "/api/v2/workspaces?organization_id=1"],
[{ organization_id: "", owner: "1" }, "/api/v2/workspaces?owner=1"],
[{ q: "" }, "/api/v2/workspaces"],
[{ q: "owner:1" }, "/api/v2/workspaces?q=owner%3A1"],
[{ organization_id: "1", owner: "me" }, "/api/v2/workspaces?organization_id=1&owner=me"],
[{ q: "owner:me" }, "/api/v2/workspaces?q=owner%3Ame"],
])(`getWorkspacesURL(%p) returns %p`, (filter, expected) => {
expect(getWorkspacesURL(filter)).toBe(expected)
})

View File

@ -120,14 +120,8 @@ export const getWorkspacesURL = (filter?: TypesGen.WorkspaceFilter): string => {
const basePath = "/api/v2/workspaces"
const searchParams = new URLSearchParams()
if (filter?.organization_id) {
searchParams.append("organization_id", filter.organization_id)
}
if (filter?.owner) {
searchParams.append("owner", filter.owner)
}
if (filter?.name) {
searchParams.append("name", filter.name)
if (filter?.q && filter.q !== "") {
searchParams.append("q", filter.q)
}
const searchString = searchParams.toString()

View File

@ -90,7 +90,7 @@ export interface CreateUserRequest {
readonly organization_id: string
}
// From codersdk/workspaces.go:34:6
// From codersdk/workspaces.go:35:6
export interface CreateWorkspaceBuildRequest {
readonly template_version_id?: string
readonly transition: WorkspaceTransition
@ -223,7 +223,7 @@ export interface ProvisionerJobLog {
readonly output: string
}
// From codersdk/workspaces.go:201:6
// From codersdk/workspaces.go:202:6
export interface PutExtendWorkspaceRequest {
readonly deadline: string
}
@ -311,12 +311,12 @@ export interface UpdateUserProfileRequest {
readonly username: string
}
// From codersdk/workspaces.go:160:6
// From codersdk/workspaces.go:161:6
export interface UpdateWorkspaceAutostartRequest {
readonly schedule?: string
}
// From codersdk/workspaces.go:180:6
// From codersdk/workspaces.go:181:6
export interface UpdateWorkspaceTTLRequest {
readonly ttl_ms?: number
}
@ -371,7 +371,7 @@ export interface UsersRequest extends Pagination {
readonly status?: string
}
// From codersdk/workspaces.go:18:6
// From codersdk/workspaces.go:19:6
export interface Workspace {
readonly id: string
readonly created_at: string
@ -461,19 +461,17 @@ export interface WorkspaceBuild {
readonly deadline: string
}
// From codersdk/workspaces.go:83:6
// From codersdk/workspaces.go:84:6
export interface WorkspaceBuildsRequest extends Pagination {
readonly WorkspaceID: string
}
// From codersdk/workspaces.go:219:6
// From codersdk/workspaces.go:220:6
export interface WorkspaceFilter {
readonly organization_id?: string
readonly owner?: string
readonly name?: string
readonly q?: string
}
// From codersdk/workspaces.go:41:6
// From codersdk/workspaces.go:42:6
export interface WorkspaceOptions {
readonly include_deleted?: boolean
}

View File

@ -104,13 +104,13 @@ describe("util > workspace", () => {
describe("workspaceQueryToFilter", () => {
it.each<[string | undefined, TypesGen.WorkspaceFilter]>([
[undefined, {}],
["", {}],
["asdkfvjn", { name: "asdkfvjn" }],
["owner:me", { owner: "me" }],
["owner:me owner:me2", { owner: "me" }],
["me/dev", { owner: "me", name: "dev" }],
["me/", { owner: "me" }],
[" key:val owner:me ", { owner: "me" }],
["", { q: "" }],
["asdkfvjn", { q: "asdkfvjn" }],
["owner:me", { q: "owner:me" }],
["owner:me owner:me2", { q: "owner:me owner:me2" }],
["me/dev", { q: "me/dev" }],
["me/", { q: "me/" }],
[" key:val owner:me ", { q: "key:val owner:me" }],
])(`query=%p, filter=%p`, (query, filter) => {
expect(workspaceQueryToFilter(query)).toEqual(filter)
})

View File

@ -263,42 +263,9 @@ export const defaultWorkspaceExtension = (__startDate?: dayjs.Dayjs): TypesGen.P
}
export const workspaceQueryToFilter = (query?: string): TypesGen.WorkspaceFilter => {
const defaultFilter: TypesGen.WorkspaceFilter = {}
const preparedQuery = query?.trim().replace(/ +/g, " ")
if (!preparedQuery) {
return defaultFilter
} else {
const parts = preparedQuery.split(" ")
for (const part of parts) {
if (part.includes(":")) {
const [key, val] = part.split(":")
if (key && val) {
if (key === "owner") {
return {
owner: val,
}
}
// skip invalid key pairs
continue
}
}
if (part.includes("/")) {
const [username, name] = part.split("/")
return {
owner: username,
name: name === "" ? undefined : name,
}
}
return {
name: part,
}
}
return defaultFilter
return {
q: preparedQuery,
}
}