diff --git a/.editorconfig b/.editorconfig index 6cf65c4e..53ce7662 100644 --- a/.editorconfig +++ b/.editorconfig @@ -682,8 +682,8 @@ dotnet_diagnostic.RCS1141.severity = suggestion # SECTION 12: SONARANALYZER (Sxxxx) # ============================================================================= # Policy: only arbitrated rules are listed; everything else rides SonarWay -# defaults, which TreatWarningsAsErrors escalates to error (chosen fail-closed -# posture, 2026-08-08). When a Sonar rule misfires, arbitrate it here with a +# defaults, which TreatWarningsAsErrors escalates to error. When a Sonar rule +# misfires, arbitrate it here with a # comment naming the winner. dotnet_diagnostic.S125.severity = suggestion dotnet_diagnostic.S2094.severity = suggestion @@ -700,7 +700,7 @@ dotnet_diagnostic.S6602.severity = none # SECTION 13: COMPILER DIAGNOSTICS (CSxxxx) # ============================================================================= # Every public member on the shipped surface is documented: generated docs come from the -# spec, hand-written docs by hand (ADR-0004). Tests are exempted in Section 15. +# spec, hand-written docs by hand. Tests are exempted in Section 15. dotnet_diagnostic.CS1591.severity = error # ============================================================================= @@ -735,8 +735,8 @@ dotnet_diagnostic.VSTHRD111.severity = none # ============================================================================= # Placed after every global C# diagnostic section so the file-scoped glob never # narrows a later section. MA0048 (file name = type name) cannot apply to the -# file-based tool entry: the SDK synthesizes the Program type, and the sealed -# entry contract pins the file name (it is the tool name: +# file-based tool entry: the SDK synthesizes the Program type, and the entry +# contract pins the file name (it is the tool name: # ./tools/opencode-tool.cs). Winner: the entry contract; the rule stays error # for every other file, generated output included (emission keeps file = type). [tools/opencode-tool.cs] @@ -785,16 +785,34 @@ dotnet_diagnostic.S2325.severity = none [tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralKind.cs] dotnet_diagnostic.CA1720.severity = none -# Generated response adapters gain shared, non-virtual mapping helpers on this -# base at emission build-out, and default interface members cannot carry shared -# behavior on the locked net472/netstandard2.0 targets (CS8701). Winner: the +# The abstract adapter base carries shared, non-virtual mapping behavior for the +# generated response adapters, and default interface members cannot carry shared +# behavior on the net472/netstandard2.0 targets (CS8701). Winner: the # abstract adapter base; S1694 remains enabled for every other file. [src/OpenCode.Sdk/Internal/ResponseAdapter.cs] dotnet_diagnostic.S1694.severity = none -# The generated route surface deliberately nests one static container per operation group -# (maintainer-approved shape, 2026-08-13); flattening would erase the grouping the public +# The generated route surface deliberately nests one static container per operation +# group; flattening would erase the grouping the public # API promises. Winner: the generated route shape; CA1034 remains enabled for every other # file. [src/OpenCode.Sdk/OpenCodeRoutes.cs] dotnet_diagnostic.CA1034.severity = none + +# The shipped SDK slices by client family with flat public namespaces: +# client-family folders are placement, a namespace is API surface, and the +# 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] +dotnet_diagnostic.IDE0130.severity = none + +[tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination}/**.cs] +dotnet_diagnostic.IDE0130.severity = none + +# DI extension methods live in the Microsoft.Extensions.DependencyInjection namespace so +# consumers get AddOpenCode without an extra using - the ecosystem convention every +# companion DI package follows. Winner: the ecosystem +# namespace contract; IDE0130 remains error for every other Extensions file. +[src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs] +dotnet_diagnostic.IDE0130.severity = none diff --git a/.gitignore b/.gitignore index 092bc6e3..158aad67 100644 --- a/.gitignore +++ b/.gitignore @@ -417,7 +417,6 @@ internal-docs/ !.claude/skills/ !.claude/skills/** -external/ tmp/ .fake diff --git a/.gitmodules b/.gitmodules index 1bd4ab1a..1b98a172 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,4 @@ path = external/opencode url = https://github.com/anomalyco/opencode shallow = true + branch = v2 diff --git a/Directory.Build.props b/Directory.Build.props index 621f8169..8a58aec7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net10.0 netstandard2.0;net472;net8.0;net9.0;$(DefaultTargetFramework) - 14.0 enable diff --git a/Directory.Packages.props b/Directory.Packages.props index 769b76b8..d7e0ac8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,7 +15,12 @@ + + + + + diff --git a/OpenCode.slnx b/OpenCode.slnx index 2254cb7a..9df1a160 100644 --- a/OpenCode.slnx +++ b/OpenCode.slnx @@ -16,12 +16,15 @@ + + + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9220ea5e..da500213 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Date: 2026-08-13 +Date: 2026-08-15 Operational state: what is done, what is next, what is open. This file shrinks as work lands. Evergreen rules and locked decisions live in `../AGENTS.md`; decision records in `adr/`. @@ -27,7 +27,38 @@ the blocker set (typed-spine leaks, silent route rewriting) lands on the PR bran issues #19–#25 pinned to the milestone that resolves it — nothing on that list outlives the M series. -The next work is M2 — the first breadth batch, planned when it starts. +**M2's first breadth batch is complete** (plan: +`superpowers/plans/2026-08-14-m2-first-breadth-batch.md`; decisions: research log Sessions +19–20). `session.list`, `session.get`, `session.create`, and `message.list` are callable +through their final generated surface — uniform `*Request` operation inputs (Q83) with the +query records riding the `ListRequest` seam, `SessionCreateRequest` bodies through the +pipeline's JSON path, cursor-list envelopes with the shared `ListCursor`, query-composing +routes, the `Session.Info` model closure, and the first 5xx arm. Riders #19 (carrier converters), #21 (fail-closed walls + P2 single-pass +envelopes: `GetMessageAsync` 67.4→56.2 μs, `ListMessagesAsync` baseline 58.1 μs/28.24 KB), +and #22 (List/Create verb rules + C17–C20) landed with it; upstream's `InvalidRequestError1` +duplicate collapses through the new `schemaAliases` curation. Demonstrated live 2026-08-14 +against `opencode2 serve` v0.0.0-next-17403 (create → list → get → messages, wire cursor +round-tripped). The #20 decision landed (blank explicit passwords refuse; the environment +fallback was later removed by Q90 — `null` sends anonymous requests) and #25 closed keep. The alignment batch is complete — the uniform +`*Request` rename (Q83), the feature-slice layout migration (Q84), and the Extensions +bring-up (Q85) — and the follow-on construction/options/DI reshape (research log Q90) +landed on the same PR: options-only construction with the read-only +`IOpenCodeClientOptions` view and configurable `Username`, no SDK environment reads, +`IHttpClientFactory`-based `AddOpenCode` returning the `IHttpClientBuilder`, pooled +connection lifetime on the owned transport, and the sandbox as the Generic Host DI +showcase. The PR #26 external review ran through adversarial verification (36 findings: +30 confirmed / 4 plausible / 2 refuted), and the verified fix batch landed 2026-08-15 +red-test-first — all ten merge blockers plus the small confirmed fold-ins, with Q91 sealed +(research log Session 23, doc 16: the caller-owned HttpClient constructor stays public +behind a fail-closed anonymous-mode guard). A second full-diff review (15 verified +findings) closed the same day: six runtime/emitter fixes landed on the branch (timeouts +route onto the transport spine, conflicting BaseAddress refuses, handle guards match the +route guards, carrier payload guards, count-guard identity, the Pagination shadow wall) +for 932 green tests, and every surviving finding lives milestone-anchored with an explicit +trigger — #31 (M2, DI lifetime shape, decision-first), #32 (M3, EscapeDataString TFM +policy), #33 (M3, carrier hand-construction semantics, rides #23), #34 (M2, contract-test +consolidation), plus the #27 and #23 enrichments. Merge is awaiting the maintainer's word; +further breadth batches follow merge. ## Milestones @@ -45,16 +76,22 @@ is revisited at each milestone boundary. `superpowers/specs/2026-08-11-production-walking-skeleton-design.md`. 2. **M2 — Breadth batches.** The generation profile grows in vertical operation batches; each batch lands its curation rows, reachable models, operation methods, and contract - tests together. Opens with the review hardening batch (#19 `Unknown*` serialization, - #20 password-semantics decision, #25 IVT decision); the generator fail-closed walls - batch (#21, includes the P2 envelope fold) and the naming/curation wall batch (#22) - land at this boundary. -3. **M3 — Streams.** SSE engine over the v2 stream surface (`v2.event.subscribe`, + tests together. The first batch (list/get/create/message-list) is complete with every + review rider resolved (#19–#22, #20, #25), and so is the alignment batch (uniform + `*Request`, feature-slice layout, Extensions bring-up — research log Q83–Q85). The + Extensions package grows in parallel with the remaining batches. +3. **M3 — Streams.** Planning opens with the **location + merged-Request input design + session** (sealed 2026-08-14, research log Session 22): the dual-channel location + mechanism, deepObject marshalling, the one-`*Request`-carrying-body-and-query shape, + and the `session.list` flat-field exception — census and mechanisms in research doc 15 + §5a/§6. Then the SSE engine over the v2 stream surface (`v2.event.subscribe`, `v2.session.log` with `after`/`follow`, cursor-paged `v2.message.list`); the v1 durable-stream design does not carry over and is re-derived here. Demo: watching a real session's event stream. The net472 `ServicePointManager` item lands here. The union single-pass deserialization and streaming adapter-boundary redesign (#23) land - on the M3 runway, gated on the performance baselines (#18). + on the M3 runway, gated on the performance baselines (#18), together with the + second-review perf mechanisms and #29; #32 (EscapeDataString TFM policy) rides the + net472 cluster and #33 (carrier hand-construction semantics) rides #23. 4. **M4 — Launcher.** `OpenCodeServer.StartAsync` with three-OS acceptance (ADR-0001) over `opencode2 serve`; demo: the SDK starts the server itself and calls health. The net472 stdout/tree-kill items land here. (`serve --stdio`'s stdin leash and the @@ -62,9 +99,9 @@ is revisited at each milestone boundary. platform detail: research doc 15.) 5. **M5 — Full surface.** Complete generation profile over the protocol surface, exclusion fingerprints (ADR-0008), packaging unblocked. -6. **M6 — Operational closure.** `refresh-spec`, Extensions DI breadth, - retry/telemetry/hooks, quarantine lane, nightly canary (the performance suite joins - it); durable decisions distill into ADRs and the `superpowers/` documents retire. Any +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 hygiene-sweep leftovers (#24) are resolved here — nothing from the review queue survives the M series. diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md b/docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md deleted file mode 100644 index a15624bf..00000000 --- a/docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md +++ /dev/null @@ -1,75 +0,0 @@ -# Post-M1 Handoff — Ship The Branch, Then Open M2 - -Date: 2026-08-13 - -Consume this document against live Git and delete it when the branch merges and the M2 batch -plan exists. It records the queue between M1 (complete) and M2 (not yet planned). - -## Onboarding Reading Order - -1. `AGENTS.md`, then ADRs 0003/0004/0005/0007/0008/0009. -2. `docs/ROADMAP.md` — M1 is complete; the status paragraph is the current-state summary. -3. Research log Sessions 17–18 — the retarget chain and the Arc B execution decisions - (mechanical payload names, User-Agent policy, route shape). -4. `tests/OpenCode.Sdk.Sandbox/README.md` — the committed debug playground. - -## Git And Workspace State - -| Item | State | -|---|---| -| Branch | `feature/m1-callable-client`, ~26 commits ahead of `origin/master`, never pushed | -| Gates | All six green at every commit; pack fails only on `.generation-incomplete` | -| Tests | 526 across tools + SDK legs (net8/9/10 locally; net472 only on Windows CI) | -| Demo evidence | `.scratchpad/m1-demo/DEMO.md` (gitignored) — paste into the PR body | - -Gate chains need `set -o pipefail` plus an explicit ALL-GREEN marker — a bare `| tail` -swallows a red gate's exit code. - -## Step 1 — Ship the branch - -1. **Multi-agent review (maintainer-triggered):** the maintainer runs `/code-review ultra` - on this branch — it is user-invoked and billed, an agent cannot start it; the no-arg form - bundles the local branch and needs no remote. Triage findings red-test-first; anything - touching public API names goes back to the maintainer before changing the baseline. -2. **Push + PR to `master`** (push only on the maintainer's explicit ask). PR body: the M1 - story, the `DEMO.md` transcript, and the pin-vs-installed version skew note - (`a6a712a` vs `opencode2` v0.0.0-next-17403 — deliberate snapshot policy). -3. **Watch first-contact CI.** This work has never seen the three-OS matrix. Known risk: the - Windows-only net472 test leg renders the shared `PublicApi.verified.txt` from embedded - (Polyfill-supplied) attributes — if it diverges, split the baseline per TFM or scrub the - attribute rendering; do not weaken the baseline test. `App.net472.config` must never flow - into the package when packaging is later enabled. - -## Step 2 — Open M2 (first breadth batch) - -Write the 1–2 page batch plan when the branch merges, not earlier (ROADMAP rule). The -standing proposal to put before the maintainer: **`v2.session.list` + `v2.message.list` + -`v2.session.get`** — it converts the demo's raw curl calls into SDK methods and pulls in the -real M2 machinery: - -- Query-parameter binding (drops the M1 `no query` wall) including optional parameters - (`limit`, `order`, `cursor`) and the deferred `location[...]` scoping decision. -- List payloads (`IReadOnlyList`) and the `{cursor, data}` / `{data, hasMore}` envelopes; - the cursor/paginator shape from the API design spec is a **public API review**. -- The `List*` verb rule joining `OperationNamePolicy` (collection GETs currently name as - `Get*`). -- Consider a deliberate pin refresh at this boundary (M2 boundary is the sanctioned moment; - drift lands as reviewable git diffs). - -## Step 3 — Horizon (order revisited at each milestone boundary) - -- **M3 streams:** SSE engine; `v2.session.log` (`after`/`follow`) semantics re-researched. -- **M4 launcher:** `OpenCodeServer.StartAsync` over `opencode2 serve`; discovery-file and - `--stdio` leash are candidate mechanisms. -- **M5 full surface:** complete profile, exclusion fingerprints (ADR-0008), packaging - unblocks. -- **M6 operational closure:** `refresh-spec`, Extensions DI (the sandbox grows its Generic - Host root then), retry/telemetry/hooks. -- Standing chores: the manual analyzer-package bump routine (nothing warns when it lags); - v2 GA watch (research doc 15's open items). - -## Stop Conditions - -Public-API taste decisions go to the maintainer as options with a recommendation. The Arc B -stop-condition list (binder needing raw OpenAPI, name-branching emitters, structural unions, -ownership ambiguity, TFM breakage, partial packs) carries into M2 unchanged. diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-15.md b/docs/agents/handover-prompts/HANDOFF-2026-08-15.md new file mode 100644 index 00000000..fc72a7cd --- /dev/null +++ b/docs/agents/handover-prompts/HANDOFF-2026-08-15.md @@ -0,0 +1,129 @@ +# Session Handoff — M3 pickup: plan-first opening after the M2 first-breadth merge + +Date: 2026-08-15 + +Consume this document against live Git; delete it when the M3 plan is written and agreed. +It supersedes `HANDOFF-2026-08-14-3.md` (deleted with this one's creation): that handoff's +whole queue completed — Q91 sealed, the ten verified merge blockers plus fold-ins fixed +red-test-first, a second full-diff review ran and closed the same day (six more fixes on +the branch, everything else issued with triggers), and PR #26 merged. What remains is the +small #34 test-consolidation PR and the M3 plan. + +## What the closing session decided (beyond the docs) + +- **Q91 sealed** (research log Session 23, doc 16): the caller-owned `(HttpClient, + options)` constructor stays public; anonymous mode refuses a default `Authorization` + fail-closed at construction and per send; with a password set the SDK's per-request + header wins and the precedence is documented. +- **Second review (15 verified findings)**: six landed on the branch — timeouts wrap into + `OpenCodeTransportException` (caller-token-untouched cancellation only), conflicting + injected `BaseAddress` refuses loudly, handle construction applies the shared + route-value guard (`EmissionSyntax.RouteValueGuard`), carrier constructors refuse + unparsed payloads, the count guard carries the `request` parameter identity, and + `Pagination` joined the writer's shadow wall. Every surviving finding lives + milestone-anchored **with an explicit Trigger section** in #31–#34 and the #27/#23 + enrichment comments — that trigger convention is now the standing shape for + review-queue issues. +- **Comment hygiene is enforced**: comments in code artifacts state the status quo only — + no doc/ADR citations, no decision dates/attribution, no milestone scoping, no future + promises, no review-finding ids (repo-wide sweep, commit `7ac5bf3`). Member ordering is + a repo rule: access modifier first, then usage order. +- **MockSeam stays** as-is for now; the instructive mock guard has no ecosystem precedent + (gRPC/Google throw messageless `NotImplementedException`; Azure/OpenAI ship nothing) + and is a deliberate defensive refinement. A rename (`MockingConstructorGuard`-style) + remains an open low-stakes option; note it at M6 if the maintainer wants it. + +## Git, Workspace, And Issue State + +| Item | State | +|---|---| +| Branch | `master` after the PR #26 merge; `feature/m2-first-breadth-batch` retired | +| Tests | 932 across all legs; all six gates green at every commit | +| Issues (all trigger-annotated) | **#31** M2, DI lifetime shape — decision-first; **#32** M3, EscapeDataString TFM policy; **#33** M3, carrier hand-construction semantics (rides #23); **#34** M2, contract-test consolidation (ready-for-agent); **#27** +5 wall gaps (R2-8..12; R2-8 likely fires next batch); **#23** + the cursor-list re-copy mechanism; #28/#29/#30/#24 unchanged | +| CI | Three-OS matrix healthy again (an Actions spending-limit outage on 2026-08-15 was billing, not code) | + +## Onboarding Reading Order + +1. `AGENTS.md`, then every ADR (0001–0009). +2. Research log Sessions 19–23 (Q76–Q91) — the M2 seals, the Q90 reshape, the upstream + re-research, and the Q91 BYO-transport seal (census in doc 16). +3. `docs/engineering/coding-style.md` and `testing-style.md`. +4. `docs/ROADMAP.md` — status, milestone anchors, open questions. +5. Research doc 15 §5a/§6 — the location census the M3 design session consumes. + +## Work Queue (in this order) + +### A. #34 — contract-test consolidation (small standalone PR) + +Ready-for-agent; land it before the next batch multiplies contract tests. One shared +scaffold home per testing-style §1, single `FixtureLoader`/`RecordingHttpHandler` pair +across test projects, wire bodies out of inline literals, plus the missing +object-envelope `{"data":null}` contract test. + +### B. M3 plan — write the short (1–2 page) plan first; align before any execution + +Planning opens with the **location + merged-Request input design session** (sealed: +Session 22 Q88). Census facts to design against (doc 15 §5a/§6): 61 deepObject +`location` ops; all 15 body+query mixes are location-caused; `session.list` is the flat +exception; server precedence query > header. The body+query wire-shape refusal landed in +the fix batch — admission waits on this design. + +M3 scope pointers beyond that session: + +- SSE engine (`v2.event.subscribe`; `v2.session.log` with `after`/`follow` — resume + guarantees still unestablished), stream endpoints hand-wired per ADR-0008. +- The paginator over the `ListRequest` seam and `ListCursor`. +- The net472 cluster: `ServicePointManager` hardening **and #32** (EscapeDataString TFM + policy — refuse/normalize/tolerate needs a seal). +- **The deliberate performance pass** (see below). +- #33's carrier hand-construction contract decides while #23 rewrites the converters. +- Spec-pin refresh is sanctioned at this boundary (submodule pointer moves in lockstep — + `spec/SNAPSHOT.md` procedure). + +Decision-first items to put before the maintainer early: #31 (DI lifetime shape — M2 +anchored, must seal before Extensions grows), #28 (curation mutual-exclusion), #32, #33. + +### C. Performance notes (maintainer-directed, 2026-08-15) + +- **SDK performance work lands at M3, deliberately, in one pass**: the three confirmed + hot-path mechanisms — UTF-16 body materialization + re-transcode, union converter + JsonDocument double-parse, cursor-list re-copy after converter validation — all + converge on #23's adapter-boundary redesign. Do them together under `MemoryDiagnoser`, + allocation-first, with before/after numbers in the PR (posture: benchmarks settle + claims, never argument). Doing them before the SSE engine reshapes buffering would be + double work. +- **Until then the discipline is baseline-keeping, not optimizing**: the perf project's + baselines (`GetMessageAsync` 56.2 μs; `ListMessagesAsync` 58.1 μs / 28.24 KB) must not + regress through breadth batches. +- **Tooling performance waits for M6** (`refresh-spec` + nightly canary; posture: speed + only, no allocation budget). Early trigger: the regen ritual noticeably slowing the + dev loop pulls #24's measured-first items (V31/V32) forward. + +## Session Mechanics The Docs Don't Carry + +- Dev loop: all six gates before every commit under `set -o pipefail` with an explicit + ALL-GREEN marker; red-test-first; commit per green increment; push on the working + branch authorized inside an agreed loop; decisions to the maintainer as plain text with + options and a recommendation — never dialog widgets; engage their notes in prose + before touching code. +- Regen ritual: emitters → `dotnet run --file tools/opencode-tool.cs -- generate` → + review the committed diff → snapshots received→verified after review → + `generate --verify` before commit. +- Review-queue issues carry a **Trigger** section and a milestone in the title; full + finding text goes into the issue body so nothing lives only in transient scratchpads. +- Multi-TFM traps are recorded in agent memory; Sonar's test-project probe misses + TUnit-only projects (arbitrate via csproj `NoWarn` + one-line slopwatch-ignore). +- The maintainer reads answers, not tool traffic; batch decisions, lead with the + recommendation. + +## Stop Conditions + +The prior list carries (a binder needing raw OpenAPI access, name-branching emitters, +structural unions, ownership ambiguity, TFM breakage, partial packs; any body+query +operation admitted without the sealed merged-Request design; Extensions dependencies +outside research doc 06's policy; any M3 stream design bypassing plan-first), plus: + +- Changing the Extensions registration shape before #31's decision seals. +- Any curation surface without a maintainer seal (#28 still waits). +- Optimizing SDK hot paths outside the #23-anchored M3 pass, or claiming performance + without benchmark numbers. diff --git a/docs/engineering/coding-style.md b/docs/engineering/coding-style.md index 528ead19..1d2c53b6 100644 --- a/docs/engineering/coding-style.md +++ b/docs/engineering/coding-style.md @@ -119,8 +119,19 @@ for a pile of static steps. folder) for data shapes, `Abstractions/` for its seams, implementation classes at the slice root. Similar things sit together; a reader predicts where a type lives before opening the tree. -- File = type (MA0048) and folder = namespace (IDE0130) are already mechanical; this - document adds the *placement* discipline analyzers cannot see. +- **The shipped SDK slices by client family with flat public namespaces** (maintainer, + 2026-08-14): client families are folders (`Sessions/`, `Health/`), the pagination + spine sits under `Pagination/`, wire models under `Models/`, runtime internals under + `Internal/`, and the root client with the response/exception spine at the project + root. Public namespaces stay `OpenCode.Sdk` and `OpenCode.Sdk.Models` — a namespace + is API surface, folders are placement (Stripe/Azure precedent) — so IDE0130's + folder-matches-namespace rule is arbitrated for the SDK's public folders through the + standing per-rule pattern. +- **A test project mirrors the layout of the project under test**: the folder path of a + SUT predicts the folder path of its tests. +- File = type (MA0048) is mechanical everywhere; folder = namespace (IDE0130) is + mechanical outside the SDK's arbitrated public folders. This document adds the + *placement* discipline analyzers cannot see. - **Split before it hurts:** a class approaching a few hundred lines, or a folder flattening into dozens of siblings, is a slice asking to be verticalized. Growth is handled by extraction and sub-foldering at the moment of pressure, not by a rewrite diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index b11e84a5..45d525b6 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1657,3 +1657,281 @@ is arbitrated for the one generated file. Route member names ride the plan **Documentation pass (this session):** ROADMAP moved to M1-complete/M2-next; the Arc B plan's checkboxes closed in the same commits as their code; the consumed handover was deleted and a fresh one (`HANDOFF-2026-08-13-3.md`) records the ship/M2/horizon queue. + +# Session 19 — 2026-08-14: M1 ship, review triage, M2 opening decisions + +PR #16 merged after a verified multi-agent review cycle (blocker set fixed red-test-first +on the branch; every surviving finding milestone-anchored in issues #17–#25) and the +BenchmarkDotNet performance suite landed with baselines. This entry records the decisions +sealed while opening M2; execution detail lives in git and the M2 plan. + +## Q76: What shape does the generated query surface take? + +**How researched:** the four M2 operations extracted from the pinned spec (session.list +carries 9 optional `anyOf [T, null]` query parameters; both list operations share the +`limit`/`order`/`cursor` trio and the `{data, cursor:{previous,next}}` envelope); Azure SDK +.NET design guidelines fetched live ("DO use the options parameter pattern for complex +service methods"; `Pageable`/`AsyncPageable` for lists); Stripe.net +(`SessionListOptions : ListOptions` shared base) and OpenAI .NET (`*CollectionOptions`) +as ecosystem precedent. + +**Decision (maintainer, sealed):** per-operation generated options records deriving from a +shared `ListOptions` base that carries only the cursor-pagination trio; a fail-closed +generator profile-detection wall derives an operation from the base only on an exact wire +match (otherwise flat options); the base is the typed seam the M3 paginator will consume. +`*Options` = call shaping, `*Request` = wire body. + +## Q77: How is the string-typed `limit` exposed? + +**Found:** the wire types `limit` as a string; `uint` was weighed and rejected — FDG bans +unsigned types in public APIs (CLS compliance), the ecosystem has zero precedent +(`Take(int)`, Stripe `long?`, OpenAI `int`), and it does not even buy the invariant +(zero stays representable, so a guard is needed either way). + +**Decision (maintainer, sealed):** public `int?`, invariant-culture conversion at the +route boundary, non-positive values refused with `ArgumentException`. + +## Q78: How does the API express parentID's root-only sentinel? + +**Found:** the wire has three states — omitted (all sessions), `parentID=` (children), +and the literal string `"null"` (root sessions only). The schema shape is +self-identifying: `anyOf` of a `^ses`-patterned string and a single-value `"null"` enum. + +**Decision (maintainer, sealed):** a hand-written public spine type +`SessionParentFilter` (`RootOnly` singleton, `Of(id)` factory) carried by the options +record; the binder recognizes the wire shape mechanically, never the parameter name +(ADR-0008 stays intact); invalid combinations are unrepresentable at compile time. + +## Q79: What shape does the first request body take? + +**Decision (maintainer, sealed):** `session.create`'s inline all-optional body binds into +a generated `SessionCreateRequest`; the operation parameter is optional — +`CreateSessionAsync()` sends an empty JSON body. The `{Subject}{Verb}Request` pattern is +the mechanical rule for future bodies. + +# Session 20 — 2026-08-14: M2 first breadth batch execution + +The batch executed per the sealed plan: binder walls opened red-test-first (query options +with the profile-detection wall, request bodies, cursor-list envelopes, merged client +families), the pipeline grew its JSON body path, the naming and fail-closed walls batches +(#22, #21) and the F07 carrier converters (#19) landed, and the four operations were +demonstrated live against `opencode2 serve` v0.0.0-next-17403 (create → list → get → +messages, all typed 200s, wire cursor round-tripped). This entry records the decisions +sealed during execution. + +## Q80: What are the concrete names of the query surface's supporting types? + +**How researched:** batched to the maintainer with previews once the binder was about to +encode names; Stripe (`SessionListOptions`) and the sealed `{Subject}{Verb}Request` rule as +anchors. + +**Decision (maintainer, sealed):** options records follow `{Group}{Verb}Options` +(`SessionListOptions`, `MessageListOptions`), mirroring the request-model rule so one +mechanical family covers both; the shared order enum is the hand-written spine +`ListOrder { Ascending, Descending }` (request-only closed enum — ADR-0009 tolerance does +not apply); the wire cursor surfaces through one hand-written `ListCursor` record on every +list envelope, giving the M3 paginator a single cursor seam. The binder validates the wire +shapes (`asc`/`desc` exactly; `{previous?, next?}` exactly) and fails closed otherwise. + +## Q81: How is upstream's InvalidRequestError1 duplicate resolved? + +**Found:** the plan's recon framed it as a naming dup; execution showed it byte-identical to +`InvalidRequestError` *including the `_tag`*, which trips the per-status duplicate-tag wall +and would poison `OpenCodeError` tag dispatch. Two candidate mechanisms were priced: a +mechanical structural dedup (which needed a novel recorded-output channel to satisfy the +maintainer's drift-visibility requirement) and a curated alias. + +**Decision (maintainer, sealed):** a `schemaAliases` curation section — one row declaring +the duplicate a spelling of its target, validated fail-closed for deep structural identity +and applied as a pure document transform before binding. Drift stays loud through existing +machinery: a deleted source or target orphans the row, a dereferenced source goes dormant +against the profile, and any structural divergence (the tag included) breaks the identity +wall. The binder's own duplicate-tag refusals stay intact for anomalies without an alias. + +## Q82: How do the two options-like parameters coexist on generated methods? + +**Decision (maintainer):** the query record binds to `options` and `OpenCodeRequestOptions` +binds to `requestOptions` on every generated method — the type's own name decides, matching +Stripe's idiom; the pre-release rename of the existing surface rode the PublicApi baseline +review. + +## Execution notes + +- The verb rules (sealed decision 5) concretized as: only the final identifier segment can + be a verb (`create`/`get`/`list` — the C18 structural fix); empty subjects fall back to + the group, pluralized for list operations under a naive fail-closed rule; response type + names fold non-Get verbs; client-placed route members mirror their method names while + root members keep the bare-verb shape (`Health.Get`). +- The P2 single-pass envelope DTOs cut `GetMessageAsync` from 67.4 μs to 56.2 μs with flat + allocations; `ListMessagesAsync` baselines at 58.1 μs / 28.24 KB. +- The contract matrix caught a real defect before any consumer could: the error-path + constructor pushed its forgiven null through the list payload's defensive-copy init + accessor; the copy now passes the forgiven null through uncopied behind the guard. + +## Q83: Do operation inputs stay split across *Request and *Options? + +**How researched:** maintainer review question (AWS-style Request/Response symmetry); +ecosystem survey — AWS and Google Cloud use a uniform `{Operation}Request` for every +operation regardless of wire placement (query vs body is a marshalling detail), Stripe and +OpenAI use a uniform `*Options` (POST bodies included), Azure's options bag carries only +optional inputs; **no surveyed SDK splits the suffix by verb** as Q76's dichotomy did. + +**Found:** the sealed dichotomy had married halves of two different families, and the +verb→suffix mapping is unstable — a future query-carrying POST would demand two input bags +per call where the uniform-Request shape carries one. + +**Decision (maintainer, sealed):** uniform `*Request`, revising Q76's naming half. Query +records rename (`SessionListRequest`, `MessageListRequest`, base `ListRequest`); the body +models already carry the name; the method and route-builder parameter becomes `request`. +The profile-detection wall and the M3 paginator seam carry over under the new name. An +operation declaring both a body and query parameters would mechanically derive one type +name twice and fail the existing collision wall — deliberate, until a merged-Request +design (AWS marshalling style) is sealed. Execution: next session +(`agents/handover-prompts/HANDOFF-2026-08-14-2.md`). + +## Q84: How does the shipped SDK lay out? + +**Decision (maintainer, sealed):** vertical feature slices with flat public namespaces — +client families as folders (`Sessions/`, `Health/`), the pagination spine under +`Pagination/`, `Models/` and `Internal/` unchanged, the root client and response/exception +spine at the project root. Public namespaces stay `OpenCode.Sdk` and `OpenCode.Sdk.Models`: +a namespace is API surface, folders are placement (Stripe/Azure precedent). Test projects +mirror the layout of the project under test. IDE0130's folder-matches-namespace rule is +arbitrated for the SDK's public folders per the standing per-rule pattern. Canonical +wording: `engineering/coding-style.md` §4. Execution: next session. + +## Q85: When does the Extensions package rise? + +**Decision (maintainer, sealed):** in parallel with M2 breadth instead of waiting for M6 — +an `AddOpenCode` bring-up (overloads mirroring the three client constructors, root plus +sub-client registrations so a consumer can resolve `SessionsClient` directly) lands as its +own batch; the ROADMAP moves "Extensions DI breadth" out of M6 accordingly. + +## Review decisions closed with the batch + +- **#20 (password semantics)** sealed as recommended and landed (`2be2d0f`): `null` = unset + with the environment fallback; empty or whitespace refused with `ArgumentException` — an + explicitly blank credential has no upstream meaning; `""` = explicit no-auth stays an + additive door if upstream ever ships passwordless serve. +- **#25 (DynamicProxyGenAssembly2 IVT)** closed keep: the friend grant is the standard + NSubstitute mechanism over internal seams and a recorded solved-once decision; internals + are not a security boundary. The recorded alternative (hand-written fake replacing the + grant) applies only if encapsulation purity is ever preferred. + +# Session 21 — 2026-08-14: Alignment batch execution + +The three sealed alignment decisions (Q83–Q85) executed on the PR #26 branch: the uniform +`*Request` rename with its body-plus-query double-derivation refusal pin, the feature-slice +layout migration with the writer's fail-closed family-folder allowlist, and the Extensions +bring-up. No sealed decision was reopened; one forward-looking note is recorded. + +## Note: generator-emitted DI registrations + +`AddOpenCode`'s sub-client registrations are hand-written — one factory line per client +family. Emitting these registrations from the generator becomes worthwhile when client +families multiply across breadth batches: the registration list is the same mechanical +projection the client emitters already own. This is a trigger, not a commitment. + +# Session 22 — 2026-08-14: Extensions grill, upstream re-research, location census + +The maintainer grilled the Q85 first cut (constructor-mirror DI shape, BYO-HttpClient +overload, missing IHttpClientFactory, options mutability, env-fallback double-headedness) +and demanded upstream verification instead of vision-doc relay. Findings re-derived at the +pinned commit `a6a712a` directly; canonical detail in research doc 15 (§5a, §6). The +`external/opencode` submodule pointer was found stranded on the 1.x line and moved to the +spec pin with a lockstep rule in `spec/SNAPSHOT.md`. + +## Q87: Is `x-opencode-directory` dead on v2? + +**Found:** no — evolved. The server's location middleware resolves dual-channel: +`location[…]` deepObject query (61 ops, spec-visible, used exclusively by the first-party +generated client) OR ambient `x-opencode-directory`/`x-opencode-workspace` headers +(spec-invisible, middleware-level), precedence query > header. The vision spec's +header-only `Directory` story is obsolete; its §6 was corrected in place. + +## Q88: Can the merged-Request need be foreseen instead of wall-triggered? + +**Found:** yes — censusable today: 15 ops mix body + query, and in all 15 the only query +parameter is `location`; `v2.session.list` is the single flat-field exception. The merged +marshalling question and the location question are one design. + +**Decision (maintainer, sealed):** the location + merged-Request input design is done +proactively in a short design session that opens M3 planning — not deferred until the +collision wall fires. Census and mechanism notes live in doc 15 §5a/§6. + +## Q89: Where do v2 endpoint and credentials actually come from? + +**Found:** no URL environment variable exists; the first-party discovery contract is the +registration file (doc 15 §6). Auth is optional (`ServerAuth.required` = configured, +non-empty password). The username is `opencode` at the pin (the pinned server hardcodes +it); `--username`/`OPENCODE_SERVER_USERNAME` is upstream direction — docs and the desktop +sidecar — not pinned-server behavior, so the `Username` option is the gateway and +forward-compatibility seam. Client-side +password-from-environment resolution lives in the CLI consumer +(`OPENCODE_PASSWORD` → legacy `OPENCODE_SERVER_PASSWORD`), not in the client library. +Feeds the pending Q86 batch (options shape, env-fallback ownership, `Username` option). + +## Q90: How do client construction, options, and DI align with .NET conventions? (the in-session "Q86" batch) + +**How researched:** the maintainer's grill of the Q85 first cut plus the Q87–Q89 upstream +findings; ecosystem verification of options immutability (init-only binds via the +reflection binder; the .NET 8+ config source generator's required/init support is the +buggy edge — dotnet/runtime #95006/#101984/#90974 — so `required` stays out). + +**Decisions (maintainer, sealed):** + +- **Construction:** `OpenCodeClient(OpenCodeClientOptions)` + the caller-owned-HttpClient + overload; the Uri constructors retire — the endpoint has one home and the + endpoint-must-stay-unset guard disappears. One way to build a client. +- **Options:** the .NET convention holds — a settable class with the `Action<>` configure + pattern — and immutability is expressed at consumption: the public read-only + `IOpenCodeClientOptions` view, snapshotted by the pipeline at construction (pinned by + test: post-construction mutation never reaches a built client). +- **Credentials:** `Username` joins the options (upstream's `--username` / + `OPENCODE_SERVER_USERNAME`, default `opencode`; the pipeline's hardcode retired); + `Password` stays optional — null sends anonymous requests, matching v2's optional auth; + blank refusal stays. The environment fallback left the SDK (upstream layering: the + consumer resolves env; the CLI's own chain is `OPENCODE_PASSWORD` → + `OPENCODE_SERVER_PASSWORD`) — this revises the env half of the #20 decision on Q89 + evidence; the blank-refusal half stands. +- **Extensions:** rebuilt on `IHttpClientFactory` — `AddOpenCode(Action<...>)` + + AOT-annotated `AddOpenCode(IConfiguration)` bind options, register a transient typed + client over a named factory client, register sub-clients explicitly, and return the + `IHttpClientBuilder` so consumer handlers/resilience/telemetry chain on without SDK + middleware. The BYO-HttpClient registration overload is gone (a captured instance + defeats factory rotation; no surveyed companion package ships one). +- **Transport:** the self-created path uses `SocketsHttpHandler` with + `PooledConnectionLifetime` on modern TFMs (vision §7.5's promise, implemented); the + net472 `ServicePointManager` half stays an M3 item. +- The sandbox is the standing DI showcase (Generic Host, builder-chained consumer + handler, direct sub-client resolution). + +# Session 23 — 2026-08-15: Q91 BYO-transport seal + +## Q91: Does the caller-owned `(HttpClient, options)` constructor stay public? + +**How researched:** seven-agent primary-source survey (2026-08-14, versions pinned at +release tags) across Azure.Core/System.ClientModel, OpenAI, AWS v4, Stripe.net, Octokit, +the typed-client ecosystem (Refit, Kiota, Grpc, Elastic, MCP), and the +BCL/IHttpClientFactory layer — census, findings, and sources in research doc 16; upstream +v2 JS client read at the pin (`packages/client`: public `fetch` option, SDK-wins header +precedence encoded in the generated client). + +**Found:** no surveyed SDK gates transport injection behind a DI companion (AWS v4 +removed the knob from its DI options type); every SDK resolves header conflicts by the +BCL's silent request-wins merge with zero guards, and every surveyed anonymous mode leaks +a caller default `Authorization`; the BCL cannot express "anonymous by omission" over a +caller client, and the same leak exists on the factory path via `ConfigureHttpClient` — +so the pipeline guard is required regardless of the constructor's visibility; the stock +typed-client pattern hard-requires a public `(HttpClient, …)` constructor. The +internalize+IVT leaning and a handler-seam-on-options alternative were both weighed and +rejected (doc 16 §4) — the latter on the Q90 bindable/snapshot options contract. + +**Decision (maintainer, sealed):** the constructor stays public. Anonymous mode fails +closed — `Password == null` while the injected client's `DefaultRequestHeaders` carry +`Authorization` refuses at construction and before every send (construction-only would +miss legal post-construction mutation). With `Password` set, the SDK's per-request +`Authorization` deterministically wins (BCL request-wins merge); the precedence contract +is documented on the constructor and options. Standalone handler/proxy/TLS composition +rides the BYO client; concrete convenience knobs remain an additive future. Executes as +review blocker #1's fix in this batch. diff --git a/docs/research/15-opencode-v2-platform.md b/docs/research/15-opencode-v2-platform.md index 2f5e1f6a..78facee4 100644 --- a/docs/research/15-opencode-v2-platform.md +++ b/docs/research/15-opencode-v2-platform.md @@ -1,17 +1,19 @@ # The opencode v2 platform: branch, surface, architecture, distribution -Date: 2026-08-13 +Date: 2026-08-14 > Retarget-decision research (docs 09/10 follow-up). Question: what is the opencode v2 platform > today — which branch carries it, what API surface it serves, how its server/client architecture -> and its distribution work, and what retargeting this SDK onto it changes. Sources, all retrieved +> and its distribution work, and what retargeting this SDK onto it changes. Sources, retrieved > 2026-08-13: `anomalyco/opencode` branch `v2` at head `1288161` ("chore: generate"), inspected > via shallow clone; the GitHub REST API (branch head, commit list, releases, default branch); the > npm registry (`@opencode-ai/cli` and `opencode-ai` dist-tags); live probes of > `update.opencode.ai`; and a live install and run of `@opencode-ai/cli@next` (0.0.0-next-17403) -> on this machine. This document is the canonical current-state picture of the v2 platform; docs -> 09 and 10 remain the dated v1-line genealogy it extends, and research log session 12 (Q58) -> recorded the branch's first sighting. +> on this machine. §5a and the auth detail in §6 were re-derived 2026-08-14 against the pinned +> spec commit `a6a712a` directly (submodule checkout, `git grep`/`git show` at the pin). This +> document is the canonical current-state picture of the v2 platform; docs 09 and 10 remain the +> dated v1-line genealogy it extends, and research log session 12 (Q58) recorded the branch's +> first sighting. ## 1. The active branch is `v2`, and the investment is visibly there @@ -115,6 +117,27 @@ mangling rule covers them. Two-day drift against session 12's census of the same (2026-08-11: 322 schemas, 422 `allOf`, 359 single-value enums) measures the churn rate directly — the branch moves daily, which is exactly what snapshot pinning is for. +### 5a. Parameter-placement census at the pin (`a6a712a`, 2026-08-14) + +120 operations: 59 GET, 44 POST, 12 DELETE, 3 PUT, 2 PATCH. Placement facts that bound the +SDK's request-marshalling design: + +- **Header parameters: zero.** No operation declares an `in: header` parameter; the + ambient location headers (§6) are middleware-level and spec-invisible. +- **`location` deepObject: 61 operations** carry an optional `location` query parameter + (`style: deepObject`, `explode: true` — wire shape `location[directory]=…&location[workspace]=…`, + schema `anyOf[{directory?, workspace?}, null]`). +- **Body + query mixing: 15 operations** (POST/PUT/PATCH/DELETE) — and in every one of them + the *only* query parameter is `location`. The merged-Request marshalling question and the + location question are the same design. +- **Flat location fields: exactly one operation** — `v2.session.list` carries + `directory`/`workspace`/`project`/`subpath` as plain query parameters instead of the + deepObject; the platform-wide mechanism is the deepObject. + +Side observation validating the Q83 rename: the v2 first-party generated client emits one +uniform `{Op}Input` type per operation (`SessionListInput`, `SessionPromptInput`, …) — +upstream's own rendering of the uniform-request idea. + **Streaming reshape.** `text/event-stream` lives on `v2.event.subscribe` and `v2.session.log`; `v2.pty.connect` carries `x-websocket`; `x-effect-stream` rides both SSE operations (2026-08-13 ingestion-level correction of this session's earlier jq census). The v1 durable @@ -162,10 +185,28 @@ dies, server exits. It scrubs `OPENCODE_PASSWORD`/`OPENCODE_SERVER_PASSWORD` fro environment and generates a random password. Made for embedding hosts (the desktop uses the bundled CLI this way). -**Auth is unchanged from v1**: HTTP Basic, username `opencode`, optional password; service mode -generates `randomBytes(32).toString("base64url")` when unconfigured; foreground `serve` prints -the password when it generated one. The SDK's decoration decisions carry over verbatim; the -`location[…]`-vs-`x-opencode-directory` addressing question is a retarget-time decision. +**Auth (re-derived at the pin, 2026-08-14)**: HTTP Basic, and **optional** — the server's +`ServerAuth.required` is true only when a password is configured and non-empty; otherwise the +authorization middleware is a pass-through (`packages/server/src/middleware/authorization.ts`). +The username is `opencode` at the pin: the pinned server layer hardcodes it, while the +`--username`/`-u` and `OPENCODE_SERVER_USERNAME` controls exist only as upstream direction +(CLI/server docs; the desktop WSL sidecar exports the variable) that has not reached the +pinned server implementation. Client-side password resolution lives in the *consumer*, not the client library: +`packages/cli/src/env.ts` resolves `OPENCODE_PASSWORD` falling back to the legacy +`OPENCODE_SERVER_PASSWORD` and hands the value to the client — the generated client itself +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 +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). ## 7. MCP is client-side only; the exposure protocol they chose is ACP @@ -245,8 +286,10 @@ lifecycle-leash bonus; connect-or-launch fits the discovery-file model). **Changes at retarget:** the ingestion wall needs one new admit rule (single-element, validation-only `allOf` unwrap); `HealthResponse` maps `ServiceHealth`; the -`location[…]`-query vs `x-opencode-directory`-header decoration policy must be decided; the M3 -durable-stream design is re-derived against `session.log` (`after` + `follow`). +`location[…]`-query vs ambient-header decoration policy is settled as a design input (§6: +dual-channel, query > header) and its SDK rendering is designed in the location + +merged-Request session at M3 planning; the M3 durable-stream design is re-derived against +`session.log` (`after` + `follow`). ## UNVERIFIED / open diff --git a/docs/research/16-byo-transport-survey.md b/docs/research/16-byo-transport-survey.md new file mode 100644 index 00000000..0214efaa --- /dev/null +++ b/docs/research/16-byo-transport-survey.md @@ -0,0 +1,101 @@ +# Caller-supplied transport: BYO-HttpClient precedent survey + +Date: 2026-08-14 + +> Research snapshot, 2026-08-14. Primary sources only, retrieved fresh at pinned release +> tags: Azure SDK for .NET source (Azure.Core 1.61.0, System.ClientModel 1.15.0, +> Microsoft.Extensions.Azure 1.14.0), openai/openai-dotnet 2.13.0, aws/aws-sdk-net v4 +> (AWSSDK.Core 4.0.101), stripe/stripe-dotnet 52.3.0, octokit/octokit.net 14.0.0, +> Refit 15.0.0, Microsoft.Kiota.Http.HttpClientLibrary 2.0.0, Grpc.Net.Client 2.83.0, +> Elastic.Clients.Elasticsearch 9.5.0, ModelContextProtocol.Core 2.2.0, dotnet/runtime +> v10.0.11, learn.microsoft.com, nuget.org. Informs Q91 (research log Session 23); the +> maintainer sealed the decision — §§5–6 record analysis, not law. + +## 1. Question and stakes + +Review blocker #1 exposed the public `(HttpClient, options)` constructor's default-header +ambiguity: with v2's optional auth, `Password == null` writes no `Authorization`, and the +BCL then copies a caller client's default `Authorization` onto the nominally anonymous +request. Q91 asked whether the constructor stays public (with a first-class resolution of +the ambiguity) or transport injection narrows to the DI companion, with no-DI needs met by +concrete option knobs. + +## 2. Census + +| SDK | Caller-supplied transport | DI-gated? | Header-conflict guard | No-DI proxy/TLS | +|---|---|---|---|---| +| Azure.Core 1.61 / SCM 1.15 | Public: `options.Transport = new HttpClientTransport(httpClient)` (options property + wrapper; never a client ctor) | No — the DI companion has zero transport API | None; BCL merge decides silently | Pass-through is the documented proxy recipe; Azure.Core adds concrete TLS knobs (`HttpPipelineTransportOptions`) | +| OpenAI 2.13 | Public: inherited SCM `Transport` property; `HttpClient` absent from its public API | No | None; per-request `Set` wins silently | Pass-through only (README mTLS recipe); zero knobs by in-source design comment | +| AWS v4 (Core 4.0.101) | Public: `ClientConfig.HttpClientFactory` abstraction (factory, never a raw instance); absent on net472 | No — v4 removed the knob from its DI options type (issue aws-sdk-net#3790) | None; SigV4 per-request wins | Concrete-knobs-first (ProxyHost/Port/Credentials…), factory escape hatch; zero TLS knobs | +| Stripe.net 52.3 | Public: ctor/options param carrying a wrapper (`new SystemNetHttpClient(httpClient)`) | No — no DI package exists | None; in-source comment claims headers per-message "even when a custom HTTP client is used" | Pass-through only via the wrapper | +| Octokit 14.0 | Public: `IHttpClient` abstraction + `Func`; a live `HttpClient` cannot be passed | No — no DI package | Structurally unrepresentable in the stock path | Handler factory; proxy ergonomics admittedly poor | +| Refit 15 / Kiota 2.0 / MCP 2.2 | Public raw `HttpClient` (method param / optional ctor param / ctor param + `ownsHttpClient` flag) | No | None | Pass-through (Kiota adds one `IWebProxy` convenience) | +| Grpc.Net.Client 2.83 | Public: `GrpcChannelOptions.HttpClient` / `HttpHandler` | No | Transport-shape guards only (throws when both are set; refuses credentials over insecure channels) | `HttpHandler` pass-through | +| Elastic 9.5 | No raw `HttpClient` anywhere — transport abstraction + full concrete knob set | No (architectural, not packaging) | N/A structurally; explicit in-source precedence rule | The lone full-knobs design; pays with an internal client factory keyed on config hashes | +| opencode JS v2 (our pin, `packages/client`) | Public `fetch` option on the generated promise client | n/a | Code-defined: SDK/per-request headers overwrite client-level `headers` | n/a — a custom `fetch` is stateless; the ambient-header hazard does not exist in JS | + +## 3. Load-bearing findings + +1. **DI-gating transport injection has zero precedent.** No surveyed SDK restricts + transport to a DI companion; Azure's companion composes over the public core knob and + owns nothing; AWS v4 deliberately deleted `HttpClientFactory` from its DI options type + and pointed users back at core. Internalize-with-IVT would be an ecosystem-first. +2. **Nobody guards the header conflict, and the anonymous leak is ecosystem-universal.** + dotnet/runtime v10.0.11: default headers are copied onto a request only for names the + request does not already set (`HttpHeaders.AddHeaders` — "we don't try to merge"). + Every SDK sets auth per-request, so caller default `Authorization` is inert on + authenticated calls; in every surveyed anonymous mode (Azure credential-less clients, + AWS `AnonymousAWSCredentials`, Kiota's anonymous provider, Refit) the caller default + flows onto the wire, undocumented and unguarded. +3. **"Anonymous by omission" is impossible over a caller client.** The BCL has no + suppress-default-header mechanism; a request lacking `Authorization` inherits the + client default. An SDK with optional auth must refuse the conflict or document the + leak — and the same leak exists on the DI path (`ConfigureHttpClient` can set default + headers on the factory client), so the Pipeline guard is required regardless of where + Q91 lands. +4. **The stock typed-client pattern hard-requires a public `(HttpClient, …)` ctor.** + `AddHttpClient()` resolves constructors via `ActivatorUtilities` → + `Type.GetConstructors()` (public only); an internal ctor fails at first + `CreateClient` with `InvalidOperationException`. The sanctioned route to an internal + ctor is `AddTypedClient(Func)` — usable by our own Extensions, + invisible to consumers' stock registrations. + +## 4. Alternatives weighed + +- **Internalize + IVT to Extensions** (the pre-survey leaning): rejected on findings 1, + 3, and 4 — precedent-free gating, safety not actually gained (the guard is needed for + the factory path anyway), stock `AddHttpClient()` forecloses, and the + standalone handler/proxy/TLS door closes with no knob replacement shipped. +- **Handler seam on options** (SDK always owns the client, consumers hand + `DelegatingHandler`s — the Octokit/Kiota family): rejected on the Q90 options + contract — `OpenCodeClientOptions` is bindable data snapshotted once at construction, + and a live handler graph breaks binding, snapshotting, and the `IOptions<>` flow. + (Honest note: ASP.NET Core itself puts handlers on options-pattern types — + `JwtBearerOptions.BackchannelHttpHandler` — so the shape exists; it conflicts with our + own sealed options semantics, not with the ecosystem.) A ctor-parameter variant would + keep options pure but still break the stock typed-client pattern. +- **Transport-wrapper options property** (Azure/OpenAI shape): rejected — it pays off + only atop a pipeline/transport abstraction this SDK deliberately does not have; for us + it is the same raw `HttpClient` with the same hazard and more public surface. + +## 5. Decision (sealed — research log Session 23) + +The `(HttpClient, options)` constructor stays public. Anonymous mode fails closed: +`Password == null` while the injected client's `DefaultRequestHeaders` carry +`Authorization` is refused at construction and before every send (construction-only would +miss legal post-construction mutation of default headers). With `Password` set, the SDK's +per-request `Authorization` deterministically wins (BCL request-wins merge) and the +precedence contract is documented on the constructor and options. Standalone +handler/proxy/TLS composition rides the BYO client; concrete convenience knobs may be +added additively if demand appears. Executes as review blocker #1's fix. + +## 6. Sources + +- Azure: [ClientOptions.cs](https://github.com/Azure/azure-sdk-for-net/blob/3830815f87881cce7af68dd9dd4126cbd90e197b/sdk/core/Azure.Core/src/ClientOptions.cs), [HttpClientTransport.cs](https://github.com/Azure/azure-sdk-for-net/blob/3830815f87881cce7af68dd9dd4126cbd90e197b/sdk/core/Azure.Core/src/Pipeline/HttpClientTransport.cs), [HttpClientTransport.Request.cs](https://github.com/Azure/azure-sdk-for-net/blob/3830815f87881cce7af68dd9dd4126cbd90e197b/sdk/core/Azure.Core/src/Pipeline/HttpClientTransport.Request.cs); [Microsoft.Extensions.Azure on nuget.org](https://www.nuget.org/packages/Microsoft.Extensions.Azure) +- OpenAI: [OpenAIClient.cs](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/OpenAI/src/Custom/OpenAIClient.cs), [OpenAIClientOptions.cs](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/OpenAI/src/Custom/OpenAIClientOptions.cs), [OpenAIClientUtilities.cs](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/OpenAI/src/Utility/OpenAIClientUtilities.cs), [README](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/README.md) +- AWS: [ClientConfig.cs (netstandard)](https://github.com/aws/aws-sdk-net/blob/36107286c1ac8dc5dee970e5b34584a7046e6231/sdk/src/Core/Amazon.Runtime/_netstandard/ClientConfig.cs), [HttpRequestMessageFactory.cs](https://github.com/aws/aws-sdk-net/blob/36107286c1ac8dc5dee970e5b34584a7046e6231/sdk/src/Core/Amazon.Runtime/Pipeline/HttpHandler/_netstandard/HttpRequestMessageFactory.cs), [AWSConfigs.netstandard.cs](https://github.com/aws/aws-sdk-net/blob/36107286c1ac8dc5dee970e5b34584a7046e6231/sdk/src/Core/_netstandard/AWSConfigs.netstandard.cs) +- Stripe: [StripeClient.cs](https://github.com/stripe/stripe-dotnet/blob/19fb69ecd290b671b64a18c9008152bdc882cc5b/src/Stripe.net/Infrastructure/Public/StripeClient.cs), [SystemNetHttpClient.cs](https://github.com/stripe/stripe-dotnet/blob/19fb69ecd290b671b64a18c9008152bdc882cc5b/src/Stripe.net/Infrastructure/Public/SystemNetHttpClient.cs) +- Octokit: [Connection.cs](https://github.com/octokit/octokit.net/blob/7fa5b0fe4a18c9b981b21290c3ca9320b2d6415b/Octokit/Http/Connection.cs), [HttpClientAdapter.cs](https://github.com/octokit/octokit.net/blob/7fa5b0fe4a18c9b981b21290c3ca9320b2d6415b/Octokit/Http/HttpClientAdapter.cs), [HttpMessageHandlerFactory.cs](https://github.com/octokit/octokit.net/blob/7fa5b0fe4a18c9b981b21290c3ca9320b2d6415b/Octokit/Http/HttpMessageHandlerFactory.cs) +- Typed-client ecosystem: [RestService.cs](https://github.com/reactiveui/refit/blob/74cbb64d51b705fb52a7bc0e7bd5f4e4b62bfb5e/src/Refit/RestService.cs), [HttpClientRequestAdapter.cs](https://github.com/microsoft/kiota-dotnet/blob/2aa504cc9b566ab92f93a4f1a3a2ba2c5048a9a4/src/http/httpClient/HttpClientRequestAdapter.cs), [KiotaClientFactory.cs](https://github.com/microsoft/kiota-dotnet/blob/2aa504cc9b566ab92f93a4f1a3a2ba2c5048a9a4/src/http/httpClient/KiotaClientFactory.cs) +- BCL/guidance: [HttpClient.cs L741-779](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs#L741-L779), [HttpHeaders.cs L617-665](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HttpHeaders.cs#L617-L665), [DefaultTypedHttpClientFactory.cs](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/Microsoft.Extensions.Http/src/DefaultTypedHttpClientFactory.cs), [ActivatorUtilities.cs L679-711](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/ActivatorUtilities.cs#L679-L711), [HttpClient guidelines](https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines) +- Upstream parity: `external/opencode` at the pin — `packages/client/src/promise/generated/client.ts` (`baseUrl` + optional `fetch` + `headers`; merge order at lines 265–269) diff --git a/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md b/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md new file mode 100644 index 00000000..dea5030c --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md @@ -0,0 +1,101 @@ +# M2 First Breadth Batch Execution Plan + +Date: 2026-08-14 + +**Goal:** make `v2.session.list`, `v2.session.get`, `v2.session.create`, and +`v2.message.list` callable through their final generated SDK surface. This batch +deliberately opens the three machines M1 walled off: query-parameter binding, request +bodies (the first non-GET verb), and list envelopes with cursor pagination. + +**Architecture:** unchanged from Arc B — SpecIR plus curation bind into plans, generic +name-blind emitters render them, generated methods delegate once into the hand-written +`Pipeline`. The `Pipeline` grows exactly one capability: sending a JSON request body +serialized through the source-generated context. + +## What the pinned spec says (recon, 2026-08-14) + +- `session.list` — GET, **9 optional query parameters**, all `anyOf [T, null]`: + `limit` (string-typed in the spec), `order` (`asc`/`desc` enum), `search`, `cursor` + (opaque), `parentID` (with a literal `"null"` string variant meaning root-only), and the + location-scoping family `workspace`/`directory`/`project`/`subpath` (flat params — the + deferred `location[...]` question resolves to these). 400 is a **multi-tag anyOf** + (`InvalidCursorError` | `InvalidRequestError1` | `InvalidRequestError`). +- `session.create` — POST, inline request-body object (all-optional nullable properties: + `id`, `title`, `agent`, …), responds `{data: Session.Info}`. +- `session.get` — GET path param, `{data: Session.Info}`, 404 `SessionNotFoundError`. +- `message.list` — GET path + `limit`/`order`/`cursor`, responds the same envelope shape, + and declares **500 `UnknownError`** — the first 5xx in a status map. +- Both list responses share one envelope: `{data: [...], cursor: {previous?, next?}}` — + one mechanical list-envelope shape covers both. +- New reachable closure: the `Session.Info` graph (13 properties, nested + time/tokens/location/revert) plus `InvalidCursorError`, `UnknownError`, and the + dotted-dup name `InvalidRequestError1` (naming resolution must stay mechanical or fail + closed into a curated override). + +## Boundaries + +- Arc B boundaries carry over verbatim: opencode-dialect-only, fail-closed on any + unsupported selected shape, name-blind emitters, curation-driven public API, mechanical + payload names with collision-only overrides, optionality/nullability independence, + non-2xx stays on the API-error channel (now including 500), undeclared 2xx is a + protocol failure. +- The Arc B stop-condition list carries unchanged: binder needing raw OpenAPI, + name-branching emitters, structural unions, ownership ambiguity, TFM breakage, partial + packs. +- **Spec pin stays at `a6a712a`** for this batch — the pin is one day old; refreshing + buys nothing but churn. Next sanctioned refresh moment: M3 boundary. +- Cursor **pagination surface stays raw** in this batch: response envelopes expose the + wire cursor (`Previous`/`Next`), request options accept an opaque `cursor` string. A + paginator/`IAsyncEnumerable` convenience is an M3 candidate, designed together with the + stream surface — not here. + +## Public-API decisions (maintainer, sealed 2026-08-14) + +1. **Query surface:** generated per-operation options records deriving from one shared + `ListOptions` base that carries only the cursor-pagination trio (`Limit`, `Order`, + `Cursor`). The generator owns a fail-closed **profile-detection wall**: an operation + derives from the base only when its wire parameters match the profile exactly; + otherwise it gets flat standalone options. The base is the typed seam the M3 paginator + consumes. Dichotomy: `*Options` shapes the call (query), `*Request` is the wire body. +2. **`limit` is `int?`** — invariant conversion to the wire string at the route boundary; + non-positive values refused with `ArgumentException`. `uint` rejected: FDG bans + unsigned in public APIs (CLS), zero ecosystem precedent, and it does not buy the + invariant (0 stays representable). +3. **`parentID` rides `SessionParentFilter`** — a small hand-written public spine type + (`RootOnly` singleton, `Of(id)` factory); the binder recognizes the wire shape + mechanically (`anyOf` of a patterned string and the literal `"null"` enum), never the + parameter name. Invalid states are unrepresentable; no magic strings. +4. **Create body model is `SessionCreateRequest`** with an optional parameter — + `CreateSessionAsync()` sends an empty body (every property is optional on the wire). +5. **`List*`/`Create*` verb rules** join `OperationNamePolicy`, including the C18 + structural-verb-position fix (#22). + +## Tasks + +- [x] **0. Decision batch** — the five decisions above, sealed with the maintainer + 2026-08-14 (research log Session 19). +- [x] **1. Curation + binding** — curation rows for the four operations; binder support + for optional nullable query parameters (fail-closed on any other query shape) and for + inline JSON request bodies bound into a generated request model; multi-tag 400 and the + 500 arm enter the status-map machinery. +- [x] **2. Model closure** — regenerate with the `Session.Info` graph, the new error + types, and the list envelopes; resolve `InvalidRequestError1` naming mechanically or + fail closed into a curated override; API baseline review. +- [x] **3. Pipeline body path** — `ExecuteAsync` overload (or body-carrying request + shape) serializing through `OpenCodeJsonContext`; `Content-Type: application/json`; + covered by pipeline tests (body bytes, header, GET stays body-less). +- [x] **4. Emitters** — operation methods carrying options records and body models; route + builders composing escaped query strings; list-envelope response adapters; the + generator fail-closed walls batch (#21) lands here, where breadth first stresses those + walls, with P2's single-pass `{data}` DTO folded into the envelope design. +- [x] **5. Naming batch (#22)** — `List*`/`Create*` rules + C18 fix; verify C17/C19/C20 + against the wall and fix what holds. +- [x] **6. Hardening riders** — F07 concrete `Unknown*` converter (#19); decisions #20 + (password semantics) and #25 (IVT) to the maintainer alongside task 0. +- [x] **7. Contract matrix + perf** — contract tests over the four operations (success, + every declared error status, cursor round-trip, undeclared-2xx, empty-marker); extend + `ClientOperationBenchmarks` with a `ListMessagesAsync` benchmark once the surface + exists. +- [x] **8. Close-out** — live sandbox demo (create → list → get → messages) against + `opencode2 serve`; ROADMAP status update; research-log entries for decisions sealed + during execution. diff --git a/docs/superpowers/specs/2026-08-09-public-api-design.md b/docs/superpowers/specs/2026-08-09-public-api-design.md index 044f8371..698ae611 100644 --- a/docs/superpowers/specs/2026-08-09-public-api-design.md +++ b/docs/superpowers/specs/2026-08-09-public-api-design.md @@ -296,12 +296,13 @@ public sealed class OpenCodeRequestOptions demonstrates the need. - `CancellationToken` stays a **separate last parameter** (TAP convention — deliberately unlike Azure's RequestContext, which folds it in). -- `Directory` resolves the ROADMAP question: client-level default (§10) + per-call - override, the .NET rendering of upstream's `createOpencodeClient({directory})` + - per-request header. Carried as the `x-opencode-directory` header on every request, - SSE included (header-only; upstream's GET/HEAD query rewrite is a browser/EventSource - constraint we don't share). Server precedence is query > header, so explicit per-op - `location[…]` parameters override the ambient value — documented behavior. +- `Directory` as a concept — client-level ambient default (§10) + per-call override — + survives, but the v1 header-only transport described here is obsolete: v2 resolves + location dual-channel (`location[…]` deepObject query parameters, used exclusively by + the first-party client, OR ambient `x-opencode-directory`/`x-opencode-workspace` + headers; precedence query > header — research doc 15 §6). The concrete SDK rendering + is designed in the location + merged-Request session at M3 planning (research log + Session 22). - The options class grows extend-only (adding properties is non-breaking). ## 7. Client composition diff --git a/external/opencode b/external/opencode index d7b115f6..a6a712a3 160000 --- a/external/opencode +++ b/external/opencode @@ -1 +1 @@ -Subproject commit d7b115f623760e68a4749d16508a9eca350f246f +Subproject commit a6a712a3ac72248c9b2f2f883e752e6e18ef8c40 diff --git a/spec/SNAPSHOT.md b/spec/SNAPSHOT.md index cf497e93..6f6ecd82 100644 --- a/spec/SNAPSHOT.md +++ b/spec/SNAPSHOT.md @@ -21,8 +21,12 @@ Platform evidence for the v2 line: `docs/research/15-opencode-v2-platform.md`. that commit (read-only `git fetch` in the `external/opencode` submodule, or `raw.githubusercontent.com` pinned to the full commit SHA — never a branch ref). 2. Copy it over `spec/openapi.json`. -3. Update the table above (commit, channel) and the `Date:` line. -4. Run `generate`; resolve what it reports (wall admits, curation rows) and review the +3. Move the `external/opencode` submodule pointer to the same commit in the same change + (`git -C external/opencode fetch origin v2 && git -C external/opencode checkout + `, then stage the gitlink) — the submodule checkout and this snapshot never + diverge, so a fresh `git submodule update --init` always lands on the pinned commit. +4. Update the table above (commit, channel) and the `Date:` line. +5. Run `generate`; resolve what it reports (wall admits, curation rows) and review the regenerated diff. A dedicated spec-refresh tool is planned — see repo tooling in `docs/ROADMAP.md`. diff --git a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj index a1747db8..272f450f 100644 --- a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj +++ b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj @@ -7,8 +7,24 @@ true + + + + + + + + + + + + + + + + diff --git a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs new file mode 100644 index 00000000..c74dd3f0 --- /dev/null +++ b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using OpenCode.Sdk; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Registers the opencode client family over IHttpClientFactory: a typed +/// riding factory-managed transport, plus every sub-client +/// resolved from it so a consumer can inject directly. The +/// returned is the composition seam — chain +/// AddHttpMessageHandler, resilience handlers, or telemetry instrumentation onto it. +/// +public static class OpenCodeServiceCollectionExtensions +{ + /// Registers the opencode client, shaping its options in code. + /// The service collection to register with. + /// Shapes the client options; the endpoint is required. + /// The typed-client builder for transport composition. + public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + _ = services.Configure(configure); + return AddOpenCodeCore(services); + } + + /// Registers the opencode client, binding its options from configuration. + /// The service collection to register with. + /// The configuration section carrying the client options. + /// The typed-client builder for transport composition. + [RequiresDynamicCode("Configuration binding uses reflection over the options type; prefer the configure-action overload on native AOT.")] + [RequiresUnreferencedCode("Configuration binding may require members trimming removes; prefer the configure-action overload when trimming.")] + public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + _ = services.AddOptions().Bind(configuration); + return AddOpenCodeCore(services); + } + + private static IHttpClientBuilder AddOpenCodeCore(IServiceCollection services) + { + // A named factory client bound as the typed client: the factory owns the HttpClient + // and its handler rotation, so the client registers transient and disposes nothing. + var builder = services.AddHttpClient(nameof(OpenCodeClient)) + .AddTypedClient(static (httpClient, provider) => new OpenCodeClient( + httpClient, + provider.GetRequiredService>().Value)); + _ = services.AddTransient(static SessionsClient (provider) => provider.GetRequiredService().Sessions); + return builder; + } +} diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index e68c919a..f76aae98 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -1,17 +1,38 @@ { "files": [ - "HealthResponse.cs", + "Health/HealthResponse.cs", "Internal/ResponseAdapters/HealthResponseAdapter.cs", + "Internal/ResponseAdapters/MessageListResponseAdapter.cs", + "Internal/ResponseAdapters/SessionCreateResponseAdapter.cs", + "Internal/ResponseAdapters/SessionListResponseAdapter.cs", "Internal/ResponseAdapters/SessionMessageResponseAdapter.cs", + "Internal/ResponseAdapters/SessionResponseAdapter.cs", + "Internal/Serialization/MessageListResponseEnvelope.cs", "Internal/Serialization/OpenCodeErrorJsonConverter.cs", "Internal/Serialization/OpenCodeJsonContext.cs", "Internal/Serialization/OptionalCollectionInput.cs", "Internal/Serialization/PromptFileSourceJsonConverter.cs", + "Internal/Serialization/SessionCreateResponseEnvelope.cs", + "Internal/Serialization/SessionForkBoundaryJsonConverter.cs", + "Internal/Serialization/SessionListResponseEnvelope.cs", "Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs", "Internal/Serialization/SessionMessageCompactionJsonConverter.cs", "Internal/Serialization/SessionMessageInfoJsonConverter.cs", + "Internal/Serialization/SessionMessageResponseEnvelope.cs", "Internal/Serialization/SessionMessageToolStateJsonConverter.cs", + "Internal/Serialization/SessionResponseEnvelope.cs", "Internal/Serialization/ToolContentJsonConverter.cs", + "Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs", + "Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs", + "Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs", + "Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs", + "Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs", + "Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs", + "Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs", + "Internal/Serialization/UnknownToolContentJsonConverter.cs", + "Models/FileDiffInfo.cs", + "Models/FileDiffInfoStatus.cs", + "Models/InvalidCursorError.cs", "Models/InvalidRequestError.cs", "Models/LocationRef.cs", "Models/MessageNotFoundError.cs", @@ -25,6 +46,13 @@ "Models/PromptMention.cs", "Models/PromptSkillAttachment.cs", "Models/ServiceHealth.cs", + "Models/SessionCreateRequest.cs", + "Models/SessionForkBoundary.cs", + "Models/SessionForkBoundaryBefore.cs", + "Models/SessionForkBoundaryThrough.cs", + "Models/SessionInfo.cs", + "Models/SessionInfoFork.cs", + "Models/SessionInfoTime.cs", "Models/SessionMessageAgentSelected.cs", "Models/SessionMessageAgentSelectedTime.cs", "Models/SessionMessageAssistant.cs", @@ -72,6 +100,7 @@ "Models/SessionMessageUser.cs", "Models/SessionMessageUserTime.cs", "Models/SessionNotFoundError.cs", + "Models/SessionRevert.cs", "Models/SessionStructuredError.cs", "Models/TokenUsageInfo.cs", "Models/TokenUsageInfoCache.cs", @@ -79,8 +108,10 @@ "Models/ToolFileContent.cs", "Models/ToolTextContent.cs", "Models/UnauthorizedError.cs", + "Models/UnknownError.cs", "Models/UnknownOpenCodeError.cs", "Models/UnknownPromptFileSource.cs", + "Models/UnknownSessionForkBoundary.cs", "Models/UnknownSessionMessageAssistantContent.cs", "Models/UnknownSessionMessageCompaction.cs", "Models/UnknownSessionMessageInfo.cs", @@ -88,8 +119,14 @@ "Models/UnknownToolContent.cs", "OpenCodeClient.cs", "OpenCodeRoutes.cs", - "SessionClient.cs", - "SessionMessageResponse.cs", - "SessionsClient.cs" + "Sessions/MessageListRequest.cs", + "Sessions/MessageListResponse.cs", + "Sessions/SessionClient.cs", + "Sessions/SessionCreateResponse.cs", + "Sessions/SessionListRequest.cs", + "Sessions/SessionListResponse.cs", + "Sessions/SessionMessageResponse.cs", + "Sessions/SessionResponse.cs", + "Sessions/SessionsClient.cs" ] } diff --git a/src/OpenCode.Sdk/.generation-incomplete b/src/OpenCode.Sdk/.generation-incomplete index a97248a4..a10db037 100644 --- a/src/OpenCode.Sdk/.generation-incomplete +++ b/src/OpenCode.Sdk/.generation-incomplete @@ -1,8 +1,12 @@ Generation is incomplete; packages must not be published. -Selected operations: 2 -Pending operations: 118 +Selected operations: 6 +Pending operations: 114 Selected: - v2.health.get +- v2.message.list +- v2.session.create +- v2.session.get +- v2.session.list - v2.session.message Pending: - v2.agent.get @@ -39,7 +43,6 @@ Pending: - v2.mcp.list - v2.mcp.remove - v2.mcp.resource.catalog -- v2.message.list - v2.model.default - v2.model.list - v2.permission.request.list @@ -69,7 +72,6 @@ Pending: - v2.session.command - v2.session.compact - v2.session.context -- v2.session.create - v2.session.export - v2.session.fork - v2.session.form.cancel @@ -79,7 +81,6 @@ Pending: - v2.session.form.reply - v2.session.form.state - v2.session.generate -- v2.session.get - v2.session.import - v2.session.inbox.cancel - v2.session.inbox.list @@ -89,7 +90,6 @@ Pending: - v2.session.instructions.entry.put - v2.session.instructions.entry.remove - v2.session.interrupt -- v2.session.list - v2.session.log - v2.session.move - v2.session.permission.create diff --git a/src/OpenCode.Sdk/HealthResponse.cs b/src/OpenCode.Sdk/Health/HealthResponse.cs similarity index 100% rename from src/OpenCode.Sdk/HealthResponse.cs rename to src/OpenCode.Sdk/Health/HealthResponse.cs diff --git a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs new file mode 100644 index 00000000..c00daad3 --- /dev/null +++ b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs @@ -0,0 +1,18 @@ +namespace OpenCode.Sdk; + +/// +/// The read-only view of the client options. Consumption sites — the pipeline, the DI +/// composition — read through this contract and snapshot at construction, so an options +/// instance mutated after a client is built never changes that client's behavior. +/// +public interface IOpenCodeClientOptions +{ + /// Gets the absolute HTTP or HTTPS server endpoint; required to build a client. + public Uri? Endpoint { get; } + + /// Gets the HTTP basic-authentication username; the pinned server accepts only the default opencode. + public string Username { get; } + + /// Gets the HTTP basic-authentication password; sends anonymous requests. + public string? Password { get; } +} diff --git a/src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs b/src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs deleted file mode 100644 index 8996eccd..00000000 --- a/src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace OpenCode.Sdk.Internal.Abstractions; - -/// Reads process environment variables behind a substitutable seam. -internal interface IEnvironmentProvider -{ - /// Reads one environment variable. - /// The variable name. - /// The value, or when the variable is not set. - public string? GetEnvironmentVariable(string name); -} diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 177806a6..a4cbbfed 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -1,82 +1,199 @@ using System.Globalization; using System.Net.Http.Headers; using System.Text; -using OpenCode.Sdk.Internal.Abstractions; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; namespace OpenCode.Sdk.Internal; /// Owns request decoration, sending, buffering, and error-channel policy for every operation. internal sealed class Pipeline : IDisposable { - private const string BasicUser = "opencode"; - private const string PasswordVariable = "OPENCODE_SERVER_PASSWORD"; - private readonly AuthenticationHeaderValue? _authorization; private readonly string _endpointBase; + private readonly bool _enforceAnonymousDefaults; private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; private readonly ProductInfoHeaderValue _userAgent; private bool _disposed; - internal Pipeline(HttpClient httpClient, bool ownsHttpClient, Uri endpoint, string? password, IEnvironmentProvider environment) + internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOptions options) { ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(endpoint); - ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(options); + var endpoint = options.Endpoint ?? throw new ArgumentException("OpenCodeClientOptions.Endpoint is required.", nameof(options)); + + // Basic credentials ride the wire as "username:password", so a blank name or a colon + // inside it would corrupt the header instead of failing a login. + var username = options.Username; + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException("The username cannot be empty or whitespace.", nameof(options)); + } + + if (username.Contains(':', StringComparison.Ordinal)) + { + throw new ArgumentException("The username cannot contain a colon.", nameof(options)); + } + + // An explicitly blank password has no upstream meaning (a server without configured + // authentication expects no credentials at all), so it fails loudly; null is the + // anonymous spelling. + var password = options.Password; + if (password is not null && string.IsNullOrWhiteSpace(password)) + { + throw new ArgumentException( + "An explicit password cannot be empty or whitespace; leave it null for a server without authentication.", + nameof(options)); + } + + // The pipeline builds every request URI absolute from Endpoint, so a BaseAddress on + // an injected client would never be consulted; refuse the conflict instead of + // silently ignoring half the caller's configuration. + if (!ownsHttpClient && httpClient.BaseAddress is not null) + { + throw new ArgumentException( + "The supplied HttpClient carries a BaseAddress, which this SDK never consults — requests are " + + "built absolute from OpenCodeClientOptions.Endpoint. Remove the BaseAddress or change the Endpoint.", + nameof(httpClient)); + } + + // Anonymous mode cannot be expressed by omission over an injected client: HttpClient + // copies its default headers onto every request that lacks the header, so a default + // Authorization would ride requests this SDK promises are anonymous. Refuse the + // conflict instead of silently sending a foreign credential. + if (!ownsHttpClient && password is null && CarriesDefaultAuthorization(httpClient)) + { + throw new ArgumentException( + "The options request anonymous requests (Password is null), but the supplied HttpClient's " + + "default headers carry an Authorization header. Remove the default header or set a password.", + nameof(httpClient)); + } _httpClient = httpClient; _ownsHttpClient = ownsHttpClient; _endpointBase = EndpointPolicy.Normalize(endpoint); _userAgent = UserAgentPolicy.Resolve(); - // The environment fallback is read exactly once, here; requests reuse the resolved header. - var resolvedPassword = string.IsNullOrEmpty(password) ? environment.GetEnvironmentVariable(PasswordVariable) : password; - _authorization = string.IsNullOrEmpty(resolvedPassword) + // The options are read exactly once, here: the pipeline holds an immutable snapshot, + // so mutating the options object after construction never changes a built client. + _authorization = password is null ? null - : new AuthenticationHeaderValue( - "Basic", - Convert.ToBase64String(Encoding.UTF8.GetBytes($"{BasicUser}:{resolvedPassword}"))); + : new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); + + // Default headers stay legally mutable after construction, so the anonymous-mode + // refusal must also run per send; owned transports never expose their client and + // authenticated sends override the default, so only anonymous injected clients pay. + _enforceAnonymousDefaults = !ownsHttpClient && _authorization is null; } - public static Pipeline Create(Uri endpoint, OpenCodeClientOptions? options, IEnvironmentProvider environment) + public static Pipeline Create(OpenCodeClientOptions options) { - ArgumentNullException.ThrowIfNull(endpoint); - ArgumentNullException.ThrowIfNull(environment); - if (options?.Endpoint is not null) + ArgumentNullException.ThrowIfNull(options); + + if (options.Endpoint is null) { - throw new ArgumentException( - "The endpoint constructor owns endpoint authority; leave OpenCodeClientOptions.Endpoint unset.", - nameof(options)); + throw new ArgumentException("OpenCodeClientOptions.Endpoint is required.", nameof(options)); } // Validate before constructing the owned client so a refused endpoint leaks nothing. - _ = EndpointPolicy.Normalize(endpoint); - return new Pipeline(new HttpClient(), ownsHttpClient: true, endpoint, options?.Password, environment); + _ = EndpointPolicy.Normalize(options.Endpoint); + var httpClient = CreateOwnedHttpClient(); + try + { + return new Pipeline(httpClient, ownsHttpClient: true, options); + } + catch + { + // Credential validation still throws inside the constructor; the owned + // transport must not outlive a refused construction. + httpClient.Dispose(); + throw; + } } - public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions options, IEnvironmentProvider environment) + public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions options) { ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(environment); + if (options.Endpoint is null) { - throw new ArgumentException( - "A caller-supplied HttpClient requires OpenCodeClientOptions.Endpoint.", - nameof(options)); + throw new ArgumentException("A caller-supplied HttpClient requires OpenCodeClientOptions.Endpoint.", nameof(options)); } - return new Pipeline(httpClient, ownsHttpClient: false, options.Endpoint, options.Password, environment); + return new Pipeline(httpClient, ownsHttpClient: false, options); } - public async Task ExecuteAsync(HttpMethod method, string route, ResponseAdapter adapter, + public Task ExecuteAsync(HttpMethod method, string route, ResponseAdapter adapter, OpenCodeRequestOptions? options, CancellationToken cancellationToken) + where TResponse : OpenCodeResponse => + ExecuteCoreAsync(method, route, body: null, bodyTypeInfo: null, adapter, options, cancellationToken); + + public Task ExecuteAsync(HttpMethod method, string route, TBody body, + JsonTypeInfo bodyTypeInfo, ResponseAdapter adapter, OpenCodeRequestOptions? options, + CancellationToken cancellationToken) + where TBody : class + where TResponse : OpenCodeResponse + { + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(bodyTypeInfo); + return ExecuteCoreAsync(method, route, body, bodyTypeInfo, adapter, options, cancellationToken); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + /// Presence beats parseability: a raw unparseable default value still rides the wire. + private static bool CarriesDefaultAuthorization(HttpClient httpClient) => httpClient.DefaultRequestHeaders.TryGetValues("Authorization", out _); + + /// The owned transport: pooled connection lifetime keeps DNS rotation alive on modern TFMs. + private static HttpClient CreateOwnedHttpClient() + { +#if NET + SocketsHttpHandler? handler = null; + try + { + handler = new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + }; + var httpClient = new HttpClient(handler, disposeHandler: true); + handler = null; + return httpClient; + } + finally + { + handler?.Dispose(); + } +#else + // net472/netstandard2.0 stay on the default handler. + return new HttpClient(); +#endif + } + + private async Task ExecuteCoreAsync(HttpMethod method, string route, TBody? body, + JsonTypeInfo? bodyTypeInfo, ResponseAdapter adapter, OpenCodeRequestOptions? options, + CancellationToken cancellationToken) + where TBody : class where TResponse : OpenCodeResponse { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(method); ArgumentException.ThrowIfNullOrWhiteSpace(route); ArgumentNullException.ThrowIfNull(adapter); + if (route[0] is not '/') { throw new ArgumentException("Routes must start with '/'.", nameof(route)); @@ -89,10 +206,29 @@ public async Task ExecuteAsync(HttpMethod method, string r throw new ArgumentOutOfRangeException(nameof(options), errorBehavior, "Unknown ErrorBehavior value."); } + if (_enforceAnonymousDefaults && CarriesDefaultAuthorization(_httpClient)) + { + throw new OpenCodeTransportException( + "The anonymous opencode client's HttpClient carries a default Authorization header; the request " + + "was refused before sending. Remove the default header or set a password."); + } + + if (!_ownsHttpClient && _httpClient.BaseAddress is not null) + { + throw new OpenCodeTransportException( + "The injected HttpClient's BaseAddress is set, and this SDK never consults it — requests are " + + "built absolute from the configured Endpoint. The request was refused before sending."); + } + int status; string rawBody; using (var request = new HttpRequestMessage(method, new Uri(_endpointBase + route, UriKind.Absolute))) { + if (body is not null) + { + request.Content = CreateJsonContent(body, bodyTypeInfo!); + } + Decorate(request); using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); status = (int)response.StatusCode; @@ -105,18 +241,12 @@ public async Task ExecuteAsync(HttpMethod method, string r : adapted; } - public void Dispose() + /// JSON is UTF-8 by definition (RFC 8259); the content type carries no charset. + private static ByteArrayContent CreateJsonContent(TBody body, JsonTypeInfo bodyTypeInfo) { - if (_disposed) - { - return; - } - - _disposed = true; - if (_ownsHttpClient) - { - _httpClient.Dispose(); - } + var content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(body, bodyTypeInfo)); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + return content; } private void Decorate(HttpRequestMessage request) @@ -140,9 +270,15 @@ private async Task SendCoreAsync(HttpRequestMessage request catch (Exception exception) when (exception is HttpRequestException or ObjectDisposedException) { // ObjectDisposedException covers a dispose-during-send race; the pre-send disposed - // guard stays outside this catch. OperationCanceledException passes through untouched. + // guard stays outside this catch. throw new OpenCodeTransportException("The opencode server could not be reached.", exception); } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + // The caller's token is untouched, so this cancellation is the transport timing + // out; genuine caller cancellation passes through in the token-canceled case. + throw new OpenCodeTransportException("The opencode server did not respond within the transport timeout.", exception); + } } private static async Task ReadBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken) @@ -153,11 +289,17 @@ private static async Task ReadBodyAsync(HttpResponseMessage response, Ca ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } - catch (Exception exception) when (exception is HttpRequestException or IOException or ObjectDisposedException - or InvalidOperationException) + catch (Exception exception) + when (exception is HttpRequestException or IOException or ObjectDisposedException or InvalidOperationException) { // InvalidOperationException covers an unusable response charset surfaced by - // ReadAsStringAsync. OperationCanceledException deliberately passes through untouched. + // ReadAsStringAsync. + throw new OpenCodeTransportException("The opencode response body could not be read.", exception); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + // The caller's token is untouched, so this cancellation is the transport timing + // out mid-body; genuine caller cancellation passes through untouched. throw new OpenCodeTransportException("The opencode response body could not be read.", exception); } } @@ -168,6 +310,7 @@ private static OpenCodeApiException CreateApiException(OpenCodeResponse response var message = response.Error is null ? $"The opencode API returned status {status}." : $"The opencode API returned status {status} ('{response.Error.Tag}')."; + return new OpenCodeApiException(message, response.Status, response.Error, response.RawBody); } } diff --git a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs new file mode 100644 index 00000000..1ed2e4a0 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using System.Text; + +namespace OpenCode.Sdk.Internal; + +/// +/// Composes the query-string suffix of a generated route; unset optionals are omitted and +/// every value is escaped. Route builders own the wire conversion rules, so refusals here +/// are the route-boundary contract. +/// +internal sealed class QueryStringBuilder +{ + private StringBuilder? _builder; + + public string Value => _builder?.ToString() ?? string.Empty; + + public void AddText(string name, string? value) + { + if (value is not null) + { + Append(name, value); + } + } + + public void AddCount(string name, int? value, string parameterName) + { + if (value is null) + { + return; + } + + if (value <= 0) + { + // The refusal surfaces from a generated route builder, so the builder passes + // the parameter name its own caller can actually see. + throw new ArgumentOutOfRangeException(parameterName, value, $"The '{name}' query value must be positive."); + } + + Append(name, value.Value.ToString(CultureInfo.InvariantCulture)); + } + + public void AddOrder(string name, ListOrder? value) + { + if (value is null) + { + return; + } + + var wire = value switch + { + ListOrder.Ascending => "asc", + ListOrder.Descending => "desc", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown ListOrder value."), + }; + Append(name, wire); + } + + public void AddParentFilter(string name, SessionParentFilter? value) + { + if (value is not null) + { + Append(name, value.WireValue); + } + } + + private void Append(string name, string value) + { + _builder ??= new StringBuilder(); + _ = _builder.Append(_builder.Length is 0 ? '?' : '&') + .Append(Uri.EscapeDataString(name)) + .Append('=') + .Append(Uri.EscapeDataString(value)); + } +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapter.cs index 6f882b27..b4daf2ae 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapter.cs @@ -38,34 +38,6 @@ protected static TPayload ReadBarePayload(string rawBody, JsonTypeInfo } } - /// Reads a {"data": ...} success payload; a malformed envelope is a protocol failure. - /// The payload type. - /// The buffered success body. - /// The source-generated payload metadata. - /// The payload. - protected static TPayload ReadDataPayload(string rawBody, JsonTypeInfo typeInfo) - { - ArgumentNullException.ThrowIfNull(rawBody); - ArgumentNullException.ThrowIfNull(typeInfo); - - try - { - using var document = JsonDocument.Parse(rawBody); - if (document.RootElement.ValueKind is not JsonValueKind.Object - || !document.RootElement.TryGetProperty("data", out var data)) - { - throw new OpenCodeTransportException("The opencode API returned a success body without its 'data' envelope."); - } - - return JsonSerializer.Deserialize(data, typeInfo) - ?? throw new OpenCodeTransportException("The opencode API returned a null success payload."); - } - catch (JsonException exception) - { - throw new OpenCodeTransportException("The opencode API returned a malformed success body.", exception); - } - } - /// Builds the protocol failure for a success status the operation does not declare. /// The undeclared 2xx status code. /// The transport failure the adapter throws. diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/MessageListResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/MessageListResponseAdapter.cs new file mode 100644 index 00000000..65a69230 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/MessageListResponseAdapter.cs @@ -0,0 +1,48 @@ +// 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/session/{sessionID}/message' responses onto 'MessageListResponse'. +/// +internal sealed class MessageListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidCursorError", "InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["SessionNotFoundError"]; + private static readonly string[] Status500Tags = ["UnknownError"]; + private MessageListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static MessageListResponseAdapter Instance { get; } = new MessageListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override MessageListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.MessageListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new MessageListResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new MessageListResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new MessageListResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + 500 => new MessageListResponse(status, ReadTolerantError(rawBody, Status500Tags), rawBody), + _ => new MessageListResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static MessageListResponse CreateSuccess(int status, MessageListResponseEnvelope envelope) => new() + { + Status = status, + Messages = envelope.Data, + Cursor = envelope.Cursor + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionCreateResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionCreateResponseAdapter.cs new file mode 100644 index 00000000..b930383b --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionCreateResponseAdapter.cs @@ -0,0 +1,41 @@ +// 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/session' responses onto 'SessionCreateResponse'. +/// +internal sealed class SessionCreateResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private SessionCreateResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionCreateResponseAdapter Instance { get; } = new SessionCreateResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionCreateResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => new SessionCreateResponse + { + Status = status, + Session = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.SessionCreateResponseEnvelope).Data + }, + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionCreateResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionCreateResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + _ => new SessionCreateResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.cs new file mode 100644 index 00000000..0b6adc9e --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.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/session' responses onto 'SessionListResponse'. +/// +internal sealed class SessionListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidCursorError", "InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private SessionListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionListResponseAdapter Instance { get; } = new SessionListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.SessionListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionListResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionListResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + _ => new SessionListResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static SessionListResponse CreateSuccess(int status, SessionListResponseEnvelope envelope) => new() + { + Status = status, + Sessions = envelope.Data, + Cursor = envelope.Cursor + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageResponseAdapter.cs index 2f47d645..402d151b 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageResponseAdapter.cs @@ -31,7 +31,7 @@ public override SessionMessageResponse Adapt(int status, string rawBody) 200 => new SessionMessageResponse { Status = status, - Message = ReadDataPayload(rawBody, OpenCodeJsonContext.Default.SessionMessageInfo) + Message = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.SessionMessageResponseEnvelope).Data }, >= 200 and < 300 => throw UndeclaredSuccessFailure(status), 400 => new SessionMessageResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionResponseAdapter.cs new file mode 100644 index 00000000..c8ed3950 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionResponseAdapter.cs @@ -0,0 +1,43 @@ +// 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/session/{sessionID}' responses onto 'SessionResponse'. +/// +internal sealed class SessionResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["SessionNotFoundError"]; + private SessionResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionResponseAdapter Instance { get; } = new SessionResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => new SessionResponse + { + Status = status, + Session = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.SessionResponseEnvelope).Data + }, + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new SessionResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new SessionResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/ListPayloadInput.cs b/src/OpenCode.Sdk/Internal/Serialization/ListPayloadInput.cs new file mode 100644 index 00000000..24c4836d --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ListPayloadInput.cs @@ -0,0 +1,20 @@ +using System.Collections.ObjectModel; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// Copies a list payload into the envelope, refusing null elements the wire contract forbids. +internal static class ListPayloadInput +{ + internal static ReadOnlyCollection CopyRejectingNullElements(IReadOnlyList value) + where T : class + { + var copy = new T[value.Count]; + for (var index = 0; index < value.Count; index++) + { + copy[index] = value[index] + ?? throw new ArgumentException("The list payload cannot contain a null element.", nameof(value)); + } + + return Array.AsReadOnly(copy); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.cs new file mode 100644 index 00000000..b699085a --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.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 'MessageListResponse' through one deserialization pass. +/// +internal sealed record MessageListResponseEnvelope +{ + [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("cursor")] + public required ListCursor Cursor { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/NullElementRejectingListJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/NullElementRejectingListJsonConverter.cs new file mode 100644 index 00000000..b624e79f --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/NullElementRejectingListJsonConverter.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// +/// Deserializes a cursor-list page whose wire contract admits neither a null array nor null +/// elements; either shape is a malformed success the adapters turn into a transport failure. +/// +/// The page's non-nullable element type. +internal sealed class NullElementRejectingListJsonConverter : JsonConverter> + where T : class +{ + /// Null must reach ; the serializer would otherwise assign it silently. + public override bool HandleNull => true; + + public override IReadOnlyList Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is not JsonTokenType.StartArray) + { + throw new JsonException("The page payload must be a JSON array."); + } + + var elementTypeInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + var items = new List(); + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.EndArray) + { + return items; + } + + if (reader.TokenType is JsonTokenType.Null) + { + throw new JsonException("The page payload does not admit null elements."); + } + + items.Add(JsonSerializer.Deserialize(ref reader, elementTypeInfo) + ?? throw new JsonException("The page payload does not admit null elements.")); + } + + throw new JsonException("The page payload array was truncated."); + } + + public override void Write(Utf8JsonWriter writer, IReadOnlyList value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + + var elementTypeInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + writer.WriteStartArray(); + foreach (var item in value) + { + JsonSerializer.Serialize(writer, item, elementTypeInfo); + } + + writer.WriteEndArray(); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs index e4f7cc01..b6c23dc5 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs @@ -10,10 +10,12 @@ internal sealed class OpenCodeErrorJsonConverter : JsonConverter { private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal) { + ["InvalidCursorError"] = typeof(InvalidCursorError), ["InvalidRequestError"] = typeof(InvalidRequestError), ["MessageNotFoundError"] = typeof(MessageNotFoundError), ["SessionNotFoundError"] = typeof(SessionNotFoundError), - ["UnauthorizedError"] = typeof(UnauthorizedError) + ["UnauthorizedError"] = typeof(UnauthorizedError), + ["UnknownError"] = typeof(UnknownError) }; public override bool HandleNull => true; diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs index 560f5924..50eb1b11 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs @@ -6,8 +6,12 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata, RespectNullableAnnotations = true)] +[JsonSerializable(typeof(FileDiffInfo))] +[JsonSerializable(typeof(FileDiffInfoStatus))] +[JsonSerializable(typeof(InvalidCursorError))] [JsonSerializable(typeof(InvalidRequestError))] [JsonSerializable(typeof(LocationRef))] +[JsonSerializable(typeof(MessageListResponseEnvelope))] [JsonSerializable(typeof(MessageNotFoundError))] [JsonSerializable(typeof(ModelRef))] [JsonSerializable(typeof(OpenCodeError))] @@ -19,6 +23,15 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(PromptMention))] [JsonSerializable(typeof(PromptSkillAttachment))] [JsonSerializable(typeof(ServiceHealth))] +[JsonSerializable(typeof(SessionCreateRequest))] +[JsonSerializable(typeof(SessionCreateResponseEnvelope))] +[JsonSerializable(typeof(SessionForkBoundary))] +[JsonSerializable(typeof(SessionForkBoundaryBefore))] +[JsonSerializable(typeof(SessionForkBoundaryThrough))] +[JsonSerializable(typeof(SessionInfo))] +[JsonSerializable(typeof(SessionInfoFork))] +[JsonSerializable(typeof(SessionInfoTime))] +[JsonSerializable(typeof(SessionListResponseEnvelope))] [JsonSerializable(typeof(SessionMessageAgentSelected))] [JsonSerializable(typeof(SessionMessageAgentSelectedTime))] [JsonSerializable(typeof(SessionMessageAssistant))] @@ -48,6 +61,7 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(SessionMessageLocationSwitchedTime))] [JsonSerializable(typeof(SessionMessageModelSelected))] [JsonSerializable(typeof(SessionMessageModelSelectedTime))] +[JsonSerializable(typeof(SessionMessageResponseEnvelope))] [JsonSerializable(typeof(SessionMessageShell))] [JsonSerializable(typeof(SessionMessageShellOutput))] [JsonSerializable(typeof(SessionMessageShellStatus))] @@ -66,6 +80,8 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(SessionMessageUser))] [JsonSerializable(typeof(SessionMessageUserTime))] [JsonSerializable(typeof(SessionNotFoundError))] +[JsonSerializable(typeof(SessionResponseEnvelope))] +[JsonSerializable(typeof(SessionRevert))] [JsonSerializable(typeof(SessionStructuredError))] [JsonSerializable(typeof(TokenUsageInfo))] [JsonSerializable(typeof(TokenUsageInfoCache))] @@ -73,8 +89,10 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(ToolFileContent))] [JsonSerializable(typeof(ToolTextContent))] [JsonSerializable(typeof(UnauthorizedError))] +[JsonSerializable(typeof(UnknownError))] [JsonSerializable(typeof(UnknownOpenCodeError))] [JsonSerializable(typeof(UnknownPromptFileSource))] +[JsonSerializable(typeof(UnknownSessionForkBoundary))] [JsonSerializable(typeof(UnknownSessionMessageAssistantContent))] [JsonSerializable(typeof(UnknownSessionMessageCompaction))] [JsonSerializable(typeof(UnknownSessionMessageInfo))] diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionCreateResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionCreateResponseEnvelope.cs new file mode 100644 index 00000000..4959a877 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionCreateResponseEnvelope.cs @@ -0,0 +1,14 @@ +// 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 'SessionCreateResponse' through one deserialization pass. +/// +internal sealed record SessionCreateResponseEnvelope +{ + [JsonPropertyName("data")] + public required SessionInfo Data { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionForkBoundaryJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionForkBoundaryJsonConverter.cs new file mode 100644 index 00000000..1908e156 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionForkBoundaryJsonConverter.cs @@ -0,0 +1,73 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class SessionForkBoundaryJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal) + { + ["before"] = typeof(SessionForkBoundaryBefore), + ["through"] = typeof(SessionForkBoundaryThrough) + }; + public override bool HandleNull => true; + + public override SessionForkBoundary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionForkBoundary payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionForkBoundary payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionForkBoundary payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for SessionForkBoundary."); + return JsonSerializer.Deserialize(payload, typeInfo) as SessionForkBoundary ?? throw new JsonException("The SessionForkBoundary payload deserialized to null."); + } + + return new UnknownSessionForkBoundary(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, SessionForkBoundary value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + if (value is UnknownSessionForkBoundary unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for SessionForkBoundary."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.cs new file mode 100644 index 00000000..7a73f0e6 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.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 'SessionListResponse' through one deserialization pass. +/// +internal sealed record SessionListResponseEnvelope +{ + [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("cursor")] + public required ListCursor Cursor { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageCompactionJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageCompactionJsonConverter.cs index e8af70ce..f7eb3208 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageCompactionJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageCompactionJsonConverter.cs @@ -32,6 +32,16 @@ public override SessionMessageCompaction Read(ref Utf8JsonReader reader, Type ty throw new JsonException("The SessionMessageCompaction payload must be a JSON object."); } + if (!payload.TryGetProperty("type", out var fixedElement)) + { + throw new JsonException("The SessionMessageCompaction payload must contain 'type'."); + } + + if (fixedElement.ValueKind != JsonValueKind.String || !fixedElement.ValueEquals("compaction")) + { + throw new JsonException("The 'type' marker must be 'compaction'."); + } + if (!payload.TryGetProperty("status", out var markerElement)) { throw new JsonException("The SessionMessageCompaction payload must contain 'status'."); diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageResponseEnvelope.cs new file mode 100644 index 00000000..63ff24c6 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageResponseEnvelope.cs @@ -0,0 +1,14 @@ +// 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 'SessionMessageResponse' through one deserialization pass. +/// +internal sealed record SessionMessageResponseEnvelope +{ + [JsonPropertyName("data")] + public required SessionMessageInfo Data { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionResponseEnvelope.cs new file mode 100644 index 00000000..c5cb5d3f --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionResponseEnvelope.cs @@ -0,0 +1,14 @@ +// 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 'SessionResponse' through one deserialization pass. +/// +internal sealed record SessionResponseEnvelope +{ + [JsonPropertyName("data")] + public required SessionInfo Data { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/StrictJsonStringEnumConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/StrictJsonStringEnumConverter.cs new file mode 100644 index 00000000..f19f3ef1 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/StrictJsonStringEnumConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// +/// The string-enum converter for every generated enum: wire values are strings by contract, +/// so a numeric token is a malformed body, not an undefined enum member. The parameterless +/// shape keeps the attribute reference AOT-safe. +/// +/// The generated enum type. +internal sealed class StrictJsonStringEnumConverter : JsonStringEnumConverter + where TEnum : struct, Enum +{ + public StrictJsonStringEnumConverter() + : base(namingPolicy: null, allowIntegerValues: false) + { + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs new file mode 100644 index 00000000..5be7ef85 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs @@ -0,0 +1,55 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownOpenCodeErrorJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownOpenCodeError Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The OpenCodeError payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The OpenCodeError payload must be a JSON object."); + } + + if (!payload.TryGetProperty("_tag", out var markerElement)) + { + throw new JsonException("The OpenCodeError payload must contain '_tag'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The '_tag' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The '_tag' marker must be a non-empty string."); + } + + return new UnknownOpenCodeError(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownOpenCodeError value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs new file mode 100644 index 00000000..9539ced9 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs @@ -0,0 +1,55 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownPromptFileSourceJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownPromptFileSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The PromptFileSource payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The PromptFileSource payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The PromptFileSource payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownPromptFileSource(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownPromptFileSource value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs new file mode 100644 index 00000000..d536ad24 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs @@ -0,0 +1,55 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionForkBoundaryJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownSessionForkBoundary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionForkBoundary payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionForkBoundary payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionForkBoundary payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownSessionForkBoundary(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionForkBoundary value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs new file mode 100644 index 00000000..afba3eaa --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs @@ -0,0 +1,55 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionMessageAssistantContentJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownSessionMessageAssistantContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageAssistantContent payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageAssistantContent payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionMessageAssistantContent payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownSessionMessageAssistantContent(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionMessageAssistantContent value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs new file mode 100644 index 00000000..6b731c15 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.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.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionMessageCompactionJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownSessionMessageCompaction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageCompaction payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageCompaction payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var fixedElement)) + { + throw new JsonException("The SessionMessageCompaction payload must contain 'type'."); + } + + if (fixedElement.ValueKind != JsonValueKind.String || !fixedElement.ValueEquals("compaction")) + { + throw new JsonException("The 'type' marker must be 'compaction'."); + } + + if (!payload.TryGetProperty("status", out var markerElement)) + { + throw new JsonException("The SessionMessageCompaction payload must contain 'status'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'status' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'status' marker must be a non-empty string."); + } + + return new UnknownSessionMessageCompaction(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionMessageCompaction value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs new file mode 100644 index 00000000..2e7b5c9f --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs @@ -0,0 +1,55 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionMessageInfoJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownSessionMessageInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageInfo payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageInfo payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionMessageInfo payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownSessionMessageInfo(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionMessageInfo value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs new file mode 100644 index 00000000..a8ee9805 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs @@ -0,0 +1,55 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionMessageToolStateJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownSessionMessageToolState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageToolState payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageToolState payload must be a JSON object."); + } + + if (!payload.TryGetProperty("status", out var markerElement)) + { + throw new JsonException("The SessionMessageToolState payload must contain 'status'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'status' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'status' marker must be a non-empty string."); + } + + return new UnknownSessionMessageToolState(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionMessageToolState value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs new file mode 100644 index 00000000..9f7ec79b --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs @@ -0,0 +1,55 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownToolContentJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownToolContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The ToolContent payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The ToolContent payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The ToolContent payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownToolContent(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownToolContent value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingJsonConverter.cs new file mode 100644 index 00000000..c8e54650 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingJsonConverter.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// +/// Rejects an explicit JSON null on an optional reference-typed property whose schema does not +/// admit null; absence never invokes a property converter, so omission stays legal. +/// +/// The property's non-nullable reference type. +internal sealed class WireNullRejectingJsonConverter : JsonConverter + where T : class +{ + /// Null must reach ; the serializer would otherwise assign it silently. + public override bool HandleNull => true; + + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + throw new JsonException("The property does not admit null; the server must omit it instead."); + } + + return JsonSerializer.Deserialize(ref reader, ResolveInner(options)); + } + + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, value, ResolveInner(options)); + } + + /// The property attribute never replaces the type's own metadata, so this cannot recurse. + private static JsonTypeInfo ResolveInner(JsonSerializerOptions options) => (JsonTypeInfo)options.GetTypeInfo(typeof(T)); +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingValueJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingValueJsonConverter.cs new file mode 100644 index 00000000..c3958f79 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingValueJsonConverter.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// +/// Rejects an explicit JSON null on an optional value-typed property whose schema does not +/// admit null; the converter targets the nullable shape because the serializer's nullable +/// wrapper would otherwise consume the null before any inner converter runs. +/// +/// The property's underlying value type. +internal sealed class WireNullRejectingValueJsonConverter : JsonConverter + where T : struct +{ + /// Null must reach ; the serializer would otherwise assign it silently. + public override bool HandleNull => true; + + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + throw new JsonException("The property does not admit null; the server must omit it instead."); + } + + return JsonSerializer.Deserialize(ref reader, ResolveInner(options)); + } + + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, value.Value, ResolveInner(options)); + } + + /// The property attribute never replaces the type's own metadata, so this cannot recurse. + private static JsonTypeInfo ResolveInner(JsonSerializerOptions options) => (JsonTypeInfo)options.GetTypeInfo(typeof(T)); +} diff --git a/src/OpenCode.Sdk/Internal/SystemEnvironmentProvider.cs b/src/OpenCode.Sdk/Internal/SystemEnvironmentProvider.cs deleted file mode 100644 index c74b5b1c..00000000 --- a/src/OpenCode.Sdk/Internal/SystemEnvironmentProvider.cs +++ /dev/null @@ -1,14 +0,0 @@ -using OpenCode.Sdk.Internal.Abstractions; - -namespace OpenCode.Sdk.Internal; - -/// Reads environment variables from the current process. -internal sealed class SystemEnvironmentProvider : IEnvironmentProvider -{ - public string? GetEnvironmentVariable(string name) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - - return Environment.GetEnvironmentVariable(name); - } -} diff --git a/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs b/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs index 178906f8..ffa3be1f 100644 --- a/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs +++ b/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs @@ -8,16 +8,18 @@ internal static class UserAgentPolicy { private const string ProductName = "OpenCode.Sdk"; + /// The informational version is process-invariant, so the reflection runs once; + /// the token value object is immutable and safe to share across header collections. + private static readonly ProductInfoHeaderValue Resolved = Compose(typeof(UserAgentPolicy).Assembly + .GetCustomAttribute()?.InformationalVersion); + /// Reads the SDK assembly's informational version into the product token. - public static ProductInfoHeaderValue Resolve() => - Compose(typeof(UserAgentPolicy).Assembly - .GetCustomAttribute()?.InformationalVersion); + public static ProductInfoHeaderValue Resolve() => Resolved; /// /// Composes the product token from an informational version. Build metadata after /// + is stripped; a missing or unparsable version omits the version token - /// entirely — never a silent substitute, never a construction failure - /// (maintainer, 2026-08-13). + /// entirely — never a silent substitute, never a construction failure. /// /// The assembly's informational version, when present. /// The product token for User-Agent decoration. diff --git a/src/OpenCode.Sdk/Models/FileDiffInfo.cs b/src/OpenCode.Sdk/Models/FileDiffInfo.cs new file mode 100644 index 00000000..cfda65cb --- /dev/null +++ b/src/OpenCode.Sdk/Models/FileDiffInfo.cs @@ -0,0 +1,40 @@ +// 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 file diff info value. +/// +public sealed record FileDiffInfo +{ + /// + /// Gets the file value. + /// + [JsonPropertyName("file")] + public required string File { get; init; } + + /// + /// Gets the patch value. + /// + [JsonPropertyName("patch")] + public required string Patch { get; init; } + + /// + /// Gets the additions value. + /// + [JsonPropertyName("additions")] + public required long Additions { get; init; } + + /// + /// Gets the deletions value. + /// + [JsonPropertyName("deletions")] + public required long Deletions { get; init; } + + /// + /// Gets the status value. + /// + [JsonPropertyName("status")] + public required FileDiffInfoStatus Status { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs b/src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs new file mode 100644 index 00000000..178968b9 --- /dev/null +++ b/src/OpenCode.Sdk/Models/FileDiffInfoStatus.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; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Defines the supported file diff info status values. +/// +[JsonConverter(typeof(StrictJsonStringEnumConverter))] +public enum FileDiffInfoStatus +{ + /// + /// Represents the 'added' wire value. + /// + [JsonStringEnumMemberName("added")] + Added, + /// + /// Represents the 'deleted' wire value. + /// + [JsonStringEnumMemberName("deleted")] + Deleted, + /// + /// Represents the 'modified' wire value. + /// + [JsonStringEnumMemberName("modified")] + Modified +} diff --git a/src/OpenCode.Sdk/Models/InvalidCursorError.cs b/src/OpenCode.Sdk/Models/InvalidCursorError.cs new file mode 100644 index 00000000..6022afe5 --- /dev/null +++ b/src/OpenCode.Sdk/Models/InvalidCursorError.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 invalid cursor error value. +/// +public sealed record InvalidCursorError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "InvalidCursorError"; + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/LocationRef.cs b/src/OpenCode.Sdk/Models/LocationRef.cs index 3b753731..1110040e 100644 --- a/src/OpenCode.Sdk/Models/LocationRef.cs +++ b/src/OpenCode.Sdk/Models/LocationRef.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record LocationRef /// [JsonPropertyName("workspaceID")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? WorkspaceId { get; init; } } diff --git a/src/OpenCode.Sdk/Models/ModelRef.cs b/src/OpenCode.Sdk/Models/ModelRef.cs index 4c3cd523..23151c4a 100644 --- a/src/OpenCode.Sdk/Models/ModelRef.cs +++ b/src/OpenCode.Sdk/Models/ModelRef.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -25,5 +26,6 @@ public sealed record ModelRef /// [JsonPropertyName("variant")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Variant { get; init; } } diff --git a/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs b/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs index 7541d366..61e4f81d 100644 --- a/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs +++ b/src/OpenCode.Sdk/Models/PromptAgentAttachment.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record PromptAgentAttachment /// [JsonPropertyName("mention")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public PromptMention? Mention { get; init; } } diff --git a/src/OpenCode.Sdk/Models/PromptFileAttachment.cs b/src/OpenCode.Sdk/Models/PromptFileAttachment.cs index 1bc0abf6..1a799241 100644 --- a/src/OpenCode.Sdk/Models/PromptFileAttachment.cs +++ b/src/OpenCode.Sdk/Models/PromptFileAttachment.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -31,6 +32,7 @@ public sealed record PromptFileAttachment /// [JsonPropertyName("name")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Name { get; init; } /// @@ -38,6 +40,7 @@ public sealed record PromptFileAttachment /// [JsonPropertyName("description")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Description { get; init; } /// @@ -45,5 +48,6 @@ public sealed record PromptFileAttachment /// [JsonPropertyName("mention")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public PromptMention? Mention { get; init; } } diff --git a/src/OpenCode.Sdk/Models/PromptSkillAttachment.cs b/src/OpenCode.Sdk/Models/PromptSkillAttachment.cs index d8eaf56d..2fe1a345 100644 --- a/src/OpenCode.Sdk/Models/PromptSkillAttachment.cs +++ b/src/OpenCode.Sdk/Models/PromptSkillAttachment.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -31,5 +32,6 @@ public sealed record PromptSkillAttachment /// [JsonPropertyName("mention")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public PromptMention? Mention { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionCreateRequest.cs b/src/OpenCode.Sdk/Models/SessionCreateRequest.cs new file mode 100644 index 00000000..6bd7c4c7 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionCreateRequest.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.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session create request value. +/// +public sealed record SessionCreateRequest +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; init; } + + /// + /// Gets the title value. + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// + /// Gets the agent value. + /// + [JsonPropertyName("agent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Agent { get; init; } + + /// + /// Gets the model value. + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelRef? Model { get; init; } + + /// + /// Gets the location value. + /// + [JsonPropertyName("location")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LocationRef? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionForkBoundary.cs b/src/OpenCode.Sdk/Models/SessionForkBoundary.cs new file mode 100644 index 00000000..2ce0e0a0 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionForkBoundary.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.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session fork boundary union. +/// +[JsonConverter(typeof(SessionForkBoundaryJsonConverter))] +public abstract record SessionForkBoundary +{ + /// + /// Gets the 'type' union marker. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} diff --git a/src/OpenCode.Sdk/Models/SessionForkBoundaryBefore.cs b/src/OpenCode.Sdk/Models/SessionForkBoundaryBefore.cs new file mode 100644 index 00000000..f6ca945e --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionForkBoundaryBefore.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 session fork boundary before value. +/// +public sealed record SessionForkBoundaryBefore : SessionForkBoundary +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "before"; + + /// + /// Gets the message id value. + /// + [JsonPropertyName("messageID")] + public required string MessageId { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionForkBoundaryThrough.cs b/src/OpenCode.Sdk/Models/SessionForkBoundaryThrough.cs new file mode 100644 index 00000000..8ac08bf7 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionForkBoundaryThrough.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 session fork boundary through value. +/// +public sealed record SessionForkBoundaryThrough : SessionForkBoundary +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "through"; + + /// + /// Gets the message id value. + /// + [JsonPropertyName("messageID")] + public required string MessageId { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionInfo.cs b/src/OpenCode.Sdk/Models/SessionInfo.cs new file mode 100644 index 00000000..af447ab2 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionInfo.cs @@ -0,0 +1,103 @@ +// 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 session info value. +/// +public sealed record SessionInfo +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Gets the parent id value. + /// + [JsonPropertyName("parentID")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? ParentId { get; init; } + + /// + /// Gets the fork value. + /// + [JsonPropertyName("fork")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public SessionInfoFork? Fork { get; init; } + + /// + /// Gets the project id value. + /// + [JsonPropertyName("projectID")] + public required string ProjectId { get; init; } + + /// + /// Gets the agent value. + /// + [JsonPropertyName("agent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? Agent { get; init; } + + /// + /// Gets the model value. + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public ModelRef? Model { get; init; } + + /// + /// Gets the cost value. + /// + [JsonPropertyName("cost")] + public required double Cost { get; init; } + + /// + /// Gets the tokens value. + /// + [JsonPropertyName("tokens")] + public required TokenUsageInfo Tokens { get; init; } + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionInfoTime Time { get; init; } + + /// + /// Gets the title value. + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? Title { get; init; } + + /// + /// Gets the location value. + /// + [JsonPropertyName("location")] + public required LocationRef Location { get; init; } + + /// + /// Gets the subpath value. + /// + [JsonPropertyName("subpath")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? Subpath { get; init; } + + /// + /// Gets the revert value. + /// + [JsonPropertyName("revert")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public SessionRevert? Revert { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionInfoFork.cs b/src/OpenCode.Sdk/Models/SessionInfoFork.cs new file mode 100644 index 00000000..b708a2b3 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionInfoFork.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 session info fork value. +/// +public sealed record SessionInfoFork +{ + /// + /// Gets the session id value. + /// + [JsonPropertyName("sessionID")] + public required string SessionId { get; init; } + + /// + /// Gets the boundary value. + /// + [JsonPropertyName("boundary")] + public required SessionForkBoundary Boundary { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionInfoTime.cs b/src/OpenCode.Sdk/Models/SessionInfoTime.cs new file mode 100644 index 00000000..ad9787e9 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionInfoTime.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 session info time value. +/// +public sealed record SessionInfoTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } + + /// + /// Gets the updated value. + /// + [JsonPropertyName("updated")] + public required double Updated { get; init; } + + /// + /// Gets the archived value. + /// + [JsonPropertyName("archived")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] + public double? Archived { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAgentSelected.cs b/src/OpenCode.Sdk/Models/SessionMessageAgentSelected.cs index e96712c2..1c70785a 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAgentSelected.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAgentSelected.cs @@ -46,5 +46,6 @@ public sealed record SessionMessageAgentSelected : SessionMessageInfo /// [JsonPropertyName("previous")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Previous { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs index 71b81f85..b5a5d900 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs @@ -66,6 +66,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("snapshot")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageAssistantSnapshot? Snapshot { get; init; } /// @@ -73,6 +74,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("finish")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public SessionMessageAssistantFinish? Finish { get; init; } /// @@ -80,6 +82,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("cost")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Cost { get; init; } /// @@ -87,6 +90,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("tokens")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public TokenUsageInfo? Tokens { get; init; } /// @@ -94,6 +98,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("error")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionStructuredError? Error { get; init; } /// @@ -101,5 +106,6 @@ public required IReadOnlyList Content /// [JsonPropertyName("retry")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageAssistantRetry? Retry { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantFinish.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantFinish.cs index be884f91..258f8d18 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantFinish.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantFinish.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message assistant finish values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageAssistantFinish { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs index 381c16df..1e982721 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs @@ -34,5 +34,6 @@ public sealed record SessionMessageAssistantReasoning : SessionMessageAssistantC /// [JsonPropertyName("time")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageAssistantReasoningTime? Time { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs index c03d7bbf..3460c8a0 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record SessionMessageAssistantReasoningTime /// [JsonPropertyName("completed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Completed { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs index dd833832..ba7038bb 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs @@ -14,6 +14,7 @@ public sealed record SessionMessageAssistantSnapshot /// [JsonPropertyName("start")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Start { get; init; } /// @@ -21,6 +22,7 @@ public sealed record SessionMessageAssistantSnapshot /// [JsonPropertyName("end")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? End { get; init; } /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs index 4d52d615..401d73f6 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record SessionMessageAssistantTime /// [JsonPropertyName("completed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Completed { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs index eb63d0b2..3395a300 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs @@ -34,6 +34,7 @@ public sealed record SessionMessageAssistantTool : SessionMessageAssistantConten /// [JsonPropertyName("executed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public bool? Executed { get; init; } /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs index 966de623..58a61cf0 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,6 +20,7 @@ public sealed record SessionMessageAssistantToolTime /// [JsonPropertyName("ran")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Ran { get; init; } /// @@ -26,5 +28,6 @@ public sealed record SessionMessageAssistantToolTime /// [JsonPropertyName("completed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Completed { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionCompletedReason.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionCompletedReason.cs index c35159b2..c9f0dd63 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageCompactionCompletedReason.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionCompletedReason.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message compaction completed reason values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageCompactionCompletedReason { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionFailedReason.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionFailedReason.cs index 78fa17a9..d785b375 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageCompactionFailedReason.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionFailedReason.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message compaction failed reason values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageCompactionFailedReason { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionRunningReason.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionRunningReason.cs index d214ed1f..97e11239 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageCompactionRunningReason.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionRunningReason.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message compaction running reason values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageCompactionRunningReason { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageLocationSwitched.cs b/src/OpenCode.Sdk/Models/SessionMessageLocationSwitched.cs index 368dd383..55019bc8 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageLocationSwitched.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageLocationSwitched.cs @@ -46,6 +46,7 @@ public sealed record SessionMessageLocationSwitched : SessionMessageInfo /// [JsonPropertyName("projectID")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? ProjectId { get; init; } /// @@ -53,6 +54,7 @@ public sealed record SessionMessageLocationSwitched : SessionMessageInfo /// [JsonPropertyName("subpath")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Subpath { get; init; } /// @@ -60,5 +62,6 @@ public sealed record SessionMessageLocationSwitched : SessionMessageInfo /// [JsonPropertyName("previous")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageLocationSwitchedPrevious? Previous { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageLocationSwitchedPrevious.cs b/src/OpenCode.Sdk/Models/SessionMessageLocationSwitchedPrevious.cs index c5041889..13884e48 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageLocationSwitchedPrevious.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageLocationSwitchedPrevious.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,6 +20,7 @@ public sealed record SessionMessageLocationSwitchedPrevious /// [JsonPropertyName("projectID")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? ProjectId { get; init; } /// @@ -26,5 +28,6 @@ public sealed record SessionMessageLocationSwitchedPrevious /// [JsonPropertyName("subpath")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Subpath { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageModelSelected.cs b/src/OpenCode.Sdk/Models/SessionMessageModelSelected.cs index 72e598d8..079061c1 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageModelSelected.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageModelSelected.cs @@ -46,5 +46,6 @@ public sealed record SessionMessageModelSelected : SessionMessageInfo /// [JsonPropertyName("previous")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public ModelRef? Previous { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageShell.cs b/src/OpenCode.Sdk/Models/SessionMessageShell.cs index ba204ead..231ce067 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageShell.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageShell.cs @@ -58,6 +58,7 @@ public sealed record SessionMessageShell : SessionMessageInfo /// [JsonPropertyName("exit")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Exit { get; init; } /// @@ -65,5 +66,6 @@ public sealed record SessionMessageShell : SessionMessageInfo /// [JsonPropertyName("output")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageShellOutput? Output { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageShellStatus.cs b/src/OpenCode.Sdk/Models/SessionMessageShellStatus.cs index 0db35d57..ed0176ab 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageShellStatus.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageShellStatus.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message shell status values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageShellStatus { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs b/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs index ffd023fd..07bdda8b 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageShellTime.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record SessionMessageShellTime /// [JsonPropertyName("completed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Completed { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs b/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs index 0a5157ff..ede2f319 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs @@ -40,6 +40,7 @@ public sealed record SessionMessageSynthetic : SessionMessageInfo /// [JsonPropertyName("description")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Description { get; init; } /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageSystem.cs b/src/OpenCode.Sdk/Models/SessionMessageSystem.cs index 9a8a53cf..d489a505 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageSystem.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageSystem.cs @@ -46,5 +46,6 @@ public sealed record SessionMessageSystem : SessionMessageInfo /// [JsonPropertyName("description")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Description { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionRevert.cs b/src/OpenCode.Sdk/Models/SessionRevert.cs new file mode 100644 index 00000000..d2ed5b03 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionRevert.cs @@ -0,0 +1,39 @@ +// 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 session revert value. +/// +public sealed record SessionRevert +{ + /// + /// Gets the message id value. + /// + [JsonPropertyName("messageID")] + public required string MessageId { get; init; } + + /// + /// Gets the part id value. + /// + [JsonPropertyName("partID")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? PartId { get; init; } + + /// + /// Gets the snapshot value. + /// + [JsonPropertyName("snapshot")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? Snapshot { get; init; } + + /// + /// Gets the files value. + /// + [JsonPropertyName("files")] + public IReadOnlyList Files { get; init => field = OptionalCollectionInput.Normalize(value, field, static input => new List(input).AsReadOnly()); } = []; +} diff --git a/src/OpenCode.Sdk/Models/SessionStructuredError.cs b/src/OpenCode.Sdk/Models/SessionStructuredError.cs index fa9a32ae..b5f891b5 100644 --- a/src/OpenCode.Sdk/Models/SessionStructuredError.cs +++ b/src/OpenCode.Sdk/Models/SessionStructuredError.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -25,5 +26,6 @@ public sealed record SessionStructuredError /// [JsonPropertyName("status")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public long? Status { get; init; } } diff --git a/src/OpenCode.Sdk/Models/UnknownError.cs b/src/OpenCode.Sdk/Models/UnknownError.cs new file mode 100644 index 00000000..b2e67db5 --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnknownError.cs @@ -0,0 +1,29 @@ +// 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 unknown error value. +/// +public sealed record UnknownError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "UnknownError"; + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } + + /// + /// Gets the ref value. + /// + [JsonPropertyName("ref")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Ref { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs index 130eb3c1..6824e247 100644 --- a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs +++ b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown open code error payload. /// +[JsonConverter(typeof(UnknownOpenCodeErrorJsonConverter))] public sealed record UnknownOpenCodeError : OpenCodeError { private readonly string _marker; @@ -17,6 +19,11 @@ public sealed record UnknownOpenCodeError : OpenCodeError public UnknownOpenCodeError(string tag, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(tag); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = tag; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs b/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs index adfee473..806acf7c 100644 --- a/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs +++ b/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown prompt file source payload. /// +[JsonConverter(typeof(UnknownPromptFileSourceJsonConverter))] public sealed record UnknownPromptFileSource : PromptFileSource { private readonly string _marker; @@ -17,6 +19,11 @@ public sealed record UnknownPromptFileSource : PromptFileSource public UnknownPromptFileSource(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs b/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs new file mode 100644 index 00000000..1514201e --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs @@ -0,0 +1,40 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown session fork boundary payload. +/// +[JsonConverter(typeof(UnknownSessionForkBoundaryJsonConverter))] +public sealed record UnknownSessionForkBoundary : SessionForkBoundary +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownSessionForkBoundary(string type, JsonElement payload) + { + ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + + _marker = type; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized 'type' marker. + /// + [JsonPropertyName("type")] + public override string Type => _marker; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs index 143c2b23..becd43fb 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown session message assistant content payload. /// +[JsonConverter(typeof(UnknownSessionMessageAssistantContentJsonConverter))] public sealed record UnknownSessionMessageAssistantContent : SessionMessageAssistantContent { private readonly string _marker; @@ -17,6 +19,11 @@ public sealed record UnknownSessionMessageAssistantContent : SessionMessageAssis public UnknownSessionMessageAssistantContent(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs index 96268f85..2d0b547f 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown session message compaction payload. /// +[JsonConverter(typeof(UnknownSessionMessageCompactionJsonConverter))] public sealed record UnknownSessionMessageCompaction : SessionMessageCompaction { private readonly string _marker; @@ -17,6 +19,11 @@ public sealed record UnknownSessionMessageCompaction : SessionMessageCompaction public UnknownSessionMessageCompaction(string status, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(status); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = status; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs index c77f4642..fec16b44 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown session message info payload. /// +[JsonConverter(typeof(UnknownSessionMessageInfoJsonConverter))] public sealed record UnknownSessionMessageInfo : SessionMessageInfo { private readonly string _marker; @@ -17,6 +19,11 @@ public sealed record UnknownSessionMessageInfo : SessionMessageInfo public UnknownSessionMessageInfo(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs index 7c1eac2f..fc5cda48 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown session message tool state payload. /// +[JsonConverter(typeof(UnknownSessionMessageToolStateJsonConverter))] public sealed record UnknownSessionMessageToolState : SessionMessageToolState { private readonly string _marker; @@ -17,6 +19,11 @@ public sealed record UnknownSessionMessageToolState : SessionMessageToolState public UnknownSessionMessageToolState(string status, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(status); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = status; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownToolContent.cs b/src/OpenCode.Sdk/Models/UnknownToolContent.cs index e961cbf6..e3dd9ab5 100644 --- a/src/OpenCode.Sdk/Models/UnknownToolContent.cs +++ b/src/OpenCode.Sdk/Models/UnknownToolContent.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown tool content payload. /// +[JsonConverter(typeof(UnknownToolContentJsonConverter))] public sealed record UnknownToolContent : ToolContent { private readonly string _marker; @@ -17,6 +19,11 @@ public sealed record UnknownToolContent : ToolContent public UnknownToolContent(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/OpenCodeClient.cs b/src/OpenCode.Sdk/OpenCodeClient.cs index 90fc48d1..e38b66db 100644 --- a/src/OpenCode.Sdk/OpenCodeClient.cs +++ b/src/OpenCode.Sdk/OpenCodeClient.cs @@ -14,30 +14,21 @@ public class OpenCodeClient : IDisposable /// /// Initializes a client that owns its connection to the endpoint. /// - /// The absolute HTTP or HTTPS server endpoint. - public OpenCodeClient(Uri endpoint) : this(endpoint, options: null) + /// The client options; the endpoint is required. + public OpenCodeClient(OpenCodeClientOptions options) { - } - - /// - /// Initializes a client that owns its connection to the endpoint. - /// - /// The absolute HTTP or HTTPS server endpoint. - /// The client options; its endpoint must stay unset on this path. - public OpenCodeClient(Uri endpoint, OpenCodeClientOptions? options) - { - _pipeline = Pipeline.Create(endpoint, options, new SystemEnvironmentProvider()); + _pipeline = Pipeline.Create(options); _sessions = new SessionsClient(_pipeline); } /// - /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. + /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. With a password set, the SDK's per-request Authorization header overrides any client default; in anonymous mode a default Authorization header is refused at construction and before every send. /// /// The caller-owned HTTP client. - /// The client options; the endpoint is required on this path. + /// The client options; the endpoint is required. public OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) { - _pipeline = Pipeline.Create(httpClient, options, new SystemEnvironmentProvider()); + _pipeline = Pipeline.Create(httpClient, options); _sessions = new SessionsClient(_pipeline); } @@ -78,13 +69,13 @@ protected virtual void Dispose(bool disposing) /// /// Check server health /// - /// The per-call options. + /// The per-call options. /// The cancellation token. /// The 'HealthResponse' 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 GetHealthAsync(OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) + public virtual Task GetHealthAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Health.Get, HealthResponseAdapter.Instance, options, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Health.Get, HealthResponseAdapter.Instance, requestOptions, cancellationToken); } } diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index b6524f2d..bc18e640 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -1,19 +1,31 @@ namespace OpenCode.Sdk; -/// Configures an opencode client for its whole lifetime. -public sealed class OpenCodeClientOptions +/// +/// Configures an opencode client for its whole lifetime. The class stays settable for the +/// options and configuration-binding patterns; clients snapshot it at construction through +/// , so later mutation never reaches a built client. +/// +public sealed class OpenCodeClientOptions : IOpenCodeClientOptions { + /// Gets or sets the absolute HTTP or HTTPS server endpoint; required to build a client. + public Uri? Endpoint { get; set; } + /// - /// Gets or sets the absolute HTTP or HTTPS server endpoint. Required when a caller - /// supplies its own HttpClient; must stay unset when a constructor receives - /// the endpoint directly. + /// Gets or sets the HTTP basic-authentication username; the default is + /// opencode, the only username the pinned server accepts. /// - public Uri? Endpoint { get; set; } + public string Username { get; set; } = "opencode"; /// - /// Gets or sets the HTTP basic-authentication password for the opencode user. - /// When unset, the OPENCODE_SERVER_PASSWORD environment variable is read once at - /// client construction. + /// Gets or sets the HTTP basic-authentication password. sends + /// anonymous requests — a server without authentication configured expects none. An + /// empty or whitespace value is refused with at client + /// construction; the SDK never reads credentials from the environment. On a + /// caller-owned the SDK's per-request Authorization + /// header overrides any client default when a password is set; in anonymous mode a + /// default Authorization header is refused fail-closed at construction and + /// before every send, because the transport would otherwise attach that foreign + /// credential to requests this SDK promises are anonymous. /// public string? Password { get; set; } } diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index 10863cba..f75d6177 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -1,5 +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; + namespace OpenCode.Sdk; /// /// Defines the wire route of every generated operation. @@ -22,6 +24,10 @@ public static class Health /// public static class Sessions { + /// + /// The 'POST /api/session' route. + /// + public const string CreateSession = "/api/session"; /// /// The 'GET /api/session/{sessionID}/message/{messageID}' route template. /// @@ -48,5 +54,86 @@ public static string GetMessage(string sessionId, string messageId) return "/api/session/" + Uri.EscapeDataString(sessionId) + "/message/" + Uri.EscapeDataString(messageId); } + + /// + /// The 'GET /api/session/{sessionID}' route template. + /// + public const string GetSessionTemplate = "/api/session/{sessionID}"; + /// + /// Builds the '/api/session/{sessionID}' route. + /// + /// The 'sessionID' route value. + /// The escaped route. + public static string GetSession(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 'GET /api/session/{sessionID}/message' route template. + /// + public const string ListMessagesTemplate = "/api/session/{sessionID}/message"; + /// + /// Builds the '/api/session/{sessionID}/message' route. + /// + /// The 'sessionID' route value. + /// The request shaping the query. + /// The escaped route. + public static string ListMessages(string sessionId, MessageListRequest? request = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + + var path = "/api/session/" + Uri.EscapeDataString(sessionId) + "/message"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddCount("limit", request.Limit, nameof(request)); + query.AddOrder("order", request.Order); + query.AddText("cursor", request.Cursor); + return path + query.Value; + } + + /// + /// The 'GET /api/session' route template. + /// + public const string ListSessionsTemplate = "/api/session"; + /// + /// Builds the '/api/session' route. + /// + /// The request shaping the query. + /// The escaped route. + public static string ListSessions(SessionListRequest? request = null) + { + var path = "/api/session"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddText("workspace", request.Workspace); + query.AddCount("limit", request.Limit, nameof(request)); + query.AddOrder("order", request.Order); + query.AddText("search", request.Search); + query.AddParentFilter("parentID", request.ParentId); + query.AddText("directory", request.Directory); + query.AddText("project", request.Project); + query.AddText("subpath", request.Subpath); + query.AddText("cursor", request.Cursor); + return path + query.Value; + } } } diff --git a/src/OpenCode.Sdk/Pagination/ListCursor.cs b/src/OpenCode.Sdk/Pagination/ListCursor.cs new file mode 100644 index 00000000..a6cc5808 --- /dev/null +++ b/src/OpenCode.Sdk/Pagination/ListCursor.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk; + +/// The wire cursor of a list page; absent directions mean no further page. +public sealed record ListCursor +{ + /// Gets the opaque cursor of the previous page, when one exists. + [JsonPropertyName("previous")] + public string? Previous { get; init; } + + /// Gets the opaque cursor of the next page, when one exists. + [JsonPropertyName("next")] + public string? Next { get; init; } +} diff --git a/src/OpenCode.Sdk/Pagination/ListOrder.cs b/src/OpenCode.Sdk/Pagination/ListOrder.cs new file mode 100644 index 00000000..1b49b3fd --- /dev/null +++ b/src/OpenCode.Sdk/Pagination/ListOrder.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk; + +/// The page order of a list operation's first page. +public enum ListOrder +{ + /// Oldest entries first. + Ascending = 0, + + /// Newest entries first. + Descending = 1, +} diff --git a/src/OpenCode.Sdk/Pagination/ListRequest.cs b/src/OpenCode.Sdk/Pagination/ListRequest.cs new file mode 100644 index 00000000..ee9bf4f9 --- /dev/null +++ b/src/OpenCode.Sdk/Pagination/ListRequest.cs @@ -0,0 +1,17 @@ +namespace OpenCode.Sdk; + +/// +/// The cursor-pagination trio shared by list operations whose wire parameters match it +/// exactly; generated per-operation request records derive from this seam. +/// +public abstract record ListRequest +{ + /// Gets the maximum number of entries to return; the server default applies when unset. + public int? Limit { get; init; } + + /// Gets the first-page order; the server default applies when unset. + public ListOrder? Order { get; init; } + + /// Gets the opaque pagination cursor returned by a previous page. + public string? Cursor { get; init; } +} diff --git a/src/OpenCode.Sdk/SessionClient.cs b/src/OpenCode.Sdk/SessionClient.cs deleted file mode 100644 index 0a148ca6..00000000 --- a/src/OpenCode.Sdk/SessionClient.cs +++ /dev/null @@ -1,46 +0,0 @@ -// 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; - -namespace OpenCode.Sdk; -/// -/// A bound 'SessionClient' handle; it holds an immutable identifier and the shared pipeline. -/// -public class SessionClient -{ - private readonly Pipeline? _pipeline; - private readonly string? _sessionId; - internal SessionClient(Pipeline pipeline, string sessionId) - { - ArgumentNullException.ThrowIfNull(pipeline); - ArgumentException.ThrowIfNullOrEmpty(sessionId); - _pipeline = pipeline; - _sessionId = sessionId; - } - - /// - /// Initializes a mocking instance; members invoked without an override throw an instructive failure. - /// - protected SessionClient() - { - } - - private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("SessionClient", "Pipeline"); - private string SessionId => _sessionId ?? throw MockSeam.CreateError("SessionClient", "SessionId"); - - /// - /// Get session message - /// - /// The 'messageID' route value. - /// The per-call options. - /// The cancellation token. - /// The 'SessionMessageResponse' 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 GetMessageAsync(string messageId, OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrEmpty(messageId); - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.GetMessage(SessionId, messageId), SessionMessageResponseAdapter.Instance, options, cancellationToken); - } -} diff --git a/src/OpenCode.Sdk/Sessions/MessageListRequest.cs b/src/OpenCode.Sdk/Sessions/MessageListRequest.cs new file mode 100644 index 00000000..7b2a9375 --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/MessageListRequest.cs @@ -0,0 +1,9 @@ +// 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/session/{sessionID}/message' query. +/// +public sealed record MessageListRequest : ListRequest +{ +} diff --git a/src/OpenCode.Sdk/Sessions/MessageListResponse.cs b/src/OpenCode.Sdk/Sessions/MessageListResponse.cs new file mode 100644 index 00000000..13e3d073 --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/MessageListResponse.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/session/{sessionID}/message' operation. +/// +public sealed record MessageListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _messages; + private readonly ListCursor? _cursor; + /// + /// Initializes a success instance of the 'MessageListResponse' envelope. + /// + public MessageListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal MessageListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Messages = null!; + Cursor = null!; + } + + /// + /// Gets the Messages payload; guarded on the error path. + /// + public required IReadOnlyList Messages { get => _messages ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Messages."); init => _messages = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } + /// + /// Gets the page cursor; guarded on the error path. + /// + public required ListCursor Cursor { get => _cursor ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Cursor."); init => _cursor = 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 (_messages is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Messages = ").Append(_messages); + return true; + } +} diff --git a/src/OpenCode.Sdk/Sessions/SessionClient.cs b/src/OpenCode.Sdk/Sessions/SessionClient.cs new file mode 100644 index 00000000..4c3fbf8e --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionClient.cs @@ -0,0 +1,78 @@ +// 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; + +namespace OpenCode.Sdk; +/// +/// A bound 'SessionClient' handle; it holds an immutable identifier and the shared pipeline. +/// +public class SessionClient +{ + private readonly Pipeline? _pipeline; + private readonly string? _sessionId; + internal SessionClient(Pipeline pipeline, string sessionId) + { + ArgumentNullException.ThrowIfNull(pipeline); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + + _pipeline = pipeline; + _sessionId = sessionId; + } + + /// + /// Initializes a mocking instance; members invoked without an override throw an instructive failure. + /// + protected SessionClient() + { + } + + private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("SessionClient", "Pipeline"); + private string SessionId => _sessionId ?? throw MockSeam.CreateError("SessionClient", "SessionId"); + + /// + /// Get session message + /// + /// The 'messageID' route value. + /// The per-call options. + /// The cancellation token. + /// The 'SessionMessageResponse' 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 GetMessageAsync(string messageId, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(messageId); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.GetMessage(SessionId, messageId), SessionMessageResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Get session + /// + /// The per-call options. + /// The cancellation token. + /// The 'SessionResponse' 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 GetSessionAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.GetSession(SessionId), SessionResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Get session messages + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'MessageListResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404, 500) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListMessagesAsync(MessageListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListMessages(SessionId, request), MessageListResponseAdapter.Instance, requestOptions, cancellationToken); + } +} diff --git a/src/OpenCode.Sdk/Sessions/SessionCreateResponse.cs b/src/OpenCode.Sdk/Sessions/SessionCreateResponse.cs new file mode 100644 index 00000000..2c2134ef --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionCreateResponse.cs @@ -0,0 +1,59 @@ +// 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/session' operation. +/// +public sealed record SessionCreateResponse : OpenCodeResponse +{ + private readonly SessionInfo? _session; + /// + /// Initializes a success instance of the 'SessionCreateResponse' envelope. + /// + public SessionCreateResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionCreateResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Session = null!; + } + + /// + /// Gets the Session payload; guarded on the error path. + /// + public required SessionInfo Session { get => _session ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Session."); init => _session = 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 (_session is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Session = ").Append(_session); + return true; + } +} diff --git a/src/OpenCode.Sdk/Sessions/SessionListRequest.cs b/src/OpenCode.Sdk/Sessions/SessionListRequest.cs new file mode 100644 index 00000000..1971fb49 --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionListRequest.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. +namespace OpenCode.Sdk; +/// +/// Shapes the 'GET /api/session' query. +/// +public sealed record SessionListRequest +{ + /// + /// Gets the 'workspace' query value; the server default applies when unset. + /// + public string? Workspace { get; init; } + /// + /// Gets the 'limit' query value; the server default applies when unset. + /// + public int? Limit { get; init; } + /// + /// Gets the 'order' query value; the server default applies when unset. + /// + public ListOrder? Order { get; init; } + /// + /// Gets the 'search' query value; the server default applies when unset. + /// + public string? Search { get; init; } + /// + /// Gets the 'parentID' query value; the server default applies when unset. + /// + public SessionParentFilter? ParentId { get; init; } + /// + /// Gets the 'directory' query value; the server default applies when unset. + /// + public string? Directory { get; init; } + /// + /// Gets the 'project' query value; the server default applies when unset. + /// + public string? Project { get; init; } + /// + /// Gets the 'subpath' query value; the server default applies when unset. + /// + public string? Subpath { get; init; } + /// + /// Gets the 'cursor' query value; the server default applies when unset. + /// + public string? Cursor { get; init; } +} diff --git a/src/OpenCode.Sdk/Sessions/SessionListResponse.cs b/src/OpenCode.Sdk/Sessions/SessionListResponse.cs new file mode 100644 index 00000000..da95411b --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionListResponse.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/session' operation. +/// +public sealed record SessionListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _sessions; + private readonly ListCursor? _cursor; + /// + /// Initializes a success instance of the 'SessionListResponse' envelope. + /// + public SessionListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Sessions = null!; + Cursor = null!; + } + + /// + /// Gets the Sessions payload; guarded on the error path. + /// + public required IReadOnlyList Sessions { get => _sessions ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Sessions."); init => _sessions = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } + /// + /// Gets the page cursor; guarded on the error path. + /// + public required ListCursor Cursor { get => _cursor ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Cursor."); init => _cursor = 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 (_sessions is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Sessions = ").Append(_sessions); + return true; + } +} diff --git a/src/OpenCode.Sdk/SessionMessageResponse.cs b/src/OpenCode.Sdk/Sessions/SessionMessageResponse.cs similarity index 100% rename from src/OpenCode.Sdk/SessionMessageResponse.cs rename to src/OpenCode.Sdk/Sessions/SessionMessageResponse.cs diff --git a/src/OpenCode.Sdk/Sessions/SessionParentFilter.cs b/src/OpenCode.Sdk/Sessions/SessionParentFilter.cs new file mode 100644 index 00000000..499fc763 --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionParentFilter.cs @@ -0,0 +1,34 @@ +namespace OpenCode.Sdk; + +/// +/// Filters a session list by parent: a concrete parent's children, or root sessions only. +/// The wire spells root-only as the literal string "null"; this type keeps that +/// sentinel unrepresentable as ordinary data. +/// +public sealed class SessionParentFilter +{ + private const string RootOnlyWireValue = "null"; + + private SessionParentFilter(string wireValue) => WireValue = wireValue; + + /// Gets the filter returning root sessions only. + public static SessionParentFilter RootOnly { get; } = new(RootOnlyWireValue); + + /// Gets the raw wire value. + internal string WireValue { get; } + + /// Creates a filter returning the children of one session. + /// The parent session identifier. + /// The filter. + /// The identifier is blank or spells the root-only sentinel. + public static SessionParentFilter Of(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (string.Equals(sessionId, RootOnlyWireValue, StringComparison.Ordinal)) + { + throw new ArgumentException("The literal 'null' is the root-only sentinel; use RootOnly instead.", nameof(sessionId)); + } + + return new SessionParentFilter(sessionId); + } +} diff --git a/src/OpenCode.Sdk/Sessions/SessionResponse.cs b/src/OpenCode.Sdk/Sessions/SessionResponse.cs new file mode 100644 index 00000000..c3b3ae8c --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionResponse.cs @@ -0,0 +1,59 @@ +// 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/session/{sessionID}' operation. +/// +public sealed record SessionResponse : OpenCodeResponse +{ + private readonly SessionInfo? _session; + /// + /// Initializes a success instance of the 'SessionResponse' envelope. + /// + public SessionResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Session = null!; + } + + /// + /// Gets the Session payload; guarded on the error path. + /// + public required SessionInfo Session { get => _session ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Session."); init => _session = 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 (_session is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Session = ").Append(_session); + return true; + } +} diff --git a/src/OpenCode.Sdk/Sessions/SessionsClient.cs b/src/OpenCode.Sdk/Sessions/SessionsClient.cs new file mode 100644 index 00000000..110ee4dd --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionsClient.cs @@ -0,0 +1,73 @@ +// 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 'SessionsClient' collection client. +/// +public class SessionsClient +{ + private readonly Pipeline? _pipeline; + internal SessionsClient(Pipeline pipeline) + { + ArgumentNullException.ThrowIfNull(pipeline); + _pipeline = pipeline; + } + + /// + /// Initializes a mocking instance; members invoked without an override throw an instructive failure. + /// + protected SessionsClient() + { + } + + /// + /// Gets a bound 'SessionClient'; the handle never caches server state. + /// + /// The 'sessionID' route value. + /// The bound 'SessionClient'. + public virtual SessionClient GetSessionClient(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + + return new SessionClient(Pipeline, sessionId); + } + + private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("SessionsClient", "Pipeline"); + + /// + /// Create session + /// + /// The request body; an empty body is sent when omitted. + /// The per-call options. + /// The cancellation token. + /// The 'SessionCreateResponse' 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 CreateSessionAsync(SessionCreateRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Sessions.CreateSession, request ?? new SessionCreateRequest(), OpenCodeJsonContext.Default.SessionCreateRequest, SessionCreateResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List sessions + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'SessionListResponse' 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 ListSessionsAsync(SessionListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListSessions(request), SessionListResponseAdapter.Instance, requestOptions, cancellationToken); + } +} diff --git a/src/OpenCode.Sdk/SessionsClient.cs b/src/OpenCode.Sdk/SessionsClient.cs deleted file mode 100644 index d24afb4e..00000000 --- a/src/OpenCode.Sdk/SessionsClient.cs +++ /dev/null @@ -1,37 +0,0 @@ -// 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; - -namespace OpenCode.Sdk; -/// -/// The 'SessionsClient' collection client. -/// -public class SessionsClient -{ - private readonly Pipeline? _pipeline; - internal SessionsClient(Pipeline pipeline) - { - ArgumentNullException.ThrowIfNull(pipeline); - _pipeline = pipeline; - } - - /// - /// Initializes a mocking instance; members invoked without an override throw an instructive failure. - /// - protected SessionsClient() - { - } - - /// - /// Gets a bound 'SessionClient'; the handle never caches server state. - /// - /// The 'sessionID' route value. - /// The bound 'SessionClient'. - public virtual SessionClient GetSessionClient(string sessionId) - { - ArgumentException.ThrowIfNullOrEmpty(sessionId); - return new SessionClient(Pipeline, sessionId); - } - - private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("SessionsClient", "Pipeline"); -} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/App.net472.config b/tests/OpenCode.Sdk.Extensions.Tests/App.net472.config new file mode 100644 index 00000000..1995750c --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/App.net472.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json b/tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json new file mode 100644 index 00000000..2bd7e2c4 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json @@ -0,0 +1 @@ +{"healthy":true,"version":"0.0.0-test","pid":100} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj new file mode 100644 index 00000000..e4382d15 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj @@ -0,0 +1,40 @@ + + + + Exe + net8.0;net9.0;$(DefaultTargetFramework) + false + + net472;$(TargetFrameworks) + + App.net472.config + + $(NoWarn);S1075 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..12a6a889 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs @@ -0,0 +1,156 @@ +using System.Net.Http.Headers; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenCode.Sdk.Extensions.Tests.Support; + +namespace OpenCode.Sdk.Extensions.Tests; + +public sealed class OpenCodeServiceCollectionExtensionsTests +{ + private static readonly Uri Endpoint = new("http://localhost:4096"); + + [Test] + public async Task AddOpenCode_Should_Resolve_The_Client_Through_The_Factory_Pipeline() + { + var payload = new FixtureLoader().LoadJson("known-health.json"); + using var handler = RecordingHttpHandler.RespondingJson(payload); + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .ConfigurePrimaryHttpMessageHandler(() => handler); + using var provider = services.BuildServiceProvider(); + + var response = await provider.GetRequiredService().GetHealthAsync(); + + await Assert.That(response.Health.Healthy).IsTrue(); + await Assert.That(handler.RequestUris.Single()).IsEqualTo(new Uri("http://localhost:4096/api/health")); + } + + [Test] + public async Task AddOpenCode_Should_Register_The_Client_As_Transient_For_Handler_Rotation() + { + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint); + using var provider = services.BuildServiceProvider(); + + var first = provider.GetRequiredService(); + var second = provider.GetRequiredService(); + + await Assert.That(first).IsNotSameReferenceAs(second); + } + + [Test] + public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_Directly() + { + var payload = new FixtureLoader().LoadJson("known-health.json"); + using var handler = RecordingHttpHandler.RespondingJson(payload); + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .ConfigurePrimaryHttpMessageHandler(() => handler); + using var provider = services.BuildServiceProvider(); + + var sessions = provider.GetRequiredService(); + + await Assert.That(sessions).IsNotNull(); + } + + [Test] + public async Task AddOpenCode_Should_Apply_The_Configure_Action_When_Building_The_Client() + { + var configured = new List(); + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => + { + options.Endpoint = Endpoint; + configured.Add(options); + }); + using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredService(); + + await Assert.That(configured).HasSingleItem(); + } + + [Test] + public async Task AddOpenCode_Should_Bind_The_Configuration_Section_Including_Credentials() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(OpenCodeConfigurationData.ProtectedServer) + .Build(); + using var handler = RecordingHttpHandler.RespondingJson(new FixtureLoader().LoadJson("known-health.json")); + var services = new ServiceCollection(); + _ = services.AddOpenCode(configuration.GetSection("OpenCode")) + .ConfigurePrimaryHttpMessageHandler(() => handler); + using var provider = services.BuildServiceProvider(); + + _ = await provider.GetRequiredService().GetHealthAsync(); + + var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:secret"))}"; + await Assert.That(handler.AuthorizationHeaders.Single()).IsEqualTo(expected); + } + + [Test] + public async Task AddOpenCode_Should_Compose_Delegating_Handlers_Through_The_Returned_Builder() + { + var payload = new FixtureLoader().LoadJson("known-health.json"); + using var handler = RecordingHttpHandler.RespondingJson(payload); + using var witness = new WitnessHandler(); + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .AddHttpMessageHandler(() => witness) + .ConfigurePrimaryHttpMessageHandler(() => handler); + using var provider = services.BuildServiceProvider(); + + _ = await provider.GetRequiredService().GetHealthAsync(); + + await Assert.That(witness.CallCount).IsEqualTo(1); + } + + [Test] + public async Task AddOpenCode_Should_Refuse_An_Anonymous_Factory_Client_Carrying_A_Default_Authorization() + { + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .ConfigureHttpClient(static client => + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token")); + using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => _ = provider.GetRequiredService()); + + await Assert.That(exception.Message).Contains("Authorization"); + } + + [Test] + public async Task AddOpenCode_Should_Refuse_A_Factory_Client_Carrying_A_BaseAddress() + { + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .ConfigureHttpClient(static client => client.BaseAddress = new Uri("http://localhost:9")); + using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => _ = provider.GetRequiredService()); + + await Assert.That(exception.Message).Contains("BaseAddress"); + } + + [Test] + public async Task AddOpenCode_Should_Refuse_A_Null_Service_Collection() + { + _ = await Assert.That(() => ((IServiceCollection)null!).AddOpenCode(static _ => { })) + .Throws(); + } + + [Test] + public async Task AddOpenCode_Should_Refuse_A_Null_Configure_Action() + { + _ = await Assert.That(() => new ServiceCollection().AddOpenCode(configure: null!)) + .Throws(); + } + + [Test] + public async Task AddOpenCode_Should_Refuse_A_Null_Configuration() + { + _ = await Assert.That(() => new ServiceCollection().AddOpenCode(configuration: null!)) + .Throws(); + } +} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs new file mode 100644 index 00000000..04d9bc47 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using System.Text; + +namespace OpenCode.Sdk.Extensions.Tests.Support; + +internal sealed class FixtureLoader +{ + private const string ResourcePrefix = "OpenCode.Sdk.Extensions.Tests.Fixtures."; + private readonly Assembly _assembly = typeof(FixtureLoader).Assembly; + + public string LoadJson(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + var resourceName = string.Concat(ResourcePrefix, name); + var stream = _assembly.GetManifestResourceStream(resourceName) + ?? throw new ArgumentException($"Embedded JSON fixture '{name}' was not found.", nameof(name)); + + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); + var content = Encoding.UTF8.GetString(reader.ReadBytes(checked((int)stream.Length))); + return content.TrimEnd('\r', '\n'); + } +} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/OpenCodeConfigurationData.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/OpenCodeConfigurationData.cs new file mode 100644 index 00000000..df165c02 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Support/OpenCodeConfigurationData.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Extensions.Tests.Support; + +/// Canned configuration sections for the binding tests. +internal static class OpenCodeConfigurationData +{ + /// A password-protected server with a non-default username. + public static IReadOnlyDictionary ProtectedServer { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["OpenCode:Endpoint"] = "http://localhost:4096", + ["OpenCode:Username"] = "admin", + ["OpenCode:Password"] = "secret", + }; +} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs new file mode 100644 index 00000000..b36aafc0 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs @@ -0,0 +1,30 @@ +using System.Net; + +namespace OpenCode.Sdk.Extensions.Tests.Support; + +/// Records every request URI and Authorization header, answering from the supplied responder. +internal sealed class RecordingHttpHandler(Func responder) : HttpMessageHandler +{ + private readonly Func _responder = + responder ?? throw new ArgumentNullException(nameof(responder)); + + private readonly List _requestUris = []; + private readonly List _authorizationHeaders = []; + + public IReadOnlyList RequestUris => _requestUris; + + public IReadOnlyList AuthorizationHeaders => _authorizationHeaders; + + public static RecordingHttpHandler RespondingJson(string body) => + new(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body), + }); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _requestUris.Add(request.RequestUri!); + _authorizationHeaders.Add(request.Headers.Authorization?.ToString()); + return Task.FromResult(_responder(request)); + } +} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs new file mode 100644 index 00000000..bab1d382 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs @@ -0,0 +1,13 @@ +namespace OpenCode.Sdk.Extensions.Tests.Support; + +/// Counts the requests flowing through a composed delegating-handler chain. +internal sealed class WitnessHandler : DelegatingHandler +{ + public int CallCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + return base.SendAsync(request, cancellationToken); + } +} diff --git a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs index ed04b343..88d7ac71 100644 --- a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs +++ b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs @@ -6,19 +6,23 @@ namespace OpenCode.Sdk.Performance.Tests.Benchmarks; /// /// End-to-end operation cost through the real pipeline over a canned in-memory response: /// request build, auth decoration, buffering, adapter, and the full deserialization chain. -/// Baselines for the envelope single-pass (P2), success-path buffering (P3), and the -/// converter walk they feed (P1); issues #18 and #23. +/// Baselines envelope deserialization, success-path buffering, and the converter walk +/// they feed. /// [MemoryDiagnoser] public class ClientOperationBenchmarks : IDisposable { private CannedResponseHandler? _messageHandler; private CannedResponseHandler? _healthHandler; + private CannedResponseHandler? _messageListHandler; private HttpClient? _messageHttpClient; private HttpClient? _healthHttpClient; + private HttpClient? _messageListHttpClient; private OpenCodeClient? _messageClient; private OpenCodeClient? _healthClient; + private OpenCodeClient? _messageListClient; private SessionClient? _session; + private SessionClient? _messageListSession; [GlobalSetup] public async Task SetupAsync() @@ -38,6 +42,12 @@ public async Task SetupAsync() _healthHandler = new CannedResponseHandler(BenchmarkFixtures.HealthBody()); _healthHttpClient = new HttpClient(_healthHandler); _healthClient = new OpenCodeClient(_healthHttpClient, options); + + var page = BenchmarkFixtures.CursorListEnvelope(await BenchmarkFixtures.DeepAssistantMessageAsync().ConfigureAwait(false)); + _messageListHandler = new CannedResponseHandler(page); + _messageListHttpClient = new HttpClient(_messageListHandler); + _messageListClient = new OpenCodeClient(_messageListHttpClient, options); + _messageListSession = _messageListClient.Sessions.GetSessionClient("ses_bench0000000000000000001"); } [Benchmark] @@ -46,6 +56,9 @@ public async Task SetupAsync() [Benchmark] public Task GetHealthAsync() => _healthClient!.GetHealthAsync(); + [Benchmark] + public Task ListMessagesAsync() => _messageListSession!.ListMessagesAsync(); + [GlobalCleanup] public void Cleanup() => Dispose(); @@ -64,9 +77,12 @@ protected virtual void Dispose(bool disposing) _messageClient?.Dispose(); _healthClient?.Dispose(); + _messageListClient?.Dispose(); _messageHttpClient?.Dispose(); _healthHttpClient?.Dispose(); + _messageListHttpClient?.Dispose(); _messageHandler?.Dispose(); _healthHandler?.Dispose(); + _messageListHandler?.Dispose(); } } diff --git a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/UnionDeserializationBenchmarks.cs b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/UnionDeserializationBenchmarks.cs index 2221606d..a3974dc2 100644 --- a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/UnionDeserializationBenchmarks.cs +++ b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/UnionDeserializationBenchmarks.cs @@ -8,8 +8,7 @@ namespace OpenCode.Sdk.Performance.Tests.Benchmarks; /// /// The nested union converter walk in isolation (message → assistant content → tool state → -/// tool content), through the same source-generated dispatch the SDK uses. Baseline for the -/// single-pass converter redesign (issue #23, P1/P5). +/// tool content), through the same source-generated dispatch the SDK uses. /// [MemoryDiagnoser] public class UnionDeserializationBenchmarks diff --git a/tests/OpenCode.Sdk.Performance.Tests/Support/BenchmarkFixtures.cs b/tests/OpenCode.Sdk.Performance.Tests/Support/BenchmarkFixtures.cs index ff15e8c0..efaa3d1f 100644 --- a/tests/OpenCode.Sdk.Performance.Tests/Support/BenchmarkFixtures.cs +++ b/tests/OpenCode.Sdk.Performance.Tests/Support/BenchmarkFixtures.cs @@ -16,6 +16,13 @@ public static byte[] DataEnvelope(byte[] payload) return [.. "{\"data\":"u8, .. payload, .. "}"u8]; } + /// Wraps one payload in the cursor-list envelope as a single-item page. + public static byte[] CursorListEnvelope(byte[] payload) + { + ArgumentNullException.ThrowIfNull(payload); + return [.. "{\"data\":["u8, .. payload, .. "],\"cursor\":{\"next\":\"cur_bench_next\"}}"u8]; + } + private static async Task ReadAsync(string name) { var stream = typeof(BenchmarkFixtures).Assembly diff --git a/tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs b/tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs new file mode 100644 index 00000000..27df1015 --- /dev/null +++ b/tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using System.Globalization; + +namespace OpenCode.Sdk.Sandbox; + +/// +/// A consumer-owned delegating handler riding the factory chain — the third extensibility +/// rung in action: the SDK ships no middleware, the ecosystem seam carries it. +/// +internal sealed class ConsoleTimingHandler : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $" [http] {request.Method} {request.RequestUri?.PathAndQuery} -> {(int)response.StatusCode} ({stopwatch.ElapsedMilliseconds} ms)")); + return response; + } +} diff --git a/tests/OpenCode.Sdk.Sandbox/OpenCode.Sdk.Sandbox.csproj b/tests/OpenCode.Sdk.Sandbox/OpenCode.Sdk.Sandbox.csproj index b03344a1..d8085b90 100644 --- a/tests/OpenCode.Sdk.Sandbox/OpenCode.Sdk.Sandbox.csproj +++ b/tests/OpenCode.Sdk.Sandbox/OpenCode.Sdk.Sandbox.csproj @@ -7,6 +7,11 @@ + + + + + diff --git a/tests/OpenCode.Sdk.Sandbox/Program.cs b/tests/OpenCode.Sdk.Sandbox/Program.cs index 2e0c1711..de27e45e 100644 --- a/tests/OpenCode.Sdk.Sandbox/Program.cs +++ b/tests/OpenCode.Sdk.Sandbox/Program.cs @@ -1,6 +1,9 @@ using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using OpenCode.Sdk; using OpenCode.Sdk.Models; +using OpenCode.Sdk.Sandbox; var endpoint = Environment.GetEnvironmentVariable("OPENCODE_SANDBOX_ENDPOINT"); if (string.IsNullOrWhiteSpace(endpoint)) @@ -8,61 +11,75 @@ await Console.Error.WriteLineAsync( "Set OPENCODE_SANDBOX_ENDPOINT to an absolute server endpoint; the launchSettings.json profile prefills it.").ConfigureAwait(false); await Console.Error.WriteLineAsync( - "Optional: OPENCODE_SERVER_PASSWORD (read by the SDK itself), OPENCODE_SANDBOX_SESSION_ID, OPENCODE_SANDBOX_MESSAGE_ID.").ConfigureAwait(false); + "Optional: OPENCODE_PASSWORD or OPENCODE_SERVER_PASSWORD (resolved here; the SDK reads no environment).").ConfigureAwait(false); return 1; } -// No explicit password: the SDK resolves OPENCODE_SERVER_PASSWORD on its own at construction. -using var client = new OpenCodeClient(new Uri(endpoint)); +// The consumer owns environment resolution (upstream's own layering; the CLI does the same): +// the SDK itself never reads environment variables. +var password = Environment.GetEnvironmentVariable("OPENCODE_PASSWORD") + ?? Environment.GetEnvironmentVariable("OPENCODE_SERVER_PASSWORD"); + +// The DI composition the Extensions package exists for: AddOpenCode registers the typed +// client over IHttpClientFactory and returns the IHttpClientBuilder, so consumer-owned +// delegating handlers (and resilience/telemetry packages) chain on without SDK middleware. +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddTransient(); +_ = builder.Services.AddOpenCode(options => + { + options.Endpoint = new Uri(endpoint); + options.Password = string.IsNullOrWhiteSpace(password) ? null : password; + }) + .AddHttpMessageHandler(); +using var host = builder.Build(); + +var client = host.Services.GetRequiredService(); var health = await client.GetHealthAsync().ConfigureAwait(false); Console.WriteLine(string.Create( CultureInfo.InvariantCulture, - $"health: status={health.Status} healthy={health.Health.Healthy} version={health.Health.Version} pid={health.Health.Pid}")); + $"health: status={health.Status} healthy={health.Health.Healthy} version={health.Health.Version} pid={health.Health.Pid}")); -var sessionId = Environment.GetEnvironmentVariable("OPENCODE_SANDBOX_SESSION_ID"); -var messageId = Environment.GetEnvironmentVariable("OPENCODE_SANDBOX_MESSAGE_ID"); -if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(messageId)) +// Sub-clients resolve directly from the container as well. +var sessionsClient = host.Services.GetRequiredService(); + +var created = await sessionsClient.CreateSessionAsync(new SessionCreateRequest { - await Console.Out.WriteLineAsync("message: skipped (set OPENCODE_SANDBOX_SESSION_ID and OPENCODE_SANDBOX_MESSAGE_ID to fetch one)").ConfigureAwait(false); - return 0; -} + Title = "sdk breadth demo", +}).ConfigureAwait(false); +Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"create: status={created.Status} id={created.Session.Id} title={created.Session.Title}")); -var message = await client.Sessions.GetSessionClient(sessionId).GetMessageAsync(messageId).ConfigureAwait(false); -Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"message: status={message.Status} type={message.Message.GetType().Name}")); -if (message.Message is SessionMessageAssistant assistant) +var page = await sessionsClient.ListSessionsAsync(new SessionListRequest { - Console.WriteLine($" id: {assistant.Id}"); - Console.WriteLine($" agent: {assistant.Agent}"); - Console.WriteLine($" model: {assistant.Model}"); - Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" finish: {assistant.Finish} cost: {assistant.Cost} tokens: {assistant.Tokens}")); - Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" content: {assistant.Content.Count} part(s)")); - foreach (var part in assistant.Content) - { - var summary = part switch - { - SessionMessageAssistantText text => Excerpt(text.Text), - SessionMessageAssistantReasoning reasoning => Excerpt(reasoning.Text), - UnknownSessionMessageAssistantContent unknown => $"unknown tag '{unknown.Type}'", - _ => Excerpt(part.ToString()), - }; - Console.WriteLine($" [{part.Type}] {summary}"); - } -} -else + Limit = 3, + Order = ListOrder.Descending, +}).ConfigureAwait(false); +Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"list: status={page.Status} sessions={page.Sessions.Count} cursor.next={page.Cursor.Next ?? ""}")); +foreach (var session in page.Sessions) { - Console.WriteLine($" payload: {Excerpt(message.Message.ToString())}"); + Console.WriteLine($" {session.Id} {session.Title}"); } -return 0; +var handle = sessionsClient.GetSessionClient(created.Session.Id); +var fetched = await handle.GetSessionAsync().ConfigureAwait(false); +Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"get: status={fetched.Status} id={fetched.Session.Id} directory={fetched.Session.Location.Directory}")); -static string Excerpt(string? value) +var messages = await handle.ListMessagesAsync(new MessageListRequest { - if (value is null) - { - return ""; - } - - var flattened = value.ReplaceLineEndings(" "); - return flattened.Length <= 160 ? flattened : $"{flattened[..160]}…"; + Limit = 5, +}).ConfigureAwait(false); +Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"messages: status={messages.Status} count={messages.Messages.Count} cursor.next={messages.Cursor.Next ?? ""}")); +foreach (var message in messages.Messages) +{ + Console.WriteLine($" {message.GetType().Name}"); } + +return 0; diff --git a/tests/OpenCode.Sdk.Sandbox/Properties/launchSettings.json b/tests/OpenCode.Sdk.Sandbox/Properties/launchSettings.json index 80e9c6f2..0225cd97 100644 --- a/tests/OpenCode.Sdk.Sandbox/Properties/launchSettings.json +++ b/tests/OpenCode.Sdk.Sandbox/Properties/launchSettings.json @@ -4,9 +4,7 @@ "commandName": "Project", "environmentVariables": { "OPENCODE_SANDBOX_ENDPOINT": "http://127.0.0.1:4096", - "OPENCODE_SERVER_PASSWORD": "123456", - "OPENCODE_SANDBOX_SESSION_ID": "ses_00e0d1436ffe86YL3QKbrn3Ldy", - "OPENCODE_SANDBOX_MESSAGE_ID": "msg_ffb0e86fa001n28S9q0u7F7JxM" + "OPENCODE_SERVER_PASSWORD": "123456" } } } diff --git a/tests/OpenCode.Sdk.Sandbox/README.md b/tests/OpenCode.Sdk.Sandbox/README.md index 283baac7..8f6796f4 100644 --- a/tests/OpenCode.Sdk.Sandbox/README.md +++ b/tests/OpenCode.Sdk.Sandbox/README.md @@ -11,35 +11,22 @@ Configuration comes from environment variables, prefilled for the IDE by | Variable | Meaning | |---|---| | `OPENCODE_SANDBOX_ENDPOINT` | Absolute server endpoint (required) | -| `OPENCODE_SERVER_PASSWORD` | Consumed by the SDK's own auth fallback, not by sandbox code | -| `OPENCODE_SANDBOX_SESSION_ID` | Optional; with a message id, fetches one message | -| `OPENCODE_SANDBOX_MESSAGE_ID` | Optional; see above | +| `OPENCODE_PASSWORD` / `OPENCODE_SERVER_PASSWORD` | Resolved by sandbox code and passed as `OpenCodeClientOptions.Password` — the SDK itself reads no environment | ## Running -Start a server with a fixed password so the checked-in profile matches (`serve` reads the -same `OPENCODE_SERVER_PASSWORD` variable the SDK falls back to; without it the server -generates and prints a random one): +Start a server with a fixed password so the checked-in profile matches (`serve` adopts the +same `OPENCODE_SERVER_PASSWORD` variable; without it the server generates and prints a +random one): ```sh OPENCODE_SERVER_PASSWORD=123456 opencode2 serve --hostname 127.0.0.1 --port 4096 ``` -Then F5 with the `sandbox` profile. The prefilled session/message ids belong to this -machine's local opencode history — replace them when they age out. - -## Finding ids with curl - -Session and message ids come from the pinned surface's list operations -(`v2.session.list`, `v2.message.list` — both still pending SDK breadth): - -```sh -# newest session id -curl -su opencode:123456 "http://127.0.0.1:4096/api/session?limit=1" | jq -r '.data[0].id' - -# first message id of that session -curl -su opencode:123456 "http://127.0.0.1:4096/api/session//message" | jq -r '.data[0].id' -``` - -The project deliberately stays a flat console for now; it grows a Generic Host composition -root when the Extensions package (DI) lands. +Then F5 with the `sandbox` profile. The program composes through the Extensions package — +a Generic Host whose `AddOpenCode` registers the typed client over `IHttpClientFactory`, +with the consumer-owned `ConsoleTimingHandler` chained onto the returned +`IHttpClientBuilder` (the third extensibility rung: the SDK ships no middleware) and +`SessionsClient` resolved straight from the container — then drives the breadth surface +end to end: health, `CreateSessionAsync`, `ListSessionsAsync` (typed page + wire cursor), +`GetSessionAsync` on the created handle, and `ListMessagesAsync`. diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-diff-status.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-diff-status.json new file mode 100644 index 00000000..c1d87c31 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-diff-status.json @@ -0,0 +1,7 @@ +{ + "file": "src/App.cs", + "patch": "@@ -1 +1 @@", + "additions": 1, + "deletions": 0, + "status": "modified" +} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session.json new file mode 100644 index 00000000..c1e42f76 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session.json @@ -0,0 +1,23 @@ +{ + "id": "ses_100", + "projectID": "prj_1", + "title": "Fix the build", + "cost": 0.42, + "tokens": { + "input": 10, + "output": 20, + "reasoning": 0, + "cache": { + "read": 1, + "write": 2 + } + }, + "time": { + "created": 1755100000, + "updated": 1755100050 + }, + "location": { + "directory": "/repo", + "workspaceID": "wrk_1" + } +} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/mismatched-compaction-marker.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/mismatched-compaction-marker.json new file mode 100644 index 00000000..a7fa50e8 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/mismatched-compaction-marker.json @@ -0,0 +1 @@ +{"details":{"phase":"later"},"status":"paused","type":"user"} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-parent-session.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-parent-session.json new file mode 100644 index 00000000..0fb8f54e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-parent-session.json @@ -0,0 +1,24 @@ +{ + "id": "ses_100", + "parentID": null, + "projectID": "prj_1", + "title": "Fix the build", + "cost": 0.42, + "tokens": { + "input": 10, + "output": 20, + "reasoning": 0, + "cache": { + "read": 1, + "write": 2 + } + }, + "time": { + "created": 1755100000, + "updated": 1755100050 + }, + "location": { + "directory": "/repo", + "workspaceID": "wrk_1" + } +} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-diff-status.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-diff-status.json new file mode 100644 index 00000000..01179919 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-diff-status.json @@ -0,0 +1,7 @@ +{ + "file": "src/App.cs", + "patch": "@@ -1 +1 @@", + "additions": 1, + "deletions": 0, + "status": 3 +} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-status-session.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-status-session.json new file mode 100644 index 00000000..243d6cab --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-status-session.json @@ -0,0 +1,35 @@ +{ + "id": "ses_100", + "projectID": "prj_1", + "title": "Fix the build", + "cost": 0.42, + "tokens": { + "input": 10, + "output": 20, + "reasoning": 0, + "cache": { + "read": 1, + "write": 2 + } + }, + "time": { + "created": 1755100000, + "updated": 1755100050 + }, + "location": { + "directory": "/repo", + "workspaceID": "wrk_1" + }, + "revert": { + "messageID": "msg_1", + "files": [ + { + "file": "src/App.cs", + "patch": "@@ -1 +1 @@", + "additions": 1, + "deletions": 0, + "status": 3 + } + ] + } +} diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs index 83b1d9c1..a628acbf 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs @@ -294,7 +294,7 @@ public async Task Client_Should_Fail_Instructively_On_The_Unoverridden_Mock_Seam [Test] public async Task Dispose_Should_Make_The_Bound_Handles_Unusable() { - var client = new OpenCodeClient(Endpoint); + var client = new OpenCodeClient(new OpenCodeClientOptions { Endpoint = Endpoint }); var session = client.Sessions.GetSessionClient("s"); client.Dispose(); @@ -312,6 +312,12 @@ public async Task Constructor_Should_Require_An_Endpoint_For_A_Byo_Client() _ = Assert.Throws(() => _ = new OpenCodeClient(httpClient, new OpenCodeClientOptions())); } + [Test] + public async Task Constructor_Should_Require_An_Endpoint_For_An_Owned_Client() + { + _ = Assert.Throws(() => _ = new OpenCodeClient(new OpenCodeClientOptions())); + } + private static OpenCodeClient CreateClient(HttpClient httpClient) => new(httpClient, new OpenCodeClientOptions { diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeClientOptionsTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeClientOptionsTests.cs new file mode 100644 index 00000000..7cdefaef --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/OpenCodeClientOptionsTests.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tests; + +public sealed class OpenCodeClientOptionsTests +{ + [Test] + public async Task OpenCodeClientOptions_Should_Default_To_The_Opencode_Username() + { + var options = new OpenCodeClientOptions(); + + await Assert.That(options.Username).IsEqualTo("opencode"); + await Assert.That(options.Endpoint).IsNull(); + await Assert.That(options.Password).IsNull(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs index f6625148..eccf7460 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs @@ -1,3 +1,6 @@ +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; + namespace OpenCode.Sdk.Tests; public sealed class OpenCodeResponseTests @@ -16,5 +19,21 @@ public async Task Constructor_Should_Default_To_The_Success_Path() await Assert.That(response.RawBody).IsNull(); } + [Test] + public async Task SessionListResponse_Should_Refuse_A_Null_Page_Element() + { + var session = new GeneratedJsonSerializer() + .Deserialize(new FixtureLoader().LoadJson("Serialization.known-session.json")); + + var exception = Assert.Throws(() => _ = new SessionListResponse + { + Status = 200, + Sessions = [session, null!], + Cursor = new ListCursor(), + }); + + await Assert.That(exception.Message).Contains("null element"); + } + private sealed record EmptyResponse : OpenCodeResponse; } diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index 3e20177e..7b70f38c 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -2,9 +2,7 @@ using System.Net.Http.Headers; using System.Reflection; using System.Text; -using NSubstitute; using OpenCode.Sdk.Internal; -using OpenCode.Sdk.Internal.Abstractions; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; @@ -12,7 +10,6 @@ namespace OpenCode.Sdk.Tests; public sealed class PipelineTests { - private const string PasswordVariable = "OPENCODE_SERVER_PASSWORD"; private static readonly Uri Endpoint = new("http://localhost:4096"); [Test] @@ -45,35 +42,90 @@ public async Task ExecuteAsync_Should_Decorate_Basic_Auth_From_The_Explicit_Pass { using var handler = new RecordingHttpHandler(); using var httpClient = new HttpClient(handler); - var environment = Substitute.For(); - using var pipeline = CreatePipeline(httpClient, password: "secret", environment: environment); + using var pipeline = CreatePipeline(httpClient, password: "secret"); _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("opencode:secret"))}"; await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); - _ = environment.DidNotReceive().GetEnvironmentVariable(Arg.Any()); } [Test] - public async Task ExecuteAsync_Should_Resolve_The_Environment_Password_Once_At_Construction() + public async Task ExecuteAsync_Should_Decorate_The_Configured_Username() { using var handler = new RecordingHttpHandler(); using var httpClient = new HttpClient(handler); - var environment = Substitute.For(); - _ = environment.GetEnvironmentVariable(PasswordVariable).Returns("fallback"); - using var pipeline = CreatePipeline(httpClient, environment: environment); + using var pipeline = CreatePipeline(httpClient, password: "secret", username: "admin"); _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:secret"))}"; + await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); + } + + [Test] + public async Task Pipeline_Should_Snapshot_The_Options_At_Construction() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + var options = new OpenCodeClientOptions + { + Endpoint = Endpoint, + Password = "secret", + }; + using var pipeline = new Pipeline(httpClient, ownsHttpClient: false, options); + options.Password = "changed"; + options.Endpoint = new Uri("http://other:1"); + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); - var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("opencode:fallback"))}"; - await Assert.That(handler.Requests.All(request => request.Authorization == expected)).IsTrue(); - _ = environment.Received(1).GetEnvironmentVariable(PasswordVariable); + var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("opencode:secret"))}"; + await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); + await Assert.That(handler.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/health")); + } + + [Test] + [Arguments("")] + [Arguments(" ")] + [Arguments("\t")] + public async Task Pipeline_Should_Refuse_A_Blank_Explicit_Password(string password) + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + + var exception = Assert.Throws(() => _ = CreatePipeline(httpClient, password: password)); + + await Assert.That(exception.Message).Contains("password"); } [Test] - public async Task ExecuteAsync_Should_Send_Anonymously_When_No_Password_Resolves() + [Arguments("")] + [Arguments(" ")] + public async Task Pipeline_Should_Refuse_A_Blank_Username(string username) + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + + var exception = Assert.Throws(() => + _ = CreatePipeline(httpClient, password: "secret", username: username)); + + await Assert.That(exception.Message).Contains("username"); + } + + [Test] + public async Task Pipeline_Should_Refuse_A_Username_Containing_A_Colon() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + + var exception = Assert.Throws(() => + _ = CreatePipeline(httpClient, password: "secret", username: "open:code")); + + await Assert.That(exception.Message).Contains("colon"); + } + + [Test] + public async Task ExecuteAsync_Should_Send_Anonymously_When_No_Password_Is_Set() { using var handler = new RecordingHttpHandler(); using var httpClient = new HttpClient(handler); @@ -84,6 +136,61 @@ public async Task ExecuteAsync_Should_Send_Anonymously_When_No_Password_Resolves await Assert.That(handler.Requests.Single().Authorization).IsNull(); } + [Test] + public async Task Pipeline_Should_Refuse_An_Anonymous_Client_Whose_Defaults_Carry_Authorization() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token"); + + var exception = Assert.Throws(() => _ = CreatePipeline(httpClient)); + + await Assert.That(exception.Message).Contains("Authorization"); + } + + [Test] + public async Task Pipeline_Should_Refuse_A_Raw_Unparseable_Default_Authorization() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + _ = httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", "???"); + + var exception = Assert.Throws(() => _ = CreatePipeline(httpClient)); + + await Assert.That(exception.Message).Contains("Authorization"); + } + + [Test] + public async Task ExecuteAsync_Should_Refuse_A_Default_Authorization_Added_After_Construction() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token"); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("Authorization"); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + public async Task ExecuteAsync_Should_Let_The_Explicit_Password_Win_Over_A_Default_Authorization() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token"); + using var pipeline = CreatePipeline(httpClient, password: "secret"); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("opencode:secret"))}"; + await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); + } + [Test] public async Task ExecuteAsync_Should_Decorate_The_User_Agent_Per_Request() { @@ -109,6 +216,77 @@ public async Task ExecuteAsync_Should_Never_Touch_The_Client_Defaults() await Assert.That(httpClient.DefaultRequestHeaders.Any()).IsFalse(); } + [Test] + public async Task ExecuteAsync_Should_Send_The_Json_Body_With_The_Json_Content_Type() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + _ = await pipeline.ExecuteAsync( + HttpMethod.Post, + "/api/session", + new TestBody { Value = "created" }, + TestBodyJsonContext.Default.TestBody, + new RecordingResponseAdapter(), + options: null, + CancellationToken.None); + + await Assert.That(handler.Requests.Single().Method).IsEqualTo(HttpMethod.Post); + await Assert.That(handler.Requests.Single().ContentType).IsEqualTo("application/json"); + await Assert.That(handler.Requests.Single().Body).IsEqualTo("""{"value":"created"}"""); + } + + [Test] + public async Task ExecuteAsync_Should_Keep_The_Bodyless_Path_Body_Free() + { + 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); + + await Assert.That(handler.Requests.Single().Body).IsNull(); + await Assert.That(handler.Requests.Single().ContentType).IsNull(); + } + + [Test] + public async Task ExecuteAsync_Should_Guard_The_Body_Arguments() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + _ = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Post, "/api/session", null!, TestBodyJsonContext.Default.TestBody, + new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + _ = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Post, "/api/session", new TestBody { Value = "created" }, bodyTypeInfo: null!, + new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + public async Task ExecuteAsync_Should_Guard_The_Route_On_The_Body_Path() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Post, "api/session", new TestBody { Value = "created" }, TestBodyJsonContext.Default.TestBody, + new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("Routes must start with '/'"); + await Assert.That(handler.Requests).IsEmpty(); + } + [Test] public async Task ExecuteAsync_Should_Buffer_The_Body_For_The_Adapter() { @@ -259,16 +437,80 @@ public async Task ExecuteAsync_Should_Refuse_An_Undefined_Error_Behavior_Before_ [Test] public async Task ExecuteAsync_Should_Pass_Cancellation_Through() { - using var handler = new RecordingHttpHandler(static _ => throw new OperationCanceledException()); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + using var handler = new RecordingHttpHandler(_ => throw new OperationCanceledException(cancellation.Token)); using var httpClient = new HttpClient(handler); using var pipeline = CreatePipeline(httpClient); _ = await Assert .That(async () => _ = await pipeline.ExecuteAsync( - HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, cancellation.Token)) .Throws(); } + [Test] + public async Task ExecuteAsync_Should_Wrap_A_Timeout_Cancellation_As_A_Transport_Failure() + { + using var handler = new RecordingHttpHandler(static _ => throw new TaskCanceledException()); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.InnerException).IsTypeOf(); + } + + [Test] + public async Task ExecuteAsync_Should_Wrap_A_Timeout_During_The_Body_Read_As_A_Transport_Failure() + { + using var handler = new RecordingHttpHandler(static _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new TimeoutContent(), + }); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.InnerException).IsTypeOf(); + } + + [Test] + public async Task Pipeline_Should_Refuse_An_Injected_Client_Carrying_A_BaseAddress() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + httpClient.BaseAddress = new Uri("http://other:1"); + + var exception = Assert.Throws(() => _ = CreatePipeline(httpClient)); + + await Assert.That(exception.Message).Contains("BaseAddress"); + } + + [Test] + public async Task ExecuteAsync_Should_Refuse_A_BaseAddress_Set_After_Construction() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + httpClient.BaseAddress = new Uri("http://other:1"); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("BaseAddress"); + await Assert.That(handler.Requests).IsEmpty(); + } + [Test] public async Task Dispose_Should_Dispose_The_Owned_Client() { @@ -328,32 +570,23 @@ public async Task Create_Should_Refuse_An_Injected_Client_Without_An_Endpoint() var exception = Assert.Throws(() => _ = Pipeline.Create( httpClient, - new OpenCodeClientOptions(), - Substitute.For())); + new OpenCodeClientOptions())); await Assert.That(exception.Message).Contains("Endpoint"); } [Test] - public async Task Create_Should_Refuse_A_Conflicting_Options_Endpoint() + public async Task Create_Should_Refuse_A_Missing_Endpoint_On_The_Owned_Path() { - var options = new OpenCodeClientOptions - { - Endpoint = new Uri("http://other:1"), - }; - - var exception = Assert.Throws(() => _ = Pipeline.Create( - Endpoint, - options, - Substitute.For())); + var exception = Assert.Throws(() => _ = Pipeline.Create(new OpenCodeClientOptions())); - await Assert.That(exception.Message).Contains("endpoint"); + await Assert.That(exception.Message).Contains("Endpoint"); } [Test] - public async Task Create_Should_Build_An_Owned_Pipeline_From_The_Endpoint() + public async Task Create_Should_Build_An_Owned_Pipeline_From_The_Options() { - using var pipeline = Pipeline.Create(Endpoint, options: null, Substitute.For()); + using var pipeline = Pipeline.Create(new OpenCodeClientOptions { Endpoint = Endpoint }); await Assert.That(pipeline).IsNotNull(); } @@ -375,6 +608,18 @@ private static Pipeline CreatePipeline( bool ownsHttpClient = false, Uri? endpoint = null, string? password = null, - IEnvironmentProvider? environment = null) => - new(httpClient, ownsHttpClient, endpoint ?? Endpoint, password, environment ?? Substitute.For()); + string? username = null) + { + var options = new OpenCodeClientOptions + { + Endpoint = endpoint ?? Endpoint, + Password = password, + }; + if (username is not null) + { + options.Username = username; + } + + return new Pipeline(httpClient, ownsHttpClient, options); + } } diff --git a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs new file mode 100644 index 00000000..734b5968 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs @@ -0,0 +1,122 @@ +using OpenCode.Sdk.Internal; + +namespace OpenCode.Sdk.Tests; + +public sealed class QueryStringBuilderTests +{ + [Test] + public async Task Value_Should_Be_Empty_When_Nothing_Was_Added() + { + var query = new QueryStringBuilder(); + + await Assert.That(query.Value).IsEqualTo(string.Empty); + } + + [Test] + public async Task AddText_Should_Skip_Null_Values() + { + var query = new QueryStringBuilder(); + + query.AddText("search", null); + + await Assert.That(query.Value).IsEqualTo(string.Empty); + } + + [Test] + public async Task AddText_Should_Join_Values_With_Query_Separators() + { + var query = new QueryStringBuilder(); + + query.AddText("search", "alpha"); + query.AddText("cursor", "beta"); + + await Assert.That(query.Value).IsEqualTo("?search=alpha&cursor=beta"); + } + + [Test] + public async Task AddText_Should_Escape_The_Value() + { + var query = new QueryStringBuilder(); + + query.AddText("search", "a b&c=d"); + + await Assert.That(query.Value).IsEqualTo("?search=a%20b%26c%3Dd"); + } + + [Test] + public async Task AddText_Should_Escape_The_Name() + { + var query = new QueryStringBuilder(); + + query.AddText("odd name&x", "v"); + + await Assert.That(query.Value).IsEqualTo("?odd%20name%26x=v"); + } + + [Test] + public async Task AddCount_Should_Write_The_Invariant_Wire_String() + { + var query = new QueryStringBuilder(); + + query.AddCount("limit", 50, "request"); + + await Assert.That(query.Value).IsEqualTo("?limit=50"); + } + + [Test] + [Arguments(0)] + [Arguments(-1)] + public async Task AddCount_Should_Refuse_A_Non_Positive_Value(int limit) + { + var query = new QueryStringBuilder(); + + var exception = Assert.Throws(() => query.AddCount("limit", limit, "request")); + + await Assert.That(exception.ParamName).IsEqualTo("request"); + await Assert.That(exception.Message).Contains("positive"); + } + + [Test] + [Arguments(ListOrder.Ascending, "?order=asc")] + [Arguments(ListOrder.Descending, "?order=desc")] + public async Task AddOrder_Should_Write_The_Wire_Spelling(ListOrder order, string expected) + { + var query = new QueryStringBuilder(); + + query.AddOrder("order", order); + + await Assert.That(query.Value).IsEqualTo(expected); + } + + [Test] + public async Task AddOrder_Should_Refuse_An_Undefined_Value() + { + var query = new QueryStringBuilder(); + + _ = Assert.Throws(() => query.AddOrder("order", (ListOrder)7)); + await Task.CompletedTask; + } + + [Test] + public async Task AddParentFilter_Should_Write_The_Wire_Value() + { + var query = new QueryStringBuilder(); + + query.AddParentFilter("parentID", SessionParentFilter.RootOnly); + query.AddParentFilter("other", SessionParentFilter.Of("ses_1")); + + await Assert.That(query.Value).IsEqualTo("?parentID=null&other=ses_1"); + } + + [Test] + public async Task Add_Should_Skip_Every_Null_Optional() + { + var query = new QueryStringBuilder(); + + query.AddCount("limit", null, "request"); + query.AddOrder("order", null); + query.AddParentFilter("parentID", null); + + await Assert.That(query.Value).IsEqualTo(string.Empty); + } +} diff --git a/tests/OpenCode.Sdk.Tests/ResponseAdapterTests.cs b/tests/OpenCode.Sdk.Tests/ResponseAdapterTests.cs index 749315bb..c01b594d 100644 --- a/tests/OpenCode.Sdk.Tests/ResponseAdapterTests.cs +++ b/tests/OpenCode.Sdk.Tests/ResponseAdapterTests.cs @@ -33,24 +33,6 @@ public async Task ReadBarePayload_Should_Treat_Null_Bodies_As_Protocol_Failures( _ = ProbeAdapter.Bare("null", OpenCodeJsonContext.Default.ServiceHealth)); } - [Test] - public async Task ReadDataPayload_Should_Unwrap_The_Data_Envelope() - { - var payload = ProbeAdapter.Data( - "{\"data\":{\"healthy\":false,\"version\":\"2\",\"pid\":9}}", - OpenCodeJsonContext.Default.ServiceHealth); - - await Assert.That(payload.Healthy).IsFalse(); - await Assert.That(payload.Version).IsEqualTo("2"); - } - - [Test] - public async Task ReadDataPayload_Should_Refuse_A_Missing_Data_Property() - { - _ = Assert.Throws(() => - _ = ProbeAdapter.Data("{\"other\":1}", OpenCodeJsonContext.Default.ServiceHealth)); - } - [Test] public async Task ReadTolerantError_Should_Keep_A_Known_Tag_At_Its_Declared_Status() { @@ -99,9 +81,6 @@ private sealed class ProbeAdapter : ResponseAdapter public static TPayload Bare(string rawBody, JsonTypeInfo typeInfo) => ReadBarePayload(rawBody, typeInfo); - public static TPayload Data(string rawBody, JsonTypeInfo typeInfo) => - ReadDataPayload(rawBody, typeInfo); - public static OpenCodeError? Error(string rawBody, IReadOnlyCollection? allowedTags) => ReadTolerantError(rawBody, allowedTags); diff --git a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs index b16352a8..836bf4f3 100644 --- a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs +++ b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; @@ -25,6 +26,75 @@ public async Task Deserialize_Should_Create_Known_Outer_Variant_When_Marker_Is_L await Assert.That(document.RootElement.GetProperty("type").GetString()).IsEqualTo("user"); } + [Test] + public async Task Deserialize_Should_Reject_Explicit_Null_On_An_Optional_Nonnull_Property() + { + var json = _fixtures.LoadJson("Serialization.null-parent-session.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)).Throws(); + } + + [Test] + public async Task Carrier_Constructor_Should_Refuse_An_Unparsed_Payload() + { + var exception = Assert.Throws(() => _ = new UnknownOpenCodeError("future", default)); + + await Assert.That(exception.ParamName).IsEqualTo("payload"); + } + + [Test] + public async Task Deserialize_Should_Reject_Null_For_A_Concrete_Unknown_Carrier() + { + var typeInfo = (JsonTypeInfo)_serializer.GetTypeInfo(typeof(UnknownOpenCodeError)); + + _ = await Assert.That(() => JsonSerializer.Deserialize("null", typeInfo)).Throws(); + } + + [Test] + public async Task Deserialize_Should_Reject_A_Contradicted_Fixed_Marker_On_The_Concrete_Carrier() + { + var json = _fixtures.LoadJson("Serialization.mismatched-compaction-marker.json"); + var typeInfo = (JsonTypeInfo)_serializer.GetTypeInfo(typeof(UnknownSessionMessageCompaction)); + + _ = await Assert.That(() => JsonSerializer.Deserialize(json, typeInfo)).Throws(); + } + + [Test] + public async Task Deserialize_Should_Reject_A_Contradicted_Fixed_Marker_On_The_Union_Base() + { + var json = _fixtures.LoadJson("Serialization.mismatched-compaction-marker.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)).Throws(); + } + + [Test] + public async Task Deserialize_Should_Reject_A_Numeric_String_Enum_Value() + { + var json = _fixtures.LoadJson("Serialization.numeric-diff-status.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)).Throws(); + } + + [Test] + public async Task Deserialize_Should_Map_A_Known_String_Enum_Wire_Value() + { + var json = _fixtures.LoadJson("Serialization.known-diff-status.json"); + + var diff = _serializer.Deserialize(json); + + await Assert.That(diff.Status).IsEqualTo(FileDiffInfoStatus.Modified); + } + + [Test] + public async Task Deserialize_Should_Keep_Treating_An_Absent_Optional_Property_As_Absent() + { + var json = _fixtures.LoadJson("Serialization.known-session.json"); + + var session = _serializer.Deserialize(json); + + await Assert.That(session.ParentId).IsNull(); + } + [Test] public async Task Deserialize_Should_Normalize_Absent_Optional_Nonnull_Collections_To_Empty() { @@ -54,6 +124,39 @@ public async Task Deserialize_Should_Preserve_Unknown_Outer_Variant_Byte_For_Byt await Assert.That(roundTrip).IsEqualTo(json); } + [Test] + public async Task Serialize_Should_Reproduce_The_Raw_Document_Through_The_Concrete_Carrier_Type() + { + var json = _fixtures.LoadJson("Serialization.unknown-session-message.json"); + var unknown = (UnknownSessionMessageInfo)_serializer.Deserialize(json); + + var roundTrip = _serializer.Serialize(unknown); + + await Assert.That(roundTrip).IsEqualTo(json); + } + + [Test] + public async Task Deserialize_Should_Preserve_The_Payload_Through_The_Concrete_Carrier_Type() + { + var json = _fixtures.LoadJson("Serialization.unknown-session-message.json"); + + var unknown = _serializer.Deserialize(json); + + await Assert.That(unknown.Type).IsEqualTo("future-message"); + await Assert.That(_serializer.Serialize(unknown)).IsEqualTo(json); + } + + [Test] + public async Task Serialize_Should_Reproduce_The_Raw_Error_Through_The_Concrete_Carrier_Type() + { + const string json = "{\"_tag\":\"BrandNewError\",\"detail\":{\"code\":7}}"; + var unknown = (UnknownOpenCodeError)_serializer.Deserialize(json); + + var roundTrip = _serializer.Serialize(unknown); + + await Assert.That(roundTrip).IsEqualTo(json); + } + [Test] public async Task Deserialize_Should_Create_Nested_Compaction_Variant_Through_The_Outer_Union() { diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs new file mode 100644 index 00000000..8a4be2e0 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -0,0 +1,188 @@ +using System.Net; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; + +namespace OpenCode.Sdk.Tests; + +public sealed class SessionClientContractTests +{ + private static readonly Uri Endpoint = new("http://localhost:4096"); + + [Test] + public async Task GetSessionAsync_Should_Return_The_Typed_Session() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.GetSessionClient("ses_100").GetSessionAsync(); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.Session.Id).IsEqualTo("ses_100"); + await Assert.That(response.Session.Location.Directory).IsEqualTo("/repo"); + await Assert.That(response.Session.Tokens.Cache.Read).IsEqualTo(1); + await Assert.That(handler.Requests.Single().RequestUri) + .IsEqualTo(new Uri("http://localhost:4096/api/session/ses_100")); + } + + [Test] + public async Task GetSessionAsync_Should_Treat_An_Explicit_Null_Parent_As_A_Malformed_Success() + { + var payload = new FixtureLoader().LoadJson("Serialization.null-parent-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").GetSessionAsync()) + .Throws(); + } + + [Test] + public async Task GetSessionAsync_Should_Treat_A_Numeric_Enum_Status_As_A_Malformed_Success() + { + var payload = new FixtureLoader().LoadJson("Serialization.numeric-status-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").GetSessionAsync()) + .Throws(); + } + + [Test] + public async Task GetSessionAsync_Should_Throw_The_Declared_404_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.NotFound, + "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"ses_9\",\"message\":\"gone\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_9").GetSessionAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(404); + await Assert.That(exception.Error).IsTypeOf(); + } + + [Test] + public async Task GetSessionAsync_Should_Throw_The_Declared_401_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.Unauthorized, + "{\"_tag\":\"UnauthorizedError\",\"message\":\"password required\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_9").GetSessionAsync()) + .Throws(); + + await Assert.That(exception!.Error).IsTypeOf(); + } + + [Test] + public async Task ListMessagesAsync_Should_Return_The_Typed_Page_With_Its_Cursor() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session-message.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse( + HttpStatusCode.OK, + $"{{\"data\":[{payload}],\"cursor\":{{\"previous\":\"cur_0\"}}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync(new MessageListRequest + { + Limit = 2, + Order = ListOrder.Ascending, + }); + + await Assert.That(response.Messages.Single()).IsTypeOf(); + await Assert.That(response.Cursor.Previous).IsEqualTo("cur_0"); + await Assert.That(response.Cursor.Next).IsNull(); + await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri) + .IsEqualTo("http://localhost:4096/api/session/ses_100/message?limit=2&order=asc"); + } + + [Test] + public async Task ListMessagesAsync_Should_Treat_A_Null_Page_Element_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.OK, + "{\"data\":[null],\"cursor\":{}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync()) + .Throws(); + } + + [Test] + public async Task ListMessagesAsync_Should_Throw_The_Declared_500_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.InternalServerError, + "{\"_tag\":\"UnknownError\",\"message\":\"boom\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(500); + await Assert.That(exception.Error).IsTypeOf(); + await Assert.That(((UnknownError)exception.Error!).Message).IsEqualTo("boom"); + } + + [Test] + public async Task ListMessagesAsync_Should_Return_The_500_Error_On_The_NoThrow_Spine() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.InternalServerError, + "{\"_tag\":\"UnknownError\",\"message\":\"boom\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.GetSessionClient("ses_100") + .ListMessagesAsync(requestOptions: OpenCodeRequestOptions.NoThrow); + + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Status).IsEqualTo(500); + await Assert.That(response.Error).IsTypeOf(); + await Assert.That(response.RawBody).Contains("boom"); + } + + [Test] + public async Task ListMessagesAsync_Should_Throw_The_Declared_404_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.NotFound, + "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"ses_9\",\"message\":\"gone\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_9").ListMessagesAsync()) + .Throws(); + + await Assert.That(exception!.Error).IsTypeOf(); + } + + private static OpenCodeClient CreateClient(HttpClient httpClient) => + new(httpClient, new OpenCodeClientOptions + { + Endpoint = Endpoint, + }); + + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) => + new(status) + { + Content = new StringContent(body), + }; +} diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionParentFilterTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionParentFilterTests.cs new file mode 100644 index 00000000..c14ee73e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionParentFilterTests.cs @@ -0,0 +1,41 @@ + +namespace OpenCode.Sdk.Tests; + +public sealed class SessionParentFilterTests +{ + [Test] + public async Task RootOnly_Should_Carry_The_Literal_Null_Wire_Value() + { + await Assert.That(SessionParentFilter.RootOnly.WireValue).IsEqualTo("null"); + } + + [Test] + public async Task RootOnly_Should_Be_A_Singleton() + { + await Assert.That(ReferenceEquals(SessionParentFilter.RootOnly, SessionParentFilter.RootOnly)).IsTrue(); + } + + [Test] + public async Task Of_Should_Carry_The_Identifier() + { + await Assert.That(SessionParentFilter.Of("ses_123").WireValue).IsEqualTo("ses_123"); + } + + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments(" ")] + public async Task Of_Should_Refuse_A_Blank_Identifier(string? sessionId) + { + _ = Assert.Throws(() => _ = SessionParentFilter.Of(sessionId!)); + await Task.CompletedTask; + } + + [Test] + public async Task Of_Should_Refuse_The_Literal_Null_Spelling() + { + var exception = Assert.Throws(() => _ = SessionParentFilter.Of("null")); + + await Assert.That(exception.Message).Contains("RootOnly"); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs new file mode 100644 index 00000000..53d70e26 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs @@ -0,0 +1,210 @@ +using System.Net; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; + +namespace OpenCode.Sdk.Tests; + +public sealed class SessionsClientContractTests +{ + private static readonly Uri Endpoint = new("http://localhost:4096"); + + [Test] + public async Task ListSessionsAsync_Should_Return_The_Typed_Page_With_Its_Cursor() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse( + HttpStatusCode.OK, + $"{{\"data\":[{payload}],\"cursor\":{{\"next\":\"cur_2\"}}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.ListSessionsAsync(); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.Sessions.Single().Id).IsEqualTo("ses_100"); + await Assert.That(response.Sessions.Single().Title).IsEqualTo("Fix the build"); + await Assert.That(response.Cursor.Next).IsEqualTo("cur_2"); + await Assert.That(response.Cursor.Previous).IsNull(); + await Assert.That(handler.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/session")); + } + + [Test] + public async Task ListSessionsAsync_Should_Compose_The_Escaped_Query() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.OK, + "{\"data\":[],\"cursor\":{}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await client.Sessions.ListSessionsAsync(new SessionListRequest + { + Limit = 5, + Order = ListOrder.Descending, + Search = "a b", + ParentId = SessionParentFilter.RootOnly, + Cursor = "cur_1", + }); + + await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( + "http://localhost:4096/api/session?limit=5&order=desc&search=a%20b&parentID=null&cursor=cur_1"); + } + + [Test] + [Arguments(" ")] + [Arguments(".")] + [Arguments("..")] + public async Task GetSessionClient_Should_Refuse_An_Id_The_Route_Would_Refuse(string sessionId) + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = Assert.Throws(() => _ = client.Sessions.GetSessionClient(sessionId)); + + await Assert.That(exception.ParamName).IsEqualTo("sessionId"); + } + + [Test] + public async Task ListSessionsAsync_Should_Treat_A_Null_Page_Element_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.OK, + "{\"data\":[null],\"cursor\":{}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + } + + [Test] + public async Task ListSessionsAsync_Should_Treat_A_Null_Page_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.OK, + "{\"data\":null,\"cursor\":{}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + } + + [Test] + public async Task ListSessionsAsync_Should_Refuse_A_Non_Positive_Limit_Before_Sending() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync(new SessionListRequest { Limit = 0 })) + .Throws(); + await Assert.That(exception!.ParamName).IsEqualTo("request"); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + [Arguments("{\"_tag\":\"InvalidCursorError\",\"message\":\"stale\"}", typeof(InvalidCursorError))] + [Arguments("{\"_tag\":\"InvalidRequestError\",\"message\":\"bad\"}", typeof(InvalidRequestError))] + public async Task ListSessionsAsync_Should_Type_Both_Declared_400_Errors(string body, Type expected) + { + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.BadRequest, body)); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(400); + await Assert.That(exception.Error!.GetType()).IsEqualTo(expected); + } + + [Test] + public async Task ListSessionsAsync_Should_Treat_An_Undeclared_Success_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse(HttpStatusCode.Accepted, "{}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + } + + [Test] + public async Task ListSessionsAsync_Should_Treat_A_Missing_Envelope_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse(HttpStatusCode.OK, "{}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + } + + [Test] + public async Task CreateSessionAsync_Should_Send_The_Empty_Body_When_Omitted() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.CreateSessionAsync(); + + await Assert.That(response.Session.Id).IsEqualTo("ses_100"); + await Assert.That(handler.Requests.Single().Method).IsEqualTo(HttpMethod.Post); + await Assert.That(handler.Requests.Single().ContentType).IsEqualTo("application/json"); + await Assert.That(handler.Requests.Single().Body).IsEqualTo("{}"); + } + + [Test] + public async Task CreateSessionAsync_Should_Send_The_Typed_Body() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await client.Sessions.CreateSessionAsync(new SessionCreateRequest + { + Title = "Fresh session", + }); + + await Assert.That(handler.Requests.Single().Body).IsEqualTo("{\"title\":\"Fresh session\"}"); + } + + [Test] + public async Task CreateSessionAsync_Should_Throw_The_Declared_400_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.BadRequest, + "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad id\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.CreateSessionAsync()) + .Throws(); + + await Assert.That(exception!.Error).IsTypeOf(); + } + + private static OpenCodeClient CreateClient(HttpClient httpClient) => + new(httpClient, new OpenCodeClientOptions + { + Endpoint = Endpoint, + }); + + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) => + new(status) + { + Content = new StringContent(body), + }; +} diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index 2690f098..da9f3f24 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -11,6 +11,43 @@ public required OpenCode.Sdk.Models.ServiceHealth Health { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } + public interface IOpenCodeClientOptions + { + System.Uri? Endpoint { get; } + string? Password { get; } + string Username { get; } + } + public sealed class ListCursor : System.IEquatable + { + public ListCursor() { } + [System.Text.Json.Serialization.JsonPropertyName("next")] + public string? Next { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("previous")] + public string? Previous { get; init; } + } + public enum ListOrder + { + Ascending = 0, + Descending = 1, + } + public abstract class ListRequest : System.IEquatable + { + protected ListRequest() { } + public string? Cursor { get; init; } + public int? Limit { get; init; } + public OpenCode.Sdk.ListOrder? Order { get; init; } + } + public sealed class MessageListRequest : OpenCode.Sdk.ListRequest, System.IEquatable + { + public MessageListRequest() { } + } + public sealed class MessageListResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public MessageListResponse() { } + public required OpenCode.Sdk.ListCursor Cursor { get; init; } + public required System.Collections.Generic.IReadOnlyList Messages { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } public class OpenCodeApiException : OpenCode.Sdk.OpenCodeException { public OpenCodeApiException() { } @@ -24,19 +61,19 @@ public class OpenCodeClient : System.IDisposable { protected OpenCodeClient() { } - public OpenCodeClient(System.Uri endpoint) { } + public OpenCodeClient(OpenCode.Sdk.OpenCodeClientOptions options) { } public OpenCodeClient(System.Net.Http.HttpClient httpClient, OpenCode.Sdk.OpenCodeClientOptions options) { } - public OpenCodeClient(System.Uri endpoint, OpenCode.Sdk.OpenCodeClientOptions? options) { } public virtual OpenCode.Sdk.SessionsClient Sessions { get; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } - public virtual System.Threading.Tasks.Task GetHealthAsync(OpenCode.Sdk.OpenCodeRequestOptions? options = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task GetHealthAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } - public sealed class OpenCodeClientOptions + public sealed class OpenCodeClientOptions : OpenCode.Sdk.IOpenCodeClientOptions { public OpenCodeClientOptions() { } public System.Uri? Endpoint { get; set; } public string? Password { get; set; } + public string Username { get; set; } } public class OpenCodeException : System.Exception { @@ -66,8 +103,15 @@ } public static class Sessions { + public const string CreateSession = "/api/session"; public const string GetMessageTemplate = "/api/session/{sessionID}/message/{messageID}"; + public const string GetSessionTemplate = "/api/session/{sessionID}"; + public const string ListMessagesTemplate = "/api/session/{sessionID}/message"; + public const string ListSessionsTemplate = "/api/session"; 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 class OpenCodeTransportException : OpenCode.Sdk.OpenCodeException @@ -79,7 +123,35 @@ public class SessionClient { protected SessionClient() { } - public virtual System.Threading.Tasks.Task GetMessageAsync(string messageId, OpenCode.Sdk.OpenCodeRequestOptions? options = null, System.Threading.CancellationToken cancellationToken = default) { } + 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 sealed class SessionCreateResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionCreateResponse() { } + public required OpenCode.Sdk.Models.SessionInfo Session { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public sealed class SessionListRequest : System.IEquatable + { + public SessionListRequest() { } + public string? Cursor { get; init; } + public string? Directory { get; init; } + public int? Limit { get; init; } + public OpenCode.Sdk.ListOrder? Order { get; init; } + public OpenCode.Sdk.SessionParentFilter? ParentId { get; init; } + public string? Project { get; init; } + public string? Search { get; init; } + public string? Subpath { get; init; } + public string? Workspace { get; init; } + } + public sealed class SessionListResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionListResponse() { } + public required OpenCode.Sdk.ListCursor Cursor { get; init; } + public required System.Collections.Generic.IReadOnlyList Sessions { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } } public sealed class SessionMessageResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { @@ -87,14 +159,59 @@ public required OpenCode.Sdk.Models.SessionMessageInfo Message { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } + public sealed class SessionParentFilter + { + public static OpenCode.Sdk.SessionParentFilter RootOnly { get; } + public static OpenCode.Sdk.SessionParentFilter Of(string sessionId) { } + } + public sealed class SessionResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionResponse() { } + public required OpenCode.Sdk.Models.SessionInfo Session { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } public class SessionsClient { protected SessionsClient() { } + public virtual System.Threading.Tasks.Task CreateSessionAsync(OpenCode.Sdk.Models.SessionCreateRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } 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) { } } } namespace OpenCode.Sdk.Models { + public sealed class FileDiffInfo : System.IEquatable + { + public FileDiffInfo() { } + [System.Text.Json.Serialization.JsonPropertyName("additions")] + public required long Additions { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("deletions")] + public required long Deletions { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("file")] + public required string File { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("patch")] + public required string Patch { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("status")] + public required OpenCode.Sdk.Models.FileDiffInfoStatus Status { get; init; } + } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] + public enum FileDiffInfoStatus + { + [System.Text.Json.Serialization.JsonStringEnumMemberName("added")] + Added = 0, + [System.Text.Json.Serialization.JsonStringEnumMemberName("deleted")] + Deleted = 1, + [System.Text.Json.Serialization.JsonStringEnumMemberName("modified")] + Modified = 2, + } + public sealed class InvalidCursorError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable + { + public InvalidCursorError() { } + [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 InvalidRequestError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable { public InvalidRequestError() { } @@ -114,6 +231,7 @@ namespace OpenCode.Sdk.Models public LocationRef() { } [System.Text.Json.Serialization.JsonPropertyName("directory")] public required string Directory { 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; } @@ -137,6 +255,7 @@ namespace OpenCode.Sdk.Models public required string Id { get; init; } [System.Text.Json.Serialization.JsonPropertyName("providerID")] public required string ProviderId { 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("variant")] public string? Variant { get; init; } @@ -151,6 +270,7 @@ namespace OpenCode.Sdk.Models public sealed class PromptAgentAttachment : System.IEquatable { public PromptAgentAttachment() { } + [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("mention")] public OpenCode.Sdk.Models.PromptMention? Mention { get; init; } @@ -162,14 +282,17 @@ namespace OpenCode.Sdk.Models public PromptFileAttachment() { } [System.Text.Json.Serialization.JsonPropertyName("data")] public required string Data { 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("description")] public string? Description { 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("mention")] public OpenCode.Sdk.Models.PromptMention? Mention { get; init; } [System.Text.Json.Serialization.JsonPropertyName("mime")] public required string Mime { 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("name")] public string? Name { get; init; } @@ -212,6 +335,7 @@ namespace OpenCode.Sdk.Models public PromptSkillAttachment() { } [System.Text.Json.Serialization.JsonPropertyName("id")] public required string Id { 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("mention")] public OpenCode.Sdk.Models.PromptMention? Mention { get; init; } @@ -230,6 +354,112 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("version")] public required string Version { get; init; } } + public sealed class SessionCreateRequest : System.IEquatable + { + public SessionCreateRequest() { } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("agent")] + public string? Agent { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string? Id { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("location")] + public OpenCode.Sdk.Models.LocationRef? Location { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("model")] + public OpenCode.Sdk.Models.ModelRef? Model { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("title")] + public string? Title { get; init; } + } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.SessionForkBoundaryJsonConverter))] + public abstract class SessionForkBoundary : System.IEquatable + { + protected SessionForkBoundary() { } + [System.Text.Json.Serialization.JsonPropertyName("type")] + public abstract string Type { get; } + } + public sealed class SessionForkBoundaryBefore : OpenCode.Sdk.Models.SessionForkBoundary, System.IEquatable + { + public SessionForkBoundaryBefore() { } + [System.Text.Json.Serialization.JsonPropertyName("messageID")] + public required string MessageId { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("type")] + public override string Type { get; } + } + public sealed class SessionForkBoundaryThrough : OpenCode.Sdk.Models.SessionForkBoundary, System.IEquatable + { + public SessionForkBoundaryThrough() { } + [System.Text.Json.Serialization.JsonPropertyName("messageID")] + public required string MessageId { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("type")] + public override string Type { get; } + } + public sealed class SessionInfo : System.IEquatable + { + public SessionInfo() { } + [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("agent")] + public string? Agent { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("cost")] + public required double Cost { 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("fork")] + public OpenCode.Sdk.Models.SessionInfoFork? Fork { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("id")] + public required string Id { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("location")] + public required OpenCode.Sdk.Models.LocationRef Location { 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("model")] + public OpenCode.Sdk.Models.ModelRef? Model { 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("parentID")] + public string? ParentId { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("projectID")] + public required string ProjectId { 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("revert")] + public OpenCode.Sdk.Models.SessionRevert? Revert { 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("subpath")] + public string? Subpath { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("time")] + public required OpenCode.Sdk.Models.SessionInfoTime Time { 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("title")] + public string? Title { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("tokens")] + public required OpenCode.Sdk.Models.TokenUsageInfo Tokens { get; init; } + } + public sealed class SessionInfoFork : System.IEquatable + { + public SessionInfoFork() { } + [System.Text.Json.Serialization.JsonPropertyName("boundary")] + public required OpenCode.Sdk.Models.SessionForkBoundary Boundary { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("sessionID")] + public required string SessionId { get; init; } + } + public sealed class SessionInfoTime : System.IEquatable + { + public SessionInfoTime() { } + [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("archived")] + public double? Archived { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("created")] + public required double Created { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("updated")] + public required double Updated { get; init; } + } public sealed class SessionMessageAgentSelected : OpenCode.Sdk.Models.SessionMessageInfo, System.IEquatable { public SessionMessageAgentSelected() { } @@ -239,6 +469,7 @@ namespace OpenCode.Sdk.Models public required string Id { get; init; } [System.Text.Json.Serialization.JsonPropertyName("metadata")] public System.Collections.Generic.IReadOnlyDictionary Metadata { 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("previous")] public string? Previous { get; init; } @@ -260,12 +491,15 @@ namespace OpenCode.Sdk.Models public required string Agent { get; init; } [System.Text.Json.Serialization.JsonPropertyName("content")] public required System.Collections.Generic.IReadOnlyList Content { 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("cost")] public double? Cost { 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("error")] public OpenCode.Sdk.Models.SessionStructuredError? Error { 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("finish")] public OpenCode.Sdk.Models.SessionMessageAssistantFinish? Finish { get; init; } @@ -275,14 +509,17 @@ namespace OpenCode.Sdk.Models public System.Collections.Generic.IReadOnlyDictionary Metadata { get; init; } [System.Text.Json.Serialization.JsonPropertyName("model")] public required OpenCode.Sdk.Models.ModelRef Model { 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("retry")] public OpenCode.Sdk.Models.SessionMessageAssistantRetry? Retry { 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("snapshot")] public OpenCode.Sdk.Models.SessionMessageAssistantSnapshot? Snapshot { get; init; } [System.Text.Json.Serialization.JsonPropertyName("time")] public required OpenCode.Sdk.Models.SessionMessageAssistantTime Time { 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("tokens")] public OpenCode.Sdk.Models.TokenUsageInfo? Tokens { get; init; } @@ -296,7 +533,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public abstract string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageAssistantFinish { [System.Text.Json.Serialization.JsonStringEnumMemberName("stop")] @@ -319,6 +556,7 @@ namespace OpenCode.Sdk.Models public System.Collections.Generic.IReadOnlyDictionary State { get; init; } [System.Text.Json.Serialization.JsonPropertyName("text")] public required string Text { 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("time")] public OpenCode.Sdk.Models.SessionMessageAssistantReasoningTime? Time { get; init; } @@ -328,6 +566,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantReasoningTime : System.IEquatable { public SessionMessageAssistantReasoningTime() { } + [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; } @@ -347,11 +586,13 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantSnapshot : System.IEquatable { public SessionMessageAssistantSnapshot() { } + [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("end")] public string? End { get; init; } [System.Text.Json.Serialization.JsonPropertyName("files")] public System.Collections.Generic.IReadOnlyList Files { 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("start")] public string? Start { get; init; } @@ -369,6 +610,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantTime : System.IEquatable { public SessionMessageAssistantTime() { } + [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; } @@ -378,6 +620,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantTool : OpenCode.Sdk.Models.SessionMessageAssistantContent, System.IEquatable { public SessionMessageAssistantTool() { } + [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("executed")] public bool? Executed { get; init; } @@ -399,11 +642,13 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantToolTime : System.IEquatable { public SessionMessageAssistantToolTime() { } + [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("created")] public required double Created { 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("ran")] public double? Ran { get; init; } @@ -435,7 +680,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageCompactionCompletedReason { [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")] @@ -467,7 +712,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageCompactionFailedReason { [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")] @@ -501,7 +746,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageCompactionRunningReason { [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")] @@ -531,12 +776,15 @@ namespace OpenCode.Sdk.Models public required OpenCode.Sdk.Models.LocationRef Location { get; init; } [System.Text.Json.Serialization.JsonPropertyName("metadata")] public System.Collections.Generic.IReadOnlyDictionary Metadata { 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("previous")] public OpenCode.Sdk.Models.SessionMessageLocationSwitchedPrevious? Previous { 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("projectID")] public string? ProjectId { 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("subpath")] public string? Subpath { get; init; } @@ -550,9 +798,11 @@ namespace OpenCode.Sdk.Models public SessionMessageLocationSwitchedPrevious() { } [System.Text.Json.Serialization.JsonPropertyName("location")] public required OpenCode.Sdk.Models.LocationRef Location { 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("projectID")] public string? ProjectId { 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("subpath")] public string? Subpath { get; init; } @@ -572,6 +822,7 @@ namespace OpenCode.Sdk.Models public System.Collections.Generic.IReadOnlyDictionary Metadata { get; init; } [System.Text.Json.Serialization.JsonPropertyName("model")] public required OpenCode.Sdk.Models.ModelRef Model { 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("previous")] public OpenCode.Sdk.Models.ModelRef? Previous { get; init; } @@ -591,6 +842,7 @@ namespace OpenCode.Sdk.Models public SessionMessageShell() { } [System.Text.Json.Serialization.JsonPropertyName("command")] public required string Command { 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; } @@ -598,6 +850,7 @@ namespace OpenCode.Sdk.Models public required string Id { get; init; } [System.Text.Json.Serialization.JsonPropertyName("metadata")] public System.Collections.Generic.IReadOnlyDictionary Metadata { 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("output")] public OpenCode.Sdk.Models.SessionMessageShellOutput? Output { get; init; } @@ -622,7 +875,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("truncated")] public required bool Truncated { get; init; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageShellStatus { [System.Text.Json.Serialization.JsonStringEnumMemberName("running")] @@ -637,6 +890,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageShellTime : System.IEquatable { public SessionMessageShellTime() { } + [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; } @@ -670,6 +924,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageSynthetic : OpenCode.Sdk.Models.SessionMessageInfo, System.IEquatable { public SessionMessageSynthetic() { } + [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("description")] public string? Description { get; init; } @@ -693,6 +948,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageSystem : OpenCode.Sdk.Models.SessionMessageInfo, System.IEquatable { public SessionMessageSystem() { } + [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("description")] public string? Description { get; init; } @@ -800,11 +1056,28 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + public sealed class SessionRevert : System.IEquatable + { + public SessionRevert() { } + [System.Text.Json.Serialization.JsonPropertyName("files")] + public System.Collections.Generic.IReadOnlyList Files { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("messageID")] + public required string MessageId { 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("partID")] + public string? PartId { 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("snapshot")] + public string? Snapshot { get; init; } + } public sealed class SessionStructuredError : System.IEquatable { public SessionStructuredError() { } [System.Text.Json.Serialization.JsonPropertyName("message")] public required string Message { 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("status")] public long? Status { get; init; } @@ -867,6 +1140,18 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + public sealed class UnknownError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable + { + public UnknownError() { } + [System.Text.Json.Serialization.JsonPropertyName("message")] + public required string Message { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("ref")] + public string? Ref { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("_tag")] + public override string Tag { get; } + } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownOpenCodeErrorJsonConverter))] public sealed class UnknownOpenCodeError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -875,6 +1160,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownPromptFileSourceJsonConverter))] public sealed class UnknownPromptFileSource : OpenCode.Sdk.Models.PromptFileSource, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -883,6 +1169,16 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionForkBoundaryJsonConverter))] + public sealed class UnknownSessionForkBoundary : OpenCode.Sdk.Models.SessionForkBoundary, System.IEquatable + { + [System.Text.Json.Serialization.JsonConstructor] + public UnknownSessionForkBoundary(string type, System.Text.Json.JsonElement payload) { } + public System.Text.Json.JsonElement Payload { get; } + [System.Text.Json.Serialization.JsonPropertyName("type")] + public override string Type { get; } + } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageAssistantContentJsonConverter))] public sealed class UnknownSessionMessageAssistantContent : OpenCode.Sdk.Models.SessionMessageAssistantContent, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -891,6 +1187,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageCompactionJsonConverter))] public sealed class UnknownSessionMessageCompaction : OpenCode.Sdk.Models.SessionMessageCompaction, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -901,6 +1198,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageInfoJsonConverter))] public sealed class UnknownSessionMessageInfo : OpenCode.Sdk.Models.SessionMessageInfo, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -909,6 +1207,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageToolStateJsonConverter))] public sealed class UnknownSessionMessageToolState : OpenCode.Sdk.Models.SessionMessageToolState, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -917,6 +1216,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("status")] public override string Status { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownToolContentJsonConverter))] public sealed class UnknownToolContent : OpenCode.Sdk.Models.ToolContent, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] diff --git a/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs b/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs index 4aef9b07..c0a20020 100644 --- a/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs +++ b/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs @@ -9,4 +9,8 @@ internal sealed record RecordedRequest public string? Authorization { get; init; } public string? UserAgent { get; init; } + + public string? ContentType { get; init; } + + public string? Body { get; init; } } diff --git a/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs b/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs index 1eafbe11..5e91e7ea 100644 --- a/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs +++ b/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs @@ -26,7 +26,7 @@ public RecordingHttpHandler(Func respon public bool IsDisposed { get; private set; } - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { _requests.Add(new RecordedRequest { @@ -34,8 +34,10 @@ protected override Task SendAsync(HttpRequestMessage reques Method = request.Method, Authorization = request.Headers.Authorization?.ToString(), UserAgent = request.Headers.UserAgent.Count is 0 ? null : request.Headers.UserAgent.ToString(), + ContentType = request.Content?.Headers.ContentType?.ToString(), + Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken), }); - return Task.FromResult(_responder(request)); + return _responder(request); } protected override void Dispose(bool disposing) diff --git a/tests/OpenCode.Sdk.Tests/Support/TestBody.cs b/tests/OpenCode.Sdk.Tests/Support/TestBody.cs new file mode 100644 index 00000000..ab938502 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/TestBody.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed record TestBody +{ + [JsonPropertyName("value")] + public required string Value { get; init; } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/TestBodyJsonContext.cs b/tests/OpenCode.Sdk.Tests/Support/TestBodyJsonContext.cs new file mode 100644 index 00000000..c6fcf58e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/TestBodyJsonContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tests.Support; + +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(TestBody))] +internal sealed partial class TestBodyJsonContext : JsonSerializerContext; diff --git a/tests/OpenCode.Sdk.Tests/Support/TimeoutContent.cs b/tests/OpenCode.Sdk.Tests/Support/TimeoutContent.cs new file mode 100644 index 00000000..d153be81 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/TimeoutContent.cs @@ -0,0 +1,16 @@ +using System.Net; + +namespace OpenCode.Sdk.Tests.Support; + +/// Simulates the transport timing out while the response body streams. +internal sealed class TimeoutContent : HttpContent +{ + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + throw new TaskCanceledException(); + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } +} diff --git a/tests/OpenCode.Sdk.Tests/SystemEnvironmentProviderTests.cs b/tests/OpenCode.Sdk.Tests/SystemEnvironmentProviderTests.cs deleted file mode 100644 index 60e30a9c..00000000 --- a/tests/OpenCode.Sdk.Tests/SystemEnvironmentProviderTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using OpenCode.Sdk.Internal; - -namespace OpenCode.Sdk.Tests; - -public sealed class SystemEnvironmentProviderTests -{ - [Test] - public async Task GetEnvironmentVariable_Should_Read_The_Process_Environment() - { - const string name = "OPENCODE_SDK_TEST_VARIABLE"; - Environment.SetEnvironmentVariable(name, "expected"); - try - { - var value = new SystemEnvironmentProvider().GetEnvironmentVariable(name); - - await Assert.That(value).IsEqualTo("expected"); - } - finally - { - Environment.SetEnvironmentVariable(name, null); - } - } - - [Test] - public async Task GetEnvironmentVariable_Should_Return_Null_When_The_Variable_Is_Absent() - { - var value = new SystemEnvironmentProvider().GetEnvironmentVariable("OPENCODE_SDK_TEST_ABSENT_VARIABLE"); - - await Assert.That(value).IsNull(); - } - - [Test] - public async Task GetEnvironmentVariable_Should_Refuse_A_Blank_Name() - { - _ = Assert.Throws(() => _ = new SystemEnvironmentProvider().GetEnvironmentVariable(" ")); - } -} diff --git a/tests/OpenCode.Sdk.Tests/UserAgentPolicyTests.cs b/tests/OpenCode.Sdk.Tests/UserAgentPolicyTests.cs index 065cac26..78bb2b04 100644 --- a/tests/OpenCode.Sdk.Tests/UserAgentPolicyTests.cs +++ b/tests/OpenCode.Sdk.Tests/UserAgentPolicyTests.cs @@ -29,6 +29,12 @@ public async Task Compose_Should_Omit_The_Version_Token_When_The_Version_Is_Unus await Assert.That(product.ToString()).IsEqualTo("OpenCode.Sdk"); } + [Test] + public async Task Resolve_Should_Cache_The_Product_Token() + { + await Assert.That(UserAgentPolicy.Resolve()).IsSameReferenceAs(UserAgentPolicy.Resolve()); + } + [Test] public async Task Resolve_Should_Read_The_Assembly_Informational_Version() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json new file mode 100644 index 00000000..08843da7 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json @@ -0,0 +1,16 @@ +{ + "groups": { + "health": { + "placement": "root" + } + }, + "envelopePayloadNames": {}, + "propertyOverrides": [], + "schemaAliases": [ + { + "schema": "InvalidRequestError1", + "aliasOf": "InvalidRequestError", + "reason": "Upstream spec-gen emits a structurally identical duplicate carrying the same _tag." + } + ] +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json index c0235006..c6ed2ebd 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json @@ -6,5 +6,6 @@ }, "envelopePayloadNames": {}, "propertyOverrides": [], + "schemaAliases": [], "mystery": true } 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 4eb66d7a..92753efb 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json @@ -5,5 +5,6 @@ } }, "envelopePayloadNames": {}, - "propertyOverrides": [] + "propertyOverrides": [], + "schemaAliases": [] } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CSharpNamePolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CSharpNamePolicyTests.cs index 7c246ef0..6113d71d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CSharpNamePolicyTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CSharpNamePolicyTests.cs @@ -17,4 +17,15 @@ public async Task ToPascalCase_Should_Use_Ordinary_Acronym_Casing(string wireNam await Assert.That(result).IsEqualTo(expected); } + + [Test] + [Arguments("class", false)] + [Arguments("string", false)] + [Arguments("record", true)] + [Arguments("Session", true)] + [Arguments("_reserved", true)] + public async Task IsValidIdentifier_Should_Reject_Reserved_Keywords(string candidate, bool expected) + { + await Assert.That(CSharpNamePolicy.IsValidIdentifier(candidate)).IsEqualTo(expected); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs index f9827c19..22bd0722 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs @@ -20,6 +20,20 @@ public async Task LoadAsync_Should_Read_Strict_Curation() await Assert.That(curation.Groups["health"].Placement).IsEqualTo(GroupPlacement.Root); await Assert.That(curation.EnvelopePayloadNames).IsEmpty(); await Assert.That(curation.PropertyOverrides).IsEmpty(); + await Assert.That(curation.SchemaAliases).IsEmpty(); + } + + [Test] + public async Task LoadAsync_Should_Read_Schema_Alias_Rows() + { + var fileSystem = CreateFileSystem("Binding.alias-curation.json"); + + var curation = await new CurationLoader(fileSystem).LoadAsync(CurationPath, CancellationToken.None); + + var alias = curation.SchemaAliases.Single(); + await Assert.That(alias.Schema).IsEqualTo("InvalidRequestError1"); + await Assert.That(alias.AliasOf).IsEqualTo("InvalidRequestError"); + await Assert.That(alias.Reason).Contains("duplicate"); } [Test] diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs new file mode 100644 index 00000000..84c1d20b --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs @@ -0,0 +1,108 @@ +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; + +public sealed class OperationNamePolicyTests +{ + [Test] + [Arguments("v2.health.get", "get", "GetHealthAsync")] + [Arguments("v2.session.message", "get", "GetMessageAsync")] + [Arguments("v2.session.get", "get", "GetSessionAsync")] + [Arguments("v2.session.list", "get", "ListSessionsAsync")] + [Arguments("v2.session.create", "post", "CreateSessionAsync")] + [Arguments("v2.message.list", "get", "ListMessagesAsync")] + 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); + } + + [Test] + public async Task MethodName_Should_Keep_A_Mid_Position_Verb_Segment_In_The_Subject() + { + await Assert.That(OperationNamePolicy.MethodName(Operation("v2.cache.get.entry"))) + .IsEqualTo("GetGetEntryAsync"); + } + + [Test] + public async Task MethodName_Should_Return_Null_When_The_Group_Cannot_Be_Pluralized() + { + await Assert.That(OperationNamePolicy.MethodName(Operation("v2.boss.list"))).IsNull(); + } + + [Test] + [Arguments("v2.health.get", "get", "HealthResponse")] + [Arguments("v2.session.message", "get", "SessionMessageResponse")] + [Arguments("v2.session.get", "get", "SessionResponse")] + [Arguments("v2.session.list", "get", "SessionListResponse")] + [Arguments("v2.session.create", "post", "SessionCreateResponse")] + [Arguments("v2.message.list", "get", "MessageListResponse")] + 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); + } + + [Test] + [Arguments("v2.health.get", "get", false, "Get")] + [Arguments("v2.session.message", "get", true, "GetMessage")] + [Arguments("v2.session.get", "get", true, "GetSession")] + [Arguments("v2.session.list", "get", true, "ListSessions")] + [Arguments("v2.session.create", "post", true, "CreateSession")] + [Arguments("v2.message.list", "get", true, "ListMessages")] + public async Task RouteMemberName_Should_Fall_Back_To_The_Group_On_Client_Placement_Only(string operationId, + string method, bool clientPlacement, string expected) + { + var placement = clientPlacement ? GroupPlacement.Client : GroupPlacement.Root; + + await Assert.That(OperationNamePolicy.RouteMemberName(Operation(operationId, method), placement)).IsEqualTo(expected); + } + + [Test] + [Arguments("v2.health.get", "get", "Health")] + [Arguments("v2.session.message", "get", "Message")] + [Arguments("v2.session.get", "get", "Session")] + [Arguments("v2.session.list", "get", "Sessions")] + [Arguments("v2.session.create", "post", "Session")] + [Arguments("v2.message.list", "get", "Messages")] + public async Task PayloadName_Should_Pluralize_The_Group_For_List_Operations(string operationId, string method, + string expected) + { + await Assert.That(OperationNamePolicy.PayloadName(Operation(operationId, method))).IsEqualTo(expected); + } + + [Test] + public async Task PayloadName_Should_Return_Null_When_The_Group_Cannot_Be_Pluralized() + { + await Assert.That(OperationNamePolicy.PayloadName(Operation("v2.boss.list"))).IsNull(); + } + + [Test] + [Arguments("v2.session.list", "get", "SessionListRequest")] + [Arguments("v2.message.list", "get", "MessageListRequest")] + [Arguments("v2.session.create", "post", "SessionCreateRequest")] + public async Task RequestTypeName_Should_Compose_Group_Subject_And_Verb(string operationId, string method, + string expected) + { + await Assert.That(OperationNamePolicy.RequestTypeName(Operation(operationId, method))).IsEqualTo(expected); + } + + private static SpecOperation Operation(string operationId, string method = "get") + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + + return new SpecOperation + { + OperationId = operationId, + Segments = [.. operationId.Split('.').Skip(1)], + Method = method, + Path = "/api/x", + HasWildcardPath = false, + IsWebSocket = false, + IsSse = false, + IsDeprecated = false, + Parameters = [], + Responses = [], + }; + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 533fd91f..b958877f 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -36,29 +36,69 @@ await Assert.That(plan.Clients.Select(static client => client.Name) await Assert.That(health.Envelope.AdapterTypeName).IsEqualTo("HealthResponseAdapter"); await Assert.That(health.Envelope.PayloadName).IsEqualTo("Health"); await Assert.That(health.Envelope.PayloadTypeName).IsEqualTo("ServiceHealth"); - await Assert.That(health.Envelope.HasDataEnvelope).IsFalse(); + await Assert.That(health.Envelope.Kind).IsEqualTo(EnvelopeKind.Bare); await Assert.That(health.ErrorMap.Statuses.Select(static status => status.StatusCode) .SequenceEqual([400, 401])).IsTrue(); await Assert.That(health.ErrorMap.Statuses[0].Tags.Single().Tag).IsEqualTo("InvalidRequestError"); await Assert.That(health.ErrorMap.Statuses[0].Tags.Single().TypeName).IsEqualTo("InvalidRequestError"); await Assert.That(health.ErrorMap.Statuses[1].Tags.Single().Tag).IsEqualTo("UnauthorizedError"); + await Assert.That(root.ContainerName).IsNull(); + var sessions = plan.Clients.Single(static client => client.Role == ClientRole.Collection); await Assert.That(sessions.Name).IsEqualTo("SessionsClient"); - await Assert.That(sessions.Operations).IsEmpty(); + await Assert.That(sessions.ContainerName).IsEqualTo("Sessions"); + await Assert.That(sessions.Operations.Select(static operation => operation.MethodName) + .SequenceEqual(["CreateSessionAsync", "ListSessionsAsync"], StringComparer.Ordinal)).IsTrue(); await Assert.That(sessions.HandleFactory!.MethodName).IsEqualTo("GetSessionClient"); await Assert.That(sessions.HandleFactory.HandleTypeName).IsEqualTo("SessionClient"); await Assert.That(sessions.HandleFactory.Parameter.WireName).IsEqualTo("sessionID"); await Assert.That(sessions.HandleFactory.Parameter.Name).IsEqualTo("sessionId"); await Assert.That(sessions.HandleFactory.Parameter.TypeName).IsEqualTo("string"); + var list = sessions.Operations.Single(static operation => operation.MethodName == "ListSessionsAsync"); + await Assert.That(list.QueryRequest!.TypeName).IsEqualTo("SessionListRequest"); + await Assert.That(list.QueryRequest.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.PropertyName) + .SequenceEqual( + ["Workspace", "Limit", "Order", "Search", "ParentId", "Directory", "Project", "Subpath", "Cursor"], + StringComparer.Ordinal)).IsTrue(); + await Assert.That(list.Envelope.Kind).IsEqualTo(EnvelopeKind.CursorList); + await Assert.That(list.Envelope.PayloadName).IsEqualTo("Sessions"); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("SessionInfo"); + await Assert.That(list.ErrorMap.Statuses[0].Tags.Select(static tag => tag.TypeName) + .SequenceEqual(["InvalidCursorError", "InvalidRequestError"], StringComparer.Ordinal)).IsTrue(); + + var create = sessions.Operations.Single(static operation => operation.MethodName == "CreateSessionAsync"); + await Assert.That(create.HttpMethod).IsEqualTo("post"); + await Assert.That(create.RequestBody!.TypeName).IsEqualTo("SessionCreateRequest"); + await Assert.That(create.RequestBody.IsOptional).IsTrue(); + await Assert.That(create.Envelope.ResponseTypeName).IsEqualTo("SessionCreateResponse"); + await Assert.That(create.Envelope.PayloadName).IsEqualTo("Session"); + } + + [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"); + await Assert.That(session.ContainerName).IsEqualTo("Sessions"); await Assert.That(session.HandleParameter!.WireName).IsEqualTo("sessionID"); await Assert.That(session.HandleParameter.IsHandleParameter).IsTrue(); - - var message = session.Operations.Single(); - await Assert.That(message.MethodName).IsEqualTo("GetMessageAsync"); + await Assert.That(session.Operations.Select(static operation => operation.MethodName) + .SequenceEqual(["GetMessageAsync", "GetSessionAsync", "ListMessagesAsync"], StringComparer.Ordinal)).IsTrue(); + + var messages = session.Operations.Single(static operation => operation.MethodName == "ListMessagesAsync"); + await Assert.That(messages.QueryRequest!.TypeName).IsEqualTo("MessageListRequest"); + await Assert.That(messages.QueryRequest.DerivesFromListRequest).IsTrue(); + await Assert.That(messages.Envelope.Kind).IsEqualTo(EnvelopeKind.CursorList); + await Assert.That(messages.Envelope.PayloadName).IsEqualTo("Messages"); + await Assert.That(messages.ErrorMap.Statuses.Select(static status => status.StatusCode) + .SequenceEqual([400, 401, 404, 500])).IsTrue(); + + var message = session.Operations.Single(static operation => operation.MethodName == "GetMessageAsync"); await Assert.That(message.RouteTemplate).IsEqualTo("/api/session/{sessionID}/message/{messageID}"); await Assert.That(message.RouteContainerName).IsEqualTo("Sessions"); await Assert.That(message.RouteMemberName).IsEqualTo("GetMessage"); @@ -73,7 +113,7 @@ await Assert.That(message.Parameters.Select(static parameter => parameter.Name) await Assert.That(message.Envelope.AdapterTypeName).IsEqualTo("SessionMessageResponseAdapter"); await Assert.That(message.Envelope.PayloadName).IsEqualTo("Message"); await Assert.That(message.Envelope.PayloadTypeName).IsEqualTo("SessionMessageInfo"); - await Assert.That(message.Envelope.HasDataEnvelope).IsTrue(); + await Assert.That(message.Envelope.Kind).IsEqualTo(EnvelopeKind.Data); await Assert.That(message.ErrorMap.Statuses.Select(static status => status.StatusCode) .SequenceEqual([400, 401, 404])).IsTrue(); await Assert.That(message.ErrorMap.Statuses[2].Tags.Select(static tag => tag.Tag) @@ -112,7 +152,7 @@ await Assert.That(part.Parameters.Select(static parameter => parameter.Name) await Assert.That(part.Envelope.AdapterTypeName).IsEqualTo("GadgetPartResponseAdapter"); await Assert.That(part.Envelope.PayloadName).IsEqualTo("Part"); await Assert.That(part.Envelope.PayloadTypeName).IsEqualTo("GadgetPart"); - await Assert.That(part.Envelope.HasDataEnvelope).IsTrue(); + await Assert.That(part.Envelope.Kind).IsEqualTo(EnvelopeKind.Data); await Assert.That(part.ErrorMap.Statuses.Single().StatusCode).IsEqualTo(404); await Assert.That(part.ErrorMap.Statuses.Single().Tags.Single().TypeName).IsEqualTo("GadgetMissingError"); } @@ -175,7 +215,37 @@ await Assert.That(part.Parameters.Select(static parameter => parameter.WireName) } [Test] - public async Task Bind_Should_Refuse_A_Non_Get_Operation() + public async Task Bind_Should_Refuse_A_Model_Colliding_With_A_Spine_Type_Name() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ListCursor", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.item", path: "/api/widget/item", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("ListCursor"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.item"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null))))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("spine", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Unsupported_Http_Method() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.health.reset", method: "put", path: "/api/health-reset", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + + await AssertOperationRefusalAsync(document, "v2.health.reset", "HTTP method"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Post_Without_A_Request_Body() { var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec .WithSchema("ItemInfo", schema => schema.Type("object") @@ -183,24 +253,467 @@ public async Task Bind_Should_Refuse_A_Non_Get_Operation() .WithOperation("v2.health.reset", method: "post", path: "/api/health-reset", configure: operation => operation .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); - await AssertOperationRefusalAsync(document, "v2.health.reset", "only GET operations"); + await AssertOperationRefusalAsync(document, "v2.health.reset", "must carry a request body"); + } + + [Test] + public async Task Bind_Should_Bind_A_Json_Request_Body_Into_A_Request_Model() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario(body => body.Type("object") + .AdditionalPropertiesFalse() + .Property("id", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))) + .Property("title", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))))); + + var plan = BindWidgets(document, "v2.widget.create"); + + var create = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(create.HttpMethod).IsEqualTo("post"); + await Assert.That(create.RequestBody).IsNotNull(); + await Assert.That(create.RequestBody!.TypeName).IsEqualTo("WidgetCreateRequest"); + await Assert.That(create.RequestBody.ParameterName).IsEqualTo("request"); + await Assert.That(create.RequestBody.IsOptional).IsTrue(); + await Assert.That(plan.Models.Select(static model => model.Name)).Contains("WidgetCreateRequest"); + await Assert.That(plan.Registry.TypeNames).Contains("WidgetCreateRequest"); + } + + [Test] + public async Task Bind_Should_Require_The_Request_Parameter_When_The_Body_Has_Required_Properties() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario(body => body.Type("object") + .Property("title", property => property.Type("string"), required: true))); + + 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!.IsOptional).IsFalse(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Non_Json_Request_Body() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario( + body => body.Type("object").Property("title", property => property.Type("string")), + mediaType: "text/plain")); + + await AssertWidgetRefusalAsync(document, "JSON", "v2.widget.create"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Non_Object_Request_Body() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario(body => body.Type("string"))); + + await AssertWidgetRefusalAsync(document, "object schema", "v2.widget.create"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Optional_Request_Body() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario( + body => body.Type("object").Property("title", property => property.Type("string")), + required: false)); + + await AssertWidgetRefusalAsync(document, "declared required", "v2.widget.create"); + } + + [Test] + public async Task Bind_Should_Bind_Optional_Nullable_Query_Parameters_Into_A_Flat_Query_Request() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString) + .Parameter("search", "query", QueryScenarioData.NullableString) + .Parameter("parentID", "query", QueryScenarioData.NullableParentFilter))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.QueryRequest).IsNotNull(); + await Assert.That(list.QueryRequest!.TypeName).IsEqualTo("WidgetListRequest"); + await Assert.That(list.QueryRequest.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.WireName) + .SequenceEqual(["limit", "order", "cursor", "search", "parentID"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.PropertyName) + .SequenceEqual(["Limit", "Order", "Cursor", "Search", "ParentId"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.Kind) + .SequenceEqual([ + QueryValueKind.PositiveCount, + QueryValueKind.ListOrder, + QueryValueKind.Text, + QueryValueKind.Text, + QueryValueKind.SessionParentFilter, + ])).IsTrue(); + await Assert.That(list.QueryRequest.Properties.All(static property => !property.IsInherited)).IsTrue(); + await Assert.That(list.Parameters).IsEmpty(); + } + + [Test] + public async Task Bind_Should_Keep_Query_Parameter_Schemas_Out_Of_The_Model_Closure() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("parentID", "query", QueryScenarioData.NullableParentFilter))); + + var plan = BindWidgets(document); + + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(plan.Unions).IsEmpty(); + await Assert.That(plan.Registry.TypeNames + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Derive_The_Query_Request_From_The_List_Request_Base_When_The_Trio_Matches_Exactly() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.QueryRequest!.TypeName).IsEqualTo("WidgetListRequest"); + await Assert.That(list.QueryRequest.DerivesFromListRequest).IsTrue(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.PropertyName) + .SequenceEqual(["Limit", "Order", "Cursor"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(list.QueryRequest.Properties.All(static property => property.IsInherited)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Keep_The_Query_Request_Flat_When_The_Trio_Has_Extra_Parameters() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString) + .Parameter("search", "query", QueryScenarioData.NullableString))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.QueryRequest!.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.Properties.All(static property => !property.IsInherited)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Keep_The_Query_Request_Flat_When_The_Trio_Is_Incomplete() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("cursor", "query", QueryScenarioData.NullableString))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.QueryRequest!.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.PropertyName) + .SequenceEqual(["Limit", "Cursor"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Leave_The_Query_Request_Absent_When_An_Operation_Has_No_Query_Parameters() + { + var document = await BindingTestHost.IngestAsync(GadgetScenario()); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.gadget.part"), + Curation(Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID")))); + + var part = plan.Clients.Single(static client => client.Role == ClientRole.Handle).Operations.Single(); + await Assert.That(part.QueryRequest).IsNull(); + } + + [Test] + public async Task Bind_Should_Bind_A_Cursor_List_Envelope() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario()); + + 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.CursorList); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("WidgetInfo"); + await Assert.That(list.Envelope.EnvelopeDtoTypeName).IsEqualTo("WidgetListResponseEnvelope"); + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(plan.Registry.TypeNames + .SequenceEqual(["WidgetInfo", "WidgetListResponseEnvelope"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Envelope_Dto_Name_Colliding_With_A_Model() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true) + .Property("extra", property => property.Ref("WidgetListResponseEnvelope"), required: true)) + .WithSchema("WidgetListResponseEnvelope", schema => schema.Type("object") + .Property("note", property => property.Type("string"), required: true)) + .WithSchema("WidgetsResponse", schema => schema.Type("object") + .AdditionalPropertiesFalse() + .Property("data", property => property.Type("array") + .Items(static item => item.Ref("WidgetInfo")), required: true) + .Property("cursor", cursor => cursor.Type("object") + .AdditionalPropertiesFalse() + .Property("previous", static property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))) + .Property("next", static property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetsResponse"))))); + + var exception = Assert.Throws(() => _ = BindWidgets(document)); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("envelope DTO", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Name_A_Component_Request_Body_From_The_Operation() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("Widget.CreatePayload", schema => schema.Type("object") + .Property("title", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.create", method: "post", path: "/api/widget", configure: operation => operation + .RequestBody("application/json", body => body.Ref("Widget.CreatePayload"), 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(plan.Models.Any(static model => model.Name == "WidgetCreateRequest")).IsTrue(); + await Assert.That(plan.Models.Any(static model => model.Name == "WidgetCreatePayload")).IsFalse(); + await Assert.That(plan.Registry.TypeNames.Contains("WidgetCreateRequest", StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Not_Let_A_Pending_Operation_Rename_A_Shared_Component() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetShared", schema => schema.Type("object") + .Property("note", property => property.Type("string"), required: true)) + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true) + .Property("shared", property => property.Ref("WidgetShared"), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))) + .WithOperation("v2.widget.create", method: "post", path: "/api/widget-create", configure: operation => operation + .RequestBody("application/json", body => body.Ref("WidgetShared"), required: true) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + var plan = BindWidgets(document); + + await Assert.That(plan.Models.Any(static model => model.Name == "WidgetShared")).IsTrue(); + await Assert.That(plan.Models.Any(static model => model.Name == "WidgetCreateRequest")).IsFalse(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Operation_Mixing_A_Body_And_Query_Parameters() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("Widget.CreatePayload", schema => schema.Type("object") + .Property("title", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.create", method: "post", path: "/api/widget", configure: operation => operation + .Parameter("search", "query", static schema => schema.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null")), required: false) + .RequestBody("application/json", body => body.Ref("Widget.CreatePayload"), required: true) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + await AssertWidgetRefusalAsync(document, "request body and query", "v2.widget.create"); + } + + [Test] + public async Task Bind_Should_Bind_A_Component_Data_Envelope_Without_Modeling_The_Wrapper() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("WidgetResponse", schema => schema.Type("object") + .AdditionalPropertiesFalse() + .Property("data", property => property.Ref("WidgetInfo"), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetResponse"))))); + + 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.Data); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("WidgetInfo"); + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Cursor_List_With_A_Malformed_Cursor() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(cursor => cursor.Type("object") + .AdditionalPropertiesFalse() + .Property("previous", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null")), required: true) + .Property("next", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))))); + + await AssertWidgetRefusalAsync(document, "cursor object"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Cursor_List_Whose_Items_Are_Not_Component_References() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(items: items => items.Type("object") + .Property("id", property => property.Type("string"), required: true))); + + await AssertWidgetRefusalAsync(document, "array of a named component schema"); + } + + [Test] + public async Task Bind_Should_Merge_Groups_Sharing_A_Client_Name() + { + var document = await BindingTestHost.IngestAsync(GadgetScenario(spec => spec + .WithOperation("v2.gizmo.list", path: "/api/gadget/{gadgetID}/gizmo", configure: operation => operation + .Parameter("gadgetID", "path", schema => schema.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Type("object") + .Property("data", property => property.Ref("GadgetPart"), required: true))))); + + var groups = new Dictionary(StringComparer.Ordinal) + { + ["gadget"] = ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID"), + ["gizmo"] = ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID"), + }; + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.gadget.part", "v2.gizmo.list"), + Curation(groups)); + + var root = plan.Clients.Single(static client => client.Role == ClientRole.Root); + await Assert.That(root.SubClients.Single().TypeName).IsEqualTo("GadgetsClient"); + var collection = plan.Clients.Single(static client => client.Role == ClientRole.Collection); + await Assert.That(collection.Operations).IsEmpty(); + var handle = plan.Clients.Single(static client => client.Role == ClientRole.Handle); + await Assert.That(handle.Operations.Select(static operation => operation.MethodName) + .Order(StringComparer.Ordinal) + .SequenceEqual(["GetPartAsync", "ListGizmosAsync"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(handle.Operations.All(static operation => operation.RouteContainerName == "Gadgets")).IsTrue(); } [Test] - public async Task Bind_Should_Refuse_A_Query_Parameter() + public async Task Bind_Should_Refuse_Merged_Groups_With_Diverging_Handles() + { + var document = await BindingTestHost.IngestAsync(GadgetScenario(spec => spec + .WithOperation("v2.gizmo.list", path: "/api/gadget/{gadgetID}/gizmo", configure: operation => operation + .Parameter("gadgetID", "path", schema => schema.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Type("object") + .Property("data", property => property.Ref("GadgetPart"), required: true))))); + + var groups = new Dictionary(StringComparer.Ordinal) + { + ["gadget"] = ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID"), + ["gizmo"] = ClientGroup(clientName: "Gadgets", handleName: null, handleParameter: null), + }; + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.part", "v2.gizmo.list"), + Curation(groups))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("identical handle", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Bind_A_500_Error_Arm() { var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec .WithSchema("ItemInfo", schema => schema.Type("object") .Property("id", property => property.Type("string"), required: true)) + .WithSchema("UnknownFailure", schema => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("UnknownFailure"), required: true) + .Property("message", property => property.Type("string"), required: true)) .WithOperation("v2.health.get", configure: operation => operation - .Parameter("limit", "query", schema => schema.Type("string")) - .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + .Response(200, "application/json", schema => schema.Ref("ItemInfo")) + .Response(500, "application/json", schema => schema.Ref("UnknownFailure"))))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(Groups("health", RootGroup()))); + + var health = plan.Clients.Single(static client => client.Role == ClientRole.Root).Operations.Single(); + var status = health.ErrorMap.Statuses.Single(); + await Assert.That(status.StatusCode).IsEqualTo(500); + await Assert.That(status.Tags.Single().TypeName).IsEqualTo("UnknownFailure"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Required_Query_Parameter() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString, required: true))); + + await AssertWidgetRefusalAsync(document, "must be optional"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Query_Parameter_That_Does_Not_Admit_Null() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", schema => schema.Type("string")))); + + await AssertWidgetRefusalAsync(document, "must admit null"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Query_Enum_Outside_The_Order_Profile() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("order", "query", schema => schema.AnyOf( + branch => branch.Type("string").Enum("asc", "desc", "shuffled"), + branch => branch.Type("null"))))); - await AssertOperationRefusalAsync(document, "v2.health.get", "required path parameter"); + await AssertWidgetRefusalAsync(document, "unsupported schema shape"); } [Test] - public async Task Bind_Should_Refuse_A_Request_Body() + public async Task Bind_Should_Refuse_An_Object_Query_Parameter() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("filter", "query", schema => schema.AnyOf( + branch => branch.Type("object") + .Property("name", property => property.Type("string"), required: true), + branch => branch.Type("null"))))); + + await AssertWidgetRefusalAsync(document, "unsupported schema shape"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Deep_Object_Query_Parameter() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("location", "query", QueryScenarioData.NullableString, deepObject: true))); + + await AssertWidgetRefusalAsync(document, "deep-object"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Request_Body_On_A_Get_Operation() { var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec .WithSchema("ItemInfo", schema => schema.Type("object") @@ -210,7 +723,7 @@ public async Task Bind_Should_Refuse_A_Request_Body() .Property("value", property => property.Type("string"), required: true)) .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); - await AssertOperationRefusalAsync(document, "v2.health.get", "request bodies"); + await AssertOperationRefusalAsync(document, "v2.health.get", "must not carry a request body"); } [Test] @@ -312,11 +825,99 @@ public async Task Bind_Should_Refuse_A_Wildcard_Path() await AssertOperationRefusalAsync(document, "v2.health.get", "wildcard"); } + [Test] + public async Task Bind_Should_Refuse_A_Reserved_Parameter_Name() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.item", path: "/api/widget/{request}", configure: operation => operation + .Parameter("request", "path", schema => schema.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.item"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null))))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("reserved by the emitted method signature", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Admit_A_Path_Parameter_Named_Options() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.item", path: "/api/widget/{options}", configure: operation => operation + .Parameter("options", "path", schema => schema.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.widget.item"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)))); + + var item = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(item.Parameters.Single().Name).IsEqualTo("options"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Operation_Declaring_Both_A_Body_And_Query_Parameters() + { + 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("dryRun", "query", QueryScenarioData.NullableString) + .RequestBody("application/json", body => body.Type("object") + .Property("title", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))), required: true) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + await AssertWidgetRefusalAsync(document, "request body and query", "v2.widget.create"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Response_Type_Shadowing_A_Model() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetStateResponse", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.state", path: "/api/widget-state", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetStateResponse"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.state"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null))))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("WidgetStateResponse", StringComparison.Ordinal) + && error.Problem.Contains("response type", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Client_Name_Colliding_With_The_Spine() + { + var document = await BindingTestHost.IngestAsync(GadgetScenario()); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.part"), + Curation(Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: "ListCursor", handleParameter: "gadgetID"))))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("ListCursor", StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Refuse_Colliding_Method_Names_On_A_Client() { var document = await BindingTestHost.IngestAsync(GadgetScenario(spec => spec - .WithOperation("v2.gadget.get.part", path: "/api/gadget/{gadgetID}/part-alias/{partID}", configure: operation => operation + .WithOperation("v2.gadget.part.get", path: "/api/gadget/{gadgetID}/part-alias/{partID}", configure: operation => operation .Parameter("gadgetID", "path", schema => schema.Type("string"), required: true) .Parameter("partID", "path", schema => schema.Type("string"), required: true) .Response(200, "application/json", schema => schema.Type("object") @@ -324,7 +925,7 @@ public async Task Bind_Should_Refuse_Colliding_Method_Names_On_A_Client() var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( document, - Selection("v2.gadget.part", "v2.gadget.get.part"), + Selection("v2.gadget.part", "v2.gadget.part.get"), Curation(Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID"))))); await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming @@ -398,6 +999,28 @@ await Assert.That(exception.Errors.Single(static error => error.Category == Bind .Contains("Status"); } + [Test] + [Arguments("EqualityContract")] + [Arguments("ToString")] + [Arguments("GetHashCode")] + [Arguments("PrintMembers")] + public async Task Bind_Should_Refuse_A_Payload_Name_Colliding_With_Record_Members(string payloadName) + { + var document = await BindingTestHost.IngestAsync(StatusScenario()); + var payloadNames = new Dictionary(StringComparer.Ordinal) + { + ["v2.widget.status"] = payloadName, + }; + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.status"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)), payloadNames))); + + await Assert.That(exception.Errors.Any(error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains(payloadName, StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Accept_An_Override_That_Resolves_A_Reserved_Payload_Collision() { @@ -454,6 +1077,80 @@ await Assert.That(exception.Errors.Any(error => error.Category == BindingErrorCa && error.Problem.Contains(expectedProblem, StringComparison.Ordinal))).IsTrue(); } + private static SpecScenario WidgetListScenario(Action configureParameters) => + SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => + { + configureParameters(operation); + _ = operation.Response(200, "application/json", schema => schema.Ref("WidgetInfo")); + })); + + private static SpecScenario CursorListScenario(Action? cursor = null, Action? items = null) => + SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("WidgetsResponse", schema => schema.Type("object") + .AdditionalPropertiesFalse() + .Property("data", property => property + .Type("array") + .Items(items ?? (static item => item.Ref("WidgetInfo"))), required: true) + .Property("cursor", cursor ?? DefaultCursor, required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetsResponse")))); + + private static void DefaultCursor(SchemaBuilder cursor) => cursor.Type("object") + .AdditionalPropertiesFalse() + .Property("previous", static property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))) + .Property("next", static property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))); + + private static SpecScenario WidgetCreateScenario(Action configureBody, + string mediaType = "application/json", bool required = true) => + 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 + .RequestBody(mediaType, configureBody, required) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo")))); + + private static EmitPlan BindWidgets(SpecDocument document, string operationId = "v2.widget.list") => + new BindingTestHost().Bind( + document, + Selection(operationId), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)))); + + private static async Task AssertWidgetRefusalAsync(SpecDocument document, string expectedProblem, + string operationId = "v2.widget.list") + { + var exception = Assert.Throws(() => _ = BindWidgets(document, operationId)); + + await Assert.That(exception.Errors.Any(error => error.Category == BindingErrorCategory.Operation + && string.Equals(error.Subject, operationId, StringComparison.Ordinal) + && error.Problem.Contains(expectedProblem, StringComparison.Ordinal))).IsTrue(); + } + + private static class QueryScenarioData + { + public static void NullableString(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null")); + + public static void NullableOrderEnum(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.Type("string").Enum("asc", "desc"), + static branch => branch.Type("null")); + + public static void NullableParentFilter(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.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")); + } + private static SpecScenario GadgetScenario(Action? extend = null, bool parametersReversed = false) => SpecScenario.Define(spec => { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index f40aee09..9037e22d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -12,10 +12,12 @@ public sealed class SpecBinderTests { private static readonly string[] ExpectedErrorTypeNames = [ + "InvalidCursorError", "InvalidRequestError", "MessageNotFoundError", "SessionNotFoundError", "UnauthorizedError", + "UnknownError", ]; [Test] @@ -61,6 +63,52 @@ await Assert.That(errors.Variants.Select(static variant => variant.TypeName).Ord .SequenceEqual(ExpectedErrorTypeNames, StringComparer.Ordinal)).IsTrue(); } + [Test] + public async Task Bind_Should_Distinguish_Wire_Null_From_Optional_Absence() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true) + .Property("note", property => property.Type("string")) + .Property("flushedAt", property => property.AnyOf( + branch => branch.Type("number"), + branch => branch.Type("null")), required: true) + .Property("extra", property => property.Unrestricted())) + .WithOperation("v2.widget.item", path: "/api/widget/item", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.widget.item"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)))); + + var item = plan.Models.OfType().Single(static model => model.Name == "ItemInfo"); + var id = item.Properties.Single(static property => property.WireName == "id"); + await Assert.That(id.AllowsWireNull).IsFalse(); + await Assert.That(id.Type.IsNullable).IsFalse(); + var note = item.Properties.Single(static property => property.WireName == "note"); + await Assert.That(note.AllowsWireNull).IsFalse(); + await Assert.That(note.Type.IsNullable).IsTrue(); + var flushedAt = item.Properties.Single(static property => property.WireName == "flushedAt"); + await Assert.That(flushedAt.AllowsWireNull).IsTrue(); + await Assert.That(flushedAt.Type.IsNullable).IsTrue(); + var extra = item.Properties.Single(static property => property.WireName == "extra"); + await Assert.That(extra.AllowsWireNull).IsTrue(); + } + + [Test] + public async Task Bind_Should_Keep_The_Pinned_Session_Parent_Wire_Null_Strict() + { + var (document, selection, curation) = await LoadPinnedInputsAsync(); + + var plan = new BindingTestHost().Bind(document, selection, curation); + + var session = plan.Models.OfType().Single(static model => model.Name == "SessionInfo"); + var parent = session.Properties.Single(static property => property.WireName == "parentID"); + await Assert.That(parent.AllowsWireNull).IsFalse(); + await Assert.That(parent.Type.IsNullable).IsTrue(); + } + [Test] public async Task Bind_Should_Be_Deterministic_For_The_Selected_Pin() { @@ -349,6 +397,326 @@ await Assert.That(exception.Errors.Single(static error => error.Category == Bind .Contains("duplicated"); } + [Test] + public async Task Bind_Should_Refuse_A_Numeric_Literal_In_The_Selected_Closure() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("VersionedItem", schema => schema.Type("object") + .Property("version", property => property.Type("number").Raw("enum", "[3]"), required: true)) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("VersionedItem"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), }))); + + await Assert.That(exception.Errors.Any(static error => + error.Problem.Contains("numeric literal", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Bind_A_Nested_Marked_OneOf_Union() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("Alpha", schema => schema.Type("object") + .Property("type", property => property.Type("string").Enum("alpha"), required: true)) + .WithSchema("WrapOne", schema => schema.Type("object") + .Property("type", property => property.Type("string").Enum("wrap"), required: true) + .Property("status", property => property.Type("string").Enum("one"), required: true)) + .WithSchema("WrapTwo", schema => schema.Type("object") + .Property("type", property => property.Type("string").Enum("wrap"), required: true) + .Property("status", property => property.Type("string").Enum("two"), required: true)) + .WithSchema("Wrap", schema => schema.OneOf(one => one.Ref("WrapOne"), two => two.Ref("WrapTwo"))) + .WithSchema("Outer", schema => schema.AnyOf(alpha => alpha.Ref("Alpha"), wrap => wrap.Ref("Wrap"))) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("Outer"))))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), })); + + var wrap = plan.Unions.Single(static union => union.Name == "Wrap"); + await Assert.That(wrap.MarkerWireName).IsEqualTo("status"); + await Assert.That(wrap.BaseTypeName).IsEqualTo("Outer"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Tag_Owned_By_Two_Closure_Schemas() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("GoneError", schema => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("SharedError"), required: true) + .Property("message", property => property.Type("string"), required: true)) + .WithSchema("LostError", schema => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("SharedError"), required: true) + .Property("detail", property => property.Type("string"), required: true)) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("ItemInfo")) + .Response(404, "application/json", schema => schema.Ref("GoneError")) + .Response(410, "application/json", schema => schema.Ref("LostError"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), }))); + + await Assert.That(exception.Errors.Any(static error => + error.Problem.Contains("multiple error schemas declare tag 'SharedError'", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Unrecognized_Group_Placement() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Type("object") + .Property("data", property => property.Type("string"), required: true))))); + var groups = new Dictionary(StringComparer.Ordinal) + { + ["health"] = new GroupCuration { Placement = (GroupPlacement)7 }, + }; + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(groups))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("not a recognized group placement", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Ingest_Should_Refuse_A_Repeated_Path_Token() + { + var scenario = SpecScenario.Define(spec => spec + .WithOperation("v2.health.get", path: "/api/{id}/echo/{id}", configure: operation => operation + .Parameter("id", "path", schema => schema.Type("string"), required: true))); + + var exception = await Assert + .That(async () => _ = await IngestAsync(scenario)) + .Throws(); + + await Assert.That(exception!.Errors.Any(static error => + error.Problem.Contains("must appear exactly once", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Collapse_A_Structurally_Identical_Schema_Alias() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration(Alias("GadgetError1", "GadgetError"))); + + var gadget = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + var status = gadget.ErrorMap.Statuses.Single(static entry => entry.StatusCode == 400); + await Assert.That(status.Tags.Select(static tag => tag.TypeName) + .SequenceEqual(["GadgetError"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(plan.Models.Any(static model => model.Name == "GadgetError1")).IsFalse(); + await Assert.That(plan.Unions.Single(static union => union.Name == "OpenCodeError") + .Variants.Select(static variant => variant.TypeName) + .SequenceEqual(["GadgetError"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Apply_A_Property_Override_Keyed_To_An_Alias_Source() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + Curation( + Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: null, handleParameter: null)), + propertyOverrides: [new PropertyOverride { Schema = "GadgetError1", Property = "message", Type = PropertyOverrideType.Uri, Reason = "The wire value is URL-semantic." }], + schemaAliases: [Alias("GadgetError1", "GadgetError")])); + + var error = plan.Models.OfType().Single(static model => model.Name == "GadgetError"); + var message = error.Properties.Single(static property => property.WireName == "message"); + await Assert.That(message.Type).IsTypeOf(); + await Assert.That(((NamedTypeReferencePlan)message.Type).Name).IsEqualTo("Uri"); + } + + [Test] + public async Task Bind_Should_Refuse_Overrides_Collapsing_Onto_The_Same_Property() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + Curation( + Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: null, handleParameter: null)), + propertyOverrides: + [ + new PropertyOverride { Schema = "GadgetError1", Property = "message", Type = PropertyOverrideType.Uri, Reason = "The wire value is URL-semantic." }, + new PropertyOverride { Schema = "GadgetError", Property = "message", Type = PropertyOverrideType.Uri, Reason = "The wire value is URL-semantic." }, + ], + schemaAliases: [Alias("GadgetError1", "GadgetError")]))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("collapse", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Duplicate_Error_Tag_Without_An_Alias() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration())); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Operation + && error.Problem.Contains("duplicate error tag", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Alias_Whose_Shapes_Differ() + { + var document = await IngestAsync(DuplicateTagScenario(duplicate => duplicate.Type("object") + .Property("_tag", property => property.Type("string").Enum("GadgetError"), required: true) + .Property("message", property => property.Type("string"), required: true) + .Property("detail", property => property.Type("string")))); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GadgetError"), "structurally identical"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Alias_Whose_Formats_Differ() + { + var document = await IngestAsync(DuplicateTagScenario(static duplicate => duplicate.Type("object") + .Property("_tag", property => property.Type("string").Enum("GadgetError"), required: true) + .Property("message", property => property.Type("string").Format("uri"), required: true))); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GadgetError"), "structurally identical"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Alias_Whose_Schema_Is_Missing() + { + var document = await IngestAsync(DuplicateTagScenario()); + + await AssertAliasRefusalAsync(document, Alias("GhostError", "GadgetError"), "does not exist", subject: "GhostError"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Alias_Whose_Target_Is_Missing() + { + var document = await IngestAsync(DuplicateTagScenario()); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GhostError"), "does not exist"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Self_Alias() + { + var document = await IngestAsync(DuplicateTagScenario()); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GadgetError1"), "itself"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Chained_Alias() + { + var document = await IngestAsync(SpecScenario.Define(spec => + { + DefineDuplicateTagSpec(spec, DefaultDuplicate); + _ = spec.WithSchema("GadgetError2", DefaultDuplicate); + })); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration(Alias("GadgetError2", "GadgetError1"), Alias("GadgetError1", "GadgetError")))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("chain", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Duplicated_Alias_Source() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration(Alias("GadgetError1", "GadgetError"), Alias("GadgetError1", "GadgetError")))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("duplicated", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Alias_Without_A_Reason() + { + var document = await IngestAsync(DuplicateTagScenario()); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GadgetError", reason: " "), "reason"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Dormant_Alias() + { + var document = await IngestAsync(SpecScenario.Define(spec => + { + DefineDuplicateTagSpec(spec, DefaultDuplicate); + _ = spec.WithSchema("LonelyError", DefaultDuplicate) + .WithSchema("LonelyError1", DefaultDuplicate); + })); + + await AssertAliasRefusalAsync( + document, + Alias("LonelyError1", "LonelyError"), + "not referenced", + subject: "LonelyError1"); + } + + private static async Task AssertAliasRefusalAsync(SpecDocument document, SchemaAlias alias, string expectedProblem, + string? subject = null) + { + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration(alias))); + + await Assert.That(exception.Errors.Any(error => error.Category == BindingErrorCategory.Curation + && string.Equals(error.Subject, subject ?? alias.Schema, StringComparison.Ordinal) + && error.Problem.Contains(expectedProblem, StringComparison.Ordinal))).IsTrue(); + } + + private static SpecScenario DuplicateTagScenario(Action? duplicate = null) => + SpecScenario.Define(spec => DefineDuplicateTagSpec(spec, duplicate ?? DefaultDuplicate)); + + private static void DefineDuplicateTagSpec(SpecDocumentBuilder spec, Action duplicate) => + _ = spec + .WithSchema("GadgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("GadgetError", DefaultDuplicate) + .WithSchema("GadgetError1", duplicate) + .WithOperation("v2.gadget.get", path: "/api/gadget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("GadgetInfo")) + .Response(400, "application/json", schema => schema.AnyOf( + static branch => branch.Ref("GadgetError1"), + static branch => branch.Ref("GadgetError")))); + + private static void DefaultDuplicate(SchemaBuilder schema) => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("GadgetError"), required: true) + .Property("message", property => property.Type("string"), required: true); + + private static GenerationCuration GadgetCuration(params SchemaAlias[] aliases) => + Curation( + Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: null, handleParameter: null)), + schemaAliases: aliases); + private static async Task<(SpecDocument Document, OperationSelection Selection, GenerationCuration Curation)> LoadPinnedInputsAsync() { var fileSystem = new RealFileSystem(); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/EnvelopeDtoEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/EnvelopeDtoEmitterTests.cs new file mode 100644 index 00000000..77f0d563 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/EnvelopeDtoEmitterTests.cs @@ -0,0 +1,15 @@ +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; + +public sealed class EnvelopeDtoEmitterTests +{ + [Test] + public async Task Emit_Should_Render_The_Envelope_Dtos() + { + var sources = EnvelopeDtoEmitter.Emit(EmitterPlanFixture.CreateClientPlans()); + + await Verify(EmitterSnapshot.Create(sources)); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs index 0fa518e8..17e45879 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs @@ -21,4 +21,15 @@ public async Task Emit_Should_Route_Optional_Nonnull_Collections_Through_The_Nul await Assert.That(source).Contains("OptionalCollectionInput.Normalize"); await Assert.That(source).DoesNotContain("value is null"); } + + [Test] + public async Task Emit_Should_Reject_Wire_Null_Only_Where_The_Schema_Forbids_It() + { + var source = EmitterSnapshot.Create(ModelEmitter.Emit(EmitterPlanFixture.CreateModelSnapshot())); + + 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); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/QueryRequestEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/QueryRequestEmitterTests.cs new file mode 100644 index 00000000..f969a26f --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/QueryRequestEmitterTests.cs @@ -0,0 +1,15 @@ +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; + +public sealed class QueryRequestEmitterTests +{ + [Test] + public async Task Emit_Should_Render_The_Query_Request_Records() + { + var sources = QueryRequestEmitter.Emit(EmitterPlanFixture.CreateClientPlans()); + + await Verify(EmitterSnapshot.Create(sources)); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs index 3d705aa1..4b774ece 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs @@ -12,4 +12,15 @@ public async Task Emit_Should_Produce_Tolerant_Marked_Union_Converters() await Verify(EmitterSnapshot.Create(sources)); } + + [Test] + public async Task Emit_Should_Guard_Carrier_Converters_Against_Null_And_Foreign_Fixed_Markers() + { + var source = EmitterSnapshot.Create(UnionEmitter.Emit(EmitterPlanFixture.CreateUnionSnapshot())); + + var carrier = source[source.IndexOf("class UnknownExamplePhaseJsonConverter", StringComparison.Ordinal)..]; + await Assert.That(carrier).Contains("HandleNull => true"); + await Assert.That(carrier).Contains("The ExamplePhase payload cannot be null."); + await Assert.That(carrier).Contains("must be 'phase'"); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs index 93263a47..4b683c5b 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs @@ -6,8 +6,8 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; /// /// The structural gate every future spec refresh runs through: representative landmarks at -/// the known lossy seams, no count assertions. A red landmark is evidence about the wire — -/// stop and classify per the deviation protocol, never patch the assertion. +/// the known lossy seams, no count assertions. A red landmark is evidence about the wire, +/// never a reason to patch the assertion. /// public sealed class PinnedSpecSmokeTests { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs index cc4abd83..107b9b15 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs @@ -16,11 +16,7 @@ public async Task WriteAsync_Should_Refuse_Unsafe_Source_Paths() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var exception = await Assert.That(async () => await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("../escape.cs", "unsafe")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("../escape.cs", "unsafe")]), CancellationToken.None)) .Throws(); @@ -37,11 +33,7 @@ public async Task WriteAsync_Should_Refuse_Unsafe_Manifest_Paths() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var exception = await Assert.That(async () => await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [], - partialMarkerContent: null, - verify: false, + Request([]), CancellationToken.None)) .Throws(); @@ -60,11 +52,7 @@ public async Task WriteAsync_Should_Refuse_Symbolic_Links_In_Owned_Paths() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var exception = await Assert.That(async () => await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/Escape.cs", "unsafe")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/Escape.cs", "unsafe")]), CancellationToken.None)) .Throws(); @@ -83,11 +71,7 @@ public async Task WriteAsync_Should_Refuse_Case_Only_Owned_Path_Changes() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var exception = await Assert.That(async () => await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/widget.cs", "new")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/widget.cs", "new")]), CancellationToken.None)) .Throws(); @@ -108,11 +92,7 @@ public async Task WriteAsync_Should_Delete_Only_Stale_Manifest_Files() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var result = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/Fresh.cs", "fresh")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/Fresh.cs", "fresh")]), CancellationToken.None); await Assert.That(fileSystem.File.Exists(stalePath)).IsFalse(); @@ -138,20 +118,8 @@ await currentFileSystem.File.WriteAllTextAsync( var writer = new GenerationWriter(fileSystem, formatter); var sources = new[] { GenerationTestData.Source(relativePath, "public record Example;\n"), }; - _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - sources, - partialMarkerContent: null, - verify: false, - CancellationToken.None); - var verify = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - sources, - partialMarkerContent: null, - verify: true, - CancellationToken.None); + _ = await writer.WriteAsync(Request(sources), CancellationToken.None); + var verify = await writer.WriteAsync(Request(sources, verify: true), CancellationToken.None); await Assert.That(formatter.CallCount).IsEqualTo(2); await Assert.That(formatter.SourcePaths).Contains(relativePath); @@ -165,23 +133,11 @@ public async Task WriteAsync_Should_Report_And_Repair_Verify_Drift() var fileSystem = GenerationTestData.CreateFileSystem(); var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var sources = new[] { GenerationTestData.Source(relativePath, "expected\n"), }; - _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - sources, - partialMarkerContent: null, - verify: false, - CancellationToken.None); + _ = await writer.WriteAsync(Request(sources), CancellationToken.None); var outputPath = fileSystem.Path.Combine(GenerationTestData.OutputRoot, relativePath); await fileSystem.File.WriteAllTextAsync(outputPath, GenerationTestData.OwnedContent("drift\n"), CancellationToken.None); - var result = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - sources, - partialMarkerContent: null, - verify: true, - CancellationToken.None); + var result = await writer.WriteAsync(Request(sources, verify: true), CancellationToken.None); await Assert.That(result.HasChanges).IsTrue(); await Assert.That(result.ChangedPaths).Contains(relativePath); @@ -196,20 +152,138 @@ public async Task WriteAsync_Should_Admit_Root_And_Adapter_Sources() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var result = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, + Request( [ GenerationTestData.Source("ExampleClient.cs", "client"), GenerationTestData.Source("Internal/ResponseAdapters/ExampleAdapter.cs", "adapter"), - ], - partialMarkerContent: null, - verify: false, + ]), CancellationToken.None); await Assert.That(result.CreatedPaths).Contains("ExampleClient.cs"); await Assert.That(result.CreatedPaths).Contains("Internal/ResponseAdapters/ExampleAdapter.cs"); } + [Test] + public async Task WriteAsync_Should_Refuse_A_Family_Folder_Shadowing_By_Case() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert + .That(async () => await writer.WriteAsync(Request([], familyFolders: ["models"]), CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("shadows"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_A_Family_Folder_Shadowing_The_Pagination_Spine() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert + .That(async () => await writer.WriteAsync(Request([], familyFolders: ["Pagination"]), CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("shadows"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_Family_Folders_Duplicated_By_Case() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert + .That(async () => await writer.WriteAsync( + Request([], familyFolders: ["Sessions", "sessions"]), + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("duplicated"); + } + + [Test] + public async Task WriteAsync_Should_Admit_Family_Folder_Sources() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var result = await writer.WriteAsync( + Request( + [GenerationTestData.Source("Sessions/SessionsClient.cs", "client")], + familyFolders: ["Sessions"]), + CancellationToken.None); + + await Assert.That(result.CreatedPaths).Contains("Sessions/SessionsClient.cs"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_A_Source_Outside_The_Admitted_Family_Folders() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + Request( + [GenerationTestData.Source("Widgets/WidgetsClient.cs", "client")], + familyFolders: ["Sessions"]), + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("outside the owned source directories"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_A_Nested_Family_Folder_Source() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + Request( + [GenerationTestData.Source("Sessions/Deep/File.cs", "nested")], + familyFolders: ["Sessions"]), + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("outside the owned source directories"); + } + + [Test] + public async Task WriteAsync_Should_Delete_Stale_Sources_Of_A_Retired_Family_Folder() + { + const string stalePath = "src/OpenCode.Sdk/Widgets/Old.cs"; + var fileSystem = GenerationTestData.CreateFileSystem(); + var manifest = JsonSerializer.Serialize(new GenerationManifest { Files = ["Widgets/Old.cs"], }); + fileSystem.Initialize().With( + new FileDescription(GenerationTestData.ManifestPath, manifest), + new FileDescription(stalePath, GenerationTestData.OwnedContent("stale"))); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var result = await writer.WriteAsync( + Request([], familyFolders: ["Sessions"]), + CancellationToken.None); + + await Assert.That(fileSystem.File.Exists(stalePath)).IsFalse(); + await Assert.That(result.DeletedPaths).Contains("Widgets/Old.cs"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_A_Family_Folder_Shadowing_A_Static_Owned_Directory() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + Request([], familyFolders: ["Models"]), + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("Models"); + } + [Test] public async Task WriteAsync_Should_Refuse_Overwriting_An_Unmanifested_File() { @@ -219,11 +293,7 @@ public async Task WriteAsync_Should_Refuse_Overwriting_An_Unmanifested_File() var exception = await Assert .That(async () => _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("ExampleClient.cs", "generated")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("ExampleClient.cs", "generated")]), CancellationToken.None)) .Throws(); @@ -244,11 +314,7 @@ public async Task WriteAsync_Should_Refuse_Overwriting_A_Manifest_Entry_Without_ var exception = await Assert .That(async () => _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/Example.cs", "generated")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/Example.cs", "generated")]), CancellationToken.None)) .Throws(); @@ -267,11 +333,7 @@ public async Task WriteAsync_Should_Refuse_Deleting_A_Manifest_Entry_Without_Pro var exception = await Assert .That(async () => _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/Fresh.cs", "generated")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/Fresh.cs", "generated")]), CancellationToken.None)) .Throws(); @@ -292,11 +354,7 @@ public async Task WriteAsync_Should_Refuse_Headerless_Generated_Sources() var exception = await Assert .That(async () => _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [headerless], - partialMarkerContent: null, - verify: false, + Request([headerless]), CancellationToken.None)) .Throws(); @@ -310,21 +368,24 @@ public async Task WriteAsync_Should_Create_And_Remove_The_Partial_Marker() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [], - "Pending operations: 3.\n", - verify: false, - CancellationToken.None); - var removal = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [], - partialMarkerContent: null, - verify: false, + Request([], partialMarkerContent: "Pending operations: 3.\n"), CancellationToken.None); + var removal = await writer.WriteAsync(Request([]), CancellationToken.None); await Assert.That(fileSystem.File.Exists(GenerationTestData.MarkerPath)).IsFalse(); await Assert.That(removal.DeletedPaths).Contains(".generation-incomplete"); } + + /// Assembles one write request over the shared test roots; flags default off. + private static GenerationWriteRequest Request(IReadOnlyList sources, + IReadOnlyList? familyFolders = null, string? partialMarkerContent = null, bool verify = false) => + new() + { + OutputRoot = GenerationTestData.OutputRoot, + ProjectPath = GenerationTestData.ProjectPath, + Sources = sources, + FamilyFolders = familyFolders ?? [], + PartialMarkerContent = partialMarkerContent, + Verify = verify, + }; } 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 2029cfe9..208fb047 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 @@ -7,6 +7,8 @@ using System.Threading; using System.Threading.Tasks; using OpenCode.Sdk.Internal; using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -19,30 +21,21 @@ public class OpenCodeClient : IDisposable /// /// Initializes a client that owns its connection to the endpoint. /// - /// The absolute HTTP or HTTPS server endpoint. - public OpenCodeClient(Uri endpoint) : this(endpoint, options: null) + /// The client options; the endpoint is required. + public OpenCodeClient(OpenCodeClientOptions options) { - } - - /// - /// Initializes a client that owns its connection to the endpoint. - /// - /// The absolute HTTP or HTTPS server endpoint. - /// The client options; its endpoint must stay unset on this path. - public OpenCodeClient(Uri endpoint, OpenCodeClientOptions? options) - { - _pipeline = Pipeline.Create(endpoint, options, new SystemEnvironmentProvider()); + _pipeline = Pipeline.Create(options); _widgets = new WidgetsClient(_pipeline); } /// - /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. + /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. With a password set, the SDK's per-request Authorization header overrides any client default; in anonymous mode a default Authorization header is refused at construction and before every send. /// /// The caller-owned HTTP client. - /// The client options; the endpoint is required on this path. + /// The client options; the endpoint is required. public OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) { - _pipeline = Pipeline.Create(httpClient, options, new SystemEnvironmentProvider()); + _pipeline = Pipeline.Create(httpClient, options); _widgets = new WidgetsClient(_pipeline); } @@ -83,18 +76,18 @@ public class OpenCodeClient : IDisposable /// /// Check example ping /// - /// The per-call options. + /// The per-call options. /// The cancellation token. /// The 'PingResponse' 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 GetPingAsync(OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) + public virtual Task GetPingAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Ping.Get, PingResponseAdapter.Instance, options, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Ping.Get, PingResponseAdapter.Instance, requestOptions, cancellationToken); } } -// File: WidgetClient.cs +// File: Widgets/WidgetClient.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; @@ -103,6 +96,8 @@ using System.Threading; using System.Threading.Tasks; using OpenCode.Sdk.Internal; using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -115,7 +110,12 @@ public class WidgetClient internal WidgetClient(Pipeline pipeline, string widgetId) { ArgumentNullException.ThrowIfNull(pipeline); - ArgumentException.ThrowIfNullOrEmpty(widgetId); + ArgumentException.ThrowIfNullOrWhiteSpace(widgetId); + if (widgetId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(widgetId)); + } + _pipeline = pipeline; _widgetId = widgetId; } @@ -134,19 +134,33 @@ public class WidgetClient /// Get one widget item /// /// The 'itemID' route value. - /// The per-call options. + /// The per-call options. /// The cancellation token. /// The 'WidgetItemResponse' envelope. /// The API returned an error status (declared: 400, 404) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task GetItemAsync(string itemId, OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) + public virtual Task GetItemAsync(string itemId, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(itemId); - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetItem(WidgetId, itemId), WidgetItemResponseAdapter.Instance, options, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetItem(WidgetId, itemId), WidgetItemResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List the widget items + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'WidgetItemListResponse' envelope. + /// The API returned an error status (declared: 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListItemsAsync(ItemListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListItems(WidgetId, request), WidgetItemListResponseAdapter.Instance, requestOptions, cancellationToken); } } -// File: WidgetsClient.cs +// File: Widgets/WidgetsClient.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; @@ -155,6 +169,8 @@ using System.Threading; using System.Threading.Tasks; using OpenCode.Sdk.Internal; using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -183,7 +199,12 @@ public class WidgetsClient /// The bound 'WidgetClient'. public virtual WidgetClient GetWidgetClient(string widgetId) { - ArgumentException.ThrowIfNullOrEmpty(widgetId); + ArgumentException.ThrowIfNullOrWhiteSpace(widgetId); + if (widgetId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(widgetId)); + } + return new WidgetClient(Pipeline, widgetId); } @@ -192,13 +213,41 @@ public class WidgetsClient /// /// List the widget overview /// - /// The per-call options. + /// The per-call options. /// The cancellation token. /// The 'WidgetOverviewResponse' 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 GetOverviewAsync(OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) + public virtual Task GetOverviewAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetOverview, WidgetOverviewResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List the widgets + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'WidgetListResponse' envelope. + /// The API returned an error status (declared: 400, 500) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListWidgetsAsync(WidgetListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListWidgets(request), WidgetListResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Create one widget + /// + /// The request body; an empty body is sent when omitted. + /// The per-call options. + /// The cancellation token. + /// The 'WidgetCreateResponse' 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 CreateWidgetAsync(WidgetCreateRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetOverview, WidgetOverviewResponseAdapter.Instance, options, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Widgets.CreateWidget, request ?? new WidgetCreateRequest(), OpenCodeJsonContext.Default.WidgetCreateRequest, WidgetCreateResponseAdapter.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 new file mode 100644 index 00000000..7f0ad3e9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt @@ -0,0 +1,71 @@ +// File: Internal/Serialization/WidgetCreateResponseEnvelope.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 'WidgetCreateResponse' through one deserialization pass. +/// +internal sealed record WidgetCreateResponseEnvelope +{ + [JsonPropertyName("data")] + public required ExampleItem Data { get; init; } +} + +// File: Internal/Serialization/WidgetItemListResponseEnvelope.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 'WidgetItemListResponse' through one deserialization pass. +/// +internal sealed record WidgetItemListResponseEnvelope +{ + [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("cursor")] + public required ListCursor Cursor { get; init; } +} + +// File: Internal/Serialization/WidgetItemResponseEnvelope.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 'WidgetItemResponse' through one deserialization pass. +/// +internal sealed record WidgetItemResponseEnvelope +{ + [JsonPropertyName("data")] + public required ExampleItem Data { get; init; } +} + +// File: Internal/Serialization/WidgetListResponseEnvelope.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 'WidgetListResponse' through one deserialization pass. +/// +internal sealed record WidgetListResponseEnvelope +{ + [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("cursor")] + public required ListCursor Cursor { 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 c816a12c..1d14b695 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 @@ -1,4 +1,4 @@ -// File: PingResponse.cs +// File: Ping/PingResponse.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; @@ -60,7 +60,138 @@ public sealed record PingResponse : OpenCodeResponse } } -// File: WidgetItemResponse.cs +// File: Widgets/WidgetCreateResponse.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 'POST /api/widget' operation. +/// +public sealed record WidgetCreateResponse : OpenCodeResponse +{ + private readonly ExampleItem? _widget; + /// + /// Initializes a success instance of the 'WidgetCreateResponse' envelope. + /// + public WidgetCreateResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetCreateResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Widget = null!; + } + + /// + /// Gets the Widget payload; guarded on the error path. + /// + public required ExampleItem Widget { get => _widget ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Widget."); init => _widget = 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 (_widget is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Widget = ").Append(_widget); + return true; + } +} + +// File: Widgets/WidgetItemListResponse.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/{widgetID}/item' operation. +/// +public sealed record WidgetItemListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _items; + private readonly ListCursor? _cursor; + /// + /// Initializes a success instance of the 'WidgetItemListResponse' envelope. + /// + public WidgetItemListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetItemListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Items = null!; + Cursor = null!; + } + + /// + /// Gets the Items payload; guarded on the error path. + /// + public required IReadOnlyList Items { get => _items ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Items."); init => _items = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } + /// + /// Gets the page cursor; guarded on the error path. + /// + public required ListCursor Cursor { get => _cursor ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Cursor."); init => _cursor = 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 (_items is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Items = ").Append(_items); + return true; + } +} + +// File: Widgets/WidgetItemResponse.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; @@ -122,7 +253,76 @@ public sealed record WidgetItemResponse : OpenCodeResponse } } -// File: WidgetOverviewResponse.cs +// File: Widgets/WidgetListResponse.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' operation. +/// +public sealed record WidgetListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _widgets; + private readonly ListCursor? _cursor; + /// + /// Initializes a success instance of the 'WidgetListResponse' envelope. + /// + public WidgetListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Widgets = null!; + Cursor = null!; + } + + /// + /// Gets the Widgets payload; guarded on the error path. + /// + public required IReadOnlyList Widgets { get => _widgets ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Widgets."); init => _widgets = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } + /// + /// Gets the page cursor; guarded on the error path. + /// + public required ListCursor Cursor { get => _cursor ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Cursor."); init => _cursor = 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 (_widgets is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Widgets = ").Append(_widgets); + return true; + } +} + +// File: Widgets/WidgetOverviewResponse.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; 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 49eeb69e..f8a8c691 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 @@ -49,8 +49,24 @@ public sealed record ExampleItem /// [JsonPropertyName("note")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Note { get; init; } + /// + /// Gets the item count. + /// + [JsonPropertyName("count")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] + public double? Count { get; init; } + + /// + /// Gets the flush timestamp, or null when never flushed. + /// + [JsonPropertyName("flushedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? FlushedAt { get; init; } + /// /// Gets the item tags. /// @@ -68,12 +84,13 @@ public sealed record ExampleItem // 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 example mode values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum ExampleMode { /// 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 new file mode 100644 index 00000000..b962e6df --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt @@ -0,0 +1,37 @@ +// File: Widgets/ItemListRequest.cs +// 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/widget/{widgetID}/item' query. +/// +public sealed record ItemListRequest : ListRequest +{ +} + +// File: Widgets/WidgetListRequest.cs +// 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/widget' query. +/// +public sealed record WidgetListRequest +{ + /// + /// Gets the 'limit' query value; the server default applies when unset. + /// + public int? Limit { get; init; } + /// + /// Gets the 'order' query value; the server default applies when unset. + /// + public ListOrder? Order { get; init; } + /// + /// Gets the 'cursor' query value; the server default applies when unset. + /// + public string? Cursor { get; init; } + /// + /// Gets the 'parentID' query value; the server default applies when unset. + /// + public SessionParentFilter? ParentId { 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 a60d367f..79b2b032 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 @@ -15,6 +15,11 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(OpenCodeError))] [JsonSerializable(typeof(UnknownExampleEvent))] [JsonSerializable(typeof(UnknownOpenCodeError))] +[JsonSerializable(typeof(WidgetCreateRequest))] +[JsonSerializable(typeof(WidgetCreateResponseEnvelope))] +[JsonSerializable(typeof(WidgetItemListResponseEnvelope))] +[JsonSerializable(typeof(WidgetItemResponseEnvelope))] +[JsonSerializable(typeof(WidgetListResponseEnvelope))] 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 59583d65..3d464fcf 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 @@ -39,6 +39,91 @@ internal sealed class PingResponseAdapter : ResponseAdapter } } +// File: Internal/ResponseAdapters/WidgetCreateResponseAdapter.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 'POST /api/widget' responses onto 'WidgetCreateResponse'. +/// +internal sealed class WidgetCreateResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["BadRequestError"]; + private WidgetCreateResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetCreateResponseAdapter Instance { get; } = new WidgetCreateResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetCreateResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => new WidgetCreateResponse + { + Status = status, + Widget = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetCreateResponseEnvelope).Data + }, + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new WidgetCreateResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + _ => new WidgetCreateResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } +} + +// File: Internal/ResponseAdapters/WidgetItemListResponseAdapter.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/{widgetID}/item' responses onto 'WidgetItemListResponse'. +/// +internal sealed class WidgetItemListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status404Tags = ["MissingError"]; + private WidgetItemListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetItemListResponseAdapter Instance { get; } = new WidgetItemListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetItemListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetItemListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 404 => new WidgetItemListResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new WidgetItemListResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } + + private static WidgetItemListResponse CreateSuccess(int status, WidgetItemListResponseEnvelope envelope) => new WidgetItemListResponse + { + Status = status, + Items = envelope.Data, + Cursor = envelope.Cursor + }; +} + // File: Internal/ResponseAdapters/WidgetItemResponseAdapter.cs // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. @@ -73,7 +158,7 @@ internal sealed class WidgetItemResponseAdapter : ResponseAdapter new WidgetItemResponse { Status = status, - Item = ReadDataPayload(rawBody, OpenCodeJsonContext.Default.ExampleItem) + Item = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetItemResponseEnvelope).Data }, >= 200 and < 300 => throw UndeclaredSuccessFailure(status), 400 => new WidgetItemResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), @@ -82,6 +167,52 @@ internal sealed class WidgetItemResponseAdapter : ResponseAdapter +/// Adapts the 'GET /api/widget' responses onto 'WidgetListResponse'. +/// +internal sealed class WidgetListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["BadRequestError"]; + private static readonly string[] Status500Tags = ["BadRequestError"]; + private WidgetListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetListResponseAdapter Instance { get; } = new WidgetListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new WidgetListResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 500 => new WidgetListResponse(status, ReadTolerantError(rawBody, Status500Tags), rawBody), + _ => new WidgetListResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } + + private static WidgetListResponse CreateSuccess(int status, WidgetListResponseEnvelope envelope) => new WidgetListResponse + { + Status = status, + Widgets = envelope.Data, + Cursor = envelope.Cursor + }; +} + // File: Internal/ResponseAdapters/WidgetOverviewResponseAdapter.cs // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. 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 ec1dd9f4..6245a73e 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 @@ -2,6 +2,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 System; +using OpenCode.Sdk.Internal; namespace OpenCode.Sdk; /// @@ -25,6 +26,10 @@ public static class OpenCodeRoutes /// public static class Widgets { + /// + /// The 'POST /api/widget' route. + /// + public const string CreateWidget = "/api/widget"; /// /// The 'GET /api/widget/{widgetID}/item/{itemID}' route template. /// @@ -56,5 +61,60 @@ public static class OpenCodeRoutes /// The 'GET /api/widget-overview' route. /// public const string GetOverview = "/api/widget-overview"; + /// + /// The 'GET /api/widget/{widgetID}/item' route template. + /// + public const string ListItemsTemplate = "/api/widget/{widgetID}/item"; + /// + /// Builds the '/api/widget/{widgetID}/item' route. + /// + /// The 'widgetID' route value. + /// The request shaping the query. + /// The escaped route. + public static string ListItems(string widgetId, ItemListRequest? request = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(widgetId); + if (widgetId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(widgetId)); + } + + var path = "/api/widget/" + Uri.EscapeDataString(widgetId) + "/item"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddCount("limit", request.Limit, nameof(request)); + query.AddOrder("order", request.Order); + query.AddText("cursor", request.Cursor); + return path + query.Value; + } + + /// + /// The 'GET /api/widget' route template. + /// + public const string ListWidgetsTemplate = "/api/widget"; + /// + /// Builds the '/api/widget' route. + /// + /// The request shaping the query. + /// The escaped route. + public static string ListWidgets(WidgetListRequest? request = null) + { + var path = "/api/widget"; + if (request is null) + { + return path; + } + + 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); + return path + query.Value; + } } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt index 41e32010..ee9baa32 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt @@ -24,11 +24,13 @@ public abstract record ExampleEvent using System; using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown example event payload. /// +[JsonConverter(typeof(UnknownExampleEventJsonConverter))] public sealed record UnknownExampleEvent : ExampleEvent { private readonly string _marker; @@ -39,6 +41,11 @@ public sealed record UnknownExampleEvent : ExampleEvent public UnknownExampleEvent(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } @@ -114,3 +121,224 @@ internal sealed class ExampleEventJsonConverter : JsonConverter JsonSerializer.Serialize(writer, value, typeInfo); } } + +// File: Internal/Serialization/UnknownExampleEventJsonConverter.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.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +internal sealed class UnknownExampleEventJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownExampleEvent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + throw new JsonException("The ExampleEvent payload cannot be null."); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + throw new JsonException("The ExampleEvent payload must be a JSON object."); + if (!payload.TryGetProperty("type", out var markerElement)) + throw new JsonException("The ExampleEvent payload must contain 'type'."); + if (markerElement.ValueKind != JsonValueKind.String) + throw new JsonException("The 'type' marker must be a string."); + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + throw new JsonException("The 'type' marker must be a non-empty string."); + return new UnknownExampleEvent(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownExampleEvent value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} + +// File: Models/ExamplePhase.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; +/// +/// Represents an example nested phase union. +/// +[JsonConverter(typeof(ExamplePhaseJsonConverter))] +public abstract record ExamplePhase : ExampleEvent +{ + /// + /// Gets the 'status' union marker. + /// + [JsonPropertyName("status")] + public abstract string Status { get; } +} + +// File: Models/UnknownExamplePhase.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.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown example phase payload. +/// +[JsonConverter(typeof(UnknownExamplePhaseJsonConverter))] +public sealed record UnknownExamplePhase : ExamplePhase +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownExamplePhase(string status, JsonElement payload) + { + ArgumentException.ThrowIfNullOrEmpty(status); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + + _marker = status; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized 'status' marker. + /// + [JsonPropertyName("status")] + public override string Status => _marker; + + /// + /// Gets the fixed 'type' marker of this nested union. + /// + [JsonPropertyName("type")] + public override string Type => "phase"; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} + +// File: Internal/Serialization/ExamplePhaseJsonConverter.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.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +internal sealed class ExamplePhaseJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new Dictionary(StringComparer.Ordinal) + { + ["started"] = typeof(PhaseStartedEvent) + }; + public override bool HandleNull => true; + + public override ExamplePhase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + throw new JsonException("The ExamplePhase payload cannot be null."); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + throw new JsonException("The ExamplePhase payload must be a JSON object."); + if (!payload.TryGetProperty("type", out var fixedElement)) + throw new JsonException("The ExamplePhase payload must contain 'type'."); + if (fixedElement.ValueKind != JsonValueKind.String || !fixedElement.ValueEquals("phase")) + throw new JsonException("The 'type' marker must be 'phase'."); + if (!payload.TryGetProperty("status", out var markerElement)) + throw new JsonException("The ExamplePhase payload must contain 'status'."); + if (markerElement.ValueKind != JsonValueKind.String) + throw new JsonException("The 'status' marker must be a string."); + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + throw new JsonException("The 'status' marker must be a non-empty string."); + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for ExamplePhase."); + return JsonSerializer.Deserialize(payload, typeInfo) as ExamplePhase ?? throw new JsonException("The ExamplePhase payload deserialized to null."); + } + + return new UnknownExamplePhase(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, ExamplePhase value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + if (value is UnknownExamplePhase unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for ExamplePhase."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} + +// File: Internal/Serialization/UnknownExamplePhaseJsonConverter.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.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +internal sealed class UnknownExamplePhaseJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownExamplePhase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + throw new JsonException("The ExamplePhase payload cannot be null."); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + throw new JsonException("The ExamplePhase payload must be a JSON object."); + if (!payload.TryGetProperty("type", out var fixedElement)) + throw new JsonException("The ExamplePhase payload must contain 'type'."); + if (fixedElement.ValueKind != JsonValueKind.String || !fixedElement.ValueEquals("phase")) + throw new JsonException("The 'type' marker must be 'phase'."); + if (!payload.TryGetProperty("status", out var markerElement)) + throw new JsonException("The ExamplePhase payload must contain 'status'."); + if (markerElement.ValueKind != JsonValueKind.String) + throw new JsonException("The 'status' marker must be a string."); + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + throw new JsonException("The 'status' marker must be a non-empty string."); + return new UnknownExamplePhase(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownExamplePhase value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs index d800cbc2..d317d703 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs @@ -13,12 +13,22 @@ public static OperationSelection Selection(params string[] operationIds) => public static GenerationCuration Curation( IReadOnlyDictionary groups, IReadOnlyDictionary? envelopePayloadNames = null, - IReadOnlyList? propertyOverrides = null) => + IReadOnlyList? propertyOverrides = null, + IReadOnlyList? schemaAliases = null) => new() { Groups = groups, EnvelopePayloadNames = envelopePayloadNames ?? new Dictionary(StringComparer.Ordinal), PropertyOverrides = propertyOverrides ?? [], + SchemaAliases = schemaAliases ?? [], + }; + + public static SchemaAlias Alias(string schema, string aliasOf, string reason = "The upstream spec emits a duplicate component.") => + new() + { + Schema = schema, + AliasOf = aliasOf, + Reason = reason, }; public static Dictionary Groups(string wireName, GroupCuration group) => diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs index c5ee5ed4..e3b03d19 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs @@ -15,6 +15,7 @@ public BindingTestHost() _binder = new SpecBinder( new ReachableSchemaCollector(), new CurationValidator(), + new SchemaAliasApplier(), new SchemaNameResolver(), new SchemaPlanBinder(), new OperationPlanBinder()); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 8b63dd0e..77b18ea3 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -14,6 +14,7 @@ public static EmitPlan Create() CreateVariant("CreatedEvent", "created", "item", "Item", Named("ExampleItem")), CreateVariant("DeletedEvent", "deleted", "id", "ID", Named("string")), CreateError(), + CreateWidgetCreateRequest(), }; var unions = new[] { @@ -40,6 +41,11 @@ public static EmitPlan Create() "OpenCodeError", "UnknownExampleEvent", "UnknownOpenCodeError", + "WidgetCreateRequest", + "WidgetCreateResponseEnvelope", + "WidgetItemListResponseEnvelope", + "WidgetItemResponseEnvelope", + "WidgetListResponseEnvelope", ], }, PendingOperations = [], @@ -68,15 +74,17 @@ public static IReadOnlyList CreateClientPlans() => Name = "WidgetClient", Namespace = "OpenCode.Sdk", Role = ClientRole.Handle, + ContainerName = "Widgets", SubClients = [], HandleParameter = CreateWidgetParameter(), - Operations = [CreateItemOperation()], + Operations = [CreateItemOperation(), CreateItemListOperation()], }, new ClientPlan { Name = "WidgetsClient", Namespace = "OpenCode.Sdk", Role = ClientRole.Collection, + ContainerName = "Widgets", SubClients = [], HandleFactory = new HandleFactoryPlan { @@ -84,7 +92,7 @@ public static IReadOnlyList CreateClientPlans() => HandleTypeName = "WidgetClient", Parameter = CreateWidgetParameter(), }, - Operations = [CreateOverviewOperation()], + Operations = [CreateOverviewOperation(), CreateWidgetListOperation(), CreateWidgetCreateOperation()], }, ]; @@ -103,7 +111,7 @@ private static OperationPlan CreatePingOperation() => AdapterTypeName = "PingResponseAdapter", PayloadName = "Ping", PayloadTypeName = "ExampleItem", - HasDataEnvelope = false, + Kind = EnvelopeKind.Bare, }, ErrorMap = new ErrorMapPlan { @@ -135,7 +143,7 @@ private static OperationPlan CreateOverviewOperation() => AdapterTypeName = "WidgetOverviewResponseAdapter", PayloadName = "Overview", PayloadTypeName = "ExampleItem", - HasDataEnvelope = false, + Kind = EnvelopeKind.Bare, }, ErrorMap = new ErrorMapPlan { @@ -170,7 +178,8 @@ private static OperationPlan CreateItemOperation() => AdapterTypeName = "WidgetItemResponseAdapter", PayloadName = "Item", PayloadTypeName = "ExampleItem", - HasDataEnvelope = true, + Kind = EnvelopeKind.Data, + EnvelopeDtoTypeName = "WidgetItemResponseEnvelope", }, ErrorMap = new ErrorMapPlan { @@ -205,6 +214,164 @@ private static OperationParameterPlan CreateWidgetParameter() => IsHandleParameter = true, }; + /// A flat query-request list operation covering every query value kind, a cursor envelope, and a 5xx arm. + private static OperationPlan CreateWidgetListOperation() => + new() + { + MethodName = "ListWidgetsAsync", + HttpMethod = "get", + RouteTemplate = "/api/widget", + RouteContainerName = "Widgets", + RouteMemberName = "ListWidgets", + Parameters = [], + QueryRequest = new QueryRequestPlan + { + TypeName = "WidgetListRequest", + DerivesFromListRequest = false, + Properties = + [ + QueryProperty("limit", "Limit", QueryValueKind.PositiveCount), + QueryProperty("order", "Order", QueryValueKind.ListOrder), + QueryProperty("cursor", "Cursor", QueryValueKind.Text), + QueryProperty("parentID", "ParentId", QueryValueKind.SessionParentFilter), + ], + }, + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetListResponse", + AdapterTypeName = "WidgetListResponseAdapter", + PayloadName = "Widgets", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.CursorList, + EnvelopeDtoTypeName = "WidgetListResponseEnvelope", + }, + ErrorMap = new ErrorMapPlan + { + Statuses = + [ + new ErrorStatusPlan + { + StatusCode = 400, + Tags = [new ErrorTagPlan { Tag = "BadRequestError", TypeName = "BadRequestError", }], + }, + new ErrorStatusPlan + { + StatusCode = 500, + Tags = [new ErrorTagPlan { Tag = "BadRequestError", TypeName = "BadRequestError", }], + }, + ], + }, + Summary = "List the widgets", + Description = null, + }; + + /// A handle-scoped list whose query request derives from the ListRequest base. + private static OperationPlan CreateItemListOperation() => + new() + { + MethodName = "ListItemsAsync", + HttpMethod = "get", + RouteTemplate = "/api/widget/{widgetID}/item", + RouteContainerName = "Widgets", + RouteMemberName = "ListItems", + Parameters = [CreateWidgetParameter()], + QueryRequest = new QueryRequestPlan + { + TypeName = "ItemListRequest", + DerivesFromListRequest = true, + Properties = + [ + QueryProperty("limit", "Limit", QueryValueKind.PositiveCount, isInherited: true), + QueryProperty("order", "Order", QueryValueKind.ListOrder, isInherited: true), + QueryProperty("cursor", "Cursor", QueryValueKind.Text, isInherited: true), + ], + }, + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetItemListResponse", + AdapterTypeName = "WidgetItemListResponseAdapter", + PayloadName = "Items", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.CursorList, + EnvelopeDtoTypeName = "WidgetItemListResponseEnvelope", + }, + ErrorMap = new ErrorMapPlan + { + Statuses = + [ + new ErrorStatusPlan + { + StatusCode = 404, + Tags = [new ErrorTagPlan { Tag = "MissingError", TypeName = "MissingError", }], + }, + ], + }, + Summary = "List the widget items", + Description = null, + }; + + /// A create operation with an optional all-optional request body. + private static OperationPlan CreateWidgetCreateOperation() => + new() + { + MethodName = "CreateWidgetAsync", + HttpMethod = "post", + RouteTemplate = "/api/widget", + RouteContainerName = "Widgets", + RouteMemberName = "CreateWidget", + Parameters = [], + RequestBody = new RequestBodyPlan + { + TypeName = "WidgetCreateRequest", + ParameterName = "request", + IsOptional = true, + }, + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetCreateResponse", + AdapterTypeName = "WidgetCreateResponseAdapter", + PayloadName = "Widget", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.Data, + EnvelopeDtoTypeName = "WidgetCreateResponseEnvelope", + }, + ErrorMap = new ErrorMapPlan + { + Statuses = + [ + new ErrorStatusPlan + { + StatusCode = 400, + Tags = [new ErrorTagPlan { Tag = "BadRequestError", TypeName = "BadRequestError", }], + }, + ], + }, + Summary = "Create one widget", + Description = null, + }; + + private static QueryPropertyPlan QueryProperty(string wireName, string propertyName, QueryValueKind kind, + bool isInherited = false) => + new() + { + WireName = wireName, + PropertyName = propertyName, + Kind = kind, + IsInherited = isInherited, + }; + + private static ObjectModelPlan CreateWidgetCreateRequest() => + new() + { + Name = "WidgetCreateRequest", + Namespace = "OpenCode.Sdk.Models", + Description = "Shapes the widget-create request body.", + Properties = + [ + Property("title", "Title", Named("string", isNullable: true), isRequired: false, "Gets the widget title."), + ], + }; + public static EmitPlan CreateModelSnapshot() { var plan = Create(); @@ -220,7 +387,29 @@ public static EmitPlan CreateModelSnapshot() }; } - public static IReadOnlyList CreateUnionSnapshot() => [CreateExampleEvent()]; + public static IReadOnlyList CreateUnionSnapshot() => [CreateExampleEvent(), CreateExamplePhase()]; + + /// A nested union whose outer tag is fixed; both converters must enforce it. + private static UnionPlan CreateExamplePhase() => + new() + { + Name = "ExamplePhase", + Namespace = "OpenCode.Sdk.Models", + UnknownTypeName = "UnknownExamplePhase", + MarkerWireName = "status", + MarkerName = "Status", + MarkerKind = LiteralKind.String, + BaseTypeName = "ExampleEvent", + FixedMarker = new UnionFixedMarkerPlan + { + WireName = "type", + Name = "Type", + Kind = LiteralKind.String, + Value = "phase", + }, + Variants = [new UnionVariantPlan { TypeName = "PhaseStartedEvent", Tag = "started", }], + Description = "Represents an example nested phase union.", + }; public static RegistryPlan CreateRegistry() => Create().Registry; @@ -234,6 +423,9 @@ private static ObjectModelPlan CreateExampleItem() => [ Property("id", "ID", Named("string"), isRequired: true, "Gets the item identifier."), Property("note", "Note", Named("string", isNullable: true), isRequired: false, description: null), + Property("count", "Count", Named("double", isNullable: true), isRequired: false, "Gets the item count."), + Property("flushedAt", "FlushedAt", Named("double", isNullable: true), isRequired: false, + "Gets the flush timestamp, or null when never flushed.", allowsWireNull: true), Property("tags", "Tags", ListOf(Named("string")), isRequired: false, "Gets the item tags."), Property("links", "Links", DictionaryOf(Named("Uri")), isRequired: false, "Gets links by relation."), ], @@ -312,13 +504,14 @@ private static UnionPlan CreateOpenCodeError() => }; private static ModelPropertyPlan Property(string wireName, string name, TypeReferencePlan type, bool isRequired, - string? description) => + string? description, bool allowsWireNull = false) => new() { WireName = wireName, Name = name, Type = type, IsRequired = isRequired, + AllowsWireNull = allowsWireNull, IsLiteral = false, Description = description, }; @@ -330,6 +523,7 @@ private static ModelPropertyPlan LiteralProperty(string wireName, string name, s Name = name, Type = Named("string"), IsRequired = true, + AllowsWireNull = false, IsLiteral = true, LiteralKind = LiteralKind.String, LiteralValue = value, diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs index a603d7ba..321752b4 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs @@ -23,7 +23,8 @@ internal static class GenerationTestData } }, "envelopePayloadNames": {}, - "propertyOverrides": [] + "propertyOverrides": [], + "schemaAliases": [] } """; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs index f5943077..7e50b580 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs @@ -41,6 +41,12 @@ public static bool IsValidIdentifier(string candidate) return false; } + // Reserved keywords would emit broken source; contextual keywords are legal identifiers. + if (Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(candidate) is not Microsoft.CodeAnalysis.CSharp.SyntaxKind.None) + { + return false; + } + return candidate.Skip(1).All(static character => char.IsLetterOrDigit(character) || character is '_'); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs index 02cde23e..cf0ca336 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -23,6 +23,7 @@ public void Validate(SpecDocument document, IReadOnlyList selecte ValidateGroups(selected, selectedGroups, documentGroups, curation, errors); ValidateEnvelopeNames(selectedIds, documentIds, curation, errors); ValidatePropertyOverrides(document, reachable, curation, errors); + ValidateSchemaAliases(document, reachable, curation, errors); } private static void ValidateGroups(IReadOnlyList selected, HashSet selectedGroups, @@ -46,6 +47,25 @@ private static void ValidateGroups(IReadOnlyList selected, HashSe ValidateGroupShape(wireName, group, selected, errors); } + + // Groups sharing a client name merge into one client family, so their handle + // declarations must agree exactly — a divergent row would fork the family. + foreach (var clientName in curation.Groups + .Where(static pair => pair.Value is { Placement: GroupPlacement.Client, ClientName: not null }) + .GroupBy(static pair => pair.Value.ClientName!, StringComparer.Ordinal) + .Where(static family => family + .Select(static pair => (pair.Value.HandleName, pair.Value.HandleParameter)) + .Distinct() + .Skip(1) + .Any()) + .Select(static family => family.Key) + .Order(StringComparer.Ordinal)) + { + errors.Add( + BindingErrorCategory.Curation, + clientName, + $"groups sharing client '{clientName}' must declare identical handle configuration"); + } } private static void ValidateGroupShape(string wireName, GroupCuration group, IReadOnlyList selected, BindingErrorCollector errors) @@ -58,6 +78,14 @@ private static void ValidateGroupShape(string wireName, GroupCuration group, IRe case GroupPlacement.Client when string.IsNullOrWhiteSpace(group.ClientName): errors.Add(BindingErrorCategory.Curation, wireName, "client group must declare clientName"); break; + case not (GroupPlacement.Root or GroupPlacement.Client): + // System.Text.Json admits numeric enum spellings even under the string + // converter, so an out-of-range placement must fail here, not drop silently. + errors.Add( + BindingErrorCategory.Curation, + wireName, + $"placement value '{((int)group.Placement).ToString(System.Globalization.CultureInfo.InvariantCulture)}' is not a recognized group placement"); + break; } if (group.ClientName is not null && !CSharpNamePolicy.IsValidIdentifier(group.ClientName)) @@ -127,7 +155,7 @@ private static void ValidateHandleParameterCoverage(string wireName, GroupCurati /// /// Payload names derive mechanically from the operation subject; curated entries are - /// overrides only (maintainer, 2026-08-13), so validation covers orphans and identifier + /// overrides only, so validation covers orphans and identifier /// legality, never presence. /// private static void ValidateEnvelopeNames(HashSet selectedIds, HashSet documentIds, @@ -187,5 +215,65 @@ private static void ValidatePropertyOverrides(SpecDocument document, ReachableSc } } + /// + /// The alias walls carry the drift contract: a deleted source or target orphans the row, + /// a dereferenced source goes dormant, and any structural divergence — the tag included — + /// breaks the identity check. Every upstream move on the duplicate is loud. + /// + private static void ValidateSchemaAliases(SpecDocument document, ReachableSchemaSet reachable, + GenerationCuration curation, BindingErrorCollector errors) + { + var reachableKeys = reachable.GraphKeys.ToHashSet(StringComparer.Ordinal); + var sources = new HashSet(StringComparer.Ordinal); + foreach (var alias in curation.SchemaAliases) + { + if (!sources.Add(alias.Schema)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "schema alias is duplicated"); + } + + if (string.IsNullOrWhiteSpace(alias.Reason)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "schema alias must declare a reason"); + } + + if (string.Equals(alias.Schema, alias.AliasOf, StringComparison.Ordinal)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "schema alias cannot target itself"); + continue; + } + + if (!document.Schemas.TryGetValue(alias.Schema, out var source)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schema does not exist in the spec"); + continue; + } + + if (!document.Schemas.TryGetValue(alias.AliasOf, out var target)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, $"alias target '{alias.AliasOf}' does not exist in the spec"); + continue; + } + + if (!reachableKeys.Contains(alias.Schema)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schema is not referenced by the selected profile"); + } + + if (!SchemaNodeComparer.DeepEquals(source, target)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schemas must be structurally identical"); + } + } + + foreach (var alias in curation.SchemaAliases + .Where(alias => !string.Equals(alias.Schema, alias.AliasOf, StringComparison.Ordinal) + && sources.Contains(alias.AliasOf)) + .OrderBy(static alias => alias.Schema, StringComparer.Ordinal)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "schema aliases cannot chain"); + } + } + private static string GetGroup(SpecOperation operation) => operation.Segments[0]; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs index 18f7533c..46294d49 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs @@ -15,6 +15,6 @@ public enum BindingErrorCategory /// A selected wire name cannot be mapped to a unique C# identifier. Naming = 3, - /// A selected operation's wire contract cannot be bound by the M1 operation binder. + /// A selected operation's wire contract cannot be bound by the operation binder. Operation = 4, } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ClientPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ClientPlan.cs index b69f3236..371e7241 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ClientPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ClientPlan.cs @@ -8,6 +8,9 @@ internal sealed record ClientPlan public required ClientRole Role { get; init; } + /// Gets the feature folder the family's files live in; keeps the root client at the project root. + public string? ContainerName { get; init; } + /// Gets the group client properties exposed by the root client. public required IReadOnlyList SubClients { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs new file mode 100644 index 00000000..61ee295f --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// Identifies how the wire success body carries the payload. +internal enum EnvelopeKind +{ + /// The body is the payload itself. + Bare = 0, + + /// The body wraps the payload in a required data property. + Data = 1, + + /// The body carries a data array beside a previous/next cursor object. + CursorList = 2, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs index e17e5025..06b2a0c1 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs @@ -8,8 +8,14 @@ internal sealed record EnvelopePlan 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 a value indicating whether the wire success body wraps the payload in a data property. - public required bool HasDataEnvelope { get; init; } + public required EnvelopeKind Kind { get; init; } + + /// + /// Gets the internal single-pass deserialization DTO type name for wrapped envelopes, + /// or when the body is the payload itself. + /// + public string? EnvelopeDtoTypeName { 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 d40cb38c..3e2895f7 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs @@ -37,4 +37,15 @@ public required IReadOnlyList PropertyOverrides field = Array.AsReadOnly([.. value]); } } = Array.AsReadOnly(Array.Empty()); + + [JsonPropertyName("schemaAliases")] + public required IReadOnlyList SchemaAliases + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs index 38fd6bbc..238db582 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs @@ -12,6 +12,10 @@ internal sealed record ModelPropertyPlan public required bool IsRequired { get; init; } + /// Whether the wire contract admits an explicit JSON null, as opposed to the + /// C# nullability an optional property gains for absence alone. + public required bool AllowsWireNull { get; init; } + public required bool IsLiteral { get; init; } public LiteralKind? LiteralKind { get; init; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs index 9c0acc38..3006f30b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs @@ -26,6 +26,12 @@ public required IReadOnlyList Parameters } } = Array.AsReadOnly(Array.Empty()); + /// Gets the query-request record plan, or when the operation has no query parameters. + public QueryRequestPlan? QueryRequest { get; init; } + + /// Gets the request-body plan, or when the operation sends no body. + public RequestBodyPlan? RequestBody { get; init; } + public required EnvelopePlan Envelope { get; init; } public required ErrorMapPlan ErrorMap { get; init; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryPropertyPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryPropertyPlan.cs new file mode 100644 index 00000000..4341afcc --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryPropertyPlan.cs @@ -0,0 +1,13 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record QueryPropertyPlan +{ + public required string WireName { get; init; } + + public required string PropertyName { get; init; } + + public required QueryValueKind Kind { get; init; } + + /// Gets a value indicating whether the property is inherited from the ListRequest base. + public required bool IsInherited { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs new file mode 100644 index 00000000..606637d2 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs @@ -0,0 +1,20 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record QueryRequestPlan +{ + public required string TypeName { get; init; } + + /// Gets a value indicating whether the record derives from the ListRequest base. + public required bool DerivesFromListRequest { get; init; } + + /// Gets every bound query parameter in wire order, including base-inherited ones. + public required IReadOnlyList Properties + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs new file mode 100644 index 00000000..0ba45fa9 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs @@ -0,0 +1,17 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// Identifies how a bound query parameter value maps between C# and the wire. +internal enum QueryValueKind +{ + /// A plain string carried verbatim. + Text = 0, + + /// A positive count exposed as int? and written invariantly to the wire string. + PositiveCount = 1, + + /// The asc/desc order enum exposed as the shared ListOrder spine type. + ListOrder = 2, + + /// The parent-session filter whose wire admits an identifier or the literal "null". + SessionParentFilter = 3, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RequestBodyPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RequestBodyPlan.cs new file mode 100644 index 00000000..11b512ab --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RequestBodyPlan.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record RequestBodyPlan +{ + public required string TypeName { get; init; } + + public required string ParameterName { get; init; } + + /// + /// Gets a value indicating whether the operation parameter is optional; a body whose + /// properties are all optional sends an empty JSON object when the caller passes nothing. + /// + public required bool IsOptional { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaAlias.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaAlias.cs new file mode 100644 index 00000000..d159af77 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaAlias.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// +/// Declares one component schema as a spelling of another; the collapse is validated +/// fail-closed for structural identity so upstream drift on either side breaks generation. +/// +internal sealed record SchemaAlias +{ + [JsonPropertyName("schema")] + public required string Schema { get; init; } + + [JsonPropertyName("aliasOf")] + public required string AliasOf { get; init; } + + [JsonPropertyName("reason")] + public required string Reason { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs index 8614d342..a7312a27 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs @@ -3,48 +3,129 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; -/// Derives C# member and type names for one bound operation from its identifier segments. +/// +/// Derives C# member and type names for one bound operation from its identifier segments. +/// Verb detection is structural — only the final segment can be a verb — so mid-position +/// segments that spell a verb stay in the subject. Derivations that need the pluralized +/// group return when the naive rule is unsafe; the binder refuses +/// such operations. +/// internal static class OperationNamePolicy { - public static string MethodName(SpecOperation operation, GroupPlacement placement) + /// Identifier segments recognized as operation verbs when they close the identifier. + private static readonly string[] KnownVerbSegments = ["create", "get", "list"]; + + /// Gets the operation verb: a recognized final identifier segment, or the HTTP method. + public static string Verb(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); - var subject = Subject(operation); - if (subject.Length is 0 && placement is GroupPlacement.Root) - { - subject = CSharpNamePolicy.ToPascalCase(operation.Segments[0]); - } + return HasVerbSegment(operation) + ? CSharpNamePolicy.ToPascalCase(operation.Segments[^1]) + : CSharpNamePolicy.ToPascalCase(operation.Method); + } - return $"{CSharpNamePolicy.ToPascalCase(operation.Method)}{subject}Async"; + public static string? MethodName(SpecOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + + var subject = SubjectOrGroupFallback(operation); + return subject is null ? null : $"{Verb(operation)}{subject}Async"; } public static string ResponseTypeName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); - return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}Response"; + var verb = Verb(operation); + var verbSuffix = string.Equals(verb, "Get", StringComparison.Ordinal) ? string.Empty : verb; + return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}{verbSuffix}Response"; } - public static string RouteMemberName(SpecOperation operation) + /// + /// Route members never restate a root container; client-placed members mirror their + /// method names so merged client families stay collision-free. + /// + public static string? RouteMemberName(SpecOperation operation, GroupPlacement placement) { ArgumentNullException.ThrowIfNull(operation); - return $"{CSharpNamePolicy.ToPascalCase(operation.Method)}{Subject(operation)}"; + if (placement is GroupPlacement.Root) + { + return $"{Verb(operation)}{Subject(operation)}"; + } + + var subject = SubjectOrGroupFallback(operation); + return subject is null ? null : $"{Verb(operation)}{subject}"; } - public static string PayloadName(SpecOperation operation) + public static string? PayloadName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); var subject = Subject(operation); - return subject.Length is 0 ? CSharpNamePolicy.ToPascalCase(operation.Segments[0]) : subject; + return subject.Length is 0 ? GroupFallback(operation) : subject; + } + + public static string RequestTypeName(SpecOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + + return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}{Verb(operation)}Request"; } - /// The operation's subject: every segment after the group that does not restate the HTTP method. - private static string Subject(SpecOperation operation) => - string.Concat(operation.Segments - .Skip(1) - .Where(segment => !string.Equals(segment, operation.Method, StringComparison.Ordinal)) - .Select(CSharpNamePolicy.ToPascalCase)); + /// + /// The operation's subject: the segments between the group and the closing verb. + /// The final segment also drops when it restates the HTTP method without being a + /// recognized verb; nothing mid-position is ever dropped. + /// + private static string Subject(SpecOperation operation) + { + var end = operation.Segments.Count; + if (HasVerbSegment(operation) + || (end > 1 && string.Equals(operation.Segments[^1], operation.Method, StringComparison.Ordinal))) + { + end--; + } + + return string.Concat(operation.Segments.Skip(1).Take(end - 1).Select(CSharpNamePolicy.ToPascalCase)); + } + + private static string? SubjectOrGroupFallback(SpecOperation operation) + { + var subject = Subject(operation); + return subject.Length is 0 ? GroupFallback(operation) : subject; + } + + /// An empty subject falls back to the group — pluralized for list operations. + private static string? GroupFallback(SpecOperation operation) + { + var group = CSharpNamePolicy.ToPascalCase(operation.Segments[0]); + return string.Equals(Verb(operation), "List", StringComparison.Ordinal) ? Pluralize(group) : group; + } + + /// + /// Naive pluralization only: words needing -es or -ies return + /// and the operation refuses. + /// + private static string? Pluralize(string word) + { + if (word[^1] is 's' or 'x' or 'z' + || word.EndsWith("ch", StringComparison.Ordinal) || word.EndsWith("sh", StringComparison.Ordinal)) + { + return null; + } + + if (word.Length > 1 && word[^1] is 'y' && !IsVowel(word[^2])) + { + return null; + } + + return $"{word}s"; + } + + private static bool IsVowel(char letter) => letter is 'a' or 'e' or 'i' or 'o' or 'u' or 'A' or 'E' or 'I' or 'O' or 'U'; + + private static bool HasVerbSegment(SpecOperation operation) => + operation.Segments.Count > 1 && KnownVerbSegments.Contains(operation.Segments[^1], StringComparer.Ordinal); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 3a946b98..4393f417 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -10,11 +10,54 @@ internal sealed class OperationPlanBinder private const string ClientNamespace = "OpenCode.Sdk"; private const string RootClientName = "OpenCodeClient"; + /// Hand-written public spine types in the client namespace; a generated twin would not compile. + private static readonly string[] ReservedSpineTypeNames = + [ + "ErrorBehavior", + "IOpenCodeClientOptions", + "ListCursor", + "ListOrder", + "ListRequest", + "OpenCodeApiException", + "OpenCodeClientOptions", + "OpenCodeException", + "OpenCodeRequestOptions", + "OpenCodeResponse", + "OpenCodeTransportException", + "SessionParentFilter", + ]; + + /// Parameter names the emitted method and route-builder signatures append themselves. + private static readonly string[] ReservedParameterNames = + [ + "cancellationToken", + "pipeline", + "request", + "requestOptions", + ]; + /// - /// Member names every generated response envelope inherits from the response spine; a - /// payload landing on one of them needs a curated override. + /// Member names every generated response envelope inherits from the response spine or + /// from /record synthesis; a payload landing on one of them needs a + /// curated override. /// - private static readonly string[] ReservedPayloadNames = ["Error", "IsError", "RawBody", "Status"]; + private static readonly string[] ReservedPayloadNames = + [ + "Cursor", + "Deconstruct", + "EqualityContract", + "Equals", + "Error", + "GetHashCode", + "GetType", + "IsError", + "MemberwiseClone", + "PrintMembers", + "RawBody", + "ReferenceEquals", + "Status", + "ToString", + ]; private readonly StringComparer _comparer = StringComparer.Ordinal; @@ -44,7 +87,7 @@ public IReadOnlyList Bind(SpecDocument document, IReadOnlyList AssembleClients(List bound) .ToArray(); var subClients = new List(); var clients = new List(); + + // Wire groups sharing a curated client name merge into one client family; the + // curation validator has already demanded identical handle declarations. foreach (var group in bound .Where(static operation => operation.Row.Placement is GroupPlacement.Client) - .GroupBy(static operation => operation.Group, _comparer) + .GroupBy(static operation => operation.Row.ClientName!, _comparer) .OrderBy(static group => group.Key, _comparer)) { var members = group.ToArray(); @@ -107,6 +153,7 @@ private IEnumerable CreateGroupClients(IReadOnlyList Name = collectionName, Namespace = ClientNamespace, Role = ClientRole.Collection, + ContainerName = row.ClientName, SubClients = [], HandleFactory = handleParameter is null ? null : new HandleFactoryPlan { @@ -124,6 +171,7 @@ private IEnumerable CreateGroupClients(IReadOnlyList Name = row.HandleName!, Namespace = ClientNamespace, Role = ClientRole.Handle, + ContainerName = row.ClientName, SubClients = [], HandleParameter = handleParameter, Operations = @@ -156,9 +204,24 @@ private void CheckMemberCollisions(List clients, BindingErrorCollect } } - private void CheckTypeNameCollisions(List clients, List bound, BindingErrorCollector errors) + private void CheckTypeNameCollisions(List clients, List bound, + IReadOnlyDictionary typeNames, BindingErrorCollector errors) { + // Model names shadow across namespaces (consumer CS0104) and hand-written spine + // names collide outright (CS0101), so both seed the owner set — reserved names + // first, each model name individually, because a bulk union would swallow a + // model/spine duplicate instead of refusing it. var owners = new HashSet(_comparer); + owners.UnionWith(ReservedSpineTypeNames); + foreach (var entry in typeNames + .Where(entry => !owners.Add(entry.Value) && ReservedSpineTypeNames.Contains(entry.Value, _comparer)) + .OrderBy(static entry => entry.Key, StringComparer.Ordinal)) + { + errors.Add( + BindingErrorCategory.Naming, + entry.Key, + $"model type name '{entry.Value}' collides with the hand-written spine type '{entry.Value}'"); + } foreach (var name in clients.Select(static client => client.Name).Where(name => !owners.Add(name))) { errors.Add(BindingErrorCategory.Naming, name, $"client type name '{name}' collides with another generated type"); @@ -172,6 +235,15 @@ private void CheckTypeNameCollisions(List clients, List + operation.Plan.QueryRequest is not null && !owners.Add(operation.Plan.QueryRequest.TypeName))) + { + errors.Add( + BindingErrorCategory.Naming, + operation.OperationId, + $"request type name '{operation.Plan.QueryRequest!.TypeName}' collides with another generated type"); + } + var routeMembers = new HashSet(_comparer); foreach (var operation in bound.Where(operation => !routeMembers.Add($"{operation.Plan.RouteContainerName}.{operation.Plan.RouteMemberName}"))) @@ -224,7 +296,18 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation var envelope = BindEnvelope(success); var errorMap = BindErrorMap(); var parameters = BindParameters(row); - if (envelope is null || errorMap is null || parameters is null) + var optionalPlanErrorsBefore = _errors.Count; + var queryRequest = BindQueryRequest(); + var requestBody = BindRequestBody(); + var methodName = OperationNamePolicy.MethodName(_operation); + var routeMemberName = OperationNamePolicy.RouteMemberName(_operation, row.Placement); + if (methodName is null || routeMemberName is null) + { + Refuse("the operation's names cannot be derived mechanically: the group does not pluralize naively"); + return null; + } + + if (envelope is null || errorMap is null || parameters is null || _errors.Count != optionalPlanErrorsBefore) { return null; } @@ -237,12 +320,14 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation IsHandleOperation = parameters.Any(static parameter => parameter.IsHandleParameter), Plan = new OperationPlan { - MethodName = OperationNamePolicy.MethodName(_operation, row.Placement), + MethodName = methodName, HttpMethod = _operation.Method, RouteTemplate = _operation.Path, RouteContainerName = row.ClientName ?? CSharpNamePolicy.ToPascalCase(group), - RouteMemberName = OperationNamePolicy.RouteMemberName(_operation), + RouteMemberName = routeMemberName, Parameters = parameters, + QueryRequest = queryRequest, + RequestBody = requestBody, Envelope = envelope, ErrorMap = errorMap, Summary = _operation.Summary, @@ -254,9 +339,11 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation private bool CheckWireShape() { var before = _errors.Count; - if (!string.Equals(_operation.Method, "get", StringComparison.Ordinal)) + var isGet = string.Equals(_operation.Method, "get", StringComparison.Ordinal); + var isPost = string.Equals(_operation.Method, "post", StringComparison.Ordinal); + if (!isGet && !isPost) { - Refuse("only GET operations are supported in M1"); + Refuse($"HTTP method '{_operation.Method}' is not supported"); } if (_operation.HasWildcardPath) @@ -274,9 +361,22 @@ private bool CheckWireShape() Refuse("event-stream responses are not supported in M1"); } - if (_operation.RequestBody is not null) + if (isGet && _operation.RequestBody is not null) + { + Refuse("GET operations must not carry a request body"); + } + + if (isPost && _operation.RequestBody is null) { - Refuse("request bodies are not supported in M1"); + Refuse("POST operations must 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)) + { + Refuse("operations mixing a request body and query parameters are not supported"); } CheckParameterShapes(); @@ -286,17 +386,17 @@ private bool CheckWireShape() private void CheckParameterShapes() { - foreach (var parameter in _operation.Parameters) + foreach (var parameter in _operation.Parameters.Where(static parameter => parameter.Location is SpecParameterLocation.Path)) { - if (parameter is not { Location: SpecParameterLocation.Path, IsRequired: true, IsDeepObject: false }) + if (parameter is not { IsRequired: true, IsDeepObject: false }) { - Refuse($"parameter '{parameter.Name}' must be a required path parameter"); + Refuse($"path parameter '{parameter.Name}' must be required and plainly encoded"); continue; } if (Resolve(parameter.Schema) is not PrimitiveNode { Kind: PrimitiveKind.String }) { - Refuse($"parameter '{parameter.Name}' must be a plain string"); + Refuse($"path parameter '{parameter.Name}' must be a plain string"); } } } @@ -332,10 +432,9 @@ private void CheckStatusShape() { SpecEnvelopeShape.Bare => BindBarePayload(success.Schema), SpecEnvelopeShape.Data => BindDataPayload(success.Schema), - SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocation or SpecEnvelopeShape.CursorData - or SpecEnvelopeShape.DataHasMore - => RefuseNull($"envelope shape '{success.EnvelopeShape}' is not supported in M1"), - _ => RefuseNull($"envelope shape '{success.EnvelopeShape}' is not supported in M1"), + SpecEnvelopeShape.CursorData => BindCursorListPayload(success.Schema), + SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocation or SpecEnvelopeShape.DataHasMore or _ => + RefuseNull($"envelope shape '{success.EnvelopeShape}' is not supported"), }; if (payload is null) { @@ -346,6 +445,22 @@ or SpecEnvelopeShape.DataHasMore var payloadName = _curation.EnvelopePayloadNames.TryGetValue(_operation.OperationId, out var curated) ? curated : OperationNamePolicy.PayloadName(_operation); + if (payloadName is null) + { + Refuse("the payload name cannot be derived mechanically: the group does not pluralize naively; curate an envelope payload name"); + return null; + } + + // Mechanical names are PascalCase by construction, but the wall covers both origins. + if (!CSharpNamePolicy.IsValidIdentifier(payloadName)) + { + _errors.Add( + BindingErrorCategory.Naming, + _operation.OperationId, + $"payload name '{payloadName}' is not a valid C# identifier"); + return null; + } + if (ReservedPayloadNames.Contains(payloadName, StringComparer.Ordinal) || string.Equals(payloadName, responseTypeName, StringComparison.Ordinal)) { @@ -356,13 +471,21 @@ or SpecEnvelopeShape.DataHasMore return null; } + var kind = success.EnvelopeShape switch + { + SpecEnvelopeShape.Data => EnvelopeKind.Data, + SpecEnvelopeShape.CursorData => EnvelopeKind.CursorList, + SpecEnvelopeShape.Bare or SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocation + or SpecEnvelopeShape.DataHasMore or _ => EnvelopeKind.Bare, + }; return new EnvelopePlan { ResponseTypeName = responseTypeName, AdapterTypeName = $"{responseTypeName}Adapter", PayloadName = payloadName, PayloadTypeName = payload, - HasDataEnvelope = success.EnvelopeShape is SpecEnvelopeShape.Data, + Kind = kind, + EnvelopeDtoTypeName = kind is EnvelopeKind.Bare ? null : $"{responseTypeName}Envelope", }; } @@ -387,6 +510,53 @@ or SpecEnvelopeShape.DataHasMore return RefuseNull("envelope payload must be a required reference to a named schema"); } + private string? BindCursorListPayload(SchemaNode schema) + { + if (schema is not RefNode reference + || !_document.Schemas.TryGetValue(reference.Target, out var target) + || target is not ObjectNode wrapper) + { + return RefuseNull("cursor-list envelope must reference an object schema"); + } + + var data = wrapper.Properties.FirstOrDefault(static property => property.Name is "data"); + var cursor = wrapper.Properties.FirstOrDefault(static property => property.Name is "cursor"); + if (wrapper.Properties.Count is not 2 || data is not { IsRequired: true } || cursor is not { IsRequired: true }) + { + return RefuseNull("cursor-list envelope must require exactly 'data' and 'cursor'"); + } + + if (!IsListCursorShape(cursor.Schema)) + { + return RefuseNull("cursor-list 'cursor' must be the optional-nullable previous/next cursor object"); + } + + // 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 not ArrayNode { Item: RefNode item } + || item.Target.Contains('#', StringComparison.Ordinal) + || !_typeNames.TryGetValue(item.Target, out var itemName)) + { + return RefuseNull("cursor-list 'data' must be an array of a named component schema"); + } + + return itemName; + } + + /// Recognizes the wire cursor contract: exactly optional-nullable string previous and next. + private bool IsListCursorShape(SchemaNode schema) + { + if (Resolve(schema) is not ObjectNode cursor + || cursor.Properties.Count is not 2 + || cursor.Properties.Select(static property => property.Name).Distinct(StringComparer.Ordinal).Count() is not 2) + { + return false; + } + + return cursor.Properties.All(property => property is { IsRequired: false, Name: "previous" or "next" } + && Resolve(property.Schema) is NullableNode { Inner: PrimitiveNode { Kind: PrimitiveKind.String } }); + } + private ErrorMapPlan? BindErrorMap() { var statuses = new List(); @@ -494,6 +664,7 @@ or SpecEnvelopeShape.DataHasMore private IReadOnlyList? BindParameters(GroupCuration row) { var plans = _operation.Parameters + .Where(static parameter => parameter.Location is SpecParameterLocation.Path) .Select(parameter => new OperationParameterPlan { WireName = parameter.Name, @@ -514,6 +685,26 @@ or SpecEnvelopeShape.DataHasMore return null; } + // Emitted signatures append these names after bind-time checks; a wire parameter + // landing on one must fail here, never as an emitted compile error. + var reserved = plans + .Select(static plan => plan.Name) + .Where(static name => ReservedParameterNames.Contains(name, StringComparer.Ordinal)) + .Order(StringComparer.Ordinal) + .ToArray(); + if (reserved.Length > 0) + { + foreach (var name in reserved) + { + _errors.Add( + BindingErrorCategory.Naming, + _operation.OperationId, + $"parameter name '{name}' is reserved by the emitted method signature"); + } + + return null; + } + return [.. plans.OrderBy(plan => TemplatePosition(plan.WireName))]; } @@ -524,6 +715,168 @@ private int TemplatePosition(string wireName) return position; } + private QueryRequestPlan? BindQueryRequest() + { + var query = _operation.Parameters + .Where(static parameter => parameter.Location is SpecParameterLocation.Query) + .ToArray(); + if (query.Length is 0) + { + return null; + } + + var properties = new List(query.Length); + foreach (var parameter in query) + { + if (parameter.IsDeepObject) + { + Refuse($"query parameter '{parameter.Name}' must not use deep-object encoding"); + continue; + } + + if (parameter.IsRequired) + { + Refuse($"query parameter '{parameter.Name}' must be optional"); + continue; + } + + if (Resolve(parameter.Schema) is not NullableNode nullable) + { + Refuse($"query parameter '{parameter.Name}' must admit null"); + continue; + } + + var kind = ResolveQueryValueKind(parameter.Name, nullable.Inner); + if (kind is null) + { + Refuse($"query parameter '{parameter.Name}' has an unsupported schema shape"); + continue; + } + + properties.Add(new QueryPropertyPlan + { + WireName = parameter.Name, + PropertyName = CSharpNamePolicy.ToPascalCase(parameter.Name), + Kind = kind.Value, + IsInherited = false, + }); + } + + var duplicate = properties.GroupBy(static property => property.PropertyName, StringComparer.Ordinal) + .FirstOrDefault(static property => property.Skip(1).Any()); + if (duplicate is not null) + { + _errors.Add( + BindingErrorCategory.Naming, + _operation.OperationId, + $"multiple query parameters map to C# name '{duplicate.Key}'"); + return null; + } + + if (MatchesListRequestProfile(properties)) + { + properties = [.. properties.Select(static property => property with { IsInherited = true })]; + } + + return new QueryRequestPlan + { + TypeName = OperationNamePolicy.RequestTypeName(_operation), + DerivesFromListRequest = properties.Count > 0 && properties[0].IsInherited, + Properties = properties, + }; + } + + private RequestBodyPlan? BindRequestBody() + { + var body = _operation.RequestBody; + if (body is null) + { + return null; + } + + if (body.ContentType is not { IsJson: true }) + { + Refuse("the request body must carry a JSON schema"); + return null; + } + + if (!body.IsRequired) + { + Refuse("the request body must be declared required"); + return null; + } + + if (body.Schema is not RefNode reference || Resolve(body.Schema) is not ObjectNode target) + { + Refuse("the request body must reference an object schema"); + return null; + } + + if (!_typeNames.TryGetValue(reference.Target, out var typeName)) + { + _errors.Add(BindingErrorCategory.Naming, reference.Target, "request body schema has no unique C# type name"); + return null; + } + + // The name resolver claims every selected body root with the operation-derived + // request name; a mismatch means the ownership map and this binding disagree. + var expected = OperationNamePolicy.RequestTypeName(_operation); + if (!string.Equals(typeName, expected, StringComparison.Ordinal)) + { + _errors.Add( + BindingErrorCategory.Naming, + reference.Target, + $"request body resolved to '{typeName}' instead of the operation-derived '{expected}'"); + return null; + } + + return new RequestBodyPlan + { + TypeName = typeName, + ParameterName = "request", + IsOptional = target.Properties.All(static property => !property.IsRequired), + }; + } + + /// + /// The fail-closed profile wall: an operation derives from the ListRequest base + /// only when its wire query parameters are exactly the cursor-pagination trio. + /// + private static bool MatchesListRequestProfile(List properties) => + properties.Count is 3 + && properties.Any(static property => property is { WireName: "limit", Kind: QueryValueKind.PositiveCount }) + && properties.Any(static property => property is { WireName: "order", Kind: QueryValueKind.ListOrder }) + && properties.Any(static property => property is { WireName: "cursor", Kind: QueryValueKind.Text }); + + private QueryValueKind? ResolveQueryValueKind(string wireName, SchemaNode inner) + { + return Resolve(inner) switch + { + PrimitiveNode { Kind: PrimitiveKind.String } => + string.Equals(wireName, "limit", StringComparison.Ordinal) ? QueryValueKind.PositiveCount : QueryValueKind.Text, + EnumNode { Values: ["asc", "desc"] } => QueryValueKind.ListOrder, + UnionNode { Classification: UnionClassification.Structural, Branches: [var first, var second] } + when IsParentFilterShape(first, second) => QueryValueKind.SessionParentFilter, + _ => null, + }; + } + + /// + /// Recognizes the parent-filter wire shape — a patterned identifier string beside the + /// literal "null" — structurally, never by parameter name. + /// + private bool IsParentFilterShape(SchemaNode first, SchemaNode second) + { + var left = Resolve(first); + var right = Resolve(second); + return (IsIdentifierString(left) && IsNullLiteral(right)) + || (IsIdentifierString(right) && IsNullLiteral(left)); + } + + private static bool IsIdentifierString(SchemaNode schema) => schema is PrimitiveNode { Kind: PrimitiveKind.String }; + + private static bool IsNullLiteral(SchemaNode schema) => schema is LiteralNode { Kind: LiteralKind.String, Value: "null" }; + private void Refuse(string problem) => _errors.Add(BindingErrorCategory.Operation, _operation.OperationId, problem); private string? RefuseNull(string problem) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs index ae97232d..b455ec48 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs @@ -16,7 +16,10 @@ public ReachableSchemaSet Collect(SpecDocument document, IReadOnlyList parameter.Location is SpecParameterLocation.Path)) { traversal.Visit(parameter.Schema); } @@ -28,7 +31,7 @@ public ReachableSchemaSet Collect(SpecDocument document, IReadOnlyList _keys = new(comparer); private readonly HashSet _responseRoots = new(comparer); - public void VisitResponse(string operationId, SchemaNode? schema) + public void VisitResponse(string operationId, SpecResponse response) { ArgumentException.ThrowIfNullOrWhiteSpace(operationId); - if (schema is RefNode reference && reference.Target.StartsWith($"op:{operationId}#", StringComparison.Ordinal)) + ArgumentNullException.ThrowIfNull(response); + + var schema = response.Schema; + if (schema is RefNode reference) { - _ = _responseRoots.Add(reference.Target); + // 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; + if (isInlineRoot || isWrapperShape) + { + _ = _responseRoots.Add(reference.Target); + } + + if (response.EnvelopeShape is SpecEnvelopeShape.CursorData) + { + ExcludeCursorObject(reference.Target); + } } if (schema is not null) @@ -57,6 +75,23 @@ public void VisitResponse(string operationId, SchemaNode? schema) } } + /// The cursor object rides the envelope contract (hand-written spine), never the model closure. + private void ExcludeCursorObject(string wrapperKey) + { + if (!_graph.TryGetValue(wrapperKey, out var wrapperSchema) || wrapperSchema is not ObjectNode wrapper) + { + return; + } + + foreach (var property in wrapper.Properties) + { + if (property is { Name: "cursor", Schema: RefNode cursorReference }) + { + _ = _responseRoots.Add(cursorReference.Target); + } + } + } + public void Visit(SchemaNode schema) { ArgumentNullException.ThrowIfNull(schema); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaAliasApplier.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaAliasApplier.cs new file mode 100644 index 00000000..289f0283 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaAliasApplier.cs @@ -0,0 +1,96 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +/// +/// Collapses curated schema aliases before binding: every reference to an aliased key is +/// rewritten to its target and the aliased schema leaves the graph, so downstream machinery +/// never sees the duplicate. Container kinds without a rewrite rule pass through unchanged; +/// a reference they might carry then dangles and fails reachability loudly. +/// +internal sealed class SchemaAliasApplier +{ + public SpecDocument Apply(SpecDocument document, IReadOnlyList aliases) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(aliases); + + if (aliases.Count is 0) + { + return document; + } + + var map = new Dictionary(StringComparer.Ordinal); + foreach (var alias in aliases) + { + map[alias.Schema] = alias.AliasOf; + } + + var schemas = new Dictionary(StringComparer.Ordinal); + foreach (var (key, schema) in document.Schemas) + { + if (!map.ContainsKey(key)) + { + schemas[key] = Rewrite(schema, map); + } + } + + return new SpecDocument + { + Operations = [.. document.Operations.Select(operation => RewriteOperation(operation, map))], + Schemas = schemas, + }; + } + + private SpecOperation RewriteOperation(SpecOperation operation, Dictionary map) => + operation with + { + Parameters = [.. operation.Parameters.Select(parameter => parameter with { Schema = Rewrite(parameter.Schema, map) })], + RequestBody = operation.RequestBody is null + ? null + : operation.RequestBody with { Schema = Rewrite(operation.RequestBody.Schema, map) }, + Responses = + [ + .. operation.Responses.Select(response => response.Schema is null + ? response + : response with { Schema = Rewrite(response.Schema, map) }), + ], + }; + + private SchemaNode Rewrite(SchemaNode node, Dictionary map) => node switch + { + RefNode reference when map.TryGetValue(reference.Target, out var target) => reference with { Target = target }, + NullableNode nullable => nullable with { Inner = Rewrite(nullable.Inner, map) }, + ArrayNode array => array with { Item = Rewrite(array.Item, map) }, + TupleNode tuple => tuple with { Items = [.. tuple.Items.Select(item => Rewrite(item, map))] }, + DictionaryNode dictionary => dictionary with { Value = Rewrite(dictionary.Value, map) }, + UnionNode union => RewriteUnion(union, map), + ObjectNode target => target with + { + Properties = [.. target.Properties.Select(property => property with { Schema = Rewrite(property.Schema, map) })], + AdditionalPropertiesSchema = target.AdditionalPropertiesSchema is null + ? null + : Rewrite(target.AdditionalPropertiesSchema, map), + }, + _ => node, + }; + + /// Collapsed branches can duplicate a reference; the duplicates fold away mechanically. + private UnionNode RewriteUnion(UnionNode union, Dictionary map) + { + var branches = new List(union.Branches.Count); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var branch in union.Branches.Select(entry => Rewrite(entry, map))) + { + if (branch is RefNode reference && !seen.Add(reference.Target)) + { + continue; + } + + branches.Add(branch); + } + + return union with { Branches = branches }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs index 88d64456..52447645 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs @@ -8,13 +8,16 @@ internal sealed class SchemaNameResolver { private readonly StringComparer _comparer = StringComparer.Ordinal; - public IReadOnlyDictionary Resolve(SpecDocument document, ReachableSchemaSet reachable, BindingErrorCollector errors) + public IReadOnlyDictionary Resolve(SpecDocument document, ReachableSchemaSet reachable, + IReadOnlyList selected, BindingErrorCollector errors) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(reachable); + ArgumentNullException.ThrowIfNull(selected); ArgumentNullException.ThrowIfNull(errors); var responseRoots = reachable.ResponseRootKeys.ToHashSet(_comparer); + var requestRoots = ResolveRequestBodyRootNames(selected, errors); var result = new Dictionary(_comparer); var owners = new Dictionary(_comparer); foreach (var key in reachable.GraphKeys) @@ -24,7 +27,15 @@ public IReadOnlyDictionary Resolve(SpecDocument document, Reacha continue; } - var name = schema is UnionNode union ? ResolveUnionName(key, union, document.Schemas) : ResolveDefault(key); + string name; + if (requestRoots.TryGetValue(key, out var requestName)) + { + name = requestName; + } + else + { + name = schema is UnionNode union ? ResolveUnionName(key, union, document.Schemas) : ResolveDefault(key); + } if (owners.TryGetValue(name, out var existing)) { errors.Add(BindingErrorCategory.Naming, key, $"C# type name '{name}' collides with schema '{existing}'"); @@ -40,6 +51,42 @@ public IReadOnlyDictionary Resolve(SpecDocument document, Reacha private static bool IsNominal(SchemaNode schema) => schema is ObjectNode or EnumNode or UnionNode; + /// + /// Every selected operation's nominal body root — inline or component-referenced — is + /// named from its operation identity, the mechanical {Subject}{Verb}Request rule + /// (public names never surface raw operation ids or dotted component spellings). + /// Ownership is scoped to the selection so a pending operation can never rename a + /// component the selected closure shares; two selected operations claiming one root + /// under different names refuse. Nested component dependencies stay shared: only the + /// root reference is claimed. + /// + private Dictionary ResolveRequestBodyRootNames(IReadOnlyList selected, + BindingErrorCollector errors) + { + var names = new Dictionary(_comparer); + foreach (var operation in selected) + { + if (operation.RequestBody?.Schema is not RefNode reference) + { + continue; + } + + var requestName = OperationNamePolicy.RequestTypeName(operation); + if (names.TryGetValue(reference.Target, out var existing) && !_comparer.Equals(existing, requestName)) + { + errors.Add( + BindingErrorCategory.Naming, + reference.Target, + $"request body root is claimed as both '{existing}' and '{requestName}' by selected operations"); + continue; + } + + names[reference.Target] = requestName; + } + + return names; + } + private string ResolveUnionName(string key, UnionNode union, IReadOnlyDictionary graph) { if (!key.Contains('#', StringComparison.Ordinal)) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs new file mode 100644 index 00000000..d9de7bcc --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs @@ -0,0 +1,72 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +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. +/// +internal static class SchemaNodeComparer +{ + public static bool DeepEquals(SchemaNode left, SchemaNode right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + + // 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) => 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, + }; + } + + 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)); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs index e6d0e3e1..e651ef84 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs @@ -323,6 +323,24 @@ private sealed record ResolvedUnionBranch(string TargetKey, string TypeName, IRe }); } + // A tag owned by two closure types would poison the converter's dispatch map at its + // first use; structurally identical duplicates have the schema-alias escape. + var duplicateTags = variants + .GroupBy(static variant => variant.Tag, StringComparer.Ordinal) + .Where(static group => group.Skip(1).Any()) + .Select(static group => group.Key) + .Order(StringComparer.Ordinal) + .ToArray(); + if (duplicateTags.Length > 0) + { + foreach (var tag in duplicateTags) + { + errors.Add(BindingErrorCategory.Schema, "OpenCodeError", $"multiple error schemas declare tag '{tag}'"); + } + + return null; + } + return new UnionPlan { Name = "OpenCodeError", @@ -348,6 +366,10 @@ private sealed record ResolvedUnionBranch(string TargetKey, string TypeName, IRe continue; } + // Captured before optional-absence widening: schema-admitted null is what the type + // binder reported, plus JsonElement, whose unrestricted schema admits every JSON + // value including null. + var allowsWireNull = type.IsNullable || type is NamedTypeReferencePlan { Name: "JsonElement" }; if (!property.IsRequired && !type.IsCollection) { type = type with { IsNullable = true }; @@ -360,6 +382,7 @@ private sealed record ResolvedUnionBranch(string TargetKey, string TypeName, IRe Name = CSharpNamePolicy.ToPascalCase(property.Name), Type = type, IsRequired = property.IsRequired, + AllowsWireNull = allowsWireNull, IsLiteral = literal is not null, LiteralKind = literal?.Kind, LiteralValue = literal?.Value, @@ -477,7 +500,9 @@ public TypePlanBinder(IReadOnlyDictionary graph, IReadOnlyDi { RefNode reference => BindReference(reference, subject, aliases), PrimitiveNode primitive => BindPrimitive(primitive), - LiteralNode literal => Named(literal.Kind is LiteralKind.Boolean ? "bool" : "string"), + LiteralNode { Kind: LiteralKind.Boolean } => Named("bool"), + LiteralNode { Kind: LiteralKind.String } => Named("string"), + LiteralNode => Refuse(subject, "numeric literal schemas are not supported by the emitter"), ArrayNode array => BindArray(array, subject, aliases), DictionaryNode dictionary => BindDictionary(dictionary, subject, aliases), FreeFormObjectNode => DictionaryOf(Named("JsonElement")), diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index 6cbacb5a..6ef137d3 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -8,6 +8,7 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; internal sealed class SpecBinder( ReachableSchemaCollector reachableSchemas, CurationValidator curationValidator, + SchemaAliasApplier schemaAliases, SchemaNameResolver schemaNames, SchemaPlanBinder schemaPlans, OperationPlanBinder operationPlans) : ISpecBinder @@ -15,6 +16,7 @@ internal sealed class SpecBinder( private readonly CurationValidator _curationValidator = curationValidator ?? throw new ArgumentNullException(nameof(curationValidator)); private readonly OperationPlanBinder _operationPlans = operationPlans ?? throw new ArgumentNullException(nameof(operationPlans)); private readonly ReachableSchemaCollector _reachableSchemas = reachableSchemas ?? throw new ArgumentNullException(nameof(reachableSchemas)); + private readonly SchemaAliasApplier _schemaAliases = schemaAliases ?? throw new ArgumentNullException(nameof(schemaAliases)); private readonly SchemaNameResolver _schemaNames = schemaNames ?? throw new ArgumentNullException(nameof(schemaNames)); private readonly SchemaPlanBinder _schemaPlans = schemaPlans ?? throw new ArgumentNullException(nameof(schemaPlans)); @@ -30,9 +32,24 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera var reachable = _reachableSchemas.Collect(document, selected, errors); _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. + 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); + curation = curation with + { + PropertyOverrides = MapOverrideKeys(curation.PropertyOverrides, curation.SchemaAliases, errors), + }; + } + // Type names are resolved exactly once per bind; schema and operation binding // consume the same map so neither can reinterpret a schema under another name. - var typeNames = _schemaNames.Resolve(document, reachable, errors); + var typeNames = _schemaNames.Resolve(document, reachable, selected, errors); var schemaResult = _schemaPlans.Bind(document, reachable, curation, typeNames, errors); var clients = _operationPlans.Bind(document, selected, curation, typeNames, errors); var selectedIds = selected.Select(static operation => operation.OperationId).ToHashSet(StringComparer.Ordinal); @@ -45,18 +62,131 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera }) .ToArray(); + CheckDtoNameCollisions(schemaResult.Registry, clients, errors); errors.ThrowIfAny(); return new EmitPlan { SelectedOperationIds = selection.OperationIds, Models = schemaResult.Models, Unions = schemaResult.Unions, - Registry = schemaResult.Registry, + Registry = ComposeRegistry(schemaResult.Registry, clients), Clients = clients, PendingOperations = pending, }; } + /// + /// 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 + /// directions refuse before composition instead of hiding behind a Distinct. + /// + private static void CheckDtoNameCollisions(RegistryPlan schemaRegistry, IReadOnlyList clients, + BindingErrorCollector errors) + { + var modelNames = schemaRegistry.TypeNames.ToHashSet(StringComparer.Ordinal); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var dtoName in clients + .SelectMany(static client => client.Operations) + .Select(static operation => operation.Envelope.EnvelopeDtoTypeName) + .OfType() + .OrderBy(static name => name, StringComparer.Ordinal)) + { + if (modelNames.Contains(dtoName)) + { + errors.Add( + BindingErrorCategory.Naming, + dtoName, + $"envelope DTO name '{dtoName}' collides with a generated model name"); + } + + if (!seen.Add(dtoName)) + { + errors.Add( + BindingErrorCategory.Naming, + dtoName, + $"envelope DTO name '{dtoName}' is derived by multiple operations"); + } + } + } + + /// Wrapped envelopes deserialize through internal DTOs, which join the serializer registry. + private static RegistryPlan ComposeRegistry(RegistryPlan schemaRegistry, IReadOnlyList clients) + { + var dtoNames = clients + .SelectMany(static client => client.Operations) + .Select(static operation => operation.Envelope.EnvelopeDtoTypeName) + .OfType(); + return new RegistryPlan + { + // Uniqueness is guaranteed by CheckDtoNameCollisions before composition. + TypeNames = [.. schemaRegistry.TypeNames.Concat(dtoNames).Order(StringComparer.Ordinal)], + }; + } + + /// + /// Property overrides follow the same collapse as reachable keys: a row keyed to an + /// alias source must land on the canonical schema the type binder actually looks up, + /// and two rows collapsing onto one logical property refuse instead of one silently + /// winning. + /// + private static ReadOnlyCollection MapOverrideKeys(IReadOnlyList overrides, + IReadOnlyList aliases, BindingErrorCollector errors) + { + // Tolerant of duplicated sources for the same reason as MapReachableKeys. + var map = new Dictionary(StringComparer.Ordinal); + foreach (var alias in aliases) + { + map[alias.Schema] = alias.AliasOf; + } + + var seen = new HashSet(StringComparer.Ordinal); + var canonical = new List(overrides.Count); + foreach (var propertyOverride in overrides) + { + var schema = map.GetValueOrDefault(propertyOverride.Schema, propertyOverride.Schema); + if (!seen.Add($"{schema}\0{propertyOverride.Property}")) + { + errors.Add( + BindingErrorCategory.Curation, + $"{schema}.{propertyOverride.Property}", + "property overrides collapse onto the same property after alias resolution"); + } + + canonical.Add(string.Equals(schema, propertyOverride.Schema, StringComparison.Ordinal) + ? propertyOverride + : propertyOverride with { Schema = schema }); + } + + 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/ClientEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs index 36ee02ad..a2b8d8fc 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs @@ -69,9 +69,13 @@ private static GeneratedSource EmitClient(ClientPlan client) "System.Threading.Tasks", "OpenCode.Sdk.Internal", "OpenCode.Sdk.Internal.ResponseAdapters", + "OpenCode.Sdk.Internal.Serialization", + "OpenCode.Sdk.Models", ], [declaration]); - return EmissionSyntax.CreateSource($"{client.Name}.cs", unit); + return EmissionSyntax.CreateSource( + client.ContainerName is null ? $"{client.Name}.cs" : $"{client.ContainerName}/{client.Name}.cs", + unit); } private static string Summary(ClientPlan client) => client.Role switch @@ -109,34 +113,12 @@ private static IEnumerable EmitRootConstructors(ClientP yield return SyntaxFactory.ConstructorDeclaration(client.Name) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Parameter(SyntaxFactory.Identifier("endpoint")).WithType(TypeSyntaxEmitter.EmitNamed("Uri"))))) - .WithInitializer(SyntaxFactory.ConstructorInitializer( - SyntaxKind.ThisConstructorInitializer, - SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( - [ - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("endpoint")), - SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)) - .WithNameColon(SyntaxFactory.NameColon("options")), - ])))) - .WithBody(SyntaxFactory.Block()) - .WithLeadingTrivia(EmissionSyntax.MemberDocumentation( - "Initializes a client that owns its connection to the endpoint.", - [new DocumentedParameter("endpoint", "The absolute HTTP or HTTPS server endpoint.")])); - yield return SyntaxFactory.ConstructorDeclaration(client.Name) - .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) - .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( - [ - SyntaxFactory.Parameter(SyntaxFactory.Identifier("endpoint")).WithType(TypeSyntaxEmitter.EmitNamed("Uri")), SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")) - .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("OpenCodeClientOptions"))), - ]))) - .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, "endpoint"))) + .WithType(TypeSyntaxEmitter.EmitNamed("OpenCodeClientOptions"))))) + .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, httpClientArgument: null))) .WithLeadingTrivia(EmissionSyntax.MemberDocumentation( "Initializes a client that owns its connection to the endpoint.", - [ - new DocumentedParameter("endpoint", "The absolute HTTP or HTTPS server endpoint."), - new DocumentedParameter("options", "The client options; its endpoint must stay unset on this path."), - ])); + [new DocumentedParameter("options", "The client options; the endpoint is required.")])); yield return SyntaxFactory.ConstructorDeclaration(client.Name) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( @@ -144,17 +126,26 @@ private static IEnumerable EmitRootConstructors(ClientP SyntaxFactory.Parameter(SyntaxFactory.Identifier("httpClient")).WithType(TypeSyntaxEmitter.EmitNamed("HttpClient")), SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(TypeSyntaxEmitter.EmitNamed("OpenCodeClientOptions")), ]))) - .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, "httpClient"))) + .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, httpClientArgument: "httpClient"))) .WithLeadingTrivia(EmissionSyntax.MemberDocumentation( - "Initializes a client over a caller-owned HttpClient; the SDK never disposes it.", + "Initializes a client over a caller-owned HttpClient; the SDK never disposes it. " + + "With a password set, the SDK's per-request Authorization header overrides any client default; " + + "in anonymous mode a default Authorization header is refused at construction and before every send.", [ new DocumentedParameter("httpClient", "The caller-owned HTTP client."), - new DocumentedParameter("options", "The client options; the endpoint is required on this path."), + new DocumentedParameter("options", "The client options; the endpoint is required."), ])); } - private static List EmitRootAssignments(ClientPlan client, string firstArgument) + private static List EmitRootAssignments(ClientPlan client, string? httpClientArgument) { + var createArguments = new List(); + if (httpClientArgument is not null) + { + createArguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName(httpClientArgument))); + } + + createArguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("options"))); var statements = new List { SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( @@ -162,11 +153,7 @@ private static List EmitRootAssignments(ClientPlan client, stri SyntaxFactory.IdentifierName("_pipeline"), EmissionSyntax.Invocation( EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Pipeline"), "Create"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName(firstArgument)), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("options")), - SyntaxFactory.Argument(SyntaxFactory.ObjectCreationExpression( - TypeSyntaxEmitter.EmitNamed("SystemEnvironmentProvider")) - .WithArgumentList(SyntaxFactory.ArgumentList()))))), + [.. createArguments]))), }; statements.AddRange(client.SubClients.Select(static reference => (StatementSyntax)SyntaxFactory.ExpressionStatement( SyntaxFactory.AssignmentExpression( @@ -209,7 +196,7 @@ private static IEnumerable EmitHandleConstructor(Client ?? throw new InvalidOperationException($"Handle client '{client.Name}' carries no handle parameter."); var statements = new List(); statements.AddRange(EmissionSyntax.ArgumentNullGuard("pipeline")); - statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(parameter.Name)); + statements.AddRange(EmissionSyntax.RouteValueGuard(parameter.Name)); statements.Add(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, SyntaxFactory.IdentifierName("_pipeline"), @@ -283,7 +270,7 @@ private static PropertyDeclarationSyntax EmitSubClientProperty(ClientPlan client private static MethodDeclarationSyntax EmitHandleFactory(ClientPlan client, HandleFactoryPlan factory) { var statements = new List(); - statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(factory.Parameter.Name)); + statements.AddRange(EmissionSyntax.RouteValueGuard(factory.Parameter.Name)); statements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression( TypeSyntaxEmitter.EmitNamed(factory.HandleTypeName)) .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs index 493f54ee..3fd81317 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs @@ -156,6 +156,43 @@ public static IReadOnlyList ArgumentNullOrEmptyGuard(string par return Array.AsReadOnly([guard]); } + /// + /// The guard pair every route value passes — non-whitespace and never a dot segment — + /// emitted identically wherever a route value enters, so acceptance at one boundary + /// can never outrun refusal at another. + /// + public static IReadOnlyList RouteValueGuard(string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(parameterName); + var parameter = SyntaxFactory.IdentifierName(parameterName); + var whitespaceGuard = SyntaxFactory.ExpressionStatement(Invocation( + MemberAccess(SyntaxFactory.IdentifierName("ArgumentException"), "ThrowIfNullOrWhiteSpace"), + SyntaxFactory.Argument(parameter))); + var dotSegmentGuard = SyntaxFactory.IfStatement( + SyntaxFactory.IsPatternExpression( + parameter, + SyntaxFactory.BinaryPattern( + SyntaxKind.OrPattern, + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("."))), + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(".."))))), + SyntaxFactory.Block(SyntaxFactory.ThrowStatement(SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.IdentifierName("ArgumentException")) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("Route values must not be dot segments."))), + SyntaxFactory.Argument(Invocation( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.Argument(parameter))), + ])))))); + return Array.AsReadOnly([whitespaceGuard, dotSegmentGuard]); + } + private static string NormalizeDocumentation(string value) { var result = new StringBuilder(value.Length); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs new file mode 100644 index 00000000..dca95a9e --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs @@ -0,0 +1,91 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +/// +/// Emits the internal single-pass deserialization DTO of every wrapped envelope; required +/// properties make a missing wire member a deserialization failure, so the adapters' +/// protocol walls hold without a second parse. +/// +internal static class EnvelopeDtoEmitter +{ + public static IReadOnlyList Emit(IReadOnlyList clients) + { + ArgumentNullException.ThrowIfNull(clients); + + return Array.AsReadOnly( + [ + .. clients + .SelectMany(static client => client.Operations) + .Where(static operation => operation.Envelope.EnvelopeDtoTypeName is not null) + .OrderBy(static operation => operation.Envelope.EnvelopeDtoTypeName, StringComparer.Ordinal) + .Select(static operation => EmitDto(operation.Envelope)), + ]); + } + + private static GeneratedSource EmitDto(EnvelopePlan envelope) + { + var data = EmitProperty("data", "Data", EmitDataType(envelope)); + if (envelope.Kind is EnvelopeKind.CursorList) + { + // 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. + data = data.AddAttributeLists(EmissionSyntax.Attribute( + "JsonConverter", + SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.Generic( + "NullElementRejectingListJsonConverter", + TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)))))); + } + + var members = new List { data, }; + if (envelope.Kind is EnvelopeKind.CursorList) + { + members.Add(EmitProperty("cursor", "Cursor", TypeSyntaxEmitter.EmitNamed("ListCursor"))); + } + + var declaration = SyntaxFactory.RecordDeclaration( + SyntaxFactory.Token(SyntaxKind.RecordKeyword), + envelope.EnvelopeDtoTypeName!) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.InternalKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + .WithMembers(SyntaxFactory.List(members)) + .WithLeadingTrivia(EmissionSyntax.Documentation( + $"Carries the wire body of '{envelope.ResponseTypeName}' through one deserialization pass.")); + var unit = EmissionSyntax.CompilationUnit( + "OpenCode.Sdk.Internal.Serialization", + ["System.Text.Json.Serialization", "OpenCode.Sdk.Models"], + [declaration]); + return EmissionSyntax.CreateSource($"Internal/Serialization/{envelope.EnvelopeDtoTypeName}.cs", unit); + } + + private static TypeSyntax EmitDataType(EnvelopePlan envelope) => envelope.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."), + }; + + private static PropertyDeclarationSyntax EmitProperty(string wireName, string name, TypeSyntax type) => + SyntaxFactory.PropertyDeclaration(type, name) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.RequiredKeyword))) + .WithAccessorList(SyntaxFactory.AccessorList(SyntaxFactory.List( + [ + SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + SyntaxFactory.AccessorDeclaration(SyntaxKind.InitAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + ]))) + .AddAttributeLists(EmissionSyntax.Attribute( + "JsonPropertyName", + SyntaxFactory.AttributeArgument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(wireName))))); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs index 62d4d753..325e168c 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs @@ -25,6 +25,25 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) { var envelope = operation.Envelope; var fieldName = $"_{CSharpNamePolicy.ToCamelCase(envelope.PayloadName)}"; + var payloadType = PayloadType(envelope); + var members = new List + { + EmitBackingField(fieldName, payloadType), + }; + if (envelope.Kind is EnvelopeKind.CursorList) + { + members.Add(EmitBackingField("_cursor", TypeSyntaxEmitter.EmitNamed("ListCursor"))); + } + + members.Add(EmitSuccessConstructor(envelope.ResponseTypeName)); + members.Add(EmitErrorConstructor(envelope)); + members.Add(EmitPayloadProperty(envelope, payloadType, fieldName)); + if (envelope.Kind is EnvelopeKind.CursorList) + { + 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), @@ -33,26 +52,23 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("OpenCodeResponse"))))) .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) - .WithMembers(SyntaxFactory.List( - [ - EmitBackingField(fieldName, envelope.PayloadTypeName), - EmitSuccessConstructor(envelope.ResponseTypeName), - EmitErrorConstructor(envelope), - EmitPayloadProperty(envelope, fieldName), - EmitPrintMembers(envelope.PayloadName, fieldName), - ])) + .WithMembers(SyntaxFactory.List(members)) .WithLeadingTrivia(EmissionSyntax.Documentation( $"Represents the response of the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' operation.")); - var unit = EmissionSyntax.CompilationUnit( - "OpenCode.Sdk", - ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Models"], - [declaration]); - return EmissionSyntax.CreateSource($"{envelope.ResponseTypeName}.cs", unit); + 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); } - private static FieldDeclarationSyntax EmitBackingField(string fieldName, string payloadTypeName) => + private static TypeSyntax PayloadType(EnvelopePlan envelope) => envelope.Kind is EnvelopeKind.CursorList + ? TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)) + : TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName); + + private static FieldDeclarationSyntax EmitBackingField(string fieldName, TypeSyntax payloadType) => SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration( - SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(payloadTypeName)), + SyntaxFactory.NullableType(payloadType), SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator(fieldName)))) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PrivateKeyword), @@ -71,7 +87,7 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en // The payload assignment is the SDK's single null-forgiveness: the guarded getter // makes the null unobservable. - var assignments = new StatementSyntax[] + var assignments = new List { Assign("Status", SyntaxFactory.IdentifierName("status")), Assign("IsError", SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression)), @@ -81,6 +97,12 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en SyntaxKind.SuppressNullableWarningExpression, SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), }; + if (envelope.Kind is EnvelopeKind.CursorList) + { + assignments.Add(Assign("Cursor", SyntaxFactory.PostfixUnaryExpression( + SyntaxKind.SuppressNullableWarningExpression, + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))); + } return SyntaxFactory.ConstructorDeclaration(responseTypeName) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.InternalKeyword))) .AddAttributeLists(EmissionSyntax.Attribute("SetsRequiredMembers")) @@ -98,9 +120,48 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en "Initializes an error-path instance; the payload stays unset behind its guard.")); } - private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelope, string fieldName) + private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelope, TypeSyntax payloadType, string fieldName) { - var guardMessage = $"The response is an error; check IsError before accessing {envelope.PayloadName}."; + ExpressionSyntax initValue = SyntaxFactory.IdentifierName("value"); + if (envelope.Kind is EnvelopeKind.CursorList) + { + initValue = DefensiveListCopy(); + } + return EmitGuardedProperty( + payloadType, + envelope.PayloadName, + fieldName, + initValue, + $"Gets the {envelope.PayloadName} payload; guarded on the error path."); + } + + private static PropertyDeclarationSyntax EmitCursorProperty() => + EmitGuardedProperty( + TypeSyntaxEmitter.EmitNamed("ListCursor"), + "Cursor", + "_cursor", + SyntaxFactory.IdentifierName("value"), + "Gets the page cursor; 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 + /// null passes through uncopied and stays behind the getter guard. + /// + private static ConditionalExpressionSyntax DefensiveListCopy() => + SyntaxFactory.ConditionalExpression( + SyntaxFactory.IsPatternExpression( + SyntaxFactory.IdentifierName("value"), + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression), + EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ListPayloadInput"), "CopyRejectingNullElements"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("value")))); + + private static PropertyDeclarationSyntax EmitGuardedProperty(TypeSyntax propertyType, string propertyName, + string fieldName, ExpressionSyntax initValue, string documentation) + { + var guardMessage = $"The response is an error; check IsError before accessing {propertyName}."; var getter = SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(SyntaxFactory.BinaryExpression( SyntaxKind.CoalesceExpression, @@ -116,15 +177,14 @@ private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelo .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, SyntaxFactory.IdentifierName(fieldName), - SyntaxFactory.IdentifierName("value")))) + initValue))) .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); - return SyntaxFactory.PropertyDeclaration(TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName), envelope.PayloadName) + return SyntaxFactory.PropertyDeclaration(propertyType, propertyName) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.RequiredKeyword))) .WithAccessorList(SyntaxFactory.AccessorList(SyntaxFactory.List([getter, setter]))) - .WithLeadingTrivia(EmissionSyntax.Documentation( - $"Gets the {envelope.PayloadName} payload; guarded on the error path.")); + .WithLeadingTrivia(EmissionSyntax.Documentation(documentation)); } private static MethodDeclarationSyntax EmitPrintMembers(string payloadName, string fieldName) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs index 42500ee3..faf0dfd8 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs @@ -9,16 +9,25 @@ namespace OpenCode.Sdk.Tools.Generator.Emission; internal static class ModelEmitter { + private static readonly IReadOnlySet PrimitiveValueTypeNames = + new HashSet(StringComparer.Ordinal) { "bool", "long", "double", }; + public static IReadOnlyList Emit(EmitPlan plan) { ArgumentNullException.ThrowIfNull(plan); var unions = plan.Unions.ToDictionary(static union => union.Name, StringComparer.Ordinal); + + // The wire-null-rejecting converter splits by class-versus-struct; generated enums are + // the only value types beyond the primitive map. + var valueTypeNames = new HashSet(PrimitiveValueTypeNames, StringComparer.Ordinal); + valueTypeNames.UnionWith(plan.Models.OfType().Select(static model => model.Name)); + var result = new List(plan.Models.Count); foreach (var model in plan.Models.OrderBy(static model => model.Name, StringComparer.Ordinal)) { result.Add(model switch { - ObjectModelPlan objectModel => EmitObject(objectModel, ResolveBaseUnion(objectModel, unions), unions), + ObjectModelPlan objectModel => EmitObject(objectModel, ResolveBaseUnion(objectModel, unions), unions, valueTypeNames), EnumModelPlan enumModel => EmitEnum(enumModel), _ => throw new InvalidOperationException($"Unknown model plan '{model.GetType().Name}'."), }); @@ -27,7 +36,8 @@ public static IReadOnlyList Emit(EmitPlan plan) return Array.AsReadOnly([.. result]); } - private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? baseUnion, IReadOnlyDictionary unions) + private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? baseUnion, IReadOnlyDictionary unions, + IReadOnlySet valueTypeNames) { // A variant overrides one abstract marker per level of its union chain: a nested // union's variant carries both its own tag and the fixed outer tag. @@ -46,7 +56,7 @@ private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? base .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)))))) + EmitProperty(property, chainMarkers.Any(wireName => IsDiscriminator(property, wireName)), valueTypeNames)))) .WithLeadingTrivia(EmissionSyntax.Documentation(model.Description ?? $"Represents a {DisplayName(model.Name)} value.")); if (model.BaseTypeName is not null) { @@ -61,7 +71,9 @@ private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? base private static GeneratedSource EmitEnum(EnumModelPlan model) { - var converterType = TypeSyntaxEmitter.Generic("JsonStringEnumConverter", TypeSyntaxEmitter.EmitNamed(model.Name)); + // Strict by contract: the schema types enum values as strings, so the permissive + // default's integer tolerance would admit malformed bodies as undefined members. + var converterType = TypeSyntaxEmitter.Generic("StrictJsonStringEnumConverter", TypeSyntaxEmitter.EmitNamed(model.Name)); var declaration = SyntaxFactory.EnumDeclaration(model.Name) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) .AddAttributeLists(EmissionSyntax.Attribute("JsonConverter", SyntaxFactory.AttributeArgument( @@ -70,11 +82,15 @@ private static GeneratedSource EmitEnum(EnumModelPlan model) .AddAttributeLists(EmissionSyntax.Attribute("JsonStringEnumMemberName", EmissionSyntax.StringArgument(value.WireValue))) .WithLeadingTrivia(EmissionSyntax.Documentation($"Represents the '{value.WireValue}' wire value."))))) .WithLeadingTrivia(EmissionSyntax.Documentation(model.Description ?? $"Defines the supported {DisplayName(model.Name)} values.")); - var unit = EmissionSyntax.CompilationUnit(model.Namespace, ["System.Text.Json.Serialization"], [declaration]); + var unit = EmissionSyntax.CompilationUnit( + model.Namespace, + ["System.Text.Json.Serialization", "OpenCode.Sdk.Internal.Serialization"], + [declaration]); return EmissionSyntax.CreateSource($"Models/{model.Name}.cs", unit); } - private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property, bool isDiscriminator) + private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property, bool isDiscriminator, + IReadOnlySet valueTypeNames) { var declaration = SyntaxFactory.PropertyDeclaration(TypeSyntaxEmitter.Emit(property.Type), property.Name) .AddAttributeLists(EmissionSyntax.Attribute("JsonPropertyName", EmissionSyntax.StringArgument(property.WireName))) @@ -89,6 +105,19 @@ private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property .WithNameEquals(SyntaxFactory.NameEquals("Condition")))); } + if (RejectsWireNull(property) && property.Type is NamedTypeReferencePlan named) + { + // The C# type stays nullable for absence; an explicit wire null is a contract + // violation the converter turns into a JsonException. + var inner = TypeSyntaxEmitter.Emit(named with { IsNullable = false }); + var converter = TypeSyntaxEmitter.Generic( + valueTypeNames.Contains(named.Name) ? "WireNullRejectingValueJsonConverter" : "WireNullRejectingJsonConverter", + inner); + declaration = declaration.AddAttributeLists(EmissionSyntax.Attribute( + "JsonConverter", + SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(converter)))); + } + if (isDiscriminator) { return declaration @@ -306,12 +335,22 @@ private static IReadOnlyList CollectUsings(ObjectModelPlan model) _ = result.Add("OpenCode.Sdk.Internal.Serialization"); } + if (RejectsWireNull(property)) + { + _ = result.Add("OpenCode.Sdk.Internal.Serialization"); + } + CollectTypeUsings(property.Type, result); } return [.. result.Order(StringComparer.Ordinal)]; } + /// An optional non-collection property whose schema does not admit null rejects an + /// explicit wire null; required properties never reach the serializer as null-for-absence. + private static bool RejectsWireNull(ModelPropertyPlan property) => + !property.IsRequired && !property.Type.IsCollection && !property.AllowsWireNull; + private static void CollectTypeUsings(TypeReferencePlan type, ISet usings) { switch (type) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs index 976cb45d..4f7526db 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs @@ -23,6 +23,11 @@ public static MethodDeclarationSyntax Emit(OperationPlan operation) statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(parameter.Name)); } + if (operation.RequestBody is { IsOptional: false } requiredBody) + { + statements.AddRange(EmissionSyntax.ArgumentNullGuard(requiredBody.ParameterName)); + } + statements.Add(SyntaxFactory.ReturnStatement(EmitDelegation(operation))); return SyntaxFactory.MethodDeclaration( TypeSyntaxEmitter.Generic("Task", TypeSyntaxEmitter.EmitNamed(operation.Envelope.ResponseTypeName)), @@ -30,12 +35,13 @@ public static MethodDeclarationSyntax Emit(OperationPlan operation) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.VirtualKeyword))) - .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(EmitParameters(methodParameters)))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(EmitParameters(operation, methodParameters)))) .WithBody(SyntaxFactory.Block(statements)) .WithLeadingTrivia(EmitDocumentation(operation, methodParameters)); } - private static IEnumerable EmitParameters(IReadOnlyList methodParameters) + private static IEnumerable EmitParameters(OperationPlan operation, + IReadOnlyList methodParameters) { foreach (var parameter in methodParameters) { @@ -43,7 +49,23 @@ private static IEnumerable EmitParameters(IReadOnlyList EmitParameters(IReadOnlyList - EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Pipeline"), "ExecuteAsync"), + private static InvocationExpressionSyntax EmitDelegation(OperationPlan operation) + { + var arguments = new List + { SyntaxFactory.Argument(EmissionSyntax.MemberAccess( SyntaxFactory.IdentifierName("HttpMethod"), CSharpNamePolicy.ToPascalCase(operation.HttpMethod))), SyntaxFactory.Argument(EmitRoute(operation)), - SyntaxFactory.Argument(EmissionSyntax.MemberAccess( - SyntaxFactory.IdentifierName(operation.Envelope.AdapterTypeName), - "Instance")), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("options")), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("cancellationToken"))); + }; + if (operation.RequestBody is not null) + { + arguments.Add(SyntaxFactory.Argument(EmitBodyArgument(operation.RequestBody))); + arguments.Add(SyntaxFactory.Argument(EmissionSyntax.MemberAccess( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), + operation.RequestBody.TypeName))); + } + + arguments.Add(SyntaxFactory.Argument(EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName(operation.Envelope.AdapterTypeName), + "Instance"))); + arguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("requestOptions"))); + arguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("cancellationToken"))); + return EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Pipeline"), "ExecuteAsync"), + [.. arguments]); + } + + /// An omitted optional body still sends the empty JSON object the wire requires. + private static ExpressionSyntax EmitBodyArgument(RequestBodyPlan body) + { + var parameter = SyntaxFactory.IdentifierName(body.ParameterName); + if (!body.IsOptional) + { + return parameter; + } + + return SyntaxFactory.BinaryExpression( + SyntaxKind.CoalesceExpression, + parameter, + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(body.TypeName)) + .WithArgumentList(SyntaxFactory.ArgumentList())); + } /// Route arguments come from the method for ordinary parameters and from the bound handle state otherwise. private static ExpressionSyntax EmitRoute(OperationPlan operation) @@ -72,17 +124,21 @@ private static ExpressionSyntax EmitRoute(OperationPlan operation) SyntaxFactory.IdentifierName("OpenCodeRoutes"), operation.RouteContainerName), operation.RouteMemberName); - if (operation.Parameters.Count is 0) + if (operation.Parameters.Count is 0 && operation.QueryRequest is null) { return member; } - return EmissionSyntax.Invocation(member, - [ - .. operation.Parameters.Select(static parameter => SyntaxFactory.Argument(parameter.IsHandleParameter - ? SyntaxFactory.IdentifierName(CSharpNamePolicy.ToPascalCase(parameter.Name)) - : SyntaxFactory.IdentifierName(parameter.Name))), - ]); + var arguments = new List(); + arguments.AddRange(operation.Parameters.Select(static parameter => SyntaxFactory.Argument(parameter.IsHandleParameter + ? SyntaxFactory.IdentifierName(CSharpNamePolicy.ToPascalCase(parameter.Name)) + : SyntaxFactory.IdentifierName(parameter.Name)))); + if (operation.QueryRequest is not null) + { + arguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("request"))); + } + + return EmissionSyntax.Invocation(member, [.. arguments]); } private static SyntaxTriviaList EmitDocumentation(OperationPlan operation, IReadOnlyList methodParameters) @@ -90,7 +146,21 @@ private static SyntaxTriviaList EmitDocumentation(OperationPlan operation, IRead var parameters = new List(); parameters.AddRange(methodParameters.Select(static parameter => new DocumentedParameter(parameter.Name, $"The '{parameter.WireName}' route value."))); - parameters.Add(new DocumentedParameter("options", "The per-call options.")); + if (operation.RequestBody is not null) + { + parameters.Add(new DocumentedParameter( + operation.RequestBody.ParameterName, + operation.RequestBody.IsOptional + ? "The request body; an empty body is sent when omitted." + : "The request body.")); + } + + if (operation.QueryRequest is not null) + { + parameters.Add(new DocumentedParameter("request", "The request shaping the query.")); + } + + parameters.Add(new DocumentedParameter("requestOptions", "The per-call options.")); parameters.Add(new DocumentedParameter("cancellationToken", "The cancellation token.")); var exceptions = new List(); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs new file mode 100644 index 00000000..f4abda1b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs @@ -0,0 +1,79 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +/// +/// Emits one public request record per query-carrying operation: derived records inherit the +/// ListRequest trio verbatim, flat records declare every bound query property. +/// +internal static class QueryRequestEmitter +{ + public static IReadOnlyList Emit(IReadOnlyList clients) + { + ArgumentNullException.ThrowIfNull(clients); + + return Array.AsReadOnly( + [ + .. clients + .SelectMany(static client => client.Operations) + .Where(static operation => operation.QueryRequest is not null) + .OrderBy(static operation => operation.QueryRequest!.TypeName, StringComparer.Ordinal) + .Select(static operation => EmitQueryRequest(operation)), + ]); + } + + private static GeneratedSource EmitQueryRequest(OperationPlan operation) + { + var queryRequest = operation.QueryRequest!; + var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), queryRequest.TypeName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + .WithMembers(SyntaxFactory.List( + [ + .. queryRequest.Properties + .Where(static property => !property.IsInherited) + .Select(static property => EmitProperty(property)), + ])) + .WithLeadingTrivia(EmissionSyntax.Documentation( + $"Shapes the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' query.")); + if (queryRequest.DerivesFromListRequest) + { + declaration = declaration.WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("ListRequest"))))); + } + + var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", [], [declaration]); + return EmissionSyntax.CreateSource($"{operation.RouteContainerName}/{queryRequest.TypeName}.cs", unit); + } + + private static PropertyDeclarationSyntax EmitProperty(QueryPropertyPlan property) + { + var accessors = SyntaxFactory.AccessorList(SyntaxFactory.List( + [ + SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + SyntaxFactory.AccessorDeclaration(SyntaxKind.InitAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + ])); + return SyntaxFactory.PropertyDeclaration(EmitPropertyType(property.Kind), property.PropertyName) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .WithAccessorList(accessors) + .WithLeadingTrivia(EmissionSyntax.Documentation( + $"Gets the '{property.WireName}' query value; the server default applies when unset.")); + } + + private static NullableTypeSyntax EmitPropertyType(QueryValueKind kind) => kind switch + { + QueryValueKind.Text => SyntaxFactory.NullableType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword))), + QueryValueKind.PositiveCount => SyntaxFactory.NullableType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword))), + QueryValueKind.ListOrder => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("ListOrder")), + QueryValueKind.SessionParentFilter => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("SessionParentFilter")), + _ => 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 ca2729ac..8e279258 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs @@ -31,6 +31,10 @@ private static GeneratedSource EmitAdapter(OperationPlan operation) .WithBody(SyntaxFactory.Block())); members.Add(EmitInstance(envelope.AdapterTypeName)); members.Add(EmitAdapt(operation)); + if (envelope.Kind is EnvelopeKind.CursorList) + { + members.Add(EmitCursorSuccessHelper(envelope)); + } var declaration = SyntaxFactory.ClassDeclaration(envelope.AdapterTypeName) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.InternalKeyword), @@ -127,16 +131,31 @@ private static MethodDeclarationSyntax EmitAdapt(OperationPlan operation) .WithLeadingTrivia(EmissionSyntax.Documentation("Maps one buffered response onto the typed envelope.")); } - private static ObjectCreationExpressionSyntax EmitSuccessCreation(EnvelopePlan envelope) + /// + /// Every success path is one deserialization pass: bare bodies read the payload type, + /// wrapped bodies read their internal envelope DTO and project its members. + /// + private static ExpressionSyntax EmitSuccessCreation(EnvelopePlan envelope) { - var reader = envelope.HasDataEnvelope ? "ReadDataPayload" : "ReadBarePayload"; - var payload = EmissionSyntax.Invocation( - SyntaxFactory.IdentifierName(reader), + var read = EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("ReadBarePayload"), SyntaxFactory.Argument(SyntaxFactory.IdentifierName("rawBody")), SyntaxFactory.Argument(EmissionSyntax.MemberAccess( EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), - envelope.PayloadTypeName))); - return SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) + envelope.EnvelopeDtoTypeName ?? envelope.PayloadTypeName))); + 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)), + }; + } + + private static ObjectCreationExpressionSyntax EmitSuccessInitializer(EnvelopePlan envelope, ExpressionSyntax payload) => + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) .WithInitializer(SyntaxFactory.InitializerExpression( SyntaxKind.ObjectInitializerExpression, SyntaxFactory.SeparatedList( @@ -150,7 +169,40 @@ private static ObjectCreationExpressionSyntax EmitSuccessCreation(EnvelopePlan e SyntaxFactory.IdentifierName(envelope.PayloadName), 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") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PrivateKeyword), + SyntaxFactory.Token(SyntaxKind.StaticKeyword))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Parameter(SyntaxFactory.Identifier("status")) + .WithType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword))), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("envelope")) + .WithType(TypeSyntaxEmitter.EmitNamed(envelope.EnvelopeDtoTypeName!)), + ]))) + .WithExpressionBody(SyntaxFactory.ArrowExpressionClause( + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) + .WithInitializer(SyntaxFactory.InitializerExpression( + SyntaxKind.ObjectInitializerExpression, + SyntaxFactory.SeparatedList( + [ + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("Status"), + SyntaxFactory.IdentifierName("status")), + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName(envelope.PayloadName), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), "Data")), + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("Cursor"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), "Cursor")), + ]))))) + .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 7184756a..d69a31b9 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs @@ -23,9 +23,17 @@ public static GeneratedSource Emit(IReadOnlyList clients) SyntaxFactory.Token(SyntaxKind.StaticKeyword))) .WithMembers(SyntaxFactory.List(containers)) .WithLeadingTrivia(EmissionSyntax.Documentation("Defines the wire route of every generated operation.")); - var usings = operations.Any(static operation => operation.Parameters.Count > 0) - ? new[] { "System" } - : []; + var usings = new List(); + if (operations.Any(static operation => operation.Parameters.Count > 0)) + { + usings.Add("System"); + } + + if (operations.Any(static operation => operation.QueryRequest is not null)) + { + usings.Add("OpenCode.Sdk.Internal"); + } + var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", usings, [declaration]); return EmissionSyntax.CreateSource("OpenCodeRoutes.cs", unit); } @@ -35,7 +43,7 @@ private static ClassDeclarationSyntax EmitContainer(IGrouping(); foreach (var operation in container.OrderBy(static operation => operation.RouteMemberName, StringComparer.Ordinal)) { - if (operation.Parameters.Count is 0) + if (operation.Parameters.Count is 0 && operation.QueryRequest is null) { members.Add(EmitConst( operation.RouteMemberName, @@ -76,14 +84,29 @@ private static MethodDeclarationSyntax EmitBuilder(OperationPlan operation) var statements = new List(); foreach (var parameter in operation.Parameters) { - statements.AddRange(EmitRouteValueGuard(parameter.Name)); + statements.AddRange(EmissionSyntax.RouteValueGuard(parameter.Name)); + } + + if (operation.QueryRequest is null) + { + statements.Add(SyntaxFactory.ReturnStatement(EmitConcatenation(operation))); + } + else + { + statements.AddRange(EmitQueryComposition(operation)); + } + + var parameters = new List(); + parameters.AddRange(operation.Parameters.Select(static parameter => + new DocumentedParameter(parameter.Name, $"The '{parameter.WireName}' route value."))); + if (operation.QueryRequest is not null) + { + parameters.Add(new DocumentedParameter("request", "The request shaping the query.")); } - statements.Add(SyntaxFactory.ReturnStatement(EmitConcatenation(operation))); var documentation = EmissionSyntax.MemberDocumentation( $"Builds the '{operation.RouteTemplate}' route.", - [.. operation.Parameters.Select(static parameter => - new DocumentedParameter(parameter.Name, $"The '{parameter.WireName}' route value."))], + parameters, "The escaped route."); return SyntaxFactory.MethodDeclaration( SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), @@ -91,42 +114,86 @@ [.. operation.Parameters.Select(static parameter => .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.StaticKeyword))) - .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(operation.Parameters.Select( - static parameter => SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameter.Name)) - .WithType(TypeSyntaxEmitter.EmitNamed(parameter.TypeName)))))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(EmitBuilderParameters(operation)))) .WithBody(SyntaxFactory.Block(statements)) .WithLeadingTrivia(documentation); } - /// - /// Guards one route value: null/empty/whitespace is refused, and so are the dot segments - /// Uri canonicalization would silently rewrite into a different request path. - /// - private static StatementSyntax[] EmitRouteValueGuard(string parameterName) + private static IEnumerable EmitBuilderParameters(OperationPlan operation) + { + foreach (var parameter in operation.Parameters) + { + yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameter.Name)) + .WithType(TypeSyntaxEmitter.EmitNamed(parameter.TypeName)); + } + + if (operation.QueryRequest is not null) + { + yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier("request")) + .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(operation.QueryRequest.TypeName))) + .WithDefault(SyntaxFactory.EqualsValueClause(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))); + } + } + + /// An unset request short-circuits to the bare path; a set one appends the composed query suffix. + private static IEnumerable EmitQueryComposition(OperationPlan operation) { - var parameter = SyntaxFactory.IdentifierName(parameterName); - var whitespaceGuard = SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ArgumentException"), "ThrowIfNullOrWhiteSpace"), - SyntaxFactory.Argument(parameter))); - var dotSegmentGuard = SyntaxFactory.IfStatement( + yield return SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration( + SyntaxFactory.IdentifierName("var"), + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("path") + .WithInitializer(SyntaxFactory.EqualsValueClause(EmitConcatenation(operation)))))); + yield return SyntaxFactory.IfStatement( SyntaxFactory.IsPatternExpression( - parameter, - SyntaxFactory.BinaryPattern( - SyntaxKind.OrPattern, - SyntaxFactory.ConstantPattern(StringLiteral(".")), - SyntaxFactory.ConstantPattern(StringLiteral("..")))), - SyntaxFactory.Block(SyntaxFactory.ThrowStatement(SyntaxFactory.ObjectCreationExpression( - SyntaxFactory.IdentifierName("ArgumentException")) - .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( - [ - SyntaxFactory.Argument(StringLiteral("Route values must not be dot segments.")), - SyntaxFactory.Argument(EmissionSyntax.Invocation( - SyntaxFactory.IdentifierName("nameof"), - SyntaxFactory.Argument(parameter))), - ])))))); - return [whitespaceGuard, dotSegmentGuard]; + SyntaxFactory.IdentifierName("request"), + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), + SyntaxFactory.Block(SyntaxFactory.ReturnStatement(SyntaxFactory.IdentifierName("path")))); + 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) + { + var arguments = new List + { + SyntaxFactory.Argument(StringLiteral(property.WireName)), + SyntaxFactory.Argument(EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName("request"), + property.PropertyName)), + }; + if (property.Kind is QueryValueKind.PositiveCount) + { + // The count guard reports the builder's own parameter, not the helper's. + arguments.Add(SyntaxFactory.Argument(EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("request"))))); + } + + yield return SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("query"), QueryAddMethod(property.Kind)), + [.. arguments])); + } + + yield return SyntaxFactory.ReturnStatement(SyntaxFactory.BinaryExpression( + SyntaxKind.AddExpression, + SyntaxFactory.IdentifierName("path"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("query"), "Value"))); } + private static string QueryAddMethod(QueryValueKind kind) => kind switch + { + QueryValueKind.Text => "AddText", + QueryValueKind.PositiveCount => "AddCount", + QueryValueKind.ListOrder => "AddOrder", + QueryValueKind.SessionParentFilter => "AddParentFilter", + _ => throw new InvalidOperationException($"Query value kind '{kind}' has no query-builder method."), + }; + + /// + /// Guards one route value: null/empty/whitespace is refused, and so are the dot segments + /// Uri canonicalization would silently rewrite into a different request path. + /// /// Folds the template into literal + escaped-parameter concatenation, in template order. private static ExpressionSyntax EmitConcatenation(OperationPlan operation) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs index b2e1c8fe..74bf810a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs @@ -15,6 +15,8 @@ public static IReadOnlyList Emit(EmitPlan plan) if (plan.Clients.Count > 0) { sources.Add(RoutesEmitter.Emit(plan.Clients)); + sources.AddRange(QueryRequestEmitter.Emit(plan.Clients)); + sources.AddRange(EnvelopeDtoEmitter.Emit(plan.Clients)); sources.AddRange(EnvelopeEmitter.Emit(plan.Clients)); sources.AddRange(ResponseAdapterEmitter.Emit(plan.Clients)); sources.AddRange(ClientEmitter.Emit(plan.Clients)); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs index 4202d690..c6ef4616 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs @@ -12,12 +12,13 @@ internal static class UnionEmitter public static IReadOnlyList Emit(IReadOnlyList unions) { ArgumentNullException.ThrowIfNull(unions); - var result = new List(unions.Count * 3); + var result = new List(unions.Count * 4); foreach (var union in unions.OrderBy(static union => union.Name, StringComparer.Ordinal)) { result.Add(EmitBase(union)); result.Add(EmitUnknown(union)); result.Add(EmitConverter(union)); + result.Add(EmitCarrierConverter(union)); } return Array.AsReadOnly([.. result]); @@ -74,6 +75,10 @@ private static GeneratedSource EmitUnknown(UnionPlan union) } members.Add(EmitUnknownPayloadProperty()); + + // The concrete-type converter keeps consumer serialization of the carrier itself + // reproducing the preserved document; without it, source-generated metadata would + // write the carrier as an ordinary record. var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), union.UnknownTypeName) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PublicKeyword), @@ -82,15 +87,104 @@ private static GeneratedSource EmitUnknown(UnionPlan union) .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed(union.Name))))) + .AddAttributeLists(EmissionSyntax.Attribute( + "JsonConverter", + SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression( + TypeSyntaxEmitter.EmitNamed($"{union.UnknownTypeName}JsonConverter"))))) .WithMembers(SyntaxFactory.List(members)) .WithLeadingTrivia(EmissionSyntax.Documentation($"Preserves an unknown {DisplayName(union.Name)} payload.")); var usingNames = union.MarkerKind is LiteralKind.String - ? new[] { "System", "System.Text.Json", "System.Text.Json.Serialization", } - : ["System.Text.Json", "System.Text.Json.Serialization"]; + ? new[] { "System", "System.Text.Json", "System.Text.Json.Serialization", "OpenCode.Sdk.Internal.Serialization", } + : ["System.Text.Json", "System.Text.Json.Serialization", "OpenCode.Sdk.Internal.Serialization"]; var unit = EmissionSyntax.CompilationUnit(union.Namespace, usingNames, [declaration]); return EmissionSyntax.CreateSource($"Models/{union.UnknownTypeName}.cs", unit); } + /// + /// The carrier's own converter: reading reproduces the base converter's fallback arm, + /// writing replays the preserved document. + /// + private static GeneratedSource EmitCarrierConverter(UnionPlan union) + { + var converterName = $"{union.UnknownTypeName}JsonConverter"; + var readStatements = new List(); + readStatements.AddRange(EmissionSyntax.ArgumentNullGuard("typeToConvert")); + readStatements.AddRange(EmissionSyntax.ArgumentNullGuard("options")); + readStatements.AddRange( + [ + EmitNullTokenCheck(union), + EmitPayloadDocument(), + Local("payload", EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("document"), "RootElement")), + EmitObjectPayloadCheck(union), + ]); + readStatements.AddRange(EmitFixedMarkerCheck(union)); + readStatements.Add(EmitMarkerPresenceCheck(union)); + readStatements.AddRange(EmitMarkerRead(union)); + readStatements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.IdentifierName(union.UnknownTypeName)) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("marker")), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("payload")), + ]))))); + var read = SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(union.UnknownTypeName), "Read") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.OverrideKeyword))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Parameter(SyntaxFactory.Identifier("reader")) + .WithType(SyntaxFactory.IdentifierName("Utf8JsonReader")) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.RefKeyword))), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("typeToConvert")).WithType(SyntaxFactory.IdentifierName("Type")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(SyntaxFactory.IdentifierName("JsonSerializerOptions")), + ]))) + .WithBody(SyntaxFactory.Block(readStatements)); + + var writeStatements = new List(); + writeStatements.AddRange(EmissionSyntax.ArgumentNullGuard("writer")); + writeStatements.AddRange(EmissionSyntax.ArgumentNullGuard("value")); + writeStatements.AddRange(EmissionSyntax.ArgumentNullGuard("options")); + writeStatements.Add(SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("value"), "Payload"), + "WriteTo"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("writer"))))); + var write = SyntaxFactory.MethodDeclaration( + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword)), + "Write") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.OverrideKeyword))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Parameter(SyntaxFactory.Identifier("writer")).WithType(SyntaxFactory.IdentifierName("Utf8JsonWriter")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("value")).WithType(TypeSyntaxEmitter.EmitNamed(union.UnknownTypeName)), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(SyntaxFactory.IdentifierName("JsonSerializerOptions")), + ]))) + .WithBody(SyntaxFactory.Block(writeStatements)); + + var declaration = SyntaxFactory.ClassDeclaration(converterName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.InternalKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.Generic( + "JsonConverter", + TypeSyntaxEmitter.EmitNamed(union.UnknownTypeName)))))) + .WithMembers(SyntaxFactory.List([EmitHandleNull(), read, write])); + var unit = EmissionSyntax.CompilationUnit( + "OpenCode.Sdk.Internal.Serialization", + [ + "OpenCode.Sdk.Models", + "System", + "System.Text.Json", + "System.Text.Json.Serialization", + ], + [declaration]); + return EmissionSyntax.CreateSource($"Internal/Serialization/{converterName}.cs", unit); + } + private static FieldDeclarationSyntax EmitUnknownMarkerField(TypeSyntax markerType) => SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration(markerType) .WithVariables(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("_marker")))) @@ -107,6 +201,26 @@ private static ConstructorDeclarationSyntax EmitUnknownConstructor(UnionPlan uni statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(markerParameterName)); } + // default(JsonElement) has no backing document; Clone would surface a bare + // InvalidOperationException instead of an argument refusal. + statements.Add(SyntaxFactory.IfStatement( + SyntaxFactory.IsPatternExpression( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "ValueKind"), + SyntaxFactory.ConstantPattern(EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName("JsonValueKind"), + "Undefined"))), + SyntaxFactory.Block(SyntaxFactory.ThrowStatement(SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.IdentifierName("ArgumentException")) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("The payload must be a parsed JSON element."))), + SyntaxFactory.Argument(EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("payload")))), + ]))))))); + statements.Add(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, SyntaxFactory.IdentifierName("_marker"), @@ -231,17 +345,13 @@ private static MethodDeclarationSyntax EmitRead(UnionPlan union) statements.AddRange(EmissionSyntax.ArgumentNullGuard("options")); statements.AddRange( [ - SyntaxFactory.IfStatement( - SyntaxFactory.BinaryExpression( - SyntaxKind.EqualsExpression, - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("reader"), "TokenType"), - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonTokenType"), "Null")), - ThrowJson($"The {union.Name} payload cannot be null.")), + EmitNullTokenCheck(union), EmitPayloadDocument(), Local("payload", EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("document"), "RootElement")), EmitObjectPayloadCheck(union), - EmitMarkerPresenceCheck(union), ]); + statements.AddRange(EmitFixedMarkerCheck(union)); + statements.Add(EmitMarkerPresenceCheck(union)); statements.AddRange(EmitMarkerRead(union)); statements.Add(EmitKnownDispatch(union)); statements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression( @@ -267,6 +377,66 @@ private static MethodDeclarationSyntax EmitRead(UnionPlan union) .WithBody(SyntaxFactory.Block(statements)); } + private static IfStatementSyntax EmitNullTokenCheck(UnionPlan union) => + SyntaxFactory.IfStatement( + SyntaxFactory.BinaryExpression( + SyntaxKind.EqualsExpression, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("reader"), "TokenType"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonTokenType"), "Null")), + ThrowJson($"The {union.Name} payload cannot be null.")); + + /// + /// A nested union's fixed outer tag is structural identity, not dispatch input: a + /// foreign value is a malformed payload, while the inner marker keeps its + /// unknown-variant tolerance. + /// + private static IReadOnlyList EmitFixedMarkerCheck(UnionPlan union) + { + if (union.FixedMarker is not { } marker) + { + return []; + } + + var presence = SyntaxFactory.IfStatement( + SyntaxFactory.PrefixUnaryExpression(SyntaxKind.LogicalNotExpression, EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "TryGetProperty"), + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(marker.WireName))), + SyntaxFactory.Argument(SyntaxFactory.DeclarationExpression( + SyntaxFactory.IdentifierName("var"), + SyntaxFactory.SingleVariableDesignation(SyntaxFactory.Identifier("fixedElement")))) + .WithRefKindKeyword(SyntaxFactory.Token(SyntaxKind.OutKeyword)))), + ThrowJson($"The {union.Name} payload must contain '{marker.WireName}'.")); + var value = SyntaxFactory.IfStatement( + EmitFixedMarkerMismatch(union, marker), + ThrowJson($"The '{marker.WireName}' marker must be '{marker.Value}'.")); + return [presence, value]; + } + + private static BinaryExpressionSyntax EmitFixedMarkerMismatch(UnionPlan union, UnionFixedMarkerPlan marker) => marker.Kind switch + { + LiteralKind.String => SyntaxFactory.BinaryExpression( + SyntaxKind.LogicalOrExpression, + SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("fixedElement"), "ValueKind"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "String")), + SyntaxFactory.PrefixUnaryExpression(SyntaxKind.LogicalNotExpression, EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("fixedElement"), "ValueEquals"), + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(marker.Value)))))), + LiteralKind.Boolean when bool.TryParse(marker.Value, out var flag) => SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("fixedElement"), "ValueKind"), + EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName("JsonValueKind"), + flag ? "True" : "False")), + LiteralKind.Number or _ => throw new InvalidOperationException( + $"Union '{union.Name}' fixes marker '{marker.WireName}' with kind '{marker.Kind}', which has no emission consumer."), + }; + private static LocalDeclarationStatementSyntax EmitPayloadDocument() { var parse = EmissionSyntax.Invocation( @@ -322,7 +492,7 @@ private static IReadOnlyList EmitStringMarkerRead(UnionPlan uni Local("marker", EmissionSyntax.Invocation( EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "GetString"))), // An empty or whitespace marker is malformed input, classified before any dispatch so the - // unknown-variant carrier's own guard can never surface as a BCL escape (ADR-0009). The + // unknown-variant carrier's own guard can never surface as a BCL escape. The // explicit null disjunct carries the non-null flow fact on TFMs whose BCL lacks the // IsNullOrWhiteSpace annotation. SyntaxFactory.IfStatement( diff --git a/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs b/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs index b7b0ec2e..8f6c24f5 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs @@ -4,6 +4,7 @@ using OpenCode.Sdk.Tools.Generator.Binding.Abstractions; using OpenCode.Sdk.Tools.Generator.Emission; using OpenCode.Sdk.Tools.Generator.Ingestion.Abstractions; +using OpenCode.Sdk.Tools.Output; using OpenCode.Sdk.Tools.Output.Abstractions; namespace OpenCode.Sdk.Tools.Generator; @@ -39,13 +40,25 @@ public async Task GenerateAsync(GenerationRequest request, Can var marker = pending.Length > 0 ? CreatePartialMarker(plan.SelectedOperationIds, pending) : null; + // The admitted family folders are the plan's own container names — never an open glob. + var familyFolders = plan.Clients + .SelectMany(static client => client.Operations) + .Select(static operation => operation.RouteContainerName) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + var writeResult = await _writer .WriteAsync( - request.OutputRoot, - request.ProjectPath, - SourceEmitter.Emit(plan), - marker, - request.Verify, + new GenerationWriteRequest + { + OutputRoot = request.OutputRoot, + ProjectPath = request.ProjectPath, + Sources = SourceEmitter.Emit(plan), + FamilyFolders = familyFolders, + PartialMarkerContent = marker, + Verify = request.Verify, + }, cancellationToken) .ConfigureAwait(false); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs index aa5decbf..a0ab5a71 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs @@ -62,9 +62,11 @@ private static HashSet ReadTokens(string path, string location, Ingestio { errors.Add(location, $"path '{path}' contains a malformed template token"); } - else + else if (!tokens.Add(token)) { - tokens.Add(token); + // Route builders substitute each token exactly once; a repeated placeholder + // would leave raw braces in the built route. + errors.Add(location, $"path token '{token}' must appear exactly once in the template"); } index = close + 1; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs index 3b467348..3d9dae50 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs @@ -188,10 +188,20 @@ private bool ValidateSpecialNumberBranches(IList branches, strin /// private bool IsMarkedUnionReference(IOpenApiSchema source) => source is OpenApiSchemaReference { Target: OpenApiSchema target } - && IsPureAnyOfBranch(target) - && target.AnyOf is { Count: > 1 } nested + && (IsPureAnyOfBranch(target) || IsPureOneOfBranch(target)) + && (target.AnyOf is { Count: > 1 } ? target.AnyOf : target.OneOf) is { Count: > 1 } nested && nested.All(branch => _literalClassifier.FindFirstMarker(branch) is not null); + /// The oneOf twin of ; a nested marked union may use either keyword exclusively. + private static bool IsPureOneOfBranch(OpenApiSchema schema) => + schema is { OneOf.Count: > 0, Type: null, Enum: null, Const: null, Items: null, Properties: null, AdditionalProperties: null } + && schema.AnyOf is not { Count: > 0 } + && schema.AllOf is not { Count: > 0 } + && schema.Required is not { Count: > 0 } + && schema.PatternProperties is not { Count: > 0 } + && schema.AdditionalPropertiesAllowed + && schema is { ContentEncoding: null, ContentMediaType: null, ContentSchema: null }; + private static ProjectedBranch[] DeduplicateReferences(IReadOnlyList branches) { var targets = new HashSet(StringComparer.Ordinal); diff --git a/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs index 96b1a9cd..5daba05d 100644 --- a/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs +++ b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs @@ -1,14 +1,6 @@ -using OpenCode.Sdk.Tools.Generator.Emission; - namespace OpenCode.Sdk.Tools.Output.Abstractions; internal interface IGenerationWriter { - public Task WriteAsync( - string outputRoot, - string projectPath, - IReadOnlyList sources, - string? partialMarkerContent, - bool verify, - CancellationToken cancellationToken); + public Task WriteAsync(GenerationWriteRequest request, CancellationToken cancellationToken); } diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationWriteRequest.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationWriteRequest.cs new file mode 100644 index 00000000..9f15c6d7 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Output/GenerationWriteRequest.cs @@ -0,0 +1,65 @@ +using OpenCode.Sdk.Tools.Generator.Emission; + +namespace OpenCode.Sdk.Tools.Output; + +/// One generation write: the sources, their admitted family folders, and the run flags. +internal sealed record GenerationWriteRequest +{ + public required string OutputRoot + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } + + public required string ProjectPath + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } + + public required IReadOnlyList Sources + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } + + /// Gets the client-family folders new sources may live in, derived from the plan's container names. + public required IReadOnlyList FamilyFolders + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } + + /// Gets the partial-generation marker content, or when generation is complete. + public string? PartialMarkerContent + { + get; + init + { + if (value is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + } + + field = value; + } + } + + public required bool Verify { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs index f7eeee55..2063775f 100644 --- a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs +++ b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs @@ -12,27 +12,19 @@ internal sealed class GenerationWriter(IFileSystem fileSystem, IProjectFormatter private const string ManifestRelativePath = ".generated-manifest.json"; private const string MarkerRelativePath = ".generation-incomplete"; private static readonly StringComparer OwnedPathComparer = StringComparer.OrdinalIgnoreCase; + + /// Admitted family folders are single plain segments that never shadow a statically owned directory. + private static readonly string[] StaticallyOwnedFolders = ["Models", "Internal", "Pagination", "Properties"]; private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); private readonly IProjectFormatter _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); - public async Task WriteAsync( - string outputRoot, - string projectPath, - IReadOnlyList sources, - string? partialMarkerContent, - bool verify, - CancellationToken cancellationToken) + public async Task WriteAsync(GenerationWriteRequest request, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot); - ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); - ArgumentNullException.ThrowIfNull(sources); - if (partialMarkerContent is not null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(partialMarkerContent); - } - + ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); - var newSources = ValidateSources(outputRoot, sources); + var outputRoot = request.OutputRoot; + var familyFolders = ValidateFamilyFolders(request.FamilyFolders); + var newSources = ValidateSources(outputRoot, request.Sources, familyFolders); var oldManifest = await LoadManifestAsync(outputRoot, cancellationToken).ConfigureAwait(false); var oldPaths = ValidateManifest(outputRoot, oldManifest); RefuseCaseOnlyPathChanges(oldPaths, newSources.Keys); @@ -50,15 +42,43 @@ public async Task WriteAsync( await WriteSourcesAsync(outputRoot, newSources, cancellationToken).ConfigureAwait(false); DeleteStaleSources(outputRoot, oldPaths, newSources.Keys); await WriteManifestAsync(outputRoot, newSources.Keys, cancellationToken).ConfigureAwait(false); - await WriteMarkerAsync(outputRoot, partialMarkerContent, cancellationToken).ConfigureAwait(false); + await WriteMarkerAsync(outputRoot, request.PartialMarkerContent, cancellationToken).ConfigureAwait(false); var sourcePaths = newSources.Keys.Order(StringComparer.Ordinal).ToArray(); - await _formatter.FormatAsync(projectPath, sourcePaths, cancellationToken).ConfigureAwait(false); + await _formatter.FormatAsync(request.ProjectPath, sourcePaths, cancellationToken).ConfigureAwait(false); var after = await SnapshotAsync(outputRoot, trackedPaths, cancellationToken).ConfigureAwait(false); - return Compare(before, after, verify); + return Compare(before, after, request.Verify); + } + + private static HashSet ValidateFamilyFolders(IReadOnlyList familyFolders) + { + // Owned paths compare case-insensitively (Windows/macOS filesystems), so the + // shadow and duplicate checks must too — 'models' collides with 'Models/' on disk. + var result = new HashSet(OwnedPathComparer); + foreach (var folder in familyFolders) + { + if (string.IsNullOrWhiteSpace(folder) || folder is "." or ".." + || folder.Contains('/', StringComparison.Ordinal) || folder.Contains('\\', StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Family folder '{folder}' is unsafe."); + } + + if (StaticallyOwnedFolders.Contains(folder, OwnedPathComparer)) + { + throw new InvalidOperationException($"Family folder '{folder}' shadows a statically owned directory."); + } + + if (!result.Add(folder)) + { + throw new InvalidOperationException($"Family folder '{folder}' is duplicated."); + } + } + + return result; } - private Dictionary ValidateSources(string outputRoot, IReadOnlyList sources) + private Dictionary ValidateSources(string outputRoot, IReadOnlyList sources, + HashSet familyFolders) { var result = new Dictionary(OwnedPathComparer); foreach (var source in sources) @@ -69,7 +89,7 @@ private Dictionary ValidateSources(string outputRoot, I } ValidateRelativePath(outputRoot, source.RelativePath); - ValidateGeneratedSourcePath(source.RelativePath); + ValidateGeneratedSourcePath(source.RelativePath, familyFolders); // A headerless source would deadlock the next run's provenance requirement. if (!GenerationProvenance.HasHeader(Encoding.UTF8.GetString(source.Utf8Source.Span))) @@ -112,7 +132,7 @@ private HashSet ValidateManifest(string outputRoot, GenerationManifest m foreach (var relativePath in manifest.Files) { ValidateRelativePath(outputRoot, relativePath); - ValidateGeneratedSourcePath(relativePath); + ValidateManifestSourcePath(relativePath); if (!result.Add(relativePath)) { throw new InvalidOperationException($"Generation manifest path '{relativePath}' is duplicated."); @@ -210,18 +230,41 @@ private void ValidateRelativePath(string outputRoot, string relativePath) _ = ResolveOwnedPath(outputRoot, relativePath); } - private static void ValidateGeneratedSourcePath(string relativePath) + /// New sources land in the root, a statically owned directory, or an admitted family folder — one level deep. + private static void ValidateGeneratedSourcePath(string relativePath, HashSet familyFolders) { if (!relativePath.EndsWith(".cs", StringComparison.Ordinal) || !(!relativePath.Contains('/', StringComparison.Ordinal) - || relativePath.StartsWith("Models/", StringComparison.Ordinal) - || relativePath.StartsWith("Internal/Serialization/", StringComparison.Ordinal) - || relativePath.StartsWith("Internal/ResponseAdapters/", StringComparison.Ordinal))) + || IsStaticallyOwnedPath(relativePath) + || IsAdmittedFamilyPath(relativePath, familyFolders))) { throw new InvalidOperationException($"Generated source path '{relativePath}' is outside the owned source directories."); } } + /// + /// Manifest entries validate shape, not family membership: a retired family's files must + /// stay deletable, and the provenance requirement still guards every overwrite and delete. + /// + private static void ValidateManifestSourcePath(string relativePath) + { + if (!relativePath.EndsWith(".cs", StringComparison.Ordinal) + || !(!relativePath.Contains('/', StringComparison.Ordinal) + || IsStaticallyOwnedPath(relativePath) + || relativePath.Split('/').Length is 2)) + { + throw new InvalidOperationException($"Generation manifest path '{relativePath}' is outside the owned source directories."); + } + } + + private static bool IsStaticallyOwnedPath(string relativePath) => + relativePath.StartsWith("Models/", StringComparison.Ordinal) + || relativePath.StartsWith("Internal/Serialization/", StringComparison.Ordinal) + || relativePath.StartsWith("Internal/ResponseAdapters/", StringComparison.Ordinal); + + private static bool IsAdmittedFamilyPath(string relativePath, HashSet familyFolders) => + relativePath.Split('/') is [var folder, _] && familyFolders.Contains(folder); + /// A new path that already exists on disk belongs to somebody else until the manifest owns it. private void RefuseUnmanifestedOverwrites(string outputRoot, HashSet oldPaths, IEnumerable newPaths) { diff --git a/tools/OpenCode.Sdk.Tools/ToolApp.cs b/tools/OpenCode.Sdk.Tools/ToolApp.cs index d7a336b5..3457c30a 100644 --- a/tools/OpenCode.Sdk.Tools/ToolApp.cs +++ b/tools/OpenCode.Sdk.Tools/ToolApp.cs @@ -36,6 +36,7 @@ public static ServiceCollection CreateServices(Action? overr services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tools/curation.json b/tools/curation.json index 29192073..6fbbac58 100644 --- a/tools/curation.json +++ b/tools/curation.json @@ -8,6 +8,12 @@ "clientName": "Sessions", "handleName": "SessionClient", "handleParameter": "sessionID" + }, + "message": { + "placement": "client", + "clientName": "Sessions", + "handleName": "SessionClient", + "handleParameter": "sessionID" } }, "envelopePayloadNames": {}, @@ -24,5 +30,12 @@ "type": "uri", "reason": "The wire field carries a URI, but the pinned schema omits format: uri." } + ], + "schemaAliases": [ + { + "schema": "InvalidRequestError1", + "aliasOf": "InvalidRequestError", + "reason": "Upstream spec-gen emits a structurally identical duplicate carrying the same _tag." + } ] } diff --git a/tools/generation-profile.txt b/tools/generation-profile.txt index 94a70703..83e81290 100644 --- a/tools/generation-profile.txt +++ b/tools/generation-profile.txt @@ -1,2 +1,6 @@ v2.health.get +v2.message.list +v2.session.create +v2.session.get +v2.session.list v2.session.message