mirror of
https://github.com/coder/coder.git
synced 2026-10-16 06:37:58 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user