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:
Spike Curtis
2026-09-21 10:50:01 +02:00
committed by GitHub
parent 839be9dc7b
commit 29ae50d9c9
7 changed files with 182 additions and 13 deletions
+6
View File
@@ -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": {
+6
View File
@@ -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": {
+6 -4
View File
@@ -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 {
+2
View File
@@ -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
View File
@@ -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,
+120
View File
@@ -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)
})
}
+5 -4
View File
@@ -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