diff --git a/.editorconfig b/.editorconfig index 53ce7662e..f4df635cb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -804,10 +804,10 @@ dotnet_diagnostic.CA1034.severity = none # test project mirrors the layout with equally flat test namespaces. Winner: the # flat-namespace contract; IDE0130 remains error for every other folder, tools included. # A new family folder extends these globs deliberately - curation changes are API reviews. -[src/OpenCode.Sdk/{Sessions,Health,Pagination}/**.cs] +[src/OpenCode.Sdk/{Sessions,Health,Pagination,Shells}/**.cs] dotnet_diagnostic.IDE0130.severity = none -[tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination}/**.cs] +[tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination,Shells}/**.cs] dotnet_diagnostic.IDE0130.severity = none # DI extension methods live in the Microsoft.Extensions.DependencyInjection namespace so diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0ff3ea9ff..a9cab7c72 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -68,11 +68,24 @@ by the SDK test project; the Extensions tests are registration-topology only). Session 24). Q92's simplicity-first construction (ADR-0010) is landed — Arc 1 complete: the transport constructor is internal friend-assembly test surface, Extensions registers a factory-less singleton client family with a roster contract test, and the Q91 guard -machinery is deleted — closing #31 by construction. #28, #32, and #33 are sealed with -their execution homes recorded (Session 24), and the location + merged-Request design is +machinery is deleted — closing #31 by construction. #32 and #33 stay sealed with their +execution homes recorded (Session 24), and the location + merged-Request design is sealed (Session 25, Q93/Q94: the binder placement map and the dual-channel location -rendering) — the body+query admission stop condition is lifted. Remaining M2 breadth -batches interleave next against the sealed design. +rendering). + +**The M2 second breadth batch is complete** (plan: +`superpowers/plans/2026-08-15-m2-second-breadth-batch.md`) — the design-prover batch: +`session.remove`/`session.rename` and the `Shells` family +(`list`/`create`/`get`/`remove`/`timeout`) ride the 204 no-content and +`{location, data}` envelope machinery, the deepObject `LocationSelector` query channel, +the merged body+query request models, the ambient options location riding the +middleware headers, and the first PATCH/DELETE verbs. #28 landed as the +`mutuallyExclusiveQueries` curation section with the route-boundary refusal on +`message.list`. Demonstrated live 2026-08-16 against `opencode2 serve` +v0.0.0-next-17403 (shell create → get → timeout → remove and session rename → remove, +204s typed, ambient location echoed). `v2.shell.output` deferred to a later batch — +its inline data object and integer cursor query params each need a mechanism of their +own. 13 operations selected, 107 pending. ## Milestones @@ -112,7 +125,13 @@ is revisited at each milestone boundary. background service's discovery file are candidate mechanisms — decided in the M4 plan; platform detail: research doc 15.) 5. **M5 — Full surface.** Complete generation profile over the protocol surface, - exclusion fingerprints (ADR-0008), packaging unblocked. + exclusion fingerprints (ADR-0008), packaging unblocked. The **ambient location + header decision (#37)** lands here and is decision-first: packaging unblocking + freezes the public surface, so this is the last free moment to drop + `OpenCodeClientOptions.Location` (option B) or fold it into the query channel + (option C). The batch admitting `project.list` / `permission.saved.*` / + `session.form.*` answers whether the header is those operations' only addressing + channel. 6. **M6 — Operational closure.** `refresh-spec`, retry/telemetry/hooks, quarantine lane, nightly canary (the performance suite joins it); durable decisions distill into ADRs and the `superpowers/` documents retire. Any diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index 255a41926..8ee9d248a 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -2023,3 +2023,70 @@ per-request `Location` wins over the ambient default because the server resolves before header — the SDK performs no client-side merge. `v2.session.list`'s flat location fields bind as ordinary query properties with no special case; the fail-closed walls stay. + +# Session 26 — 2026-08-16: Q95 location-channel tour, measured + +The maintainer challenged the ambient location header mid-batch ("deprecate olmuştu, +şemada da yok artık"). The claim was investigated against upstream rather than argued, +the batch's encoding defect was found and fixed as a result, and the ambient channel's +future became an M5 decision (#37). + +## Q95: Is the `x-opencode-directory` header deprecated, and how does location actually resolve? + +**How researched:** three parallel primary-source sweeps over `external/opencode` at the +spec pin (`a6a712a`) — the generated JS SDK, the server/core internals, and every +consumer — plus live probes against `opencode2 serve` v0.0.0-next-17403 started in a +directory distinct from every probe target. + +**Found — not deprecated, and never removed from the schema:** + +- The submodule sits exactly on the spec pin, and upstream `v2` is 164 commits ahead of + it; at that live HEAD `packages/server/src/location.ts:31,34` still reads both headers, + still `decodeURIComponent`s the directory one, and **no commit has touched the file + since the pin**. No deprecation marker exists anywhere in `packages/server` or + `packages/core`. +- The header was never expressible in the document, so it was never scrubbed from it. The + spec is `OpenApi.fromApi(ClientApi)` (`packages/protocol/script/generate-openapi.ts`), a + 16-line script with no filtering, and that pipeline derives parameters only from declared + endpoint schemas. Headers *are* expressible there, but no protocol endpoint declares + `headers:` at all; the location headers are read imperatively inside middleware, which + contributes nothing to the parameter surface. **Control case: `Authorization` is invisible + the same way** — the server genuinely requires Basic auth while the generated document + carries empty `securitySchemes`. +- Git history rules out replacement: `location.ts` was created in `56a37c3640` with the + header *and* query branches already present, so the query channel was additive from day + one. `specs/v2/schema-changelog.md` frames it as preserving header routing for + compatibility. + +**Found — the channels are not interchangeable:** + +- `LocationMiddleware` is attached **per group** (`packages/protocol/src/api.ts:150-180`) + and applies to every endpoint in that group regardless of whether it declares the query + parameter. So the header reaches operations the query channel cannot (`project.list`, + `permission.saved.*`), and is inert on groups without it (health, server, message, + event, debug, migration) and on session-scoped endpoints, which resolve location from + the session DB row and ignore both channels. +- **Precedence is per field, not per location object** (measured): query beats header beats + `process.cwd()`, but `directory` and `workspaceID` resolve independently and the code uses + `||`, so an empty query value falls through to the header. Consequently an ambient + `{directory, workspaceID}` plus a per-request query carrying only `directory` resolves to + the per-request directory **and the ambient workspace** — a per-request location does not + wholesale-replace the ambient one. +- No first-party client *library* offers an ambient location: `ClientOptions` is + `{baseUrl, fetch, headers}` and every UI (app/web, TUI, CLI) threads location per call + through the query channel. Only `packages/drive`, a single-directory test harness, pins an + ambient header — the same shape this SDK ships. + +**Found — a defect in this batch:** the ambient directory header rode the wire raw. Measured +against the live server, a directory literally named `loc%20test` returns **HTTP 500 with an +empty body** when sent raw (the server decodes `%20` into a space and resolves a directory +that does not exist) and **200 with the correct location** when percent-encoded. The +workspace header is *not* decoded server-side, so the escaping is asymmetric by contract. + +**Decisions (maintainer, sealed):** the ambient channel stays for now (option A) with its +documentation corrected to state per-field precedence and the coverage boundary; the +directory header is percent-encoded and the workspace header stays verbatim, mirroring the +server. Whether to drop the ambient channel entirely (option B — "convenience does not +justify supporting a channel outside the public contract"; the MCP server will set location +explicitly anyway) or fold it into the query channel (option C) is anchored at **M5 as +issue #37**, decision-first because packaging unblocking freezes the public surface. diff --git a/docs/research/15-opencode-v2-platform.md b/docs/research/15-opencode-v2-platform.md index 78facee45..d957de44f 100644 --- a/docs/research/15-opencode-v2-platform.md +++ b/docs/research/15-opencode-v2-platform.md @@ -197,13 +197,32 @@ pinned server implementation. Client-side password resolution lives in the *cons reads no environment. Service mode generates `randomBytes(32).toString("base64url")` when unconfigured; foreground `serve` prints the password when it generated one. -**Location addressing is dual-channel (re-derived at the pin, 2026-08-14)**: the server's -location middleware (`packages/server/src/location.ts`) resolves per request as -`location[workspace]` query **or** `x-opencode-workspace` header, and `location[directory]` -query **or** `x-opencode-directory` header **or** the server's cwd — precedence query > -header. The per-operation `location` deepObject query parameter (61 operations, §5a) is the -spec-visible channel the first-party generated client uses exclusively; the headers are the -spec-invisible ambient channel other consumers (drive driver, CLI) ride. The SDK-side +**Location addressing is dual-channel (re-derived at the pin, 2026-08-14; measured +2026-08-16 — research log Q95)**: the server's location middleware +(`packages/server/src/location.ts`) resolves per request as `location[workspace]` query +**or** `x-opencode-workspace` header, and `location[directory]` query **or** +`x-opencode-directory` header **or** the server's cwd — precedence query > header. Three +mechanics matter and are easy to get wrong: + +- **Precedence is per field, not per location object**, and the code uses `||`, so an empty + query value falls through to the header. An ambient `{directory, workspaceID}` plus a + per-request query carrying only `directory` resolves to the per-request directory and the + **ambient** workspace. +- **The middleware is attached per group** (`packages/protocol/src/api.ts:150-180`), not per + endpoint that declares the parameter. It therefore reaches operations with no location + query parameter at all (`project.list`, `permission.saved.*`) and is inert on the groups + without it (health, server, message, event, debug, migration) and on session-scoped + endpoints, which resolve location from the session DB row and ignore both channels. +- **Only the directory header is percent-decoded** server-side; the workspace header is read + verbatim, so a client's escaping must be asymmetric to match. + +The headers are absent from the document because they were never expressible in it, not +because they were removed: the spec is `OpenApi.fromApi(ClientApi)` over declared endpoint +schemas, and middleware contributes nothing to the parameter surface — `Authorization` is +invisible for exactly the same reason while remaining mandatory. The per-operation `location` +deepObject query parameter (61 operations, §5a) is the spec-visible channel the first-party +generated client uses exclusively; the headers are the spec-invisible ambient channel other +consumers (drive driver, CLI) ride. The SDK-side rendering — per-op request property vs ambient client/request-option default, plus the deepObject marshalling and the `session.list` flat-field exception — is the location + merged-Request design session that opens M3 planning (research log Session 22). diff --git a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md index 327da8ff6..74bf849fd 100644 --- a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md +++ b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md @@ -55,16 +55,33 @@ introduced gets exercised by a real operation. Sealed inputs: research log Sessi ## Work plan -- [ ] Placement map in the binder, red-test-first; body+query admission for - `shell.create`/`shell.timeout` only through it. -- [ ] Location: request-record property + deepObject route composition; ambient header - default in options/pipeline (explicit query wins by server precedence — no client - merge logic). -- [ ] Envelope profiles: `{location, data}` and 204-bodyless, with binder walls and - contract tests. -- [ ] Curation rows + `Shell.Info1` alias; regen; model closure; PATCH/DELETE; - operation methods + contract tests per op. -- [ ] #28 rider end to end. -- [ ] Live demo against `opencode2 serve`: shell create → list → get → output → remove, - plus session rename → remove. -- [ ] ROADMAP + this plan updated in the same commits. +- [x] Placement map in the binder, red-test-first: a body+query mix merges into one + uniform request model exactly when every query property is the location selector + (`QueryRequestPlan.RidesRequestBody`); the model gains `[JsonIgnore]` query-side + properties, the route builder types itself with the body model, and every other + mix keeps the deliberate wall. +- [x] Location: the hand-written `LocationSelector` spine (the wire's query selector is + an inline `{directory?, workspace?}` object, not `Location.Info`, so it follows the + `SessionParentFilter` precedent) with request-record property + deepObject route + composition; ambient header default in options/pipeline (explicit query wins by + server precedence — no client merge logic). +- [x] Envelope profiles: `{location, data}` (object and list variants) and 204-bodyless, + with binder walls and emitter snapshots; contract tests land with admission. +- [x] Curation rows + `Shell.Info1` alias; regen; model closure; PATCH/DELETE (PATCH + rides the internal `OpenCodeHttpMethod` spine — `HttpMethod.Patch` is absent + downlevel); operation methods + contract tests per op. The verb vocabulary gained + `remove`/`rename`/`timeout`, `RequestTypeName` folds Get like the response name, + and reachability recollects post-alias so an alias target's promoted inline + children stay bound; the alias comparer resolves promoted references structurally. + **`v2.shell.output` deferred:** its inline data object and integer cursor/limit + query params each need a mechanism no other admitted operation needs; it returns + with a later batch. +- [x] #28 rider end to end: the `mutuallyExclusiveQueries` curation section (pairs only, + fail-closed validated), the binder pins each name to the bound query surface, the + route builder refuses the pair before any request, and the no-send contract test + observes it on `message.list`. +- [x] Live demo against `opencode2 serve` v0.0.0-next-17403 (2026-08-16): shell + create → list → get → timeout → remove (204), session rename → remove (204), all + typed; the ambient options location rode the header channel and came back in the + location echo. (`output` deferred with its operation.) +- [x] ROADMAP + this plan updated in the same commits. diff --git a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs index c88b17e94..775227b22 100644 --- a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs +++ b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs @@ -49,6 +49,7 @@ private static IServiceCollection AddOpenCodeCore(IServiceCollection services) _ = services.AddSingleton(static provider => new OpenCodeClient( provider.GetRequiredService>().Value)); _ = services.AddSingleton(static SessionsClient (provider) => provider.GetRequiredService().Sessions); + _ = services.AddSingleton(static ShellsClient (provider) => provider.GetRequiredService().Shells); return services; } } diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index f76aae980..d41233eac 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -6,7 +6,14 @@ "Internal/ResponseAdapters/SessionCreateResponseAdapter.cs", "Internal/ResponseAdapters/SessionListResponseAdapter.cs", "Internal/ResponseAdapters/SessionMessageResponseAdapter.cs", + "Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs", + "Internal/ResponseAdapters/SessionRenameResponseAdapter.cs", "Internal/ResponseAdapters/SessionResponseAdapter.cs", + "Internal/ResponseAdapters/ShellCreateResponseAdapter.cs", + "Internal/ResponseAdapters/ShellListResponseAdapter.cs", + "Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs", + "Internal/ResponseAdapters/ShellResponseAdapter.cs", + "Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs", "Internal/Serialization/MessageListResponseEnvelope.cs", "Internal/Serialization/OpenCodeErrorJsonConverter.cs", "Internal/Serialization/OpenCodeJsonContext.cs", @@ -21,6 +28,10 @@ "Internal/Serialization/SessionMessageResponseEnvelope.cs", "Internal/Serialization/SessionMessageToolStateJsonConverter.cs", "Internal/Serialization/SessionResponseEnvelope.cs", + "Internal/Serialization/ShellCreateResponseEnvelope.cs", + "Internal/Serialization/ShellListResponseEnvelope.cs", + "Internal/Serialization/ShellResponseEnvelope.cs", + "Internal/Serialization/ShellTimeoutResponseEnvelope.cs", "Internal/Serialization/ToolContentJsonConverter.cs", "Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs", "Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs", @@ -34,6 +45,8 @@ "Models/FileDiffInfoStatus.cs", "Models/InvalidCursorError.cs", "Models/InvalidRequestError.cs", + "Models/LocationInfo.cs", + "Models/LocationInfoProject.cs", "Models/LocationRef.cs", "Models/MessageNotFoundError.cs", "Models/ModelRef.cs", @@ -100,8 +113,15 @@ "Models/SessionMessageUser.cs", "Models/SessionMessageUserTime.cs", "Models/SessionNotFoundError.cs", + "Models/SessionRenameRequest.cs", "Models/SessionRevert.cs", "Models/SessionStructuredError.cs", + "Models/ShellCreateRequest.cs", + "Models/ShellInfo.cs", + "Models/ShellInfoStatus.cs", + "Models/ShellInfoTime.cs", + "Models/ShellNotFoundError.cs", + "Models/ShellTimeoutRequest.cs", "Models/TokenUsageInfo.cs", "Models/TokenUsageInfoCache.cs", "Models/ToolContent.cs", @@ -126,7 +146,19 @@ "Sessions/SessionListRequest.cs", "Sessions/SessionListResponse.cs", "Sessions/SessionMessageResponse.cs", + "Sessions/SessionRemoveResponse.cs", + "Sessions/SessionRenameResponse.cs", "Sessions/SessionResponse.cs", - "Sessions/SessionsClient.cs" + "Sessions/SessionsClient.cs", + "Shells/ShellClient.cs", + "Shells/ShellCreateResponse.cs", + "Shells/ShellListRequest.cs", + "Shells/ShellListResponse.cs", + "Shells/ShellRemoveRequest.cs", + "Shells/ShellRemoveResponse.cs", + "Shells/ShellRequest.cs", + "Shells/ShellResponse.cs", + "Shells/ShellTimeoutResponse.cs", + "Shells/ShellsClient.cs" ] } diff --git a/src/OpenCode.Sdk/.generation-incomplete b/src/OpenCode.Sdk/.generation-incomplete index a10db037e..c302326d3 100644 --- a/src/OpenCode.Sdk/.generation-incomplete +++ b/src/OpenCode.Sdk/.generation-incomplete @@ -1,6 +1,6 @@ Generation is incomplete; packages must not be published. -Selected operations: 6 -Pending operations: 114 +Selected operations: 13 +Pending operations: 107 Selected: - v2.health.get - v2.message.list @@ -8,6 +8,13 @@ Selected: - v2.session.get - v2.session.list - v2.session.message +- v2.session.remove +- v2.session.rename +- v2.shell.create +- v2.shell.get +- v2.shell.list +- v2.shell.remove +- v2.shell.timeout Pending: - v2.agent.get - v2.agent.list @@ -100,8 +107,6 @@ Pending: - v2.session.question.list - v2.session.question.reject - v2.session.question.reply -- v2.session.remove -- v2.session.rename - v2.session.revert.clear - v2.session.revert.commit - v2.session.revert.stage @@ -111,12 +116,7 @@ Pending: - v2.session.switchModel - v2.session.synthetic - v2.session.wait -- v2.shell.create -- v2.shell.get -- v2.shell.list - v2.shell.output -- v2.shell.remove -- v2.shell.timeout - v2.skill.list - v2.vcs.diff - v2.vcs.get diff --git a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs index c00daad32..f08b1e46b 100644 --- a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs @@ -15,4 +15,7 @@ public interface IOpenCodeClientOptions /// Gets the HTTP basic-authentication password; sends anonymous requests. public string? Password { get; } + + /// Gets the ambient location sent on every request as location headers, or for none. + public LocationSelector? Location { get; } } diff --git a/src/OpenCode.Sdk/Internal/OpenCodeHttpMethod.cs b/src/OpenCode.Sdk/Internal/OpenCodeHttpMethod.cs new file mode 100644 index 000000000..c37df1db9 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/OpenCodeHttpMethod.cs @@ -0,0 +1,10 @@ +namespace OpenCode.Sdk.Internal; + +/// +/// Verb singletons the downlevel BCL does not expose; HttpMethod.Patch only exists +/// on modern targets, so generated methods reference this spine instead. +/// +internal static class OpenCodeHttpMethod +{ + public static HttpMethod Patch { get; } = new HttpMethod("PATCH"); +} diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 783772c4a..e1564cccb 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -12,6 +12,7 @@ internal sealed class Pipeline : IDisposable private readonly AuthenticationHeaderValue? _authorization; private readonly string _endpointBase; private readonly HttpClient _httpClient; + private readonly LocationSelector? _location; private readonly bool _ownsHttpClient; private readonly ProductInfoHeaderValue _userAgent; private bool _disposed; @@ -49,6 +50,7 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt _httpClient = httpClient; _ownsHttpClient = ownsHttpClient; _endpointBase = EndpointPolicy.Normalize(endpoint); + _location = options.Location; _userAgent = UserAgentPolicy.Resolve(); // The options are read exactly once, here: the pipeline holds an immutable snapshot, @@ -210,6 +212,22 @@ private void Decorate(HttpRequestMessage request) request.Headers.Authorization = _authorization; } + // The ambient location rides the middleware's header channel, and the two members + // travel differently: the server percent-decodes the directory header but reads the + // workspace one verbatim, so the escaping mirrors that asymmetry exactly. Escaping + // also keeps a non-ASCII path sendable, since header values cannot carry it raw. The + // server resolves any explicit per-request location query first, so no client-side + // merge exists. + if (_location?.Directory is { } directory) + { + _ = request.Headers.TryAddWithoutValidation("x-opencode-directory", Uri.EscapeDataString(directory)); + } + + if (_location?.Workspace is { } workspace) + { + _ = request.Headers.TryAddWithoutValidation("x-opencode-workspace", workspace); + } + request.Headers.UserAgent.Add(_userAgent); } diff --git a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs index 1ed2e4a02..da5426a07 100644 --- a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs +++ b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs @@ -63,6 +63,24 @@ public void AddParentFilter(string name, SessionParentFilter? value) } } + public void AddLocation(string name, LocationSelector? value) + { + if (value is null) + { + return; + } + + if (value.Directory is not null) + { + AppendBracketed(name, "directory", value.Directory); + } + + if (value.Workspace is not null) + { + AppendBracketed(name, "workspace", value.Workspace); + } + } + private void Append(string name, string value) { _builder ??= new StringBuilder(); @@ -71,4 +89,20 @@ private void Append(string name, string value) .Append('=') .Append(Uri.EscapeDataString(value)); } + + /// + /// DeepObject keys ride the wire with literal brackets, matching the first-party + /// client's serialization; the member names are SDK literals, never caller input. + /// + private void AppendBracketed(string name, string member, string value) + { + _builder ??= new StringBuilder(); + _ = _builder.Append(_builder.Length is 0 ? '?' : '&') + .Append(Uri.EscapeDataString(name)) + .Append('[') + .Append(member) + .Append(']') + .Append('=') + .Append(Uri.EscapeDataString(value)); + } } diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapter.cs index b4daf2ae2..1b260f2a6 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapter.cs @@ -44,6 +44,12 @@ protected static TPayload ReadBarePayload(string rawBody, JsonTypeInfo protected static OpenCodeTransportException UndeclaredSuccessFailure(int status) => new($"The opencode API returned undeclared success status {status.ToString(CultureInfo.InvariantCulture)}."); + /// Builds the protocol failure for a declared no-content success arriving with a body. + /// The declared no-content success status code. + /// The transport failure the adapter throws. + protected static OpenCodeTransportException NonEmptyNoContentFailure(int status) => + new($"The opencode API returned no-content status {status.ToString(CultureInfo.InvariantCulture)} with an unexpected body."); + /// /// Reads a typed error tolerantly: malformed JSON yields so the raw /// body remains the only record; an unknown tag keeps its carrier; a known tag outside the diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs new file mode 100644 index 000000000..ba6653b4c --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs @@ -0,0 +1,42 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'DELETE /api/session/{sessionID}' responses onto 'SessionRemoveResponse'. +/// +internal sealed class SessionRemoveResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["SessionNotFoundError"]; + private SessionRemoveResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionRemoveResponseAdapter Instance { get; } = new SessionRemoveResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionRemoveResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 204 => rawBody.Length is 0 ? new SessionRemoveResponse + { + Status = status + } + + : throw NonEmptyNoContentFailure(status), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionRemoveResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionRemoveResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new SessionRemoveResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new SessionRemoveResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRenameResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRenameResponseAdapter.cs new file mode 100644 index 000000000..a873350e1 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRenameResponseAdapter.cs @@ -0,0 +1,42 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'POST /api/session/{sessionID}/rename' responses onto 'SessionRenameResponse'. +/// +internal sealed class SessionRenameResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["SessionNotFoundError"]; + private SessionRenameResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionRenameResponseAdapter Instance { get; } = new SessionRenameResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionRenameResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 204 => rawBody.Length is 0 ? new SessionRenameResponse + { + Status = status + } + + : throw NonEmptyNoContentFailure(status), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionRenameResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionRenameResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new SessionRenameResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new SessionRenameResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellCreateResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellCreateResponseAdapter.cs new file mode 100644 index 000000000..f41edfb03 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellCreateResponseAdapter.cs @@ -0,0 +1,44 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'POST /api/shell' responses onto 'ShellCreateResponse'. +/// +internal sealed class ShellCreateResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private ShellCreateResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellCreateResponseAdapter Instance { get; } = new ShellCreateResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellCreateResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.ShellCreateResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellCreateResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellCreateResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + _ => new ShellCreateResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static ShellCreateResponse CreateSuccess(int status, ShellCreateResponseEnvelope envelope) => new() + { + Status = status, + Shell = envelope.Data, + Location = envelope.Location + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellListResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellListResponseAdapter.cs new file mode 100644 index 000000000..4b60af882 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellListResponseAdapter.cs @@ -0,0 +1,44 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'GET /api/shell' responses onto 'ShellListResponse'. +/// +internal sealed class ShellListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private ShellListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellListResponseAdapter Instance { get; } = new ShellListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.ShellListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellListResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellListResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + _ => new ShellListResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static ShellListResponse CreateSuccess(int status, ShellListResponseEnvelope envelope) => new() + { + Status = status, + Shells = envelope.Data, + Location = envelope.Location + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs new file mode 100644 index 000000000..d3f7c98c3 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs @@ -0,0 +1,42 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'DELETE /api/shell/{id}' responses onto 'ShellRemoveResponse'. +/// +internal sealed class ShellRemoveResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["ShellNotFoundError"]; + private ShellRemoveResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellRemoveResponseAdapter Instance { get; } = new ShellRemoveResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellRemoveResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 204 => rawBody.Length is 0 ? new ShellRemoveResponse + { + Status = status + } + + : throw NonEmptyNoContentFailure(status), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellRemoveResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellRemoveResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new ShellRemoveResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new ShellRemoveResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellResponseAdapter.cs new file mode 100644 index 000000000..9f361fadd --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellResponseAdapter.cs @@ -0,0 +1,46 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'GET /api/shell/{id}' responses onto 'ShellResponse'. +/// +internal sealed class ShellResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["ShellNotFoundError"]; + private ShellResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellResponseAdapter Instance { get; } = new ShellResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.ShellResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new ShellResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new ShellResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static ShellResponse CreateSuccess(int status, ShellResponseEnvelope envelope) => new() + { + Status = status, + Shell = envelope.Data, + Location = envelope.Location + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs new file mode 100644 index 000000000..2e2e14a4b --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs @@ -0,0 +1,46 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'PATCH /api/shell/{id}/timeout' responses onto 'ShellTimeoutResponse'. +/// +internal sealed class ShellTimeoutResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["ShellNotFoundError"]; + private ShellTimeoutResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellTimeoutResponseAdapter Instance { get; } = new ShellTimeoutResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellTimeoutResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.ShellTimeoutResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellTimeoutResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellTimeoutResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new ShellTimeoutResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new ShellTimeoutResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static ShellTimeoutResponse CreateSuccess(int status, ShellTimeoutResponseEnvelope envelope) => new() + { + Status = status, + Shell = envelope.Data, + Location = envelope.Location + }; +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs index b6c23dc57..d63d28a8d 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs @@ -14,6 +14,7 @@ internal sealed class OpenCodeErrorJsonConverter : JsonConverter ["InvalidRequestError"] = typeof(InvalidRequestError), ["MessageNotFoundError"] = typeof(MessageNotFoundError), ["SessionNotFoundError"] = typeof(SessionNotFoundError), + ["ShellNotFoundError"] = typeof(ShellNotFoundError), ["UnauthorizedError"] = typeof(UnauthorizedError), ["UnknownError"] = typeof(UnknownError) }; diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs index 50eb1b110..0c838e557 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs @@ -10,6 +10,8 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(FileDiffInfoStatus))] [JsonSerializable(typeof(InvalidCursorError))] [JsonSerializable(typeof(InvalidRequestError))] +[JsonSerializable(typeof(LocationInfo))] +[JsonSerializable(typeof(LocationInfoProject))] [JsonSerializable(typeof(LocationRef))] [JsonSerializable(typeof(MessageListResponseEnvelope))] [JsonSerializable(typeof(MessageNotFoundError))] @@ -80,9 +82,20 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(SessionMessageUser))] [JsonSerializable(typeof(SessionMessageUserTime))] [JsonSerializable(typeof(SessionNotFoundError))] +[JsonSerializable(typeof(SessionRenameRequest))] [JsonSerializable(typeof(SessionResponseEnvelope))] [JsonSerializable(typeof(SessionRevert))] [JsonSerializable(typeof(SessionStructuredError))] +[JsonSerializable(typeof(ShellCreateRequest))] +[JsonSerializable(typeof(ShellCreateResponseEnvelope))] +[JsonSerializable(typeof(ShellInfo))] +[JsonSerializable(typeof(ShellInfoStatus))] +[JsonSerializable(typeof(ShellInfoTime))] +[JsonSerializable(typeof(ShellListResponseEnvelope))] +[JsonSerializable(typeof(ShellNotFoundError))] +[JsonSerializable(typeof(ShellResponseEnvelope))] +[JsonSerializable(typeof(ShellTimeoutRequest))] +[JsonSerializable(typeof(ShellTimeoutResponseEnvelope))] [JsonSerializable(typeof(TokenUsageInfo))] [JsonSerializable(typeof(TokenUsageInfoCache))] [JsonSerializable(typeof(ToolContent))] diff --git a/src/OpenCode.Sdk/Internal/Serialization/ShellCreateResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/ShellCreateResponseEnvelope.cs new file mode 100644 index 000000000..395cda16c --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ShellCreateResponseEnvelope.cs @@ -0,0 +1,17 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'ShellCreateResponse' through one deserialization pass. +/// +internal sealed record ShellCreateResponseEnvelope +{ + [JsonPropertyName("data")] + public required ShellInfo Data { get; init; } + + [JsonPropertyName("location")] + public required LocationInfo Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/ShellListResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/ShellListResponseEnvelope.cs new file mode 100644 index 000000000..575127790 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ShellListResponseEnvelope.cs @@ -0,0 +1,18 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'ShellListResponse' through one deserialization pass. +/// +internal sealed record ShellListResponseEnvelope +{ + [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("location")] + public required LocationInfo Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/ShellResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/ShellResponseEnvelope.cs new file mode 100644 index 000000000..abd400d39 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ShellResponseEnvelope.cs @@ -0,0 +1,17 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'ShellResponse' through one deserialization pass. +/// +internal sealed record ShellResponseEnvelope +{ + [JsonPropertyName("data")] + public required ShellInfo Data { get; init; } + + [JsonPropertyName("location")] + public required LocationInfo Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/ShellTimeoutResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/ShellTimeoutResponseEnvelope.cs new file mode 100644 index 000000000..45cb18304 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ShellTimeoutResponseEnvelope.cs @@ -0,0 +1,17 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'ShellTimeoutResponse' through one deserialization pass. +/// +internal sealed record ShellTimeoutResponseEnvelope +{ + [JsonPropertyName("data")] + public required ShellInfo Data { get; init; } + + [JsonPropertyName("location")] + public required LocationInfo Location { get; init; } +} diff --git a/src/OpenCode.Sdk/LocationSelector.cs b/src/OpenCode.Sdk/LocationSelector.cs new file mode 100644 index 000000000..f320bccf2 --- /dev/null +++ b/src/OpenCode.Sdk/LocationSelector.cs @@ -0,0 +1,48 @@ +namespace OpenCode.Sdk; + +/// +/// Selects the directory and workspace a request addresses; unset members leave the +/// server's own resolution in place. On a request it rides the wire as +/// location[directory] and location[workspace] query keys; as the client +/// options' ambient default it rides the x-opencode-directory and +/// x-opencode-workspace headers, which the server resolves after any query value. +/// +public sealed record LocationSelector +{ + private readonly string? _directory; + private readonly string? _workspace; + + /// Gets the directory the request addresses; a blank value is refused. + public string? Directory + { + get => _directory; + init + { + if (value is not null && string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException( + "The location directory cannot be empty or whitespace; leave it null when unset.", + nameof(value)); + } + + _directory = value; + } + } + + /// Gets the workspace the request addresses; a blank value is refused. + public string? Workspace + { + get => _workspace; + init + { + if (value is not null && string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException( + "The location workspace cannot be empty or whitespace; leave it null when unset.", + nameof(value)); + } + + _workspace = value; + } + } +} diff --git a/src/OpenCode.Sdk/Models/LocationInfo.cs b/src/OpenCode.Sdk/Models/LocationInfo.cs new file mode 100644 index 000000000..8c985c744 --- /dev/null +++ b/src/OpenCode.Sdk/Models/LocationInfo.cs @@ -0,0 +1,31 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a location info value. +/// +public sealed record LocationInfo +{ + /// + /// Gets the directory value. + /// + [JsonPropertyName("directory")] + public required string Directory { get; init; } + + /// + /// Gets the workspace id value. + /// + [JsonPropertyName("workspaceID")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? WorkspaceId { get; init; } + + /// + /// Gets the project value. + /// + [JsonPropertyName("project")] + public required LocationInfoProject Project { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/LocationInfoProject.cs b/src/OpenCode.Sdk/Models/LocationInfoProject.cs new file mode 100644 index 000000000..dc15d0457 --- /dev/null +++ b/src/OpenCode.Sdk/Models/LocationInfoProject.cs @@ -0,0 +1,28 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a location info project value. +/// +public sealed record LocationInfoProject +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Gets the directory value. + /// + [JsonPropertyName("directory")] + public required string Directory { get; init; } + + /// + /// Gets the canonical value. + /// + [JsonPropertyName("canonical")] + public required string Canonical { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionRenameRequest.cs b/src/OpenCode.Sdk/Models/SessionRenameRequest.cs new file mode 100644 index 000000000..1d5ae60ba --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionRenameRequest.cs @@ -0,0 +1,16 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session rename request value. +/// +public sealed record SessionRenameRequest +{ + /// + /// Gets the title value. + /// + [JsonPropertyName("title")] + public required string Title { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellCreateRequest.cs b/src/OpenCode.Sdk/Models/ShellCreateRequest.cs new file mode 100644 index 000000000..3d9bdad99 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellCreateRequest.cs @@ -0,0 +1,45 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell create request value. +/// +public sealed record ShellCreateRequest +{ + /// + /// Gets the command value. + /// + [JsonPropertyName("command")] + public required string Command { get; init; } + + /// + /// Gets the cwd value. + /// + [JsonPropertyName("cwd")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? Cwd { get; init; } + + /// + /// Gets the timeout value. + /// + [JsonPropertyName("timeout")] + public required long Timeout { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata { get; init => field = OptionalCollectionInput.Normalize(value, field, static input => new ReadOnlyDictionary(input.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal))); } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + [JsonIgnore] + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellInfo.cs b/src/OpenCode.Sdk/Models/ShellInfo.cs new file mode 100644 index 000000000..9898c8481 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellInfo.cs @@ -0,0 +1,85 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell info value. +/// +public sealed record ShellInfo +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Gets the status value. + /// + [JsonPropertyName("status")] + public required ShellInfoStatus Status { get; init; } + + /// + /// Gets the command value. + /// + [JsonPropertyName("command")] + public required string Command { get; init; } + + /// + /// Gets the cwd value. + /// + [JsonPropertyName("cwd")] + public required string Cwd { get; init; } + + /// + /// Gets the shell value. + /// + [JsonPropertyName("shell")] + public required string Shell { get; init; } + + /// + /// Gets the file value. + /// + [JsonPropertyName("file")] + public required string File { get; init; } + + /// + /// Gets the pid value. + /// + [JsonPropertyName("pid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] + public long? Pid { get; init; } + + /// + /// Gets the exit value. + /// + [JsonPropertyName("exit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] + public double? Exit { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public required IReadOnlyDictionary Metadata + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required ShellInfoTime Time { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellInfoStatus.cs b/src/OpenCode.Sdk/Models/ShellInfoStatus.cs new file mode 100644 index 000000000..cae3418c1 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellInfoStatus.cs @@ -0,0 +1,33 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Defines the supported shell info status values. +/// +[JsonConverter(typeof(StrictJsonStringEnumConverter))] +public enum ShellInfoStatus +{ + /// + /// Represents the 'running' wire value. + /// + [JsonStringEnumMemberName("running")] + Running, + /// + /// Represents the 'exited' wire value. + /// + [JsonStringEnumMemberName("exited")] + Exited, + /// + /// Represents the 'timeout' wire value. + /// + [JsonStringEnumMemberName("timeout")] + Timeout, + /// + /// Represents the 'killed' wire value. + /// + [JsonStringEnumMemberName("killed")] + Killed +} diff --git a/src/OpenCode.Sdk/Models/ShellInfoTime.cs b/src/OpenCode.Sdk/Models/ShellInfoTime.cs new file mode 100644 index 000000000..98a419266 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellInfoTime.cs @@ -0,0 +1,25 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell info time value. +/// +public sealed record ShellInfoTime +{ + /// + /// Gets the started value. + /// + [JsonPropertyName("started")] + public required double Started { get; init; } + + /// + /// Gets the completed value. + /// + [JsonPropertyName("completed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] + public double? Completed { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellNotFoundError.cs b/src/OpenCode.Sdk/Models/ShellNotFoundError.cs new file mode 100644 index 000000000..58c2284b3 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellNotFoundError.cs @@ -0,0 +1,28 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell not found error value. +/// +public sealed record ShellNotFoundError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "ShellNotFoundError"; + + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellTimeoutRequest.cs b/src/OpenCode.Sdk/Models/ShellTimeoutRequest.cs new file mode 100644 index 000000000..d9e56909d --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellTimeoutRequest.cs @@ -0,0 +1,22 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell timeout request value. +/// +public sealed record ShellTimeoutRequest +{ + /// + /// Gets the timeout value. + /// + [JsonPropertyName("timeout")] + public required long Timeout { get; init; } + + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + [JsonIgnore] + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/OpenCodeClient.cs b/src/OpenCode.Sdk/OpenCodeClient.cs index db473711a..cfba24d2b 100644 --- a/src/OpenCode.Sdk/OpenCodeClient.cs +++ b/src/OpenCode.Sdk/OpenCodeClient.cs @@ -11,6 +11,7 @@ public class OpenCodeClient : IDisposable { private readonly Pipeline? _pipeline; private readonly SessionsClient? _sessions; + private readonly ShellsClient? _shells; /// /// Initializes a client that owns its connection to the endpoint. /// @@ -19,6 +20,7 @@ public OpenCodeClient(OpenCodeClientOptions options) { _pipeline = Pipeline.Create(options); _sessions = new SessionsClient(_pipeline); + _shells = new ShellsClient(_pipeline); } /// @@ -30,6 +32,7 @@ internal OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) { _pipeline = Pipeline.Create(httpClient, options); _sessions = new SessionsClient(_pipeline); + _shells = new ShellsClient(_pipeline); } /// @@ -64,6 +67,10 @@ protected virtual void Dispose(bool disposing) /// Gets the 'Sessions' collection client. /// public virtual SessionsClient Sessions => _sessions ?? throw MockSeam.CreateError("OpenCodeClient", "Sessions"); + /// + /// Gets the 'Shells' collection client. + /// + public virtual ShellsClient Shells => _shells ?? throw MockSeam.CreateError("OpenCodeClient", "Shells"); private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("OpenCodeClient", "Pipeline"); /// diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index 8ea57320e..cfe741f3f 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -23,4 +23,16 @@ public sealed class OpenCodeClientOptions : IOpenCodeClientOptions /// construction; the SDK never reads credentials from the environment. /// public string? Password { get; set; } + + /// + /// Gets or sets the ambient location, sent on every request as the + /// x-opencode-directory and x-opencode-workspace headers; + /// leaves the server's own resolution in place. The server + /// honors these headers only on the operations whose group resolves location from the + /// request — operations that resolve it from a session instead, and those that do not + /// resolve it at all, ignore them. A per-request location travels on the query channel + /// and takes precedence per member: it overrides only the members it sets, and + /// the rest still come from here. + /// + public LocationSelector? Location { get; set; } } diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index f75d61773..0bac5dc3b 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -1,6 +1,7 @@ // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -99,6 +100,11 @@ public static string ListMessages(string sessionId, MessageListRequest? request return path; } + if (request.Order is not null && request.Cursor is not null) + { + throw new ArgumentException("The 'order' and 'cursor' query values cannot combine.", nameof(request)); + } + var query = new QueryStringBuilder(); query.AddCount("limit", request.Limit, nameof(request)); query.AddOrder("order", request.Order); @@ -135,5 +141,182 @@ public static string ListSessions(SessionListRequest? request = null) query.AddText("cursor", request.Cursor); return path + query.Value; } + + /// + /// The 'DELETE /api/session/{sessionID}' route template. + /// + public const string RemoveSessionTemplate = "/api/session/{sessionID}"; + /// + /// Builds the '/api/session/{sessionID}' route. + /// + /// The 'sessionID' route value. + /// The escaped route. + public static string RemoveSession(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + + return "/api/session/" + Uri.EscapeDataString(sessionId); + } + + /// + /// The 'POST /api/session/{sessionID}/rename' route template. + /// + public const string RenameSessionTemplate = "/api/session/{sessionID}/rename"; + /// + /// Builds the '/api/session/{sessionID}/rename' route. + /// + /// The 'sessionID' route value. + /// The escaped route. + public static string RenameSession(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + + return "/api/session/" + Uri.EscapeDataString(sessionId) + "/rename"; + } + } + + /// + /// Defines the 'Shells' routes. + /// + public static class Shells + { + /// + /// The 'POST /api/shell' route template. + /// + public const string CreateShellTemplate = "/api/shell"; + /// + /// Builds the '/api/shell' route. + /// + /// The request shaping the query. + /// The escaped route. + public static string CreateShell(ShellCreateRequest? request = null) + { + var path = "/api/shell"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + + /// + /// The 'GET /api/shell/{id}' route template. + /// + public const string GetShellTemplate = "/api/shell/{id}"; + /// + /// Builds the '/api/shell/{id}' route. + /// + /// The 'id' route value. + /// The request shaping the query. + /// The escaped route. + public static string GetShell(string id, ShellRequest? request = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + var path = "/api/shell/" + Uri.EscapeDataString(id); + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + + /// + /// The 'GET /api/shell' route template. + /// + public const string ListShellsTemplate = "/api/shell"; + /// + /// Builds the '/api/shell' route. + /// + /// The request shaping the query. + /// The escaped route. + public static string ListShells(ShellListRequest? request = null) + { + var path = "/api/shell"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + + /// + /// The 'DELETE /api/shell/{id}' route template. + /// + public const string RemoveShellTemplate = "/api/shell/{id}"; + /// + /// Builds the '/api/shell/{id}' route. + /// + /// The 'id' route value. + /// The request shaping the query. + /// The escaped route. + public static string RemoveShell(string id, ShellRemoveRequest? request = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + var path = "/api/shell/" + Uri.EscapeDataString(id); + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + + /// + /// The 'PATCH /api/shell/{id}/timeout' route template. + /// + public const string TimeoutShellTemplate = "/api/shell/{id}/timeout"; + /// + /// Builds the '/api/shell/{id}/timeout' route. + /// + /// The 'id' route value. + /// The request shaping the query. + /// The escaped route. + public static string TimeoutShell(string id, ShellTimeoutRequest? request = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + var path = "/api/shell/" + Uri.EscapeDataString(id) + "/timeout"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } } } diff --git a/src/OpenCode.Sdk/Sessions/SessionClient.cs b/src/OpenCode.Sdk/Sessions/SessionClient.cs index 4c3fbf8ef..2d83fe95f 100644 --- a/src/OpenCode.Sdk/Sessions/SessionClient.cs +++ b/src/OpenCode.Sdk/Sessions/SessionClient.cs @@ -2,6 +2,8 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using OpenCode.Sdk.Internal; using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -75,4 +77,32 @@ public virtual Task ListMessagesAsync(MessageListRequest? r { return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListMessages(SessionId, request), MessageListResponseAdapter.Instance, requestOptions, cancellationToken); } + + /// + /// Delete session + /// + /// The per-call options. + /// The cancellation token. + /// The 'SessionRemoveResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RemoveSessionAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Delete, OpenCodeRoutes.Sessions.RemoveSession(SessionId), SessionRemoveResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Rename session + /// + /// The request body. + /// The per-call options. + /// The cancellation token. + /// The 'SessionRenameResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RenameSessionAsync(SessionRenameRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Sessions.RenameSession(SessionId), request, OpenCodeJsonContext.Default.SessionRenameRequest, SessionRenameResponseAdapter.Instance, requestOptions, cancellationToken); + } } diff --git a/src/OpenCode.Sdk/Sessions/SessionRemoveResponse.cs b/src/OpenCode.Sdk/Sessions/SessionRemoveResponse.cs new file mode 100644 index 000000000..dab0a9c67 --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionRemoveResponse.cs @@ -0,0 +1,30 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'DELETE /api/session/{sessionID}' operation. +/// +public sealed record SessionRemoveResponse : OpenCodeResponse +{ + /// + /// Initializes a success instance of the 'SessionRemoveResponse' envelope. + /// + public SessionRemoveResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionRemoveResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + } +} diff --git a/src/OpenCode.Sdk/Sessions/SessionRenameResponse.cs b/src/OpenCode.Sdk/Sessions/SessionRenameResponse.cs new file mode 100644 index 000000000..93733ecfc --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionRenameResponse.cs @@ -0,0 +1,30 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'POST /api/session/{sessionID}/rename' operation. +/// +public sealed record SessionRenameResponse : OpenCodeResponse +{ + /// + /// Initializes a success instance of the 'SessionRenameResponse' envelope. + /// + public SessionRenameResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionRenameResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellClient.cs b/src/OpenCode.Sdk/Shells/ShellClient.cs new file mode 100644 index 000000000..ce502ad3e --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellClient.cs @@ -0,0 +1,81 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// A bound 'ShellClient' handle; it holds an immutable identifier and the shared pipeline. +/// +public class ShellClient +{ + private readonly Pipeline? _pipeline; + private readonly string? _id; + internal ShellClient(Pipeline pipeline, string id) + { + ArgumentNullException.ThrowIfNull(pipeline); + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + _pipeline = pipeline; + _id = id; + } + + /// + /// Initializes a mocking instance; members invoked without an override throw an instructive failure. + /// + protected ShellClient() + { + } + + private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("ShellClient", "Pipeline"); + private string Id => _id ?? throw MockSeam.CreateError("ShellClient", "Id"); + + /// + /// Get shell command + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'ShellResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task GetShellAsync(ShellRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Shells.GetShell(Id, request), ShellResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Remove shell command + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'ShellRemoveResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RemoveShellAsync(ShellRemoveRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Delete, OpenCodeRoutes.Shells.RemoveShell(Id, request), ShellRemoveResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Update shell timeout + /// + /// The request body. + /// The per-call options. + /// The cancellation token. + /// The 'ShellTimeoutResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task TimeoutShellAsync(ShellTimeoutRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return Pipeline.ExecuteAsync(OpenCodeHttpMethod.Patch, OpenCodeRoutes.Shells.TimeoutShell(Id, request), request, OpenCodeJsonContext.Default.ShellTimeoutRequest, ShellTimeoutResponseAdapter.Instance, requestOptions, cancellationToken); + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellCreateResponse.cs b/src/OpenCode.Sdk/Shells/ShellCreateResponse.cs new file mode 100644 index 000000000..3f14957ba --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellCreateResponse.cs @@ -0,0 +1,65 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'POST /api/shell' operation. +/// +public sealed record ShellCreateResponse : OpenCodeResponse +{ + private readonly ShellInfo? _shell; + private readonly LocationInfo? _location; + /// + /// Initializes a success instance of the 'ShellCreateResponse' envelope. + /// + public ShellCreateResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellCreateResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Shell = null!; + Location = null!; + } + + /// + /// Gets the Shell payload; guarded on the error path. + /// + public required ShellInfo Shell { get => _shell ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Shell."); init => _shell = value; } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required LocationInfo Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_shell is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Shell = ").Append(_shell); + return true; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellListRequest.cs b/src/OpenCode.Sdk/Shells/ShellListRequest.cs new file mode 100644 index 000000000..fcd393938 --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellListRequest.cs @@ -0,0 +1,13 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk; +/// +/// Shapes the 'GET /api/shell' query. +/// +public sealed record ShellListRequest +{ + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Shells/ShellListResponse.cs b/src/OpenCode.Sdk/Shells/ShellListResponse.cs new file mode 100644 index 000000000..c5c46ee2c --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellListResponse.cs @@ -0,0 +1,66 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'GET /api/shell' operation. +/// +public sealed record ShellListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _shells; + private readonly LocationInfo? _location; + /// + /// Initializes a success instance of the 'ShellListResponse' envelope. + /// + public ShellListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Shells = null!; + Location = null!; + } + + /// + /// Gets the Shells payload; guarded on the error path. + /// + public required IReadOnlyList Shells { get => _shells ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Shells."); init => _shells = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required LocationInfo Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_shells is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Shells = ").Append(_shells); + return true; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellRemoveRequest.cs b/src/OpenCode.Sdk/Shells/ShellRemoveRequest.cs new file mode 100644 index 000000000..da41b54e9 --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellRemoveRequest.cs @@ -0,0 +1,13 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk; +/// +/// Shapes the 'DELETE /api/shell/{id}' query. +/// +public sealed record ShellRemoveRequest +{ + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Shells/ShellRemoveResponse.cs b/src/OpenCode.Sdk/Shells/ShellRemoveResponse.cs new file mode 100644 index 000000000..baa66a489 --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellRemoveResponse.cs @@ -0,0 +1,30 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'DELETE /api/shell/{id}' operation. +/// +public sealed record ShellRemoveResponse : OpenCodeResponse +{ + /// + /// Initializes a success instance of the 'ShellRemoveResponse' envelope. + /// + public ShellRemoveResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellRemoveResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellRequest.cs b/src/OpenCode.Sdk/Shells/ShellRequest.cs new file mode 100644 index 000000000..b8d4c0bea --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellRequest.cs @@ -0,0 +1,13 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk; +/// +/// Shapes the 'GET /api/shell/{id}' query. +/// +public sealed record ShellRequest +{ + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Shells/ShellResponse.cs b/src/OpenCode.Sdk/Shells/ShellResponse.cs new file mode 100644 index 000000000..9ff5c2ddf --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellResponse.cs @@ -0,0 +1,65 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'GET /api/shell/{id}' operation. +/// +public sealed record ShellResponse : OpenCodeResponse +{ + private readonly ShellInfo? _shell; + private readonly LocationInfo? _location; + /// + /// Initializes a success instance of the 'ShellResponse' envelope. + /// + public ShellResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Shell = null!; + Location = null!; + } + + /// + /// Gets the Shell payload; guarded on the error path. + /// + public required ShellInfo Shell { get => _shell ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Shell."); init => _shell = value; } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required LocationInfo Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_shell is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Shell = ").Append(_shell); + return true; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellTimeoutResponse.cs b/src/OpenCode.Sdk/Shells/ShellTimeoutResponse.cs new file mode 100644 index 000000000..ae4e0edeb --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellTimeoutResponse.cs @@ -0,0 +1,65 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'PATCH /api/shell/{id}/timeout' operation. +/// +public sealed record ShellTimeoutResponse : OpenCodeResponse +{ + private readonly ShellInfo? _shell; + private readonly LocationInfo? _location; + /// + /// Initializes a success instance of the 'ShellTimeoutResponse' envelope. + /// + public ShellTimeoutResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellTimeoutResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Shell = null!; + Location = null!; + } + + /// + /// Gets the Shell payload; guarded on the error path. + /// + public required ShellInfo Shell { get => _shell ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Shell."); init => _shell = value; } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required LocationInfo Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_shell is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Shell = ").Append(_shell); + return true; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellsClient.cs b/src/OpenCode.Sdk/Shells/ShellsClient.cs new file mode 100644 index 000000000..638a0df60 --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellsClient.cs @@ -0,0 +1,74 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// The 'ShellsClient' collection client. +/// +public class ShellsClient +{ + private readonly Pipeline? _pipeline; + internal ShellsClient(Pipeline pipeline) + { + ArgumentNullException.ThrowIfNull(pipeline); + _pipeline = pipeline; + } + + /// + /// Initializes a mocking instance; members invoked without an override throw an instructive failure. + /// + protected ShellsClient() + { + } + + /// + /// Gets a bound 'ShellClient'; the handle never caches server state. + /// + /// The 'id' route value. + /// The bound 'ShellClient'. + public virtual ShellClient GetShellClient(string id) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + return new ShellClient(Pipeline, id); + } + + private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("ShellsClient", "Pipeline"); + + /// + /// Run shell command + /// + /// The request body. + /// The per-call options. + /// The cancellation token. + /// The 'ShellCreateResponse' envelope. + /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task CreateShellAsync(ShellCreateRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Shells.CreateShell(request), request, OpenCodeJsonContext.Default.ShellCreateRequest, ShellCreateResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List running shell commands + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'ShellListResponse' envelope. + /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListShellsAsync(ShellListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Shells.ListShells(request), ShellListResponseAdapter.Instance, requestOptions, cancellationToken); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-shell.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-shell.json new file mode 100644 index 000000000..944cd7ad6 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-shell.json @@ -0,0 +1,13 @@ +{ + "id": "sh_100", + "status": "running", + "command": "ls -la", + "cwd": "/repo", + "shell": "/bin/bash", + "file": "/tmp/sh_100.log", + "pid": 4242, + "metadata": {}, + "time": { + "started": 1755200000 + } +} diff --git a/tests/OpenCode.Sdk.Tests/LocationSelectorTests.cs b/tests/OpenCode.Sdk.Tests/LocationSelectorTests.cs new file mode 100644 index 000000000..803aa2afa --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/LocationSelectorTests.cs @@ -0,0 +1,31 @@ +namespace OpenCode.Sdk.Tests; + +public sealed class LocationSelectorTests +{ + [Test] + public async Task Members_Should_Stay_Null_When_Unset() + { + var selector = new LocationSelector(); + + await Assert.That(selector.Directory).IsNull(); + await Assert.That(selector.Workspace).IsNull(); + } + + [Test] + [Arguments("")] + [Arguments(" ")] + public async Task Directory_Should_Refuse_A_Blank_Value(string value) + { + _ = await Assert.That(() => _ = new LocationSelector { Directory = value }) + .Throws(); + } + + [Test] + [Arguments("")] + [Arguments(" ")] + public async Task Workspace_Should_Refuse_A_Blank_Value(string value) + { + _ = await Assert.That(() => _ = new LocationSelector { Workspace = value }) + .Throws(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index 5c101ea4b..6c889c964 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -151,6 +151,106 @@ public async Task ExecuteAsync_Should_Let_The_Explicit_Password_Win_Over_A_Defau await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); } + [Test] + public async Task ExecuteAsync_Should_Decorate_The_Ambient_Location_Headers() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient, location: new LocationSelector + { + Directory = "/repo", + Workspace = "wrk_1", + }); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var request = handler.Requests.Single(); + await Assert.That(request.Headers["x-opencode-directory"]).IsEqualTo("%2Frepo"); + await Assert.That(request.Headers["x-opencode-workspace"]).IsEqualTo("wrk_1"); + } + + [Test] + public async Task ExecuteAsync_Should_Omit_An_Unset_Location_Member() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient, location: new LocationSelector { Directory = "/repo" }); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var request = handler.Requests.Single(); + await Assert.That(request.Headers["x-opencode-directory"]).IsEqualTo("%2Frepo"); + await Assert.That(request.Headers.ContainsKey("x-opencode-workspace")).IsFalse(); + } + + [Test] + public async Task ExecuteAsync_Should_Escape_A_Directory_Whose_Name_Contains_A_Percent_Sequence() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient, location: new LocationSelector + { + Directory = "/data/reports%20q3", + }); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + // The server decodes this header, so an unescaped '%20' would arrive as a space and + // silently address a different directory. + await Assert.That(handler.Requests.Single().Headers["x-opencode-directory"]) + .IsEqualTo("%2Fdata%2Freports%2520q3"); + } + + [Test] + public async Task ExecuteAsync_Should_Send_A_Non_Ascii_Directory_As_Ascii() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient, location: new LocationSelector + { + Directory = "/home/deniz/işler", + }); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + // Header values above Latin-1 are unsendable, so the escape is what keeps a + // non-ASCII path on the wire at all. + await Assert.That(handler.Requests.Single().Headers["x-opencode-directory"]) + .IsEqualTo("%2Fhome%2Fdeniz%2Fi%C5%9Fler"); + } + + [Test] + public async Task ExecuteAsync_Should_Omit_The_Location_Headers_Without_An_Ambient_Location() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var request = handler.Requests.Single(); + await Assert.That(request.Headers.ContainsKey("x-opencode-directory")).IsFalse(); + await Assert.That(request.Headers.ContainsKey("x-opencode-workspace")).IsFalse(); + } + + [Test] + public async Task ExecuteAsync_Should_Snapshot_The_Ambient_Location_At_Construction() + { + var options = new OpenCodeClientOptions + { + Endpoint = Endpoint, + Location = new LocationSelector { Directory = "/before" }, + }; + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = Pipeline.Create(httpClient, options); + options.Location = new LocationSelector { Directory = "/after" }; + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + await Assert.That(handler.Requests.Single().Headers["x-opencode-directory"]).IsEqualTo("%2Fbefore"); + } + [Test] public async Task ExecuteAsync_Should_Decorate_The_User_Agent_Per_Request() { @@ -539,12 +639,14 @@ private static Pipeline CreatePipeline( bool ownsHttpClient = false, Uri? endpoint = null, string? password = null, - string? username = null) + string? username = null, + LocationSelector? location = null) { var options = new OpenCodeClientOptions { Endpoint = endpoint ?? Endpoint, Password = password, + Location = location, }; if (username is not null) { diff --git a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs index 734b59687..11f5376e0 100644 --- a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs +++ b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs @@ -116,7 +116,48 @@ public async Task Add_Should_Skip_Every_Null_Optional() query.AddCount("limit", null, "request"); query.AddOrder("order", null); query.AddParentFilter("parentID", null); + query.AddLocation("location", null); await Assert.That(query.Value).IsEqualTo(string.Empty); } + + [Test] + public async Task AddLocation_Should_Write_Both_Members_With_Literal_Brackets() + { + var query = new QueryStringBuilder(); + + query.AddLocation("location", new LocationSelector { Directory = "/repo", Workspace = "wrk_1" }); + + await Assert.That(query.Value).IsEqualTo("?location[directory]=%2Frepo&location[workspace]=wrk_1"); + } + + [Test] + public async Task AddLocation_Should_Skip_Unset_Members() + { + var query = new QueryStringBuilder(); + + query.AddLocation("location", new LocationSelector { Workspace = "wrk_1" }); + + await Assert.That(query.Value).IsEqualTo("?location[workspace]=wrk_1"); + } + + [Test] + public async Task AddLocation_Should_Write_Nothing_For_An_Empty_Selector() + { + var query = new QueryStringBuilder(); + + query.AddLocation("location", new LocationSelector()); + + await Assert.That(query.Value).IsEqualTo(string.Empty); + } + + [Test] + public async Task AddLocation_Should_Escape_The_Member_Values() + { + var query = new QueryStringBuilder(); + + query.AddLocation("location", new LocationSelector { Directory = "a b&c=d" }); + + await Assert.That(query.Value).IsEqualTo("?location[directory]=a%20b%26c%3Dd"); + } } diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index 6b8c0439d..f32d9472c 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -137,6 +137,55 @@ public async Task ListMessagesAsync_Should_Return_The_500_Error_On_The_NoThrow_S await Assert.That(response.RawBody).Contains("boom"); } + [Test] + public async Task ListMessagesAsync_Should_Refuse_Order_Combined_With_Cursor_Before_Sending() + { + using var scenario = ContractScenario.Responding(); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_100").ListMessagesAsync(new MessageListRequest + { + Order = ListOrder.Ascending, + Cursor = "cur_1", + })) + .Throws(); + + await Assert.That(exception!.ParamName).IsEqualTo("request"); + await Assert.That(scenario.Requests).IsEmpty(); + } + + [Test] + public async Task RemoveSessionAsync_Should_Treat_The_204_As_A_Bodiless_Success() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + var response = await scenario.Client.Sessions.GetSessionClient("ses_100").RemoveSessionAsync(); + + await Assert.That(response.Status).IsEqualTo(204); + await Assert.That(response.IsError).IsFalse(); + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/session/ses_100")); + } + + [Test] + public async Task RenameSessionAsync_Should_Send_The_Typed_Body() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + var response = await scenario.Client.Sessions.GetSessionClient("ses_100").RenameSessionAsync(new SessionRenameRequest + { + Title = "Renamed session", + }); + + await Assert.That(response.Status).IsEqualTo(204); + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(request.RequestUri!.AbsoluteUri) + .IsEqualTo("http://localhost:4096/api/session/ses_100/rename"); + await Assert.That(request.Body).IsEqualTo("{\"title\":\"Renamed session\"}"); + } + [Test] public async Task ListMessagesAsync_Should_Throw_The_Declared_404_Error() { diff --git a/tests/OpenCode.Sdk.Tests/Shells/ShellClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Shells/ShellClientContractTests.cs new file mode 100644 index 000000000..e176ca061 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Shells/ShellClientContractTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +public sealed class ShellClientContractTests +{ + [Test] + public async Task GetShellAsync_Should_Return_The_Typed_Shell_With_Its_Location() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope(payload)); + + var response = await scenario.Client.Shells.GetShellClient("sh_100").GetShellAsync(); + + await Assert.That(response.Shell.Id).IsEqualTo("sh_100"); + await Assert.That(response.Shell.Time.Started).IsEqualTo(1755200000); + await Assert.That(response.Location.Project.Id).IsEqualTo("prj_1"); + await Assert.That(scenario.Requests.Single().RequestUri) + .IsEqualTo(new Uri("http://localhost:4096/api/shell/sh_100")); + } + + [Test] + public async Task RemoveShellAsync_Should_Treat_The_204_As_A_Bodiless_Success() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + var response = await scenario.Client.Shells.GetShellClient("sh_100").RemoveShellAsync(); + + await Assert.That(response.Status).IsEqualTo(204); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.RawBody).IsNull(); + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/shell/sh_100")); + } + + [Test] + public async Task RemoveShellAsync_Should_Treat_A_204_With_A_Body_As_A_Protocol_Failure() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, "{}"); + + _ = await Assert + .That(async () => _ = await scenario.Client.Shells.GetShellClient("sh_100").RemoveShellAsync()) + .Throws(); + } + + [Test] + public async Task RemoveShellAsync_Should_Treat_A_200_As_An_Undeclared_Success() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, "{}"); + + _ = await Assert + .That(async () => _ = await scenario.Client.Shells.GetShellClient("sh_100").RemoveShellAsync()) + .Throws(); + } + + [Test] + public async Task TimeoutShellAsync_Should_Send_The_Patch_Body_Beside_The_Location_Query() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope(payload)); + + _ = await scenario.Client.Shells.GetShellClient("sh_100").TimeoutShellAsync(new ShellTimeoutRequest + { + Timeout = 9000, + Location = new LocationSelector { Workspace = "wrk_1" }, + }); + + var request = scenario.Requests.Single(); + await Assert.That(request.Method.Method).IsEqualTo("PATCH"); + await Assert.That(request.RequestUri!.AbsoluteUri) + .IsEqualTo("http://localhost:4096/api/shell/sh_100/timeout?location[workspace]=wrk_1"); + await Assert.That(request.Body).IsEqualTo("{\"timeout\":9000}"); + } + + [Test] + public async Task GetShellAsync_Should_Throw_The_Declared_404_Error() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NotFound, WireBodyData.ShellNotFoundError); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Shells.GetShellClient("sh_9").GetShellAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(404); + await Assert.That(exception.Error).IsTypeOf(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Shells/ShellsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Shells/ShellsClientContractTests.cs new file mode 100644 index 000000000..4a13f0602 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Shells/ShellsClientContractTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +public sealed class ShellsClientContractTests +{ + [Test] + public async Task ListShellsAsync_Should_Return_The_Typed_Page_With_Its_Location() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope($"[{payload}]")); + + var response = await scenario.Client.Shells.ListShellsAsync(); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.Shells.Single().Id).IsEqualTo("sh_100"); + await Assert.That(response.Shells.Single().Status).IsEqualTo(ShellInfoStatus.Running); + await Assert.That(response.Location.Directory).IsEqualTo("/repo"); + await Assert.That(scenario.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/shell")); + } + + [Test] + public async Task ListShellsAsync_Should_Compose_The_Deep_Object_Location_Query() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope($"[{payload}]")); + + _ = await scenario.Client.Shells.ListShellsAsync(new ShellListRequest + { + Location = new LocationSelector { Directory = "/a b", Workspace = "wrk_1" }, + }); + + await Assert.That(scenario.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( + "http://localhost:4096/api/shell?location[directory]=%2Fa%20b&location[workspace]=wrk_1"); + } + + [Test] + public async Task CreateShellAsync_Should_Send_The_Body_Beside_The_Location_Query() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope(payload)); + + var response = await scenario.Client.Shells.CreateShellAsync(new ShellCreateRequest + { + Command = "ls -la", + Timeout = 5000, + Location = new LocationSelector { Directory = "/repo" }, + }); + + await Assert.That(response.Shell.Id).IsEqualTo("sh_100"); + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(request.RequestUri!.AbsoluteUri) + .IsEqualTo("http://localhost:4096/api/shell?location[directory]=%2Frepo"); + await Assert.That(request.Body).IsEqualTo("{\"command\":\"ls -la\",\"timeout\":5000,\"metadata\":{}}"); + } + + [Test] + public async Task CreateShellAsync_Should_Throw_The_Declared_400_Error() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.InvalidRequestError); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Shells.CreateShellAsync(new ShellCreateRequest + { + Command = "ls", + Timeout = 1, + })) + .Throws(); + + await Assert.That(exception!.Error).IsTypeOf(); + } + + [Test] + public async Task ListShellsAsync_Should_Treat_A_Missing_Location_Sibling_As_A_Protocol_Failure() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, $"{{\"data\":[{payload}]}}"); + + _ = await Assert + .That(async () => _ = await scenario.Client.Shells.ListShellsAsync()) + .Throws(); + } + + [Test] + [Arguments(" ")] + [Arguments(".")] + [Arguments("..")] + public async Task GetShellClient_Should_Refuse_An_Id_The_Route_Would_Refuse(string id) + { + using var scenario = ContractScenario.Responding(); + + var exception = Assert.Throws(() => _ = scenario.Client.Shells.GetShellClient(id)); + + await Assert.That(exception.ParamName).IsEqualTo("id"); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index f267e10c3..56c788f60 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -14,6 +14,7 @@ public interface IOpenCodeClientOptions { System.Uri? Endpoint { get; } + OpenCode.Sdk.LocationSelector? Location { get; } string? Password { get; } string Username { get; } } @@ -37,6 +38,12 @@ public int? Limit { get; init; } public OpenCode.Sdk.ListOrder? Order { get; init; } } + public sealed class LocationSelector : System.IEquatable + { + public LocationSelector() { } + public string? Directory { get; init; } + public string? Workspace { get; init; } + } public sealed class MessageListRequest : OpenCode.Sdk.ListRequest, System.IEquatable { public MessageListRequest() { } @@ -63,6 +70,7 @@ protected OpenCodeClient() { } public OpenCodeClient(OpenCode.Sdk.OpenCodeClientOptions options) { } public virtual OpenCode.Sdk.SessionsClient Sessions { get; } + public virtual OpenCode.Sdk.ShellsClient Shells { get; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } public virtual System.Threading.Tasks.Task GetHealthAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } @@ -71,6 +79,7 @@ { public OpenCodeClientOptions() { } public System.Uri? Endpoint { get; set; } + public OpenCode.Sdk.LocationSelector? Location { get; set; } public string? Password { get; set; } public string Username { get; set; } } @@ -107,10 +116,27 @@ public const string GetSessionTemplate = "/api/session/{sessionID}"; public const string ListMessagesTemplate = "/api/session/{sessionID}/message"; public const string ListSessionsTemplate = "/api/session"; + public const string RemoveSessionTemplate = "/api/session/{sessionID}"; + public const string RenameSessionTemplate = "/api/session/{sessionID}/rename"; public static string GetMessage(string sessionId, string messageId) { } public static string GetSession(string sessionId) { } public static string ListMessages(string sessionId, OpenCode.Sdk.MessageListRequest? request = null) { } public static string ListSessions(OpenCode.Sdk.SessionListRequest? request = null) { } + public static string RemoveSession(string sessionId) { } + public static string RenameSession(string sessionId) { } + } + public static class Shells + { + public const string CreateShellTemplate = "/api/shell"; + public const string GetShellTemplate = "/api/shell/{id}"; + public const string ListShellsTemplate = "/api/shell"; + public const string RemoveShellTemplate = "/api/shell/{id}"; + public const string TimeoutShellTemplate = "/api/shell/{id}/timeout"; + public static string CreateShell(OpenCode.Sdk.Models.ShellCreateRequest? request = null) { } + public static string GetShell(string id, OpenCode.Sdk.ShellRequest? request = null) { } + public static string ListShells(OpenCode.Sdk.ShellListRequest? request = null) { } + public static string RemoveShell(string id, OpenCode.Sdk.ShellRemoveRequest? request = null) { } + public static string TimeoutShell(string id, OpenCode.Sdk.Models.ShellTimeoutRequest? request = null) { } } } public class OpenCodeTransportException : OpenCode.Sdk.OpenCodeException @@ -125,6 +151,8 @@ public virtual System.Threading.Tasks.Task GetMessageAsync(string messageId, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task GetSessionAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task ListMessagesAsync(OpenCode.Sdk.MessageListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task RemoveSessionAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task RenameSessionAsync(OpenCode.Sdk.Models.SessionRenameRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } public sealed class SessionCreateResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { @@ -163,6 +191,14 @@ public static OpenCode.Sdk.SessionParentFilter RootOnly { get; } public static OpenCode.Sdk.SessionParentFilter Of(string sessionId) { } } + public sealed class SessionRemoveResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionRemoveResponse() { } + } + public sealed class SessionRenameResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionRenameResponse() { } + } public sealed class SessionResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { public SessionResponse() { } @@ -176,6 +212,67 @@ public virtual OpenCode.Sdk.SessionClient GetSessionClient(string sessionId) { } public virtual System.Threading.Tasks.Task ListSessionsAsync(OpenCode.Sdk.SessionListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } + public class ShellClient + { + protected ShellClient() { } + public virtual System.Threading.Tasks.Task GetShellAsync(OpenCode.Sdk.ShellRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task RemoveShellAsync(OpenCode.Sdk.ShellRemoveRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task TimeoutShellAsync(OpenCode.Sdk.Models.ShellTimeoutRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + } + public sealed class ShellCreateResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellCreateResponse() { } + public required OpenCode.Sdk.Models.LocationInfo Location { get; init; } + public required OpenCode.Sdk.Models.ShellInfo Shell { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public sealed class ShellListRequest : System.IEquatable + { + public ShellListRequest() { } + public OpenCode.Sdk.LocationSelector? Location { get; init; } + } + public sealed class ShellListResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellListResponse() { } + public required OpenCode.Sdk.Models.LocationInfo Location { get; init; } + public required System.Collections.Generic.IReadOnlyList Shells { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public sealed class ShellRemoveRequest : System.IEquatable + { + public ShellRemoveRequest() { } + public OpenCode.Sdk.LocationSelector? Location { get; init; } + } + public sealed class ShellRemoveResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellRemoveResponse() { } + } + public sealed class ShellRequest : System.IEquatable + { + public ShellRequest() { } + public OpenCode.Sdk.LocationSelector? Location { get; init; } + } + public sealed class ShellResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellResponse() { } + public required OpenCode.Sdk.Models.LocationInfo Location { get; init; } + public required OpenCode.Sdk.Models.ShellInfo Shell { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public sealed class ShellTimeoutResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellTimeoutResponse() { } + public required OpenCode.Sdk.Models.LocationInfo Location { get; init; } + public required OpenCode.Sdk.Models.ShellInfo Shell { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public class ShellsClient + { + protected ShellsClient() { } + public virtual System.Threading.Tasks.Task CreateShellAsync(OpenCode.Sdk.Models.ShellCreateRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual OpenCode.Sdk.ShellClient GetShellClient(string id) { } + public virtual System.Threading.Tasks.Task ListShellsAsync(OpenCode.Sdk.ShellListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + } } namespace OpenCode.Sdk.Models { @@ -225,6 +322,28 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + public sealed class LocationInfo : System.IEquatable + { + public LocationInfo() { } + [System.Text.Json.Serialization.JsonPropertyName("directory")] + public required string Directory { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("project")] + public required OpenCode.Sdk.Models.LocationInfoProject Project { get; init; } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("workspaceID")] + public string? WorkspaceId { get; init; } + } + public sealed class LocationInfoProject : System.IEquatable + { + public LocationInfoProject() { } + [System.Text.Json.Serialization.JsonPropertyName("canonical")] + public required string Canonical { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("directory")] + public required string Directory { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("id")] + public required string Id { get; init; } + } public sealed class LocationRef : System.IEquatable { public LocationRef() { } @@ -1055,6 +1174,12 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + public sealed class SessionRenameRequest : System.IEquatable + { + public SessionRenameRequest() { } + [System.Text.Json.Serialization.JsonPropertyName("title")] + public required string Title { get; init; } + } public sealed class SessionRevert : System.IEquatable { public SessionRevert() { } @@ -1083,6 +1208,90 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public required string Type { get; init; } } + public sealed class ShellCreateRequest : System.IEquatable + { + public ShellCreateRequest() { } + [System.Text.Json.Serialization.JsonPropertyName("command")] + public required string Command { get; init; } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("cwd")] + public string? Cwd { get; init; } + [System.Text.Json.Serialization.JsonIgnore] + public OpenCode.Sdk.LocationSelector? Location { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("metadata")] + public System.Collections.Generic.IReadOnlyDictionary Metadata { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("timeout")] + public required long Timeout { get; init; } + } + public sealed class ShellInfo : System.IEquatable + { + public ShellInfo() { } + [System.Text.Json.Serialization.JsonPropertyName("command")] + public required string Command { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("cwd")] + public required string Cwd { get; init; } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingValueJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("exit")] + public double? Exit { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("file")] + public required string File { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("id")] + public required string Id { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("metadata")] + public required System.Collections.Generic.IReadOnlyDictionary Metadata { get; init; } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingValueJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("pid")] + public long? Pid { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("shell")] + public required string Shell { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("status")] + public required OpenCode.Sdk.Models.ShellInfoStatus Status { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("time")] + public required OpenCode.Sdk.Models.ShellInfoTime Time { get; init; } + } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] + public enum ShellInfoStatus + { + [System.Text.Json.Serialization.JsonStringEnumMemberName("running")] + Running = 0, + [System.Text.Json.Serialization.JsonStringEnumMemberName("exited")] + Exited = 1, + [System.Text.Json.Serialization.JsonStringEnumMemberName("timeout")] + Timeout = 2, + [System.Text.Json.Serialization.JsonStringEnumMemberName("killed")] + Killed = 3, + } + public sealed class ShellInfoTime : System.IEquatable + { + public ShellInfoTime() { } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingValueJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("completed")] + public double? Completed { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("started")] + public required double Started { get; init; } + } + public sealed class ShellNotFoundError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable + { + public ShellNotFoundError() { } + [System.Text.Json.Serialization.JsonPropertyName("id")] + public required string Id { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("message")] + public required string Message { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("_tag")] + public override string Tag { get; } + } + public sealed class ShellTimeoutRequest : System.IEquatable + { + public ShellTimeoutRequest() { } + [System.Text.Json.Serialization.JsonIgnore] + public OpenCode.Sdk.LocationSelector? Location { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("timeout")] + public required long Timeout { get; init; } + } public sealed class TokenUsageInfo : System.IEquatable { public TokenUsageInfo() { } diff --git a/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs index 591828e75..6f7b6cb46 100644 --- a/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs +++ b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs @@ -17,6 +17,11 @@ internal static class WireBodyData public const string InvalidRequestError = "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad\"}"; + public const string ShellNotFoundError = "{\"_tag\":\"ShellNotFoundError\",\"id\":\"sh_9\",\"message\":\"gone\"}"; + + public const string ResolvedLocation = + "{\"directory\":\"/repo\",\"project\":{\"id\":\"prj_1\",\"directory\":\"/repo\",\"canonical\":\"/repo\"}}"; + public static string Envelope(string datum) { ArgumentNullException.ThrowIfNull(datum); @@ -24,6 +29,14 @@ public static string Envelope(string datum) return $"{{\"data\":{datum}}}"; } + public static string LocationEnvelope(string datum, string location = ResolvedLocation) + { + ArgumentNullException.ThrowIfNull(datum); + ArgumentNullException.ThrowIfNull(location); + + return $"{{\"location\":{location},\"data\":{datum}}}"; + } + public static string Page(string items, string? previous = null, string? next = null) { ArgumentNullException.ThrowIfNull(items); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json index 08843da76..c23a6b6ab 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json @@ -12,5 +12,6 @@ "aliasOf": "InvalidRequestError", "reason": "Upstream spec-gen emits a structurally identical duplicate carrying the same _tag." } - ] + ], + "mutuallyExclusiveQueries": [] } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json index 92753efb2..548744ec1 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json @@ -6,5 +6,6 @@ }, "envelopePayloadNames": {}, "propertyOverrides": [], - "schemaAliases": [] + "schemaAliases": [], + "mutuallyExclusiveQueries": [] } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs index 84c1d20b1..e41e1c57e 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs @@ -13,6 +13,11 @@ public sealed class OperationNamePolicyTests [Arguments("v2.session.list", "get", "ListSessionsAsync")] [Arguments("v2.session.create", "post", "CreateSessionAsync")] [Arguments("v2.message.list", "get", "ListMessagesAsync")] + [Arguments("v2.session.remove", "delete", "RemoveSessionAsync")] + [Arguments("v2.session.rename", "post", "RenameSessionAsync")] + [Arguments("v2.shell.list", "get", "ListShellsAsync")] + [Arguments("v2.shell.timeout", "patch", "TimeoutShellAsync")] + [Arguments("v2.shell.output", "get", "GetOutputAsync")] public async Task MethodName_Should_Lead_With_The_Structural_Verb(string operationId, string method, string expected) { await Assert.That(OperationNamePolicy.MethodName(Operation(operationId, method))).IsEqualTo(expected); @@ -38,6 +43,10 @@ public async Task MethodName_Should_Return_Null_When_The_Group_Cannot_Be_Plurali [Arguments("v2.session.list", "get", "SessionListResponse")] [Arguments("v2.session.create", "post", "SessionCreateResponse")] [Arguments("v2.message.list", "get", "MessageListResponse")] + [Arguments("v2.session.remove", "delete", "SessionRemoveResponse")] + [Arguments("v2.shell.timeout", "patch", "ShellTimeoutResponse")] + [Arguments("v2.shell.output", "get", "ShellOutputResponse")] + [Arguments("v2.shell.get", "get", "ShellResponse")] public async Task ResponseTypeName_Should_Fold_Non_Get_Verbs(string operationId, string method, string expected) { await Assert.That(OperationNamePolicy.ResponseTypeName(Operation(operationId, method))).IsEqualTo(expected); @@ -81,6 +90,10 @@ public async Task PayloadName_Should_Return_Null_When_The_Group_Cannot_Be_Plural [Arguments("v2.session.list", "get", "SessionListRequest")] [Arguments("v2.message.list", "get", "MessageListRequest")] [Arguments("v2.session.create", "post", "SessionCreateRequest")] + [Arguments("v2.session.rename", "post", "SessionRenameRequest")] + [Arguments("v2.shell.timeout", "patch", "ShellTimeoutRequest")] + [Arguments("v2.shell.output", "get", "ShellOutputRequest")] + [Arguments("v2.shell.get", "get", "ShellRequest")] public async Task RequestTypeName_Should_Compose_Group_Subject_And_Verb(string operationId, string method, string expected) { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index b958877f9..716bd7f00 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -13,13 +13,17 @@ public async Task Bind_Should_Create_The_Selected_Pinned_Client_Plans() var plan = await new BindingTestHost().BindPinnedAsync(); await Assert.That(plan.Clients.Select(static client => client.Name) - .SequenceEqual(["OpenCodeClient", "SessionClient", "SessionsClient"], StringComparer.Ordinal)).IsTrue(); + .SequenceEqual( + ["OpenCodeClient", "SessionClient", "SessionsClient", "ShellClient", "ShellsClient"], + StringComparer.Ordinal)).IsTrue(); await Assert.That(plan.Clients.All(static client => client.Namespace == "OpenCode.Sdk")).IsTrue(); var root = plan.Clients.Single(static client => client.Role == ClientRole.Root); await Assert.That(root.Name).IsEqualTo("OpenCodeClient"); - await Assert.That(root.SubClients.Single().PropertyName).IsEqualTo("Sessions"); - await Assert.That(root.SubClients.Single().TypeName).IsEqualTo("SessionsClient"); + await Assert.That(root.SubClients.Select(static subClient => subClient.PropertyName) + .SequenceEqual(["Sessions", "Shells"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(root.SubClients.Select(static subClient => subClient.TypeName) + .SequenceEqual(["SessionsClient", "ShellsClient"], StringComparer.Ordinal)).IsTrue(); await Assert.That(root.HandleFactory).IsNull(); await Assert.That(root.HandleParameter).IsNull(); @@ -45,8 +49,7 @@ await Assert.That(health.ErrorMap.Statuses.Select(static status => status.Status await Assert.That(root.ContainerName).IsNull(); - var sessions = plan.Clients.Single(static client => client.Role == ClientRole.Collection); - await Assert.That(sessions.Name).IsEqualTo("SessionsClient"); + var sessions = plan.Clients.Single(static client => client.Name == "SessionsClient"); await Assert.That(sessions.ContainerName).IsEqualTo("Sessions"); await Assert.That(sessions.Operations.Select(static operation => operation.MethodName) .SequenceEqual(["CreateSessionAsync", "ListSessionsAsync"], StringComparer.Ordinal)).IsTrue(); @@ -77,18 +80,66 @@ await Assert.That(list.ErrorMap.Statuses[0].Tags.Select(static tag => tag.TypeNa await Assert.That(create.Envelope.PayloadName).IsEqualTo("Session"); } + [Test] + public async Task Bind_Should_Create_The_Selected_Pinned_Shell_Plans() + { + var plan = await new BindingTestHost().BindPinnedAsync(); + + var shells = plan.Clients.Single(static client => client.Name == "ShellsClient"); + await Assert.That(shells.ContainerName).IsEqualTo("Shells"); + await Assert.That(shells.Operations.Select(static operation => operation.MethodName) + .SequenceEqual(["CreateShellAsync", "ListShellsAsync"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(shells.HandleFactory!.MethodName).IsEqualTo("GetShellClient"); + await Assert.That(shells.HandleFactory.Parameter.WireName).IsEqualTo("id"); + + var createShell = shells.Operations.Single(static operation => operation.MethodName == "CreateShellAsync"); + await Assert.That(createShell.RequestBody!.TypeName).IsEqualTo("ShellCreateRequest"); + await Assert.That(createShell.QueryRequest!.RidesRequestBody).IsTrue(); + await Assert.That(createShell.QueryRequest.TypeName).IsEqualTo("ShellCreateRequest"); + await Assert.That(createShell.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocation); + await Assert.That(createShell.Envelope.PayloadTypeName).IsEqualTo("ShellInfo"); + await Assert.That(createShell.Envelope.LocationTypeName).IsEqualTo("LocationInfo"); + + var listShells = shells.Operations.Single(static operation => operation.MethodName == "ListShellsAsync"); + await Assert.That(listShells.QueryRequest!.TypeName).IsEqualTo("ShellListRequest"); + await Assert.That(listShells.QueryRequest.RidesRequestBody).IsFalse(); + await Assert.That(listShells.QueryRequest.Properties.Single().Kind).IsEqualTo(QueryValueKind.Location); + await Assert.That(listShells.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocationList); + } + [Test] public async Task Bind_Should_Create_The_Selected_Pinned_Handle_Plans() { var plan = await new BindingTestHost().BindPinnedAsync(); - var session = plan.Clients.Single(static client => client.Role == ClientRole.Handle); - await Assert.That(session.Name).IsEqualTo("SessionClient"); + var session = plan.Clients.Single(static client => client.Name == "SessionClient"); await Assert.That(session.ContainerName).IsEqualTo("Sessions"); await Assert.That(session.HandleParameter!.WireName).IsEqualTo("sessionID"); await Assert.That(session.HandleParameter.IsHandleParameter).IsTrue(); await Assert.That(session.Operations.Select(static operation => operation.MethodName) - .SequenceEqual(["GetMessageAsync", "GetSessionAsync", "ListMessagesAsync"], StringComparer.Ordinal)).IsTrue(); + .SequenceEqual( + ["GetMessageAsync", "GetSessionAsync", "ListMessagesAsync", "RemoveSessionAsync", "RenameSessionAsync"], + StringComparer.Ordinal)).IsTrue(); + + var remove = session.Operations.Single(static operation => operation.MethodName == "RemoveSessionAsync"); + await Assert.That(remove.HttpMethod).IsEqualTo("delete"); + await Assert.That(remove.Envelope.Kind).IsEqualTo(EnvelopeKind.NoContent); + await Assert.That(remove.Envelope.SuccessStatusCode).IsEqualTo(204); + await Assert.That(remove.Envelope.ResponseTypeName).IsEqualTo("SessionRemoveResponse"); + + var shell = plan.Clients.Single(static client => client.Name == "ShellClient"); + await Assert.That(shell.HandleParameter!.WireName).IsEqualTo("id"); + await Assert.That(shell.Operations.Select(static operation => operation.MethodName) + .SequenceEqual(["GetShellAsync", "RemoveShellAsync", "TimeoutShellAsync"], StringComparer.Ordinal)).IsTrue(); + + var timeout = shell.Operations.Single(static operation => operation.MethodName == "TimeoutShellAsync"); + await Assert.That(timeout.HttpMethod).IsEqualTo("patch"); + await Assert.That(timeout.RequestBody!.TypeName).IsEqualTo("ShellTimeoutRequest"); + await Assert.That(timeout.QueryRequest!.RidesRequestBody).IsTrue(); + + var getShell = shell.Operations.Single(static operation => operation.MethodName == "GetShellAsync"); + await Assert.That(getShell.QueryRequest!.TypeName).IsEqualTo("ShellRequest"); + await Assert.That(getShell.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocation); var messages = session.Operations.Single(static operation => operation.MethodName == "ListMessagesAsync"); await Assert.That(messages.QueryRequest!.TypeName).IsEqualTo("MessageListRequest"); @@ -448,6 +499,65 @@ await Assert.That(plan.Registry.TypeNames .SequenceEqual(["WidgetInfo", "WidgetListResponseEnvelope"], StringComparer.Ordinal)).IsTrue(); } + [Test] + public async Task Bind_Should_Bind_A_Data_Location_Envelope() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario()); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocation); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("WidgetInfo"); + await Assert.That(list.Envelope.LocationTypeName).IsEqualTo("PlaceInfo"); + await Assert.That(list.Envelope.EnvelopeDtoTypeName).IsEqualTo("WidgetListResponseEnvelope"); + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["PlaceInfo", "WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(plan.Registry.TypeNames.Contains("WidgetListResponseEnvelope", StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Bind_A_Data_Location_List_Envelope() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario( + data: static property => property.Type("array").Items(static item => item.Ref("WidgetInfo")))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocationList); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("WidgetInfo"); + await Assert.That(list.Envelope.LocationTypeName).IsEqualTo("PlaceInfo"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Data_Location_Envelope_With_An_Optional_Location() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario(locationRequired: false)); + + await AssertWidgetRefusalAsync(document, "require exactly"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Data_Location_Sibling_Without_A_Named_Schema() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario( + location: static property => property.Type("object") + .Property("directory", static inner => inner.Type("string"), required: true))); + + await AssertWidgetRefusalAsync(document, "location sibling"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Data_Location_List_Of_Promoted_Inline_Items() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario( + data: static property => property.Type("array").Items(static item => item.Type("object") + .Property("id", static inner => inner.Type("string"), required: true)))); + + await AssertWidgetRefusalAsync(document, "named component schema"); + } + [Test] public async Task Bind_Should_Refuse_An_Envelope_Dto_Name_Colliding_With_A_Model() { @@ -703,6 +813,82 @@ public async Task Bind_Should_Refuse_An_Object_Query_Parameter() await AssertWidgetRefusalAsync(document, "unsupported schema shape"); } + [Test] + public async Task Bind_Should_Record_A_Curated_Mutually_Exclusive_Query_Pair() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.widget.list"), + Curation( + Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)), + mutuallyExclusiveQueries: [ExclusiveQuery("v2.widget.list", "order", "cursor")])); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + var pair = list.QueryRequest!.MutuallyExclusivePairs.Single(); + await Assert.That(pair.FirstWireName).IsEqualTo("order"); + await Assert.That(pair.SecondWireName).IsEqualTo("cursor"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Mutually_Exclusive_Row_When_The_Operation_Binds_No_Query_Surface() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(static _ => { })); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.list"), + Curation( + Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)), + mutuallyExclusiveQueries: [ExclusiveQuery("v2.widget.list", "order", "cursor")]))); + + await Assert.That(exception.Errors.Any(static error => + error.Problem.Contains("does not carry query parameter", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Mutually_Exclusive_Row_Naming_An_Absent_Parameter() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.list"), + Curation( + Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)), + mutuallyExclusiveQueries: [ExclusiveQuery("v2.widget.list", "order", "cursor")]))); + + await Assert.That(exception.Errors.Any(static error => + error.Problem.Contains("does not carry query parameter", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Merge_A_Location_Query_Into_The_Request_Body_Model() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.create", method: "post", path: "/api/widget", configure: operation => operation + .Parameter("location", "query", QueryScenarioData.NullableLocationSelector, deepObject: true) + .RequestBody("application/json", body => body.Type("object") + .Property("title", property => property.Type("string"), required: true), required: true) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + var plan = BindWidgets(document, "v2.widget.create"); + + var create = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(create.RequestBody!.TypeName).IsEqualTo("WidgetCreateRequest"); + await Assert.That(create.QueryRequest!.RidesRequestBody).IsTrue(); + await Assert.That(create.QueryRequest.TypeName).IsEqualTo("WidgetCreateRequest"); + await Assert.That(create.QueryRequest.Properties.Single().Kind).IsEqualTo(QueryValueKind.Location); + var model = (ObjectModelPlan)plan.Models.Single(static model => model.Name is "WidgetCreateRequest"); + await Assert.That(model.RequestQueryProperties.Single().PropertyName).IsEqualTo("Location"); + } + [Test] public async Task Bind_Should_Refuse_A_Deep_Object_Query_Parameter() { @@ -712,6 +898,84 @@ public async Task Bind_Should_Refuse_A_Deep_Object_Query_Parameter() await AssertWidgetRefusalAsync(document, "deep-object"); } + [Test] + public async Task Bind_Should_Bind_The_Location_Selector_Deep_Object() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("location", "query", QueryScenarioData.NullableLocationSelector, deepObject: true))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + var property = list.QueryRequest!.Properties.Single(); + await Assert.That(property.Kind).IsEqualTo(QueryValueKind.Location); + await Assert.That(property.PropertyName).IsEqualTo("Location"); + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Deep_Object_Outside_The_Location_Selector_Shape() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("location", "query", QueryScenarioData.NullableSelectorWithExtraMember, deepObject: true))); + + await AssertWidgetRefusalAsync(document, "location selector"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Required_Location_Selector() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("location", "query", QueryScenarioData.NullableLocationSelector, required: true, deepObject: true))); + + await AssertWidgetRefusalAsync(document, "location selector"); + } + + [Test] + public async Task Bind_Should_Bind_A_Delete_Operation_With_A_No_Content_Success() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithOperation("v2.widget.remove", method: "delete", path: "/api/widget/{id}", configure: operation => + { + _ = operation.Parameter("id", "path", schema => schema.Type("string"), required: true) + .WithoutResponse(200) + .Response(204); + }))); + + var plan = BindWidgets(document, "v2.widget.remove"); + + var remove = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(remove.MethodName).IsEqualTo("RemoveWidgetAsync"); + await Assert.That(remove.HttpMethod).IsEqualTo("delete"); + await Assert.That(remove.Envelope.Kind).IsEqualTo(EnvelopeKind.NoContent); + } + + [Test] + public async Task Bind_Should_Refuse_A_Request_Body_On_A_Delete_Operation() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithOperation("v2.widget.remove", method: "delete", path: "/api/widget", configure: operation => operation + .RequestBody("application/json", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithoutResponse(200) + .Response(204)))); + + await AssertWidgetRefusalAsync(document, "must not carry a request body", "v2.widget.remove"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Patch_Operation_Without_A_Request_Body() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.timeout", method: "patch", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + await AssertWidgetRefusalAsync(document, "must carry a request body", "v2.widget.timeout"); + } + [Test] public async Task Bind_Should_Refuse_A_Request_Body_On_A_Get_Operation() { @@ -752,6 +1016,45 @@ public async Task Bind_Should_Refuse_A_Non_200_Success() await AssertOperationRefusalAsync(document, "v2.health.get", "status 200"); } + [Test] + public async Task Bind_Should_Bind_A_No_Content_Success_Into_A_Payload_Free_Envelope() + { + var document = await BindingTestHost.IngestAsync(NoContentScenario()); + + var plan = BindWidgets(document, "v2.widget.create"); + + var create = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(create.Envelope.Kind).IsEqualTo(EnvelopeKind.NoContent); + await Assert.That(create.Envelope.SuccessStatusCode).IsEqualTo(204); + await Assert.That(create.Envelope.ResponseTypeName).IsEqualTo("WidgetCreateResponse"); + await Assert.That(create.Envelope.PayloadName).IsNull(); + await Assert.That(create.Envelope.PayloadTypeName).IsNull(); + await Assert.That(create.Envelope.EnvelopeDtoTypeName).IsNull(); + await Assert.That(plan.Registry.TypeNames.Contains("WidgetCreateResponseEnvelope", StringComparer.Ordinal)).IsFalse(); + } + + [Test] + public async Task Bind_Should_Record_The_200_Success_Status_On_The_Envelope() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(static _ => { })); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Envelope.SuccessStatusCode).IsEqualTo(200); + } + + [Test] + public async Task Bind_Should_Refuse_A_No_Content_Success_Carrying_Content() + { + var document = await BindingTestHost.IngestAsync(NoContentScenario(static operation => _ = operation + .WithoutResponse(204) + .Response(204, "application/json", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)))); + + await AssertWidgetRefusalAsync(document, "must not carry content", "v2.widget.create"); + } + [Test] public async Task Bind_Should_Refuse_A_Success_Without_Json_Content() { @@ -770,7 +1073,7 @@ public async Task Bind_Should_Refuse_An_Unsupported_Envelope_Shape() .WithOperation("v2.health.get", configure: operation => operation .Response(200, "application/json", schema => schema.Type("object") .Property("data", property => property.Ref("ItemInfo"), required: true) - .Property("location", property => property.Type("string"), required: true))))); + .Property("hasMore", property => property.Type("boolean"), required: true))))); await AssertOperationRefusalAsync(document, "v2.health.get", "envelope shape"); } @@ -1118,6 +1421,32 @@ private static SpecScenario WidgetCreateScenario(Action configure .RequestBody(mediaType, configureBody, required) .Response(200, "application/json", schema => schema.Ref("WidgetInfo")))); + private static SpecScenario DataLocationScenario(Action? data = null, + Action? location = null, bool locationRequired = true) => + SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("PlaceInfo", schema => schema.Type("object") + .Property("directory", property => property.Type("string"), required: true)) + .WithSchema("WidgetEnvelope", schema => schema.Type("object") + .AdditionalPropertiesFalse() + .Property("location", location ?? (static property => property.Ref("PlaceInfo")), required: locationRequired) + .Property("data", data ?? (static property => property.Ref("WidgetInfo")), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetEnvelope")))); + + private static SpecScenario NoContentScenario(Action? configure = null) => + SpecScenario.Define(spec => spec + .WithOperation("v2.widget.create", method: "post", path: "/api/widget", configure: operation => + { + _ = operation + .RequestBody("application/json", schema => schema.Type("object") + .Property("title", property => property.Type("string"), required: true), required: true) + .WithoutResponse(200) + .Response(204); + configure?.Invoke(operation); + })); + private static EmitPlan BindWidgets(SpecDocument document, string operationId = "v2.widget.list") => new BindingTestHost().Bind( document, @@ -1149,6 +1478,31 @@ public static void NullableParentFilter(SchemaBuilder schema) => schema.AnyOf( static inner => inner.Type("string").AllOf(static constraint => constraint.Raw("pattern", "\"^wid\"")), static inner => inner.Type("string").Enum("null")), static branch => branch.Type("null")); + + public static void NullableLocationSelector(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.Type("object") + .AdditionalPropertiesFalse() + .Property("directory", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))) + .Property("workspace", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))), + static branch => branch.Type("null")); + + public static void NullableSelectorWithExtraMember(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.Type("object") + .AdditionalPropertiesFalse() + .Property("directory", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))) + .Property("workspace", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))) + .Property("project", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))), + static branch => branch.Type("null")); } private static SpecScenario GadgetScenario(Action? extend = null, bool parametersReversed = false) => diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index 9037e22d1..ea5db2c31 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -16,6 +16,7 @@ public sealed class SpecBinderTests "InvalidRequestError", "MessageNotFoundError", "SessionNotFoundError", + "ShellNotFoundError", "UnauthorizedError", "UnknownError", ]; diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs index 17e458799..ec2510149 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs @@ -30,6 +30,6 @@ public async Task Emit_Should_Reject_Wire_Null_Only_Where_The_Schema_Forbids_It( await Assert.That(source).Contains("[JsonConverter(typeof(WireNullRejectingJsonConverter))]"); await Assert.That(source).Contains("[JsonConverter(typeof(WireNullRejectingValueJsonConverter))]"); var occurrences = source.Split("WireNullRejecting").Length - 1; - await Assert.That(occurrences).IsEqualTo(2); + await Assert.That(occurrences).IsEqualTo(3); } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt index 88023a648..03dae63b5 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt @@ -248,6 +248,47 @@ public class WidgetsClient /// The server could not be reached or returned a malformed success body. public virtual Task CreateWidgetAsync(WidgetCreateRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Widgets.CreateWidget, request ?? new WidgetCreateRequest(), OpenCodeJsonContext.Default.WidgetCreateRequest, WidgetCreateResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Widgets.CreateWidget(request), request ?? new WidgetCreateRequest(), OpenCodeJsonContext.Default.WidgetCreateRequest, WidgetCreateResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Remove one widget + /// + /// The 'widgetID' route value. + /// The per-call options. + /// The cancellation token. + /// The 'WidgetRemoveResponse' envelope. + /// The API returned an error status (declared: 400) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RemoveWidgetAsync(string widgetId, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(widgetId); + return Pipeline.ExecuteAsync(HttpMethod.Delete, OpenCodeRoutes.Widgets.RemoveWidget(widgetId), WidgetRemoveResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Get one widget spot + /// + /// The per-call options. + /// The cancellation token. + /// The 'WidgetSpotResponse' envelope. + /// The API returned an error status and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task GetWidgetSpotAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetWidgetSpot, WidgetSpotResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List widget spots + /// + /// The per-call options. + /// The cancellation token. + /// The 'WidgetSpotListResponse' envelope. + /// The API returned an error status and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListWidgetSpotsAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListWidgetSpots, WidgetSpotListResponseAdapter.Instance, requestOptions, cancellationToken); } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt index 7f0ad3e99..49e81b1a0 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt @@ -69,3 +69,42 @@ internal sealed record WidgetListResponseEnvelope [JsonPropertyName("cursor")] public required ListCursor Cursor { get; init; } } + +// File: Internal/Serialization/WidgetSpotListResponseEnvelope.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'WidgetSpotListResponse' through one deserialization pass. +/// +internal sealed record WidgetSpotListResponseEnvelope +{ + [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("location")] + public required ExamplePlace Location { get; init; } +} + +// File: Internal/Serialization/WidgetSpotResponseEnvelope.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'WidgetSpotResponse' through one deserialization pass. +/// +internal sealed record WidgetSpotResponseEnvelope +{ + [JsonPropertyName("data")] + public required ExampleItem Data { get; init; } + + [JsonPropertyName("location")] + public required ExamplePlace Location { get; init; } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt index 1d14b6950..a2ef08bcf 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt @@ -383,3 +383,172 @@ public sealed record WidgetOverviewResponse : OpenCodeResponse return true; } } + +// File: Widgets/WidgetRemoveResponse.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'DELETE /api/widget/{widgetID}' operation. +/// +public sealed record WidgetRemoveResponse : OpenCodeResponse +{ + /// + /// Initializes a success instance of the 'WidgetRemoveResponse' envelope. + /// + public WidgetRemoveResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetRemoveResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + } +} + +// File: Widgets/WidgetSpotListResponse.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'GET /api/widget-spot-list' operation. +/// +public sealed record WidgetSpotListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _spots; + private readonly ExamplePlace? _location; + /// + /// Initializes a success instance of the 'WidgetSpotListResponse' envelope. + /// + public WidgetSpotListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetSpotListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Spots = null!; + Location = null!; + } + + /// + /// Gets the Spots payload; guarded on the error path. + /// + public required IReadOnlyList Spots { get => _spots ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Spots."); init => _spots = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required ExamplePlace Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_spots is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Spots = ").Append(_spots); + return true; + } +} + +// File: Widgets/WidgetSpotResponse.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'GET /api/widget-spot' operation. +/// +public sealed record WidgetSpotResponse : OpenCodeResponse +{ + private readonly ExampleItem? _spot; + private readonly ExamplePlace? _location; + /// + /// Initializes a success instance of the 'WidgetSpotResponse' envelope. + /// + public WidgetSpotResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetSpotResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Spot = null!; + Location = null!; + } + + /// + /// Gets the Spot payload; guarded on the error path. + /// + public required ExampleItem Spot { get => _spot ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Spot."); init => _spot = value; } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required ExamplePlace Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_spot is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Spot = ").Append(_spot); + return true; + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt index f8a8c691f..6306c1d1c 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt @@ -104,3 +104,30 @@ public enum ExampleMode [JsonStringEnumMemberName("safe")] Safe } + +// File: Models/WidgetCreateRequest.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Shapes the widget-create request body. +/// +public sealed record WidgetCreateRequest +{ + /// + /// Gets the widget title. + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? Title { get; init; } + + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + [JsonIgnore] + public LocationSelector? Location { get; init; } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt index b962e6df1..8c17d588a 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt @@ -34,4 +34,8 @@ public sealed record WidgetListRequest /// Gets the 'parentID' query value; the server default applies when unset. /// public SessionParentFilter? ParentId { get; init; } + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt index 79b2b0321..27ac0c21b 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt @@ -12,6 +12,7 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(ExampleEvent))] [JsonSerializable(typeof(ExampleItem))] [JsonSerializable(typeof(ExampleMode))] +[JsonSerializable(typeof(ExamplePlace))] [JsonSerializable(typeof(OpenCodeError))] [JsonSerializable(typeof(UnknownExampleEvent))] [JsonSerializable(typeof(UnknownOpenCodeError))] @@ -20,6 +21,8 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(WidgetItemListResponseEnvelope))] [JsonSerializable(typeof(WidgetItemResponseEnvelope))] [JsonSerializable(typeof(WidgetListResponseEnvelope))] +[JsonSerializable(typeof(WidgetSpotListResponseEnvelope))] +[JsonSerializable(typeof(WidgetSpotResponseEnvelope))] internal sealed partial class OpenCodeJsonContext : JsonSerializerContext { } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt index 3d464fcf9..8a80c3da1 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt @@ -251,3 +251,129 @@ internal sealed class WidgetOverviewResponseAdapter : ResponseAdapter new WidgetOverviewResponse(status, ReadTolerantError(rawBody, null), rawBody)}; } } + +// File: Internal/ResponseAdapters/WidgetRemoveResponseAdapter.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'DELETE /api/widget/{widgetID}' responses onto 'WidgetRemoveResponse'. +/// +internal sealed class WidgetRemoveResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["BadRequestError"]; + private WidgetRemoveResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetRemoveResponseAdapter Instance { get; } = new WidgetRemoveResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetRemoveResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 204 => rawBody.Length is 0 ? new WidgetRemoveResponse + { + Status = status + } + + : throw NonEmptyNoContentFailure(status), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new WidgetRemoveResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + _ => new WidgetRemoveResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } +} + +// File: Internal/ResponseAdapters/WidgetSpotListResponseAdapter.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'GET /api/widget-spot-list' responses onto 'WidgetSpotListResponse'. +/// +internal sealed class WidgetSpotListResponseAdapter : ResponseAdapter +{ + private WidgetSpotListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetSpotListResponseAdapter Instance { get; } = new WidgetSpotListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetSpotListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetSpotListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + _ => new WidgetSpotListResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } + + private static WidgetSpotListResponse CreateSuccess(int status, WidgetSpotListResponseEnvelope envelope) => new WidgetSpotListResponse + { + Status = status, + Spots = envelope.Data, + Location = envelope.Location + }; +} + +// File: Internal/ResponseAdapters/WidgetSpotResponseAdapter.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'GET /api/widget-spot' responses onto 'WidgetSpotResponse'. +/// +internal sealed class WidgetSpotResponseAdapter : ResponseAdapter +{ + private WidgetSpotResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetSpotResponseAdapter Instance { get; } = new WidgetSpotResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetSpotResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetSpotResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + _ => new WidgetSpotResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } + + private static WidgetSpotResponse CreateSuccess(int status, WidgetSpotResponseEnvelope envelope) => new WidgetSpotResponse + { + Status = status, + Spot = envelope.Data, + Location = envelope.Location + }; +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt index 6245a73e2..aa8501f25 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt @@ -3,6 +3,7 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System; using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -27,9 +28,27 @@ public static class OpenCodeRoutes public static class Widgets { /// - /// The 'POST /api/widget' route. + /// The 'POST /api/widget' route template. /// - public const string CreateWidget = "/api/widget"; + public const string CreateWidgetTemplate = "/api/widget"; + /// + /// Builds the '/api/widget' route. + /// + /// The request shaping the query. + /// The escaped route. + public static string CreateWidget(WidgetCreateRequest? request = null) + { + var path = "/api/widget"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + /// /// The 'GET /api/widget/{widgetID}/item/{itemID}' route template. /// @@ -62,6 +81,10 @@ public static class OpenCodeRoutes /// public const string GetOverview = "/api/widget-overview"; /// + /// The 'GET /api/widget-spot' route. + /// + public const string GetWidgetSpot = "/api/widget-spot"; + /// /// The 'GET /api/widget/{widgetID}/item' route template. /// public const string ListItemsTemplate = "/api/widget/{widgetID}/item"; @@ -92,6 +115,10 @@ public static class OpenCodeRoutes return path + query.Value; } + /// + /// The 'GET /api/widget-spot-list' route. + /// + public const string ListWidgetSpots = "/api/widget-spot-list"; /// /// The 'GET /api/widget' route template. /// @@ -109,12 +136,38 @@ public static class OpenCodeRoutes return path; } + if (request.Order is not null && request.Cursor is not null) + { + throw new ArgumentException("The 'order' and 'cursor' query values cannot combine.", nameof(request)); + } + var query = new QueryStringBuilder(); query.AddCount("limit", request.Limit, nameof(request)); query.AddOrder("order", request.Order); query.AddText("cursor", request.Cursor); query.AddParentFilter("parentID", request.ParentId); + query.AddLocation("location", request.Location); return path + query.Value; } + + /// + /// The 'DELETE /api/widget/{widgetID}' route template. + /// + public const string RemoveWidgetTemplate = "/api/widget/{widgetID}"; + /// + /// Builds the '/api/widget/{widgetID}' route. + /// + /// The 'widgetID' route value. + /// The escaped route. + public static string RemoveWidget(string widgetId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(widgetId); + if (widgetId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(widgetId)); + } + + return "/api/widget/" + Uri.EscapeDataString(widgetId); + } } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs index d317d7036..fe893519d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs @@ -14,13 +14,23 @@ public static GenerationCuration Curation( IReadOnlyDictionary groups, IReadOnlyDictionary? envelopePayloadNames = null, IReadOnlyList? propertyOverrides = null, - IReadOnlyList? schemaAliases = null) => + IReadOnlyList? schemaAliases = null, + IReadOnlyList? mutuallyExclusiveQueries = null) => new() { Groups = groups, EnvelopePayloadNames = envelopePayloadNames ?? new Dictionary(StringComparer.Ordinal), PropertyOverrides = propertyOverrides ?? [], SchemaAliases = schemaAliases ?? [], + MutuallyExclusiveQueries = mutuallyExclusiveQueries ?? [], + }; + + public static MutuallyExclusiveQuery ExclusiveQuery(string operation, string first, string second) => + new() + { + Operation = operation, + Parameters = [first, second], + Reason = "The pinned server refuses the pair.", }; public static SchemaAlias Alias(string schema, string aliasOf, string reason = "The upstream spec emits a duplicate component.") => diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 77b18ea33..8d82f77db 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -10,6 +10,7 @@ public static EmitPlan Create() var models = new ModelPlan[] { CreateExampleItem(), + CreateExamplePlace(), CreateExampleMode(), CreateVariant("CreatedEvent", "created", "item", "Item", Named("ExampleItem")), CreateVariant("DeletedEvent", "deleted", "id", "ID", Named("string")), @@ -38,6 +39,7 @@ public static EmitPlan Create() "ExampleEvent", "ExampleItem", "ExampleMode", + "ExamplePlace", "OpenCodeError", "UnknownExampleEvent", "UnknownOpenCodeError", @@ -46,6 +48,8 @@ public static EmitPlan Create() "WidgetItemListResponseEnvelope", "WidgetItemResponseEnvelope", "WidgetListResponseEnvelope", + "WidgetSpotListResponseEnvelope", + "WidgetSpotResponseEnvelope", ], }, PendingOperations = [], @@ -92,7 +96,15 @@ public static IReadOnlyList CreateClientPlans() => HandleTypeName = "WidgetClient", Parameter = CreateWidgetParameter(), }, - Operations = [CreateOverviewOperation(), CreateWidgetListOperation(), CreateWidgetCreateOperation()], + Operations = + [ + CreateOverviewOperation(), + CreateWidgetListOperation(), + CreateWidgetCreateOperation(), + CreateWidgetRemoveOperation(), + CreateWidgetSpotOperation(), + CreateWidgetSpotListOperation(), + ], }, ]; @@ -112,6 +124,7 @@ private static OperationPlan CreatePingOperation() => PayloadName = "Ping", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.Bare, + SuccessStatusCode = 200, }, ErrorMap = new ErrorMapPlan { @@ -144,6 +157,7 @@ private static OperationPlan CreateOverviewOperation() => PayloadName = "Overview", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.Bare, + SuccessStatusCode = 200, }, ErrorMap = new ErrorMapPlan { @@ -179,6 +193,7 @@ private static OperationPlan CreateItemOperation() => PayloadName = "Item", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.Data, + SuccessStatusCode = 200, EnvelopeDtoTypeName = "WidgetItemResponseEnvelope", }, ErrorMap = new ErrorMapPlan @@ -234,6 +249,11 @@ private static OperationPlan CreateWidgetListOperation() => QueryProperty("order", "Order", QueryValueKind.ListOrder), QueryProperty("cursor", "Cursor", QueryValueKind.Text), QueryProperty("parentID", "ParentId", QueryValueKind.SessionParentFilter), + QueryProperty("location", "Location", QueryValueKind.Location), + ], + MutuallyExclusivePairs = + [ + new ExclusiveQueryPairPlan { FirstWireName = "order", SecondWireName = "cursor", }, ], }, Envelope = new EnvelopePlan @@ -243,6 +263,7 @@ private static OperationPlan CreateWidgetListOperation() => PayloadName = "Widgets", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.CursorList, + SuccessStatusCode = 200, EnvelopeDtoTypeName = "WidgetListResponseEnvelope", }, ErrorMap = new ErrorMapPlan @@ -293,6 +314,7 @@ private static OperationPlan CreateItemListOperation() => PayloadName = "Items", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.CursorList, + SuccessStatusCode = 200, EnvelopeDtoTypeName = "WidgetItemListResponseEnvelope", }, ErrorMap = new ErrorMapPlan @@ -320,6 +342,13 @@ private static OperationPlan CreateWidgetCreateOperation() => RouteContainerName = "Widgets", RouteMemberName = "CreateWidget", Parameters = [], + QueryRequest = new QueryRequestPlan + { + TypeName = "WidgetCreateRequest", + DerivesFromListRequest = false, + RidesRequestBody = true, + Properties = [QueryProperty("location", "Location", QueryValueKind.Location)], + }, RequestBody = new RequestBodyPlan { TypeName = "WidgetCreateRequest", @@ -333,6 +362,7 @@ private static OperationPlan CreateWidgetCreateOperation() => PayloadName = "Widget", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.Data, + SuccessStatusCode = 200, EnvelopeDtoTypeName = "WidgetCreateResponseEnvelope", }, ErrorMap = new ErrorMapPlan @@ -350,6 +380,110 @@ private static OperationPlan CreateWidgetCreateOperation() => Description = null, }; + private static OperationPlan CreateWidgetRemoveOperation() => + new() + { + MethodName = "RemoveWidgetAsync", + HttpMethod = "delete", + RouteTemplate = "/api/widget/{widgetID}", + RouteContainerName = "Widgets", + RouteMemberName = "RemoveWidget", + Parameters = + [ + new OperationParameterPlan + { + WireName = "widgetID", + Name = "widgetId", + TypeName = "string", + IsHandleParameter = false, + }, + ], + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetRemoveResponse", + AdapterTypeName = "WidgetRemoveResponseAdapter", + PayloadName = null, + PayloadTypeName = null, + Kind = EnvelopeKind.NoContent, + SuccessStatusCode = 204, + }, + ErrorMap = new ErrorMapPlan + { + Statuses = + [ + new ErrorStatusPlan + { + StatusCode = 400, + Tags = [new ErrorTagPlan { Tag = "BadRequestError", TypeName = "BadRequestError", }], + }, + ], + }, + Summary = "Remove one widget", + Description = null, + }; + + private static OperationPlan CreateWidgetSpotOperation() => + new() + { + MethodName = "GetWidgetSpotAsync", + HttpMethod = "get", + RouteTemplate = "/api/widget-spot", + RouteContainerName = "Widgets", + RouteMemberName = "GetWidgetSpot", + Parameters = [], + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetSpotResponse", + AdapterTypeName = "WidgetSpotResponseAdapter", + PayloadName = "Spot", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.DataLocation, + SuccessStatusCode = 200, + EnvelopeDtoTypeName = "WidgetSpotResponseEnvelope", + LocationTypeName = "ExamplePlace", + }, + ErrorMap = new ErrorMapPlan { Statuses = [], }, + Summary = "Get one widget spot", + Description = null, + }; + + private static OperationPlan CreateWidgetSpotListOperation() => + new() + { + MethodName = "ListWidgetSpotsAsync", + HttpMethod = "get", + RouteTemplate = "/api/widget-spot-list", + RouteContainerName = "Widgets", + RouteMemberName = "ListWidgetSpots", + Parameters = [], + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetSpotListResponse", + AdapterTypeName = "WidgetSpotListResponseAdapter", + PayloadName = "Spots", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.DataLocationList, + SuccessStatusCode = 200, + EnvelopeDtoTypeName = "WidgetSpotListResponseEnvelope", + LocationTypeName = "ExamplePlace", + }, + ErrorMap = new ErrorMapPlan { Statuses = [], }, + Summary = "List widget spots", + Description = null, + }; + + private static ObjectModelPlan CreateExamplePlace() => + new() + { + Name = "ExamplePlace", + Namespace = "OpenCode.Sdk.Models", + Description = "Represents the resolved example place.", + Properties = + [ + Property("directory", "Directory", Named("string"), isRequired: true, "Gets the resolved directory."), + ], + }; + private static QueryPropertyPlan QueryProperty(string wireName, string propertyName, QueryValueKind kind, bool isInherited = false) => new() @@ -370,6 +504,7 @@ private static ObjectModelPlan CreateWidgetCreateRequest() => [ Property("title", "Title", Named("string", isNullable: true), isRequired: false, "Gets the widget title."), ], + RequestQueryProperties = [QueryProperty("location", "Location", QueryValueKind.Location)], }; public static EmitPlan CreateModelSnapshot() @@ -382,6 +517,7 @@ public static EmitPlan CreateModelSnapshot() plan.Models.Single(static model => model.Name == "BadRequestError"), plan.Models.Single(static model => model.Name == "ExampleItem"), plan.Models.Single(static model => model.Name == "ExampleMode"), + plan.Models.Single(static model => model.Name == "WidgetCreateRequest"), ], Unions = [plan.Unions.Single(static union => union.Name == "OpenCodeError")], }; diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs index 321752b47..45d177fae 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs @@ -24,7 +24,8 @@ internal static class GenerationTestData }, "envelopePayloadNames": {}, "propertyOverrides": [], - "schemaAliases": [] + "schemaAliases": [], + "mutuallyExclusiveQueries": [] } """; diff --git a/tests/Shared/RecordedRequest.cs b/tests/Shared/RecordedRequest.cs index 1f9bd271e..6e0f6faaf 100644 --- a/tests/Shared/RecordedRequest.cs +++ b/tests/Shared/RecordedRequest.cs @@ -6,6 +6,9 @@ internal sealed record RecordedRequest public required HttpMethod Method { get; init; } + /// Gets every request header, values joined with commas. + public required IReadOnlyDictionary Headers { get; init; } + public string? Authorization { get; init; } public string? UserAgent { get; init; } diff --git a/tests/Shared/RecordingHttpHandler.cs b/tests/Shared/RecordingHttpHandler.cs index 9296d3126..7a0b05c72 100644 --- a/tests/Shared/RecordingHttpHandler.cs +++ b/tests/Shared/RecordingHttpHandler.cs @@ -33,6 +33,10 @@ protected override async Task SendAsync(HttpRequestMessage { RequestUri = request.RequestUri, Method = request.Method, + Headers = request.Headers.ToDictionary( + static header => header.Key, + static header => string.Join(',', header.Value), + StringComparer.OrdinalIgnoreCase), Authorization = request.Headers.Authorization?.ToString(), UserAgent = request.Headers.UserAgent.Count is 0 ? null : request.Headers.UserAgent.ToString(), ContentType = request.Content?.Headers.ContentType?.ToString(), diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs index cf0ca3362..c3cb35218 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -24,6 +24,7 @@ public void Validate(SpecDocument document, IReadOnlyList selecte ValidateEnvelopeNames(selectedIds, documentIds, curation, errors); ValidatePropertyOverrides(document, reachable, curation, errors); ValidateSchemaAliases(document, reachable, curation, errors); + ValidateMutuallyExclusiveQueries(selectedIds, documentIds, curation, errors); } private static void ValidateGroups(IReadOnlyList selected, HashSet selectedGroups, @@ -260,7 +261,7 @@ private static void ValidateSchemaAliases(SpecDocument document, ReachableSchema errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schema is not referenced by the selected profile"); } - if (!SchemaNodeComparer.DeepEquals(source, target)) + if (!SchemaNodeComparer.DeepEquals(source, target, document.Schemas)) { errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schemas must be structurally identical"); } @@ -275,5 +276,39 @@ private static void ValidateSchemaAliases(SpecDocument document, ReachableSchema } } + /// + /// The mutual-exclusion relation lives only in spec prose, so a curated row carries it; + /// pairs only — larger groups wait for an operation that needs one. + /// + private static void ValidateMutuallyExclusiveQueries(HashSet selectedIds, + HashSet documentIds, GenerationCuration curation, BindingErrorCollector errors) + { + foreach (var row in curation.MutuallyExclusiveQueries) + { + if (string.IsNullOrWhiteSpace(row.Reason)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query rows must state a reason"); + } + + if (!documentIds.Contains(row.Operation)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query row names an operation absent from the spec"); + continue; + } + + if (!selectedIds.Contains(row.Operation)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query row names an operation outside the selected profile"); + } + + if (row.Parameters.Count is not 2 + || row.Parameters.Distinct(StringComparer.Ordinal).Count() is not 2 + || row.Parameters.Any(string.IsNullOrWhiteSpace)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query rows must name exactly two distinct parameters"); + } + } + } + private static string GetGroup(SpecOperation operation) => operation.Segments[0]; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs index 61ee295f5..bdd9d2ffa 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs @@ -11,4 +11,13 @@ internal enum EnvelopeKind /// The body carries a data array beside a previous/next cursor object. CursorList = 2, + + /// The success carries no body at all; the response has no payload property. + NoContent = 3, + + /// The body wraps a data object beside a required location echo. + DataLocation = 4, + + /// The body carries a data array beside a required location echo. + DataLocationList = 5, } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs index 06b2a0c1b..365b52bef 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs @@ -6,16 +6,29 @@ internal sealed record EnvelopePlan public required string AdapterTypeName { get; init; } - public required string PayloadName { get; init; } + /// Gets the payload property name, or for a no-content success. + public required string? PayloadName { get; init; } - /// Gets the payload type name; for cursor lists, the element type of the data array. - public required string PayloadTypeName { get; init; } + /// + /// Gets the payload type name — for cursor lists, the element type of the data + /// array — or for a no-content success. + /// + public required string? PayloadTypeName { get; init; } public required EnvelopeKind Kind { get; init; } + /// Gets the single declared success status the adapter accepts. + public required int SuccessStatusCode { get; init; } + /// /// Gets the internal single-pass deserialization DTO type name for wrapped envelopes, - /// or when the body is the payload itself. + /// or when the body is the payload itself or absent. /// public string? EnvelopeDtoTypeName { get; init; } + + /// + /// Gets the type name of the required location envelope sibling, or + /// when the envelope carries none. + /// + public string? LocationTypeName { get; init; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ExclusiveQueryPairPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ExclusiveQueryPairPlan.cs new file mode 100644 index 000000000..d5f767185 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ExclusiveQueryPairPlan.cs @@ -0,0 +1,9 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// A curated pair of query parameters the route builder refuses to combine. +internal sealed record ExclusiveQueryPairPlan +{ + public required string FirstWireName { get; init; } + + public required string SecondWireName { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs index 3e2895f71..e253e5be8 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs @@ -48,4 +48,15 @@ public required IReadOnlyList SchemaAliases field = Array.AsReadOnly([.. value]); } } = Array.AsReadOnly(Array.Empty()); + + [JsonPropertyName("mutuallyExclusiveQueries")] + public required IReadOnlyList MutuallyExclusiveQueries + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/MutuallyExclusiveQuery.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/MutuallyExclusiveQuery.cs new file mode 100644 index 000000000..8239efb09 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/MutuallyExclusiveQuery.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// +/// Declares two query parameters of one operation the server refuses to combine; the +/// relation lives only in the spec's prose, so it cannot be derived mechanically. +/// +internal sealed record MutuallyExclusiveQuery +{ + [JsonPropertyName("operation")] + public required string Operation { get; init; } + + [JsonPropertyName("parameters")] + public required IReadOnlyList Parameters + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + [JsonPropertyName("reason")] + public required string Reason { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs index 7cd90e7d5..6ae7608e9 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs @@ -13,4 +13,18 @@ public required IReadOnlyList Properties } = Array.AsReadOnly(Array.Empty()); public string? BaseTypeName { get; init; } + + /// + /// Gets the query-side properties a merged operation request carries beside its body + /// properties; they never serialize and the route builder consumes them instead. + /// + public IReadOnlyList RequestQueryProperties + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs index 606637d21..1e10e27b1 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs @@ -7,6 +7,23 @@ internal sealed record QueryRequestPlan /// Gets a value indicating whether the record derives from the ListRequest base. public required bool DerivesFromListRequest { get; init; } + /// + /// Gets a value indicating whether the query properties ride the operation's request + /// body model instead of a standalone record; the type names match by construction. + /// + public bool RidesRequestBody { get; init; } + + /// Gets the curated query pairs the route builder refuses to combine. + public IReadOnlyList MutuallyExclusivePairs + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + /// Gets every bound query parameter in wire order, including base-inherited ones. public required IReadOnlyList Properties { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs index 0ba45fa96..c7a43ebfa 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs @@ -14,4 +14,7 @@ internal enum QueryValueKind /// The parent-session filter whose wire admits an identifier or the literal "null". SessionParentFilter = 3, + + /// The deepObject location selector exposed as the shared LocationSelector spine type. + Location = 4, } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs index a7312a27b..3328f4efd 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs @@ -13,7 +13,7 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; internal static class OperationNamePolicy { /// Identifier segments recognized as operation verbs when they close the identifier. - private static readonly string[] KnownVerbSegments = ["create", "get", "list"]; + private static readonly string[] KnownVerbSegments = ["create", "get", "list", "remove", "rename", "timeout"]; /// Gets the operation verb: a recognized final identifier segment, or the HTTP method. public static string Verb(SpecOperation operation) @@ -67,11 +67,14 @@ public static string ResponseTypeName(SpecOperation operation) return subject.Length is 0 ? GroupFallback(operation) : subject; } + /// Folds the Get verb exactly like the response name, so the pair stays uniform. public static string RequestTypeName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); - return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}{Verb(operation)}Request"; + var verb = Verb(operation); + var verbSuffix = string.Equals(verb, "Get", StringComparison.Ordinal) ? string.Empty : verb; + return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}{verbSuffix}Request"; } /// diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 4393f417c..a7ddabb21 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -18,6 +18,7 @@ internal sealed class OperationPlanBinder "ListCursor", "ListOrder", "ListRequest", + "LocationSelector", "OpenCodeApiException", "OpenCodeClientOptions", "OpenCodeException", @@ -51,6 +52,7 @@ internal sealed class OperationPlanBinder "GetHashCode", "GetType", "IsError", + "Location", "MemberwiseClone", "PrintMembers", "RawBody", @@ -235,8 +237,10 @@ private void CheckTypeNameCollisions(List clients, List - operation.Plan.QueryRequest is not null && !owners.Add(operation.Plan.QueryRequest.TypeName))) + operation.Plan.QueryRequest is { RidesRequestBody: false } query && !owners.Add(query.TypeName))) { errors.Add( BindingErrorCategory.Naming, @@ -292,13 +296,25 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation return null; } - var success = _operation.Responses.Single(static response => response.StatusCode is 200); + var success = _operation.Responses.Single(static response => response.StatusCode is 200 or 204); var envelope = BindEnvelope(success); var errorMap = BindErrorMap(); var parameters = BindParameters(row); var optionalPlanErrorsBefore = _errors.Count; var queryRequest = BindQueryRequest(); var requestBody = BindRequestBody(); + if (queryRequest is not null && requestBody is not null) + { + // A body and query merge into one uniform request model only for the + // location channel; every other mix keeps the deliberate wall. + if (queryRequest.Properties.Any(static property => property.Kind is not QueryValueKind.Location)) + { + Refuse("operations mixing a request body and query parameters are supported only for the location selector"); + return null; + } + + queryRequest = queryRequest with { RidesRequestBody = true }; + } var methodName = OperationNamePolicy.MethodName(_operation); var routeMemberName = OperationNamePolicy.RouteMemberName(_operation, row.Placement); if (methodName is null || routeMemberName is null) @@ -341,7 +357,9 @@ private bool CheckWireShape() var before = _errors.Count; var isGet = string.Equals(_operation.Method, "get", StringComparison.Ordinal); var isPost = string.Equals(_operation.Method, "post", StringComparison.Ordinal); - if (!isGet && !isPost) + var isDelete = string.Equals(_operation.Method, "delete", StringComparison.Ordinal); + var isPatch = string.Equals(_operation.Method, "patch", StringComparison.Ordinal); + if (!isGet && !isPost && !isDelete && !isPatch) { Refuse($"HTTP method '{_operation.Method}' is not supported"); } @@ -361,22 +379,14 @@ private bool CheckWireShape() Refuse("event-stream responses are not supported in M1"); } - if (isGet && _operation.RequestBody is not null) - { - Refuse("GET operations must not carry a request body"); - } - - if (isPost && _operation.RequestBody is null) + if ((isGet || isDelete) && _operation.RequestBody is not null) { - Refuse("POST operations must carry a request body"); + Refuse($"{_operation.Method.ToUpperInvariant()} operations must not carry a request body"); } - // Refused independently of any name collision: no admitted operation shape - // carries both a request body and query parameters. - if (_operation.RequestBody is not null - && _operation.Parameters.Any(static parameter => parameter.Location is SpecParameterLocation.Query)) + if ((isPost || isPatch) && _operation.RequestBody is null) { - Refuse("operations mixing a request body and query parameters are not supported"); + Refuse($"{_operation.Method.ToUpperInvariant()} operations must carry a request body"); } CheckParameterShapes(); @@ -408,9 +418,9 @@ private void CheckStatusShape() { Refuse("operation must declare exactly one success response"); } - else if (successes[0].StatusCode is not 200) + else if (successes[0].StatusCode is not (200 or 204)) { - Refuse("the success response must use status 200"); + Refuse("the success response must use status 200 or a content-free 204"); } foreach (var response in _operation.Responses @@ -422,6 +432,11 @@ private void CheckStatusShape() private EnvelopePlan? BindEnvelope(SpecResponse success) { + if (success.StatusCode is 204) + { + return BindNoContentEnvelope(success); + } + if (success.ContentType is not { IsJson: true } || success.Schema is null) { Refuse("the success response must carry a JSON schema"); @@ -433,15 +448,53 @@ private void CheckStatusShape() SpecEnvelopeShape.Bare => BindBarePayload(success.Schema), SpecEnvelopeShape.Data => BindDataPayload(success.Schema), SpecEnvelopeShape.CursorData => BindCursorListPayload(success.Schema), - SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocation or SpecEnvelopeShape.DataHasMore or _ => + SpecEnvelopeShape.DataLocation => BindDataLocationPayload(success.Schema), + SpecEnvelopeShape.None or SpecEnvelopeShape.DataHasMore or _ => RefuseNull($"envelope shape '{success.EnvelopeShape}' is not supported"), }; + var locationTypeName = success.EnvelopeShape is SpecEnvelopeShape.DataLocation + ? BindLocationSibling(success.Schema) + : null; + if (success.EnvelopeShape is SpecEnvelopeShape.DataLocation && locationTypeName is null) + { + return null; + } + if (payload is null) { return null; } var responseTypeName = OperationNamePolicy.ResponseTypeName(_operation); + var payloadName = DerivePayloadName(responseTypeName); + if (payloadName is null) + { + return null; + } + + var kind = success.EnvelopeShape switch + { + SpecEnvelopeShape.Data => EnvelopeKind.Data, + SpecEnvelopeShape.CursorData => EnvelopeKind.CursorList, + SpecEnvelopeShape.DataLocation => DataLocationKind(success.Schema), + SpecEnvelopeShape.Bare or SpecEnvelopeShape.None + or SpecEnvelopeShape.DataHasMore or _ => EnvelopeKind.Bare, + }; + return new EnvelopePlan + { + ResponseTypeName = responseTypeName, + AdapterTypeName = $"{responseTypeName}Adapter", + PayloadName = payloadName, + PayloadTypeName = payload, + Kind = kind, + SuccessStatusCode = 200, + EnvelopeDtoTypeName = kind is EnvelopeKind.Bare ? null : $"{responseTypeName}Envelope", + LocationTypeName = locationTypeName, + }; + } + + private string? DerivePayloadName(string responseTypeName) + { var payloadName = _curation.EnvelopePayloadNames.TryGetValue(_operation.OperationId, out var curated) ? curated : OperationNamePolicy.PayloadName(_operation); @@ -471,21 +524,27 @@ private void CheckStatusShape() return null; } - var kind = success.EnvelopeShape switch + return payloadName; + } + + private EnvelopePlan? BindNoContentEnvelope(SpecResponse success) + { + if (success.ContentType is not null || success.Schema is not null) { - SpecEnvelopeShape.Data => EnvelopeKind.Data, - SpecEnvelopeShape.CursorData => EnvelopeKind.CursorList, - SpecEnvelopeShape.Bare or SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocation - or SpecEnvelopeShape.DataHasMore or _ => EnvelopeKind.Bare, - }; + Refuse("a 204 success must not carry content"); + return null; + } + + var responseTypeName = OperationNamePolicy.ResponseTypeName(_operation); return new EnvelopePlan { ResponseTypeName = responseTypeName, AdapterTypeName = $"{responseTypeName}Adapter", - PayloadName = payloadName, - PayloadTypeName = payload, - Kind = kind, - EnvelopeDtoTypeName = kind is EnvelopeKind.Bare ? null : $"{responseTypeName}Envelope", + PayloadName = null, + PayloadTypeName = null, + Kind = EnvelopeKind.NoContent, + SuccessStatusCode = 204, + EnvelopeDtoTypeName = null, }; } @@ -543,6 +602,102 @@ SpecEnvelopeShape.Bare or SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocati return itemName; } + private string? BindDataLocationPayload(SchemaNode schema) + { + var wrapper = ResolveDataLocationWrapper(schema); + if (wrapper is null) + { + return null; + } + + var data = wrapper.Properties.Single(static property => property.Name is "data"); + if (data.Schema is RefNode datum + && !datum.Target.Contains('#', StringComparison.Ordinal) + && _typeNames.TryGetValue(datum.Target, out var datumName)) + { + return datumName; + } + + // Items must reference top-level components: a promoted inline item would take its + // name from the excluded response root, so the dialect keeps list items nominal. + if (data.Schema is ArrayNode { Item: RefNode item } + && !item.Target.Contains('#', StringComparison.Ordinal) + && _typeNames.TryGetValue(item.Target, out var itemName)) + { + return itemName; + } + + return RefuseNull("location envelope 'data' must reference a named component schema, or be an array of one"); + } + + /// + /// The payload binder owns the wrapper walls; the sibling and kind readers resolve + /// leniently because a malformed wrapper is already refused once. + /// + private ObjectNode? ResolveDataLocationWrapper(SchemaNode schema) + { + if (schema is not RefNode reference + || !_document.Schemas.TryGetValue(reference.Target, out var target) + || target is not ObjectNode wrapper) + { + return RefuseNull("location envelope must reference an object schema"); + } + + var data = wrapper.Properties.FirstOrDefault(static property => property.Name is "data"); + var location = wrapper.Properties.FirstOrDefault(static property => property.Name is "location"); + if (wrapper.Properties.Count is not 2 || data is not { IsRequired: true } || location is not { IsRequired: true }) + { + return RefuseNull("location envelope must require exactly 'data' and 'location'"); + } + + return wrapper; + } + + private string? BindLocationSibling(SchemaNode schema) + { + if (schema is not RefNode reference + || !_document.Schemas.TryGetValue(reference.Target, out var target) + || target is not ObjectNode wrapper + || wrapper.Properties.FirstOrDefault(static property => property.Name is "location") is not { IsRequired: true } location) + { + return null; + } + + // A promoted inline sibling would take its name from the excluded response root, + // so the dialect keeps the location echo nominal. + return location.Schema is RefNode sibling + && !sibling.Target.Contains('#', StringComparison.Ordinal) + && _typeNames.TryGetValue(sibling.Target, out var name) + ? name + : RefuseNull("the location sibling must reference a named component schema"); + } + + private EnvelopeKind DataLocationKind(SchemaNode schema) => + schema is RefNode reference + && _document.Schemas.TryGetValue(reference.Target, out var target) + && target is ObjectNode wrapper + && wrapper.Properties.FirstOrDefault(static property => property.Name is "data")?.Schema is ArrayNode + ? EnvelopeKind.DataLocationList + : EnvelopeKind.DataLocation; + + /// + /// Recognizes the dual-channel location selector structurally — exactly the + /// optional-nullable string directory and workspace members — so the + /// route serializer's fixed member set stays safe. + /// + private bool IsLocationSelectorShape(SchemaNode schema) + { + if (Resolve(schema) is not ObjectNode selector + || selector.Properties.Count is not 2 + || selector.Properties.Select(static property => property.Name).Distinct(StringComparer.Ordinal).Count() is not 2) + { + return false; + } + + return selector.Properties.All(property => property is { IsRequired: false, Name: "directory" or "workspace" } + && Resolve(property.Schema) is NullableNode { Inner: PrimitiveNode { Kind: PrimitiveKind.String } }); + } + /// Recognizes the wire cursor contract: exactly optional-nullable string previous and next. private bool IsListCursorShape(SchemaNode schema) { @@ -722,6 +877,9 @@ private int TemplatePosition(string wireName) .ToArray(); if (query.Length is 0) { + // An operation with no query surface still answers for its curated rows; + // otherwise a row naming it would go dormant instead of refusing. + _ = BindExclusivePairs([]); return null; } @@ -730,7 +888,12 @@ private int TemplatePosition(string wireName) { if (parameter.IsDeepObject) { - Refuse($"query parameter '{parameter.Name}' must not use deep-object encoding"); + var location = BindLocationSelector(parameter); + if (location is not null) + { + properties.Add(location); + } + continue; } @@ -783,6 +946,56 @@ private int TemplatePosition(string wireName) TypeName = OperationNamePolicy.RequestTypeName(_operation), DerivesFromListRequest = properties.Count > 0 && properties[0].IsInherited, Properties = properties, + MutuallyExclusivePairs = BindExclusivePairs(properties), + }; + } + + /// The validator owns the row shape; this wall pins each name to the bound query surface. + private List BindExclusivePairs(List properties) + { + var pairs = new List(); + foreach (var parameters in _curation.MutuallyExclusiveQueries + .Where(row => string.Equals(row.Operation, _operation.OperationId, StringComparison.Ordinal)) + .Select(static row => row.Parameters)) + { + var missing = parameters.FirstOrDefault(parameter => + !properties.Any(property => string.Equals(property.WireName, parameter, StringComparison.Ordinal))); + if (missing is not null) + { + Refuse($"the operation does not carry query parameter '{missing}' named by its mutually-exclusive row"); + continue; + } + + if (parameters.Count is 2) + { + pairs.Add(new ExclusiveQueryPairPlan + { + FirstWireName = parameters[0], + SecondWireName = parameters[1], + }); + } + } + + return pairs; + } + + /// The one admitted deep-object encoding is the optional nullable location selector. + private QueryPropertyPlan? BindLocationSelector(SpecParameter parameter) + { + if (parameter.IsRequired + || Resolve(parameter.Schema) is not NullableNode nullable + || !IsLocationSelectorShape(nullable.Inner)) + { + Refuse($"query parameter '{parameter.Name}' uses deep-object encoding outside the optional location selector shape"); + return null; + } + + return new QueryPropertyPlan + { + WireName = parameter.Name, + PropertyName = CSharpNamePolicy.ToPascalCase(parameter.Name), + Kind = QueryValueKind.Location, + IsInherited = false, }; } @@ -885,6 +1098,13 @@ private bool IsParentFilterShape(SchemaNode first, SchemaNode second) return null; } + private T? RefuseNull(string problem) + where T : class + { + Refuse(problem); + return null; + } + private IReadOnlyList? RefuseNullTags(string problem) { Refuse(problem); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs index b455ec48a..6f2f30249 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs @@ -57,7 +57,8 @@ public void VisitResponse(string operationId, SpecResponse response) // Data-carrying wrappers are envelope structure, never models — component-named // wrappers join the op-inline roots in the excluded set. var isInlineRoot = reference.Target.StartsWith($"op:{operationId}#", StringComparison.Ordinal); - var isWrapperShape = response.EnvelopeShape is SpecEnvelopeShape.Data or SpecEnvelopeShape.CursorData; + var isWrapperShape = response.EnvelopeShape + is SpecEnvelopeShape.Data or SpecEnvelopeShape.CursorData or SpecEnvelopeShape.DataLocation; if (isInlineRoot || isWrapperShape) { _ = _responseRoots.Add(reference.Target); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs index d9de7bcce..f429c3392 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs @@ -4,69 +4,109 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// /// Structural equality over projected schema trees; node kinds without an explicit rule -/// compare unequal, so aliasing them refuses instead of guessing. +/// compare unequal, so aliasing them refuses instead of guessing. Promoted inline schemas +/// carry no nominal identity, so their references compare by resolved structure while +/// nominal component references keep target identity. /// internal static class SchemaNodeComparer { - public static bool DeepEquals(SchemaNode left, SchemaNode right) + public static bool DeepEquals(SchemaNode left, SchemaNode right, IReadOnlyDictionary graph) { ArgumentNullException.ThrowIfNull(left); ArgumentNullException.ThrowIfNull(right); + ArgumentNullException.ThrowIfNull(graph); - // Format is type-affecting ('format: uri' emits Uri, not string), so it joins - // identity ahead of the kind switch and rides every recursive comparison. - if (!string.Equals(left.Format, right.Format, StringComparison.Ordinal)) + return new GraphComparison(graph).NodesEqual(left, right); + } + + private sealed class GraphComparison(IReadOnlyDictionary graph) + { + private readonly IReadOnlyDictionary _graph = + graph ?? throw new ArgumentNullException(nameof(graph)); + + private readonly HashSet _visitedPairs = new(StringComparer.Ordinal); + + public bool NodesEqual(SchemaNode left, SchemaNode right) { - return false; + // Format is type-affecting ('format: uri' emits Uri, not string), so it joins + // identity ahead of the kind switch and rides every recursive comparison. + if (!string.Equals(left.Format, right.Format, StringComparison.Ordinal)) + { + return false; + } + + return (left, right) switch + { + (PrimitiveNode a, PrimitiveNode b) => a.Kind == b.Kind, + (LiteralNode a, LiteralNode b) => a.Kind == b.Kind && a.Dialect == b.Dialect + && string.Equals(a.Value, b.Value, StringComparison.Ordinal), + (EnumNode a, EnumNode b) => a.Values.SequenceEqual(b.Values, StringComparer.Ordinal), + (RefNode a, RefNode b) => RefsEqual(a, b), + (NullableNode a, NullableNode b) => NodesEqual(a.Inner, b.Inner), + (ArrayNode a, ArrayNode b) => NodesEqual(a.Item, b.Item), + (TupleNode a, TupleNode b) => SequencesEqual(a.Items, b.Items), + (DictionaryNode a, DictionaryNode b) => NodesEqual(a.Value, b.Value), + (UnionNode a, UnionNode b) => a.Keyword == b.Keyword && a.Classification == b.Classification + && SequencesEqual(a.Branches, b.Branches), + (ObjectNode a, ObjectNode b) => ObjectsEqual(a, b), + (FreeFormObjectNode, FreeFormObjectNode) => true, + (UnrestrictedNode, UnrestrictedNode) => true, + _ => false, + }; } - return (left, right) switch + private bool RefsEqual(RefNode left, RefNode right) { - (PrimitiveNode a, PrimitiveNode b) => a.Kind == b.Kind, - (LiteralNode a, LiteralNode b) => a.Kind == b.Kind && a.Dialect == b.Dialect - && string.Equals(a.Value, b.Value, StringComparison.Ordinal), - (EnumNode a, EnumNode b) => a.Values.SequenceEqual(b.Values, StringComparer.Ordinal), - (RefNode a, RefNode b) => string.Equals(a.Target, b.Target, StringComparison.Ordinal), - (NullableNode a, NullableNode b) => DeepEquals(a.Inner, b.Inner), - (ArrayNode a, ArrayNode b) => DeepEquals(a.Item, b.Item), - (TupleNode a, TupleNode b) => SequenceEquals(a.Items, b.Items), - (DictionaryNode a, DictionaryNode b) => DeepEquals(a.Value, b.Value), - (UnionNode a, UnionNode b) => a.Keyword == b.Keyword && a.Classification == b.Classification - && SequenceEquals(a.Branches, b.Branches), - (ObjectNode a, ObjectNode b) => ObjectsEqual(a, b), - (FreeFormObjectNode, FreeFormObjectNode) => true, - (UnrestrictedNode, UnrestrictedNode) => true, - _ => false, - }; - } + if (string.Equals(left.Target, right.Target, StringComparison.Ordinal)) + { + return true; + } - private static bool ObjectsEqual(ObjectNode left, ObjectNode right) => - left.AdditionalProperties == right.AdditionalProperties - && left.ErrorStyle == right.ErrorStyle - && AdditionalSchemasEqual(left, right) - && left.Properties.Count == right.Properties.Count - && left.Properties.Zip(right.Properties).All(static pair => PropertiesEqual(pair.First, pair.Second)) - && left.LiteralMarkers.Count == right.LiteralMarkers.Count - && left.LiteralMarkers.Zip(right.LiteralMarkers).All(static pair => MarkersEqual(pair.First, pair.Second)); - - private static bool AdditionalSchemasEqual(ObjectNode left, ObjectNode right) => - (left.AdditionalPropertiesSchema, right.AdditionalPropertiesSchema) switch - { - (null, null) => true, - (not null, not null) => DeepEquals(left.AdditionalPropertiesSchema, right.AdditionalPropertiesSchema), - _ => false, - }; - - private static bool PropertiesEqual(SpecProperty left, SpecProperty right) => - string.Equals(left.Name, right.Name, StringComparison.Ordinal) - && left.IsRequired == right.IsRequired - && DeepEquals(left.Schema, right.Schema); - - private static bool MarkersEqual(LiteralMarker left, LiteralMarker right) => - string.Equals(left.PropertyName, right.PropertyName, StringComparison.Ordinal) - && left.Kind == right.Kind - && string.Equals(left.Value, right.Value, StringComparison.Ordinal); - - private static bool SequenceEquals(IReadOnlyList left, IReadOnlyList right) => - left.Count == right.Count && left.Zip(right).All(static pair => DeepEquals(pair.First, pair.Second)); + if (!left.Target.Contains('#', StringComparison.Ordinal) + || !right.Target.Contains('#', StringComparison.Ordinal)) + { + return false; + } + + // A revisited pair is a cycle inside a comparison that has not failed yet. + if (!_visitedPairs.Add($"{left.Target}\0{right.Target}")) + { + return true; + } + + return _graph.TryGetValue(left.Target, out var leftTarget) + && _graph.TryGetValue(right.Target, out var rightTarget) + && NodesEqual(leftTarget, rightTarget); + } + + private bool ObjectsEqual(ObjectNode left, ObjectNode right) => + left.AdditionalProperties == right.AdditionalProperties + && left.ErrorStyle == right.ErrorStyle + && AdditionalSchemasEqual(left, right) + && left.Properties.Count == right.Properties.Count + && left.Properties.Zip(right.Properties).All(pair => PropertiesEqual(pair.First, pair.Second)) + && left.LiteralMarkers.Count == right.LiteralMarkers.Count + && left.LiteralMarkers.Zip(right.LiteralMarkers).All(static pair => MarkersEqual(pair.First, pair.Second)); + + private bool AdditionalSchemasEqual(ObjectNode left, ObjectNode right) => + (left.AdditionalPropertiesSchema, right.AdditionalPropertiesSchema) switch + { + (null, null) => true, + (not null, not null) => NodesEqual(left.AdditionalPropertiesSchema, right.AdditionalPropertiesSchema), + _ => false, + }; + + private bool PropertiesEqual(SpecProperty left, SpecProperty right) => + string.Equals(left.Name, right.Name, StringComparison.Ordinal) + && left.IsRequired == right.IsRequired + && NodesEqual(left.Schema, right.Schema); + + private static bool MarkersEqual(LiteralMarker left, LiteralMarker right) => + string.Equals(left.PropertyName, right.PropertyName, StringComparison.Ordinal) + && left.Kind == right.Kind + && string.Equals(left.Value, right.Value, StringComparison.Ordinal); + + private bool SequencesEqual(IReadOnlyList left, IReadOnlyList right) => + left.Count == right.Count && left.Zip(right).All(pair => NodesEqual(pair.First, pair.Second)); + } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index 6ef137d30..6dea2ddc0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -33,14 +33,16 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera _curationValidator.Validate(document, selected, reachable, curation, errors); // Aliases are validated against the raw document above; the collapse happens before - // names resolve so the rest of the pipeline never sees the duplicates. + // names resolve so the rest of the pipeline never sees the duplicates. Reachability + // recollects on the transformed document because an alias target's promoted inline + // children only become reachable through the rewritten references. var aliased = _schemaAliases.Apply(document, curation.SchemaAliases); if (!ReferenceEquals(aliased, document)) { document = aliased; var aliasedById = document.Operations.ToDictionary(static operation => operation.OperationId, StringComparer.Ordinal); selected = Array.AsReadOnly([.. selected.Select(operation => aliasedById[operation.OperationId])]); - reachable = MapReachableKeys(reachable, curation.SchemaAliases); + reachable = _reachableSchemas.Collect(document, selected, errors); curation = curation with { PropertyOverrides = MapOverrideKeys(curation.PropertyOverrides, curation.SchemaAliases, errors), @@ -62,12 +64,13 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera }) .ToArray(); + var models = AttachRequestQueryProperties(schemaResult.Models, clients, errors); CheckDtoNameCollisions(schemaResult.Registry, clients, errors); errors.ThrowIfAny(); return new EmitPlan { SelectedOperationIds = selection.OperationIds, - Models = schemaResult.Models, + Models = models, Unions = schemaResult.Unions, Registry = ComposeRegistry(schemaResult.Registry, clients), Clients = clients, @@ -75,6 +78,49 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera }; } + /// + /// A merged operation's query properties ride its body model; the model plan gains + /// them here because schema binding cannot see operation placement. + /// + private static IReadOnlyList AttachRequestQueryProperties(IReadOnlyList models, + IReadOnlyList clients, BindingErrorCollector errors) + { + var merged = clients + .SelectMany(static client => client.Operations) + .Where(static operation => operation.QueryRequest is { RidesRequestBody: true }) + .ToDictionary(static operation => operation.QueryRequest!.TypeName, StringComparer.Ordinal); + if (merged.Count is 0) + { + return models; + } + + var result = new List(models.Count); + foreach (var model in models) + { + if (!merged.TryGetValue(model.Name, out var operation)) + { + result.Add(model); + continue; + } + + _ = merged.Remove(model.Name); + if (model is not ObjectModelPlan objectModel) + { + errors.Add(BindingErrorCategory.Operation, model.Name, "a merged request body must bind to an object model"); + continue; + } + + result.Add(objectModel with { RequestQueryProperties = operation.QueryRequest!.Properties }); + } + + foreach (var missing in merged.Keys.Order(StringComparer.Ordinal)) + { + errors.Add(BindingErrorCategory.Operation, missing, "a merged request body model is absent from the model closure"); + } + + return result; + } + /// /// A DTO sharing a model's name would make the emitted DTO recursively typed through /// same-namespace resolution and collapse two registry identities into one; both @@ -132,7 +178,8 @@ private static RegistryPlan ComposeRegistry(RegistryPlan schemaRegistry, IReadOn private static ReadOnlyCollection MapOverrideKeys(IReadOnlyList overrides, IReadOnlyList aliases, BindingErrorCollector errors) { - // Tolerant of duplicated sources for the same reason as MapReachableKeys. + // Tolerant of duplicated sources: the validator has already recorded them and the + // batched failure throws before any plan leaves this bind. var map = new Dictionary(StringComparer.Ordinal); foreach (var alias in aliases) { @@ -160,33 +207,6 @@ private static ReadOnlyCollection MapOverrideKeys(IReadOnlyLis return Array.AsReadOnly([.. canonical]); } - /// - /// The post-collapse reachable set follows from the raw one mechanically: aliased keys - /// vanish and their targets take their place, so no second traversal (and no duplicated - /// traversal diagnostics) is needed. - /// - private static ReachableSchemaSet MapReachableKeys(ReachableSchemaSet reachable, IReadOnlyList aliases) - { - // Tolerant of duplicated sources: the validator has already recorded them and the - // batched failure throws before any plan leaves this bind. - var map = new Dictionary(StringComparer.Ordinal); - foreach (var alias in aliases) - { - map[alias.Schema] = alias.AliasOf; - } - return new ReachableSchemaSet - { - GraphKeys = [.. reachable.GraphKeys - .Select(key => map.GetValueOrDefault(key, key)) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal)], - ResponseRootKeys = [.. reachable.ResponseRootKeys - .Select(key => map.GetValueOrDefault(key, key)) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal)], - }; - } - private static ReadOnlyCollection SelectOperations(OperationSelection selection, Dictionary operationsById, BindingErrorCollector errors) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs index dca95a9ee..aab0ec067 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs @@ -28,8 +28,10 @@ .. clients private static GeneratedSource EmitDto(EnvelopePlan envelope) { - var data = EmitProperty("data", "Data", EmitDataType(envelope)); - if (envelope.Kind is EnvelopeKind.CursorList) + var payloadTypeName = envelope.PayloadTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + var data = EmitProperty("data", "Data", EmitDataType(envelope.Kind, payloadTypeName)); + if (envelope.Kind is EnvelopeKind.CursorList or EnvelopeKind.DataLocationList) { // The page's element schema admits no null; the converter turns a null array or a // null element into a JsonException the adapters map to the transport wall. @@ -37,7 +39,7 @@ private static GeneratedSource EmitDto(EnvelopePlan envelope) "JsonConverter", SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.Generic( "NullElementRejectingListJsonConverter", - TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)))))); + TypeSyntaxEmitter.EmitNamed(payloadTypeName)))))); } var members = new List { data, }; @@ -46,6 +48,13 @@ private static GeneratedSource EmitDto(EnvelopePlan envelope) members.Add(EmitProperty("cursor", "Cursor", TypeSyntaxEmitter.EmitNamed("ListCursor"))); } + if (envelope.Kind is EnvelopeKind.DataLocation or EnvelopeKind.DataLocationList) + { + var locationTypeName = envelope.LocationTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no location sibling."); + members.Add(EmitProperty("location", "Location", TypeSyntaxEmitter.EmitNamed(locationTypeName))); + } + var declaration = SyntaxFactory.RecordDeclaration( SyntaxFactory.Token(SyntaxKind.RecordKeyword), envelope.EnvelopeDtoTypeName!) @@ -64,11 +73,12 @@ private static GeneratedSource EmitDto(EnvelopePlan envelope) return EmissionSyntax.CreateSource($"Internal/Serialization/{envelope.EnvelopeDtoTypeName}.cs", unit); } - private static TypeSyntax EmitDataType(EnvelopePlan envelope) => envelope.Kind switch + private static TypeSyntax EmitDataType(EnvelopeKind kind, string payloadTypeName) => kind switch { - EnvelopeKind.Data => TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName), - EnvelopeKind.CursorList => TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)), - EnvelopeKind.Bare or _ => throw new InvalidOperationException($"Envelope kind '{envelope.Kind}' has no DTO."), + EnvelopeKind.Data or EnvelopeKind.DataLocation => TypeSyntaxEmitter.EmitNamed(payloadTypeName), + EnvelopeKind.CursorList or EnvelopeKind.DataLocationList => + TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(payloadTypeName)), + EnvelopeKind.Bare or EnvelopeKind.NoContent or _ => throw new InvalidOperationException($"Envelope kind '{kind}' has no DTO."), }; private static PropertyDeclarationSyntax EmitProperty(string wireName, string name, TypeSyntax type) => diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs index 325e168c9..14c2ab5fc 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs @@ -24,7 +24,42 @@ .. clients private static GeneratedSource EmitEnvelope(OperationPlan operation) { var envelope = operation.Envelope; - var fieldName = $"_{CSharpNamePolicy.ToCamelCase(envelope.PayloadName)}"; + var members = envelope.Kind is EnvelopeKind.NoContent + ? EmitNoContentMembers(envelope) + : EmitPayloadMembers(envelope); + var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), envelope.ResponseTypeName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("OpenCodeResponse"))))) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + .WithMembers(SyntaxFactory.List(members)) + .WithLeadingTrivia(EmissionSyntax.Documentation( + $"Represents the response of the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' operation.")); + IReadOnlyList usings = envelope.Kind switch + { + EnvelopeKind.NoContent => ["System.Diagnostics.CodeAnalysis", "OpenCode.Sdk.Models"], + EnvelopeKind.CursorList or EnvelopeKind.DataLocationList => + ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Internal.Serialization", "OpenCode.Sdk.Models"], + EnvelopeKind.Bare or EnvelopeKind.Data or EnvelopeKind.DataLocation or _ => + ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Models"], + }; + var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", usings, [declaration]); + return EmissionSyntax.CreateSource($"{operation.RouteContainerName}/{envelope.ResponseTypeName}.cs", unit); + } + + private static List EmitNoContentMembers(EnvelopePlan envelope) => + [ + EmitSuccessConstructor(envelope.ResponseTypeName), + EmitErrorConstructor(envelope), + ]; + + private static List EmitPayloadMembers(EnvelopePlan envelope) + { + var payloadName = RequirePayloadName(envelope); + var fieldName = $"_{CSharpNamePolicy.ToCamelCase(payloadName)}"; var payloadType = PayloadType(envelope); var members = new List { @@ -35,6 +70,11 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) members.Add(EmitBackingField("_cursor", TypeSyntaxEmitter.EmitNamed("ListCursor"))); } + if (envelope.LocationTypeName is not null) + { + members.Add(EmitBackingField("_location", TypeSyntaxEmitter.EmitNamed(envelope.LocationTypeName))); + } + members.Add(EmitSuccessConstructor(envelope.ResponseTypeName)); members.Add(EmitErrorConstructor(envelope)); members.Add(EmitPayloadProperty(envelope, payloadType, fieldName)); @@ -43,28 +83,27 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) members.Add(EmitCursorProperty()); } - members.Add(EmitPrintMembers(envelope.PayloadName, fieldName)); - var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), envelope.ResponseTypeName) - .WithModifiers(SyntaxFactory.TokenList( - SyntaxFactory.Token(SyntaxKind.PublicKeyword), - SyntaxFactory.Token(SyntaxKind.SealedKeyword))) - .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("OpenCodeResponse"))))) - .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) - .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) - .WithMembers(SyntaxFactory.List(members)) - .WithLeadingTrivia(EmissionSyntax.Documentation( - $"Represents the response of the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' operation.")); - IReadOnlyList usings = envelope.Kind is EnvelopeKind.CursorList - ? ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Internal.Serialization", "OpenCode.Sdk.Models"] - : ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Models"]; - var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", usings, [declaration]); - return EmissionSyntax.CreateSource($"{operation.RouteContainerName}/{envelope.ResponseTypeName}.cs", unit); + if (envelope.LocationTypeName is not null) + { + members.Add(EmitLocationProperty(envelope.LocationTypeName)); + } + + members.Add(EmitPrintMembers(payloadName, fieldName)); + return members; } - private static TypeSyntax PayloadType(EnvelopePlan envelope) => envelope.Kind is EnvelopeKind.CursorList - ? TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)) - : TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName); + private static string RequirePayloadName(EnvelopePlan envelope) => + envelope.PayloadName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + + private static TypeSyntax PayloadType(EnvelopePlan envelope) + { + var payloadTypeName = envelope.PayloadTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + return envelope.Kind is EnvelopeKind.CursorList or EnvelopeKind.DataLocationList + ? TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(payloadTypeName)) + : TypeSyntaxEmitter.EmitNamed(payloadTypeName); + } private static FieldDeclarationSyntax EmitBackingField(string fieldName, TypeSyntax payloadType) => SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration( @@ -85,24 +124,36 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en { var responseTypeName = envelope.ResponseTypeName; - // The payload assignment is the SDK's single null-forgiveness: the guarded getter - // makes the null unobservable. var assignments = new List { Assign("Status", SyntaxFactory.IdentifierName("status")), Assign("IsError", SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression)), Assign("Error", SyntaxFactory.IdentifierName("error")), Assign("RawBody", SyntaxFactory.IdentifierName("rawBody")), - Assign(envelope.PayloadName, SyntaxFactory.PostfixUnaryExpression( - SyntaxKind.SuppressNullableWarningExpression, - SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), }; + + // The payload assignment is the SDK's single null-forgiveness: the guarded getter + // makes the null unobservable. A no-content envelope has no payload to forgive. + if (envelope.PayloadName is not null) + { + assignments.Add(Assign(envelope.PayloadName, SyntaxFactory.PostfixUnaryExpression( + SyntaxKind.SuppressNullableWarningExpression, + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))); + } + if (envelope.Kind is EnvelopeKind.CursorList) { assignments.Add(Assign("Cursor", SyntaxFactory.PostfixUnaryExpression( SyntaxKind.SuppressNullableWarningExpression, SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))); } + + if (envelope.LocationTypeName is not null) + { + assignments.Add(Assign("Location", SyntaxFactory.PostfixUnaryExpression( + SyntaxKind.SuppressNullableWarningExpression, + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))); + } return SyntaxFactory.ConstructorDeclaration(responseTypeName) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.InternalKeyword))) .AddAttributeLists(EmissionSyntax.Attribute("SetsRequiredMembers")) @@ -122,17 +173,18 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelope, TypeSyntax payloadType, string fieldName) { + var payloadName = RequirePayloadName(envelope); ExpressionSyntax initValue = SyntaxFactory.IdentifierName("value"); - if (envelope.Kind is EnvelopeKind.CursorList) + if (envelope.Kind is EnvelopeKind.CursorList or EnvelopeKind.DataLocationList) { initValue = DefensiveListCopy(); } return EmitGuardedProperty( payloadType, - envelope.PayloadName, + payloadName, fieldName, initValue, - $"Gets the {envelope.PayloadName} payload; guarded on the error path."); + $"Gets the {payloadName} payload; guarded on the error path."); } private static PropertyDeclarationSyntax EmitCursorProperty() => @@ -143,6 +195,14 @@ private static PropertyDeclarationSyntax EmitCursorProperty() => SyntaxFactory.IdentifierName("value"), "Gets the page cursor; guarded on the error path."); + private static PropertyDeclarationSyntax EmitLocationProperty(string locationTypeName) => + EmitGuardedProperty( + TypeSyntaxEmitter.EmitNamed(locationTypeName), + "Location", + "_location", + SyntaxFactory.IdentifierName("value"), + "Gets the location the server resolved for the request; guarded on the error path."); + /// /// List payloads defensively copy on init so the envelope stays immutable; the copy /// refuses null elements the wire contract forbids, while the error path's forgiven diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs index faf0dfd83..395e33dbc 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs @@ -55,8 +55,12 @@ private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? base SyntaxFactory.Token(SyntaxKind.SealedKeyword))) .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) - .WithMembers(SyntaxFactory.List(model.Properties.Select(property => - EmitProperty(property, chainMarkers.Any(wireName => IsDiscriminator(property, wireName)), valueTypeNames)))) + .WithMembers(SyntaxFactory.List( + [ + .. model.Properties.Select(property => + EmitProperty(property, chainMarkers.Any(wireName => IsDiscriminator(property, wireName)), valueTypeNames)), + .. model.RequestQueryProperties.Select(static property => EmitRequestQueryProperty(property)), + ])) .WithLeadingTrivia(EmissionSyntax.Documentation(model.Description ?? $"Represents a {DisplayName(model.Name)} value.")); if (model.BaseTypeName is not null) { @@ -89,6 +93,18 @@ private static GeneratedSource EmitEnum(EnumModelPlan model) return EmissionSyntax.CreateSource($"Models/{model.Name}.cs", unit); } + /// A merged request's query-side property never serializes; the route builder reads it. + private static PropertyDeclarationSyntax EmitRequestQueryProperty(QueryPropertyPlan property) + { + // The documentation trivia must stay ahead of the added attribute list. + var declaration = QueryRequestEmitter.EmitProperty(property); + var documentation = declaration.GetLeadingTrivia(); + return declaration + .WithoutLeadingTrivia() + .AddAttributeLists(EmissionSyntax.Attribute("JsonIgnore")) + .WithLeadingTrivia(documentation); + } + private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property, bool isDiscriminator, IReadOnlySet valueTypeNames) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs index 4f7526dbd..928dd8aae 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs @@ -58,7 +58,8 @@ private static IEnumerable EmitParameters(OperationPlan operati : body.WithType(TypeSyntaxEmitter.EmitNamed(operation.RequestBody.TypeName)); } - if (operation.QueryRequest is not null) + // A merged request already surfaced as the body parameter above. + if (operation.QueryRequest is { RidesRequestBody: false }) { yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier("request")) .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(operation.QueryRequest.TypeName))) @@ -75,10 +76,14 @@ private static IEnumerable EmitParameters(OperationPlan operati private static InvocationExpressionSyntax EmitDelegation(OperationPlan operation) { + // HttpMethod.Patch is absent from the downlevel BCL, so it rides the internal spine. + var verbContainer = string.Equals(operation.HttpMethod, "patch", StringComparison.Ordinal) + ? "OpenCodeHttpMethod" + : "HttpMethod"; var arguments = new List { SyntaxFactory.Argument(EmissionSyntax.MemberAccess( - SyntaxFactory.IdentifierName("HttpMethod"), + SyntaxFactory.IdentifierName(verbContainer), CSharpNamePolicy.ToPascalCase(operation.HttpMethod))), SyntaxFactory.Argument(EmitRoute(operation)), }; @@ -155,7 +160,7 @@ private static SyntaxTriviaList EmitDocumentation(OperationPlan operation, IRead : "The request body.")); } - if (operation.QueryRequest is not null) + if (operation.QueryRequest is { RidesRequestBody: false }) { parameters.Add(new DocumentedParameter("request", "The request shaping the query.")); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs index f4abda1bc..a6fe2132e 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs @@ -19,7 +19,7 @@ public static IReadOnlyList Emit(IReadOnlyList clie [ .. clients .SelectMany(static client => client.Operations) - .Where(static operation => operation.QueryRequest is not null) + .Where(static operation => operation.QueryRequest is { RidesRequestBody: false }) .OrderBy(static operation => operation.QueryRequest!.TypeName, StringComparer.Ordinal) .Select(static operation => EmitQueryRequest(operation)), ]); @@ -52,7 +52,7 @@ .. queryRequest.Properties return EmissionSyntax.CreateSource($"{operation.RouteContainerName}/{queryRequest.TypeName}.cs", unit); } - private static PropertyDeclarationSyntax EmitProperty(QueryPropertyPlan property) + internal static PropertyDeclarationSyntax EmitProperty(QueryPropertyPlan property) { var accessors = SyntaxFactory.AccessorList(SyntaxFactory.List( [ @@ -74,6 +74,7 @@ private static PropertyDeclarationSyntax EmitProperty(QueryPropertyPlan property QueryValueKind.PositiveCount => SyntaxFactory.NullableType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword))), QueryValueKind.ListOrder => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("ListOrder")), QueryValueKind.SessionParentFilter => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("SessionParentFilter")), + QueryValueKind.Location => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("LocationSelector")), _ => throw new InvalidOperationException($"Query value kind '{kind}' has no property type."), }; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs index 8e279258a..3fc0e6be0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs @@ -33,7 +33,12 @@ private static GeneratedSource EmitAdapter(OperationPlan operation) members.Add(EmitAdapt(operation)); if (envelope.Kind is EnvelopeKind.CursorList) { - members.Add(EmitCursorSuccessHelper(envelope)); + members.Add(EmitProjectingSuccessHelper(envelope, "Cursor")); + } + + if (envelope.Kind is EnvelopeKind.DataLocation or EnvelopeKind.DataLocationList) + { + members.Add(EmitProjectingSuccessHelper(envelope, "Location")); } var declaration = SyntaxFactory.ClassDeclaration(envelope.AdapterTypeName) .WithModifiers(SyntaxFactory.TokenList( @@ -93,7 +98,7 @@ private static MethodDeclarationSyntax EmitAdapt(OperationPlan operation) var arms = new List { SyntaxFactory.SwitchExpressionArm( - SyntaxFactory.ConstantPattern(Number(200)), + SyntaxFactory.ConstantPattern(Number(envelope.SuccessStatusCode)), EmitSuccessCreation(envelope)), // Any other 2xx is outside the declared contract: a protocol failure, never an API error. SyntaxFactory.SwitchExpressionArm( @@ -133,27 +138,57 @@ private static MethodDeclarationSyntax EmitAdapt(OperationPlan operation) /// /// Every success path is one deserialization pass: bare bodies read the payload type, - /// wrapped bodies read their internal envelope DTO and project its members. + /// wrapped bodies read their internal envelope DTO and project its members, and a + /// no-content success refuses any body outright. /// private static ExpressionSyntax EmitSuccessCreation(EnvelopePlan envelope) { - var read = EmissionSyntax.Invocation( - SyntaxFactory.IdentifierName("ReadBarePayload"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("rawBody")), - SyntaxFactory.Argument(EmissionSyntax.MemberAccess( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), - envelope.EnvelopeDtoTypeName ?? envelope.PayloadTypeName))); + ExpressionSyntax Read() + { + var readTypeName = envelope.EnvelopeDtoTypeName + ?? envelope.PayloadTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + return EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("ReadBarePayload"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("rawBody")), + SyntaxFactory.Argument(EmissionSyntax.MemberAccess( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), + readTypeName))); + } + return envelope.Kind switch { - EnvelopeKind.Bare => EmitSuccessInitializer(envelope, read), - EnvelopeKind.Data => EmitSuccessInitializer(envelope, EmissionSyntax.MemberAccess(read, "Data")), - EnvelopeKind.CursorList or _ => EmissionSyntax.Invocation( - SyntaxFactory.IdentifierName("CreateSuccess"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("status")), - SyntaxFactory.Argument(read)), + EnvelopeKind.Bare => EmitSuccessInitializer(envelope, Read()), + EnvelopeKind.Data => EmitSuccessInitializer(envelope, EmissionSyntax.MemberAccess(Read(), "Data")), + EnvelopeKind.NoContent => EmitNoContentSuccess(envelope), + EnvelopeKind.CursorList or EnvelopeKind.DataLocation or EnvelopeKind.DataLocationList or _ => + EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("CreateSuccess"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("status")), + SyntaxFactory.Argument(Read())), }; } + /// A declared no-content success must arrive bodiless; anything else is a protocol failure. + private static ConditionalExpressionSyntax EmitNoContentSuccess(EnvelopePlan envelope) => + SyntaxFactory.ConditionalExpression( + SyntaxFactory.IsPatternExpression( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("rawBody"), "Length"), + SyntaxFactory.ConstantPattern(Number(0))), + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) + .WithInitializer(SyntaxFactory.InitializerExpression( + SyntaxKind.ObjectInitializerExpression, + SyntaxFactory.SeparatedList( + [ + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("Status"), + SyntaxFactory.IdentifierName("status")), + ]))), + SyntaxFactory.ThrowExpression(EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("NonEmptyNoContentFailure"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("status"))))); + private static ObjectCreationExpressionSyntax EmitSuccessInitializer(EnvelopePlan envelope, ExpressionSyntax payload) => SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) .WithInitializer(SyntaxFactory.InitializerExpression( @@ -166,13 +201,20 @@ private static ObjectCreationExpressionSyntax EmitSuccessInitializer(EnvelopePla SyntaxFactory.IdentifierName("status")), SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName(envelope.PayloadName), + SyntaxFactory.IdentifierName(RequirePayloadName(envelope)), payload), ]))); - /// Cursor lists project the DTO twice, so a private helper keeps the switch arm single-read. - private static MethodDeclarationSyntax EmitCursorSuccessHelper(EnvelopePlan envelope) => - SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName), "CreateSuccess") + private static string RequirePayloadName(EnvelopePlan envelope) => + envelope.PayloadName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + + /// Sibling-carrying envelopes project the DTO twice, so a private helper keeps the switch arm single-read. + private static MethodDeclarationSyntax EmitProjectingSuccessHelper(EnvelopePlan envelope, string siblingName) + { + var dtoTypeName = envelope.EnvelopeDtoTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no DTO."); + return SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName), "CreateSuccess") .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PrivateKeyword), SyntaxFactory.Token(SyntaxKind.StaticKeyword))) @@ -181,7 +223,7 @@ private static MethodDeclarationSyntax EmitCursorSuccessHelper(EnvelopePlan enve SyntaxFactory.Parameter(SyntaxFactory.Identifier("status")) .WithType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword))), SyntaxFactory.Parameter(SyntaxFactory.Identifier("envelope")) - .WithType(TypeSyntaxEmitter.EmitNamed(envelope.EnvelopeDtoTypeName!)), + .WithType(TypeSyntaxEmitter.EmitNamed(dtoTypeName)), ]))) .WithExpressionBody(SyntaxFactory.ArrowExpressionClause( SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) @@ -195,14 +237,15 @@ private static MethodDeclarationSyntax EmitCursorSuccessHelper(EnvelopePlan enve SyntaxFactory.IdentifierName("status")), SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName(envelope.PayloadName), + SyntaxFactory.IdentifierName(RequirePayloadName(envelope)), EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), "Data")), SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName("Cursor"), - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), "Cursor")), + SyntaxFactory.IdentifierName(siblingName), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), siblingName)), ]))))) .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); + } private static ObjectCreationExpressionSyntax EmitErrorCreation(EnvelopePlan envelope, ExpressionSyntax allowedTags) => SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs index d69a31b97..7c7d4c988 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs @@ -34,6 +34,12 @@ public static GeneratedSource Emit(IReadOnlyList clients) usings.Add("OpenCode.Sdk.Internal"); } + // A merged request types its route builder with the body model. + if (operations.Any(static operation => operation.QueryRequest is { RidesRequestBody: true })) + { + usings.Add("OpenCode.Sdk.Models"); + } + var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", usings, [declaration]); return EmissionSyntax.CreateSource("OpenCodeRoutes.cs", unit); } @@ -147,13 +153,18 @@ private static IEnumerable EmitQueryComposition(OperationPlan o SyntaxFactory.IdentifierName("request"), SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), SyntaxFactory.Block(SyntaxFactory.ReturnStatement(SyntaxFactory.IdentifierName("path")))); + foreach (var pair in operation.QueryRequest!.MutuallyExclusivePairs) + { + yield return EmitExclusivePairGuard(operation.QueryRequest, pair); + } + yield return SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration( SyntaxFactory.IdentifierName("var"), SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("query") .WithInitializer(SyntaxFactory.EqualsValueClause(SyntaxFactory.ObjectCreationExpression( TypeSyntaxEmitter.EmitNamed("QueryStringBuilder")) .WithArgumentList(SyntaxFactory.ArgumentList())))))); - foreach (var property in operation.QueryRequest!.Properties) + foreach (var property in operation.QueryRequest.Properties) { var arguments = new List { @@ -181,12 +192,43 @@ private static IEnumerable EmitQueryComposition(OperationPlan o EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("query"), "Value"))); } + /// The server refuses the curated pair, so the route boundary refuses it first. + private static IfStatementSyntax EmitExclusivePairGuard(QueryRequestPlan queryRequest, ExclusiveQueryPairPlan pair) + { + var first = queryRequest.Properties.Single(property => + string.Equals(property.WireName, pair.FirstWireName, StringComparison.Ordinal)); + var second = queryRequest.Properties.Single(property => + string.Equals(property.WireName, pair.SecondWireName, StringComparison.Ordinal)); + return SyntaxFactory.IfStatement( + SyntaxFactory.BinaryExpression( + SyntaxKind.LogicalAndExpression, + SyntaxFactory.IsPatternExpression( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("request"), first.PropertyName), + SyntaxFactory.UnaryPattern(SyntaxFactory.ConstantPattern( + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))), + SyntaxFactory.IsPatternExpression( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("request"), second.PropertyName), + SyntaxFactory.UnaryPattern(SyntaxFactory.ConstantPattern( + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))))), + SyntaxFactory.Block(SyntaxFactory.ThrowStatement(SyntaxFactory.ObjectCreationExpression( + TypeSyntaxEmitter.EmitNamed("ArgumentException")) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(StringLiteral( + $"The '{pair.FirstWireName}' and '{pair.SecondWireName}' query values cannot combine.")), + SyntaxFactory.Argument(EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("request")))), + ])))))); + } + private static string QueryAddMethod(QueryValueKind kind) => kind switch { QueryValueKind.Text => "AddText", QueryValueKind.PositiveCount => "AddCount", QueryValueKind.ListOrder => "AddOrder", QueryValueKind.SessionParentFilter => "AddParentFilter", + QueryValueKind.Location => "AddLocation", _ => throw new InvalidOperationException($"Query value kind '{kind}' has no query-builder method."), }; diff --git a/tools/curation.json b/tools/curation.json index 6fbbac583..752224007 100644 --- a/tools/curation.json +++ b/tools/curation.json @@ -14,6 +14,12 @@ "clientName": "Sessions", "handleName": "SessionClient", "handleParameter": "sessionID" + }, + "shell": { + "placement": "client", + "clientName": "Shells", + "handleName": "ShellClient", + "handleParameter": "id" } }, "envelopePayloadNames": {}, @@ -36,6 +42,18 @@ "schema": "InvalidRequestError1", "aliasOf": "InvalidRequestError", "reason": "Upstream spec-gen emits a structurally identical duplicate carrying the same _tag." + }, + { + "schema": "Shell.Info1", + "aliasOf": "Shell.Info", + "reason": "Upstream spec-gen emits a structurally identical duplicate of the shell info schema." + } + ], + "mutuallyExclusiveQueries": [ + { + "operation": "v2.message.list", + "parameters": ["order", "cursor"], + "reason": "The pinned message-list contract forbids combining cursor with order and the server refuses the pair with InvalidCursorError; known invariants refuse at the route boundary." } ] } diff --git a/tools/generation-profile.txt b/tools/generation-profile.txt index 83e812908..e1545bc49 100644 --- a/tools/generation-profile.txt +++ b/tools/generation-profile.txt @@ -4,3 +4,10 @@ v2.session.create v2.session.get v2.session.list v2.session.message +v2.session.remove +v2.session.rename +v2.shell.create +v2.shell.get +v2.shell.list +v2.shell.remove +v2.shell.timeout