mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: accept include_related query param on get workspace by ID (#29539)
Part of the lite `codersdk.Workspace` work ([GRU-82](https://linear.app/codercom/issue/GRU-82), [RFC](https://app.notion.com/p/coderhq/Lite-Workspace-API-Requests-3aad579be5928060b5e7cc65c539717f)). Follows #28639, which added the `wsrelated` package and parser. Wires the `include_related` query parameter into `GET /api/v2/workspaces/{workspace}`, the first endpoint to accept it. When present, the value is parsed with `wsrelated.Parse` and the resulting selection is threaded into `workspaceData`, so only the requested related data is queried. Omitting the parameter is backward compatible and returns everything (`wsrelated.All`). An invalid value returns `400`. Because related data can now be omitted, the handler no longer assumes the build and template are present: it uses zero values when they are absent, and only enforces the template read-authorization check (`403`) when the template was actually requested. ### Partial-build conversion `convertWorkspaceBuilds` now tolerates a build whose provisioner job or template version was deliberately omitted from the selection, so selections such as `latest_build` (build only) or `latest_build.resources.*` (resources without the job) convert correctly instead of erroring. The omitted fields are left zero (empty `Job`, empty `TemplateVersionName`). Resources are keyed by the build's `JobID` rather than the fetched job row, so they load independently of whether the job is selected. The integrity error is preserved for full-data callers: when a job or template version is requested but missing, conversion still fails. The selection config (`wsrelated.LatestBuild`) is threaded into `convertWorkspaceBuilds` to distinguish "not requested" from "missing". Only this endpoint is wired; the other endpoints that produce `codersdk.Workspace` objects, and the codersdk client functions, come in later PRs. --- *Generated by Coder Agents on behalf of @spikecurtis.*
This commit is contained in:
Generated
+6
@@ -15793,6 +15793,12 @@ const docTemplate = `{
|
||||
"description": "Return data instead of HTTP 404 if the workspace is deleted",
|
||||
"name": "include_deleted",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of related data to include (e.g. ` + "`" + `template,latest_build.resources.agents.*` + "`" + `). Omit to include everything.",
|
||||
"name": "include_related",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
Generated
+6
@@ -14041,6 +14041,12 @@
|
||||
"description": "Return data instead of HTTP 404 if the workspace is deleted",
|
||||
"name": "include_deleted",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of related data to include (e.g. `template,latest_build.resources.agents.*`). Omit to include everything.",
|
||||
"name": "include_related",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -201,6 +201,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
apiBuilds, err := api.convertWorkspaceBuilds(
|
||||
wsrelated.AllLatestBuild(),
|
||||
workspaceBuilds,
|
||||
[]database.Workspace{workspace},
|
||||
data.jobs,
|
||||
@@ -1391,6 +1392,7 @@ func newWorkspaceBuildIndex(
|
||||
}
|
||||
|
||||
func (api *API) convertWorkspaceBuilds(
|
||||
cfg wsrelated.LatestBuild,
|
||||
workspaceBuilds []database.WorkspaceBuild,
|
||||
workspaces []database.Workspace,
|
||||
jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow,
|
||||
@@ -1432,7 +1434,7 @@ func (api *API) convertWorkspaceBuilds(
|
||||
apiBuilds := []codersdk.WorkspaceBuild{}
|
||||
for _, build := range workspaceBuilds {
|
||||
job, exists := jobByID[build.JobID]
|
||||
if !exists {
|
||||
if !exists && cfg.Job != nil {
|
||||
return nil, xerrors.New("build job not found")
|
||||
}
|
||||
workspace, exists := workspaceByID[build.WorkspaceID]
|
||||
@@ -1440,7 +1442,7 @@ func (api *API) convertWorkspaceBuilds(
|
||||
return nil, xerrors.New("workspace not found")
|
||||
}
|
||||
templateVersion, exists := templateVersionByID[build.TemplateVersionID]
|
||||
if !exists {
|
||||
if !exists && cfg.TemplateVersion {
|
||||
return nil, xerrors.New("template version not found")
|
||||
}
|
||||
|
||||
@@ -1468,9 +1470,9 @@ func (api *API) convertWorkspaceBuild(
|
||||
index *workspaceBuildIndex,
|
||||
templateVersion database.TemplateVersion,
|
||||
) (codersdk.WorkspaceBuild, error) {
|
||||
matchedProvisioners := db2sdk.MatchedProvisioners(index.daemonsByJobID[job.ProvisionerJob.ID], job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval)
|
||||
matchedProvisioners := db2sdk.MatchedProvisioners(index.daemonsByJobID[build.JobID], job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval)
|
||||
|
||||
resources := index.resourcesByJobID[job.ProvisionerJob.ID]
|
||||
resources := index.resourcesByJobID[build.JobID]
|
||||
apiResources := make([]codersdk.WorkspaceResource, 0)
|
||||
resourceAgentsMinOrder := map[uuid.UUID]int32{} // map[resource.ID]minOrder
|
||||
for _, resource := range resources {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/wsrelated"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
)
|
||||
@@ -148,6 +149,7 @@ func newConvertAPI() *API {
|
||||
|
||||
func (f buildFixture) convert(api *API) ([]codersdk.WorkspaceBuild, error) {
|
||||
return api.convertWorkspaceBuilds(
|
||||
wsrelated.AllLatestBuild(),
|
||||
f.builds,
|
||||
f.workspaces,
|
||||
f.jobs,
|
||||
|
||||
+37
-5
@@ -67,6 +67,7 @@ var (
|
||||
// @Tags Workspaces
|
||||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||||
// @Param include_deleted query bool false "Return data instead of HTTP 404 if the workspace is deleted"
|
||||
// @Param include_related query string false "Comma-separated list of related data to include (e.g. `template,latest_build.resources.agents.*`). Omit to include everything."
|
||||
// @Success 200 {object} codersdk.Workspace
|
||||
// @Router /api/v2/workspaces/{workspace} [get]
|
||||
func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -98,7 +99,22 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := api.workspaceData(ctx, []database.Workspace{workspace}, wsrelated.All())
|
||||
// Omitting include_related is backward compatible: everything is returned.
|
||||
// When present, only the requested related data is loaded.
|
||||
related := wsrelated.All()
|
||||
if r.URL.Query().Has("include_related") {
|
||||
var err error
|
||||
related, err = wsrelated.Parse(r.URL.Query().Get("include_related"))
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid include_related query param.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, err := api.workspaceData(ctx, []database.Workspace{workspace}, related)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace resources.",
|
||||
@@ -107,7 +123,10 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(data.templates) == 0 {
|
||||
// workspaceData omits templates the requester cannot read. If the template
|
||||
// was requested but is absent, the requester is not authorized to read it.
|
||||
// When the template was not requested there is nothing to authorize here.
|
||||
if related.Template && len(data.templates) == 0 {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
@@ -117,13 +136,23 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
|
||||
appStatus = data.appStatuses[0]
|
||||
}
|
||||
|
||||
// Related data may be omitted, in which case these carry zero values.
|
||||
var build codersdk.WorkspaceBuild
|
||||
if len(data.builds) > 0 {
|
||||
build = data.builds[0]
|
||||
}
|
||||
var template database.Template
|
||||
if len(data.templates) > 0 {
|
||||
template = data.templates[0]
|
||||
}
|
||||
|
||||
w, err := convertWorkspace(
|
||||
ctx,
|
||||
api.Logger,
|
||||
apiKey.UserID,
|
||||
workspace,
|
||||
data.builds[0],
|
||||
data.templates[0],
|
||||
build,
|
||||
template,
|
||||
api.AllowWorkspaceRenames,
|
||||
appStatus,
|
||||
)
|
||||
@@ -2742,14 +2771,17 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa
|
||||
}
|
||||
|
||||
var data workspaceBuildsData
|
||||
var latestBuildCfg wsrelated.LatestBuild
|
||||
if cfg.LatestBuild != nil {
|
||||
data, err = api.workspaceBuildsData(ctx, builds, *cfg.LatestBuild)
|
||||
latestBuildCfg = *cfg.LatestBuild
|
||||
data, err = api.workspaceBuildsData(ctx, builds, latestBuildCfg)
|
||||
if err != nil {
|
||||
return workspaceData{}, xerrors.Errorf("get workspace builds data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
apiBuilds, err := api.convertWorkspaceBuilds(
|
||||
latestBuildCfg,
|
||||
builds,
|
||||
workspaces,
|
||||
data.jobs,
|
||||
|
||||
@@ -6448,3 +6448,123 @@ func mustSchedule(t *testing.T, s string) *cron.Schedule {
|
||||
require.NoError(t, err)
|
||||
return sched
|
||||
}
|
||||
|
||||
// TestWorkspaceIncludeRelated verifies the include_related query parameter on
|
||||
// GET /workspaces/{workspace}: omitting it returns everything, while a path
|
||||
// list narrows the related data loaded, and an invalid value is rejected.
|
||||
func TestWorkspaceIncludeRelated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.PlanComplete,
|
||||
ProvisionApply: []*proto.Response{{
|
||||
Type: &proto.Response_Apply{
|
||||
Apply: &proto.ApplyComplete{},
|
||||
},
|
||||
}},
|
||||
ProvisionGraph: []*proto.Response{{
|
||||
Type: &proto.Response_Graph{
|
||||
Graph: &proto.GraphComplete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{Token: uuid.NewString()},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
get := func(ctx context.Context, t *testing.T, includeRelated string) (codersdk.Workspace, int) {
|
||||
t.Helper()
|
||||
resp, err := client.Request(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/v2/workspaces/%s", workspace.ID), nil,
|
||||
codersdk.WithQueryParam("include_related", includeRelated))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return codersdk.Workspace{}, resp.StatusCode
|
||||
}
|
||||
var w codersdk.Workspace
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&w))
|
||||
return w, resp.StatusCode
|
||||
}
|
||||
|
||||
t.Run("Full", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
// Without the parameter, the build and template are both returned.
|
||||
full, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, workspace.LatestBuild.ID, full.LatestBuild.ID)
|
||||
require.Equal(t, version.ID, full.TemplateActiveVersionID)
|
||||
})
|
||||
|
||||
t.Run("TemplateOnly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
w, code := get(ctx, t, "template")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.Equal(t, workspace.ID, w.ID)
|
||||
// The build was not requested, so it is omitted (zero value).
|
||||
require.Equal(t, uuid.Nil, w.LatestBuild.ID)
|
||||
// The template was requested, so template-derived fields are populated.
|
||||
require.Equal(t, version.ID, w.TemplateActiveVersionID)
|
||||
})
|
||||
|
||||
t.Run("LatestBuildOnly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
w, code := get(ctx, t, "latest_build.*")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.Equal(t, workspace.ID, w.ID)
|
||||
// The build was requested, so it is populated.
|
||||
require.Equal(t, workspace.LatestBuild.ID, w.LatestBuild.ID)
|
||||
// The template was not requested, so template-derived fields are zero.
|
||||
require.Equal(t, uuid.Nil, w.TemplateActiveVersionID)
|
||||
})
|
||||
|
||||
t.Run("Invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
_, code := get(ctx, t, "bogus")
|
||||
require.Equal(t, http.StatusBadRequest, code)
|
||||
})
|
||||
|
||||
t.Run("LatestBuildWithoutJob", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
w, code := get(ctx, t, "latest_build")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.Equal(t, workspace.LatestBuild.ID, w.LatestBuild.ID)
|
||||
// The job was not requested, so it is zero.
|
||||
require.Equal(t, uuid.Nil, w.LatestBuild.Job.ID)
|
||||
// The template version was not requested, so its name is empty.
|
||||
require.Empty(t, w.LatestBuild.TemplateVersionName)
|
||||
// Resources were not requested, so none are returned.
|
||||
require.Empty(t, w.LatestBuild.Resources)
|
||||
})
|
||||
|
||||
t.Run("LatestBuildResourcesWithoutJob", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
// Resources hang off the build, not the job, so they load even when the
|
||||
// job is omitted from the selection.
|
||||
w, code := get(ctx, t, "latest_build.resources.*")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.Equal(t, workspace.LatestBuild.ID, w.LatestBuild.ID)
|
||||
require.Equal(t, uuid.Nil, w.LatestBuild.Job.ID)
|
||||
require.NotEmpty(t, w.LatestBuild.Resources)
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+5
-4
@@ -1374,10 +1374,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|-------------------|-------|--------------|----------|-------------------------------------------------------------|
|
||||
| `workspace` | path | string(uuid) | true | Workspace ID |
|
||||
| `include_deleted` | query | boolean | false | Return data instead of HTTP 404 if the workspace is deleted |
|
||||
| Name | In | Type | Required | Description |
|
||||
|-------------------|-------|--------------|----------|--------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `workspace` | path | string(uuid) | true | Workspace ID |
|
||||
| `include_deleted` | query | boolean | false | Return data instead of HTTP 404 if the workspace is deleted |
|
||||
| `include_related` | query | string | false | Comma-separated list of related data to include (e.g. `template,latest_build.resources.agents.*`). Omit to include everything. |
|
||||
|
||||
### Example responses
|
||||
|
||||
|
||||
Reference in New Issue
Block a user