feat: add include_related option to client.Workspace in codersdk (#29540)

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)).
Stacked on #29539, which wired `include_related` into `GET
/api/v2/workspaces/{workspace}`.

Adds `include_related` support to the `codersdk` client, expressed with
the typed selection tree so only supported selections are representable:

- Moves the `wsrelated` package from `coderd/` to `codersdk/` so both
the server and the client can share `wsrelated.Config`. `coderd` already
depends on `codersdk`, so this only changes import paths on the server
side.
- Adds `Config.QueryParam`, which encodes a selection into the
`include_related` value: the dotted path of each selected node that has
no selected descendants. Selecting a node implies its ancestors, so
`Parse` round-trips the result back into an equal `Config` (covered by a
round-trip test).
- `WorkspaceOptions` gains `IncludeRelated *wsrelated.Config`. A nil
value omits the parameter (server returns everything, unchanged); a
non-nil value is encoded via `QueryParam`, and a zero `Config` loads
only the workspace.
- `Client.Workspace` now accepts optional `WorkspaceOptions`, backward
compatible with existing `client.Workspace(ctx, id)` calls.

### Testing

- `codersdk` internal test asserts `WorkspaceOptions.asRequestOption`
sets `include_related` from the selection, following the existing
`WorkspaceFilter` test pattern rather than spinning up a server.
- `wsrelated` tests cover `QueryParam` output and a `Parse`/`QueryParam`
round trip.

`make gen` regenerated the `WorkspaceOptions` TypeScript type. Because
the wire representation of `include_related` is a comma-separated
string, `scripts/apitypings` maps `wsrelated.Config` to `string` in
TypeScript, so `include_related?: string` rather than a nested object
the frontend cannot serialize. `api.getWorkspace` already forwards
`WorkspaceOptions` as query params, and a frontend test in `api.test.ts`
confirms `include_related` is sent through.

---

*Generated by Coder Agents on behalf of @spikecurtis.*
This commit is contained in:
Spike Curtis
2026-09-21 11:53:11 +02:00
committed by GitHub
parent 29ae50d9c9
commit 9a29aab2ee
14 changed files with 262 additions and 7 deletions
+1 -1
View File
@@ -35,8 +35,8 @@ import (
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/coderd/wspubsub"
"github.com/coder/coder/v2/coderd/wsrelated"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsrelated"
)
// @Summary Get workspace build
+1
View File
@@ -14,6 +14,7 @@ import (
"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/codersdk/wsrelated"
"github.com/coder/coder/v2/tailnet"
)
+1 -1
View File
@@ -43,10 +43,10 @@ import (
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/coderd/wspubsub"
"github.com/coder/coder/v2/coderd/wsrelated"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/coder/v2/codersdk/wsrelated"
"github.com/coder/websocket"
)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/wsrelated"
"github.com/coder/coder/v2/codersdk/wsrelated"
"github.com/coder/coder/v2/testutil"
)
+14 -2
View File
@@ -16,6 +16,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/coder/v2/codersdk/wsrelated"
"github.com/coder/websocket"
)
@@ -167,6 +168,10 @@ type CreateWorkspaceBuildOnSuccessRequest struct {
type WorkspaceOptions struct {
IncludeDeleted bool `json:"include_deleted,omitempty"`
// IncludeRelated selects which related data to load alongside the workspace.
// A nil value loads everything; a non-nil value is encoded into the
// include_related query parameter and loads only the selected data.
IncludeRelated *wsrelated.Config `json:"include_related,omitempty"`
}
// asRequestOption returns a function that can be used in (*Client).Request.
@@ -177,13 +182,20 @@ func (o WorkspaceOptions) asRequestOption() RequestOption {
if o.IncludeDeleted {
q.Set("include_deleted", "true")
}
if o.IncludeRelated != nil {
q.Set("include_related", o.IncludeRelated.QueryParam())
}
r.URL.RawQuery = q.Encode()
}
}
// Workspace returns a single workspace.
func (c *Client) Workspace(ctx context.Context, id uuid.UUID) (Workspace, error) {
return c.getWorkspace(ctx, id)
func (c *Client) Workspace(ctx context.Context, id uuid.UUID, opts ...WorkspaceOptions) (Workspace, error) {
reqOpts := make([]RequestOption, 0, len(opts))
for _, o := range opts {
reqOpts = append(reqOpts, o.asRequestOption())
}
return c.getWorkspace(ctx, id, reqOpts...)
}
// DeletedWorkspace returns a single workspace that was deleted.
+16
View File
@@ -6,6 +6,8 @@ import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk/wsrelated"
)
func TestWorkspaceFilterAsRequestOption(t *testing.T) {
@@ -86,3 +88,17 @@ func TestWorkspaceFilterAsRequestOption(t *testing.T) {
})
}
}
// TestWorkspaceOptionsAsRequestOption verifies that WorkspaceOptions sets the
// include_related query parameter from its IncludeRelated selection.
func TestWorkspaceOptionsAsRequestOption(t *testing.T) {
t.Parallel()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil)
require.NoError(t, err)
config := wsrelated.Config{Template: true}
WorkspaceOptions{IncludeRelated: &config}.asRequestOption()(req)
require.Equal(t, config.QueryParam(), req.URL.Query().Get("include_related"))
}
@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/wsrelated"
"github.com/coder/coder/v2/codersdk/wsrelated"
)
func TestParse(t *testing.T) {
+104
View File
@@ -0,0 +1,104 @@
package wsrelated_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk/wsrelated"
)
func TestConfigQueryParam(t *testing.T) {
t.Parallel()
cases := []struct {
name string
config wsrelated.Config
want string
}{
{
name: "Empty",
config: wsrelated.Config{},
want: "",
},
{
name: "Template",
config: wsrelated.Config{Template: true},
want: "template",
},
{
name: "LatestBuildOnly",
config: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{}},
want: "latest_build",
},
{
name: "TemplateVersion",
config: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{TemplateVersion: true}},
want: "latest_build.template_version",
},
{
name: "JobWithoutQueuePosition",
config: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{Job: &wsrelated.Job{}}},
want: "latest_build.job",
},
{
name: "JobQueuePosition",
config: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{Job: &wsrelated.Job{QueuePosition: true}}},
want: "latest_build.job.queue_position",
},
{
name: "ResourcesOnly",
config: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{Resources: &wsrelated.Resources{}}},
want: "latest_build.resources",
},
{
name: "AgentsOnly",
config: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{}}}},
want: "latest_build.resources.agents",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, tc.config.QueryParam())
})
}
}
// TestConfigQueryParamRoundTrip asserts that encoding a Config and parsing the
// result yields an equal Config.
func TestConfigQueryParamRoundTrip(t *testing.T) {
t.Parallel()
cases := []struct {
name string
config wsrelated.Config
}{
{name: "Empty", config: wsrelated.Config{}},
{name: "All", config: wsrelated.All()},
{name: "Template", config: wsrelated.Config{Template: true}},
{
name: "Mixed",
config: wsrelated.Config{
Template: true,
LatestBuild: &wsrelated.LatestBuild{
Job: &wsrelated.Job{},
Resources: &wsrelated.Resources{
Metadata: true,
Agents: &wsrelated.Agents{Scripts: true},
},
},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := wsrelated.Parse(tc.config.QueryParam())
require.NoError(t, err)
require.Equal(t, tc.config, got)
})
}
}
@@ -12,6 +12,8 @@
// identifiers of its children.
package wsrelated
import "strings"
// Config is the root of the selection tree. A zero value (nil branches) selects
// nothing but the workspace itself. All selects everything.
//
@@ -29,6 +31,22 @@ func (c *Config) ensureLatestBuild() *LatestBuild {
return c.LatestBuild
}
// QueryParam encodes the selection as an include_related query parameter value:
// a comma-separated list of the dotted path of each selected node that has no
// selected descendants. Selecting a node implicitly selects its ancestors, so
// only these frontier nodes are emitted, and Parse round-trips the result back
// into an equal Config.
func (c Config) QueryParam() string {
var paths []string
if c.Template {
paths = append(paths, "template")
}
if c.LatestBuild != nil {
paths = c.LatestBuild.appendPaths(paths, "latest_build")
}
return strings.Join(paths, ",")
}
type LatestBuild struct {
Job *Job // latest_build.job
Resources *Resources // latest_build.resources
@@ -60,10 +78,36 @@ func (c *LatestBuild) AppStatuses() bool {
c.Resources.Agents.Apps.Statuses
}
// appendPaths appends the frontier paths under base, or base itself when this
// node has no selected descendants.
func (c *LatestBuild) appendPaths(paths []string, base string) []string {
before := len(paths)
if c.Job != nil {
paths = c.Job.appendPaths(paths, base+".job")
}
if c.Resources != nil {
paths = c.Resources.appendPaths(paths, base+".resources")
}
if c.TemplateVersion {
paths = append(paths, base+".template_version")
}
if len(paths) == before {
paths = append(paths, base)
}
return paths
}
type Job struct {
QueuePosition bool // latest_build.job.queue_position
}
func (c *Job) appendPaths(paths []string, base string) []string {
if c.QueuePosition {
return append(paths, base+".queue_position")
}
return append(paths, base)
}
type Resources struct {
Metadata bool // latest_build.resources.metadata
Agents *Agents // latest_build.resources.agents
@@ -76,6 +120,20 @@ func (c *Resources) ensureAgents() *Agents {
return c.Agents
}
func (c *Resources) appendPaths(paths []string, base string) []string {
before := len(paths)
if c.Metadata {
paths = append(paths, base+".metadata")
}
if c.Agents != nil {
paths = c.Agents.appendPaths(paths, base+".agents")
}
if len(paths) == before {
paths = append(paths, base)
}
return paths
}
type Agents struct {
Apps *Apps // latest_build.resources.agents.apps
Scripts bool // latest_build.resources.agents.scripts
@@ -89,10 +147,34 @@ func (c *Agents) ensureApps() *Apps {
return c.Apps
}
func (c *Agents) appendPaths(paths []string, base string) []string {
before := len(paths)
if c.Apps != nil {
paths = c.Apps.appendPaths(paths, base+".apps")
}
if c.Scripts {
paths = append(paths, base+".scripts")
}
if c.LogSources {
paths = append(paths, base+".log_sources")
}
if len(paths) == before {
paths = append(paths, base)
}
return paths
}
type Apps struct {
Statuses bool // latest_build.resources.agents.apps.statuses
}
func (c *Apps) appendPaths(paths []string, base string) []string {
if c.Statuses {
return append(paths, base+".statuses")
}
return append(paths, base)
}
// All returns a selection that loads every related object. It reproduces the
// behavior of callers that have not been narrowed to a specific subset.
func All() Config {
@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/wsrelated"
"github.com/coder/coder/v2/codersdk/wsrelated"
)
// TestAll verifies that All selects every node in the related-data hierarchy.
+4
View File
@@ -136,6 +136,10 @@ func TypeMappings(gen *guts.GoParser) error {
"github.com/coder/serpent.URL": "string",
"github.com/coder/serpent.HostPort": "string",
"encoding/json.RawMessage": "map[string]string",
// wsrelated.Config is a client-side selection tree that the Go client
// marshals to the include_related query string via Config.QueryParam.
// Its wire representation is that comma-separated string.
"github.com/coder/coder/v2/codersdk/wsrelated.Config": "string",
// decimal.Decimal preserves exact pricing precision (e.g. $3.50 per
// million tokens) and serializes as a JSON string to avoid
// floating-point loss in transit.
+30
View File
@@ -768,4 +768,34 @@ describe("api.ts", () => {
);
});
});
describe("getWorkspace", () => {
it("passes include_related through as a query param", async () => {
const getSpy = vi
.spyOn(axiosInstance, "get")
.mockResolvedValueOnce({ data: MockWorkspace });
const params: TypesGen.WorkspaceOptions = { include_related: "template" };
const result = await API.getWorkspace(MockWorkspace.id, params);
expect(getSpy).toHaveBeenCalledWith(
`/api/v2/workspaces/${MockWorkspace.id}`,
{ params },
);
expect(result).toBe(MockWorkspace);
});
it("omits params when none are given", async () => {
const getSpy = vi
.spyOn(axiosInstance, "get")
.mockResolvedValueOnce({ data: MockWorkspace });
await API.getWorkspace(MockWorkspace.id);
expect(getSpy).toHaveBeenCalledWith(
`/api/v2/workspaces/${MockWorkspace.id}`,
{ params: undefined },
);
});
});
});
+6
View File
@@ -11740,6 +11740,12 @@ export interface WorkspaceHealth {
// From codersdk/workspaces.go
export interface WorkspaceOptions {
readonly include_deleted?: boolean;
/**
* IncludeRelated selects which related data to load alongside the workspace.
* A nil value loads everything; a non-nil value is encoded into the
* include_related query parameter and loads only the selected data.
*/
readonly include_related?: string;
}
// From codersdk/workspaceproxy.go