feat(sdk-generator): Go SDK + contract-test backends - #57
Merged
calvin-archastro merged 3 commits intoJul 24, 2026
Conversation
Paths outside the versioned API prefix (e.g. /oauth/token, /oauth/device/*) were silently dropped in multi-version mode, so SDKs lost the platform's OAuth surface. They now join the default version's resource tree (client.v1.oauth + the client.oauth alias), matching the behavior the regenerated JS SDK already relies on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK repos (js, python, swift) ship under MIT; "All Rights Reserved" in their generated headers contradicted the LICENSE. Generated files now say "Licensed under the MIT License." Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds `--lang go` and `--lang contract-tests-go`, modeled on the Swift backend: one name registry per package, response-shape classification shared with Python/Swift, and the same inline-object hoisting. Three things Go forces that the other targets don't: - One package per directory, so the whole SDK lands flat in `<out>/<packageName>/` with a role prefix per file, and the contract tests live in a sibling package that imports the SDK by path. Both the package name and that import path come from a new `go` block in the generator config. - Types and functions share one package-level namespace, so channel join and topic helpers (which have to be package-level functions — Go has no static methods) claim names through the same registry as the structs. - No default arguments, so every query-bearing operation takes a single generated `…Params` struct instead of an optional-argument tail. Struct fields follow one rule: refs are always pointers. A Go struct cannot contain itself by value, and it gives every nested model an absent state; optional fields get the same indirection so `omitempty` can tell "unset" from "zero". Slices and maps keep their own nil. The emitter writes structurally correct Go, not column-aligned Go — consuming repos run `gofmt -w` over the output as part of regeneration, which is the same split protoc-gen-go uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review on ArchCode
What changed
Adds a Go target to
@archastro/sdk-generator:--lang goemits a typed Go SDK,--lang contract-tests-goemits the matchinggo testcontract suite. The structure follows the Swift backend — one name registry per output namespace, response-shape classification shared with Python/Swift, the same inline-object hoisting — so the four targets stay recognizably the same generator.Three properties of Go drove the design decisions that differ from the other targets:
One package per directory. Go cannot nest source files inside a package, so the whole SDK lands flat in
<out>/<packageName>/with a role prefix per filename (types_*.go,v1_*.go,channels_*.go,client.go,auth.go). The contract tests must therefore live in a sibling package that imports the SDK by path — which is whyFrontendConfiggains agoblock carrying both the package name and its import path. A pleasant side effect: the generated tests can only reach the SDK's exported surface, so they prove the public API is sufficient.Types and functions share one package-level namespace. Go has no static methods, so each channel's topic builder and join constructor are package-level functions (
APIChatChannelTopicTeamThread,JoinAPIChatChannelTeamThread). Those names claim through the sameGoNameRegistryas the structs, in a fixed order, so the SDK pass and the contract-test pass agree on every identifier.No default arguments. Every query-bearing operation takes a single generated
…Paramsstruct rather than an optional-argument tail. Path and scope parameters stay positional afterctx context.Context.Struct fields follow one rule: refs are always pointers. A Go struct cannot contain itself by value, so this is what keeps recursive schemas legal, and it gives every nested model an absent state. Optional fields take the same indirection so
omitemptycan distinguish "unset" from "zero"; slices and maps keep their own nil.The emitter writes structurally correct Go, not column-aligned Go. Consuming repos run
gofmt -wover the output as part of regeneration and gate CI ongofmt -lbeing empty — the same splitprotoc-gen-gouses between emission andgo/format.Two commits ahead of the Go work were carried over from a branch whose PR already merged:
attach unversioned paths to the default versionandMIT license line in generated-file headers. They are small, unrelated to Go, and were the state the Go backend was developed and tested against.Generation flow
sequenceDiagram participant CLI as sdk-generator CLI participant Frontend as OpenAPI frontend participant Prepare as prepareGoSpec participant Registry as GoNameRegistry participant Emitters as Go emitters participant Disk as writeGoFiles CLI->>Frontend: parseOpenApiSpec with the go config block Frontend-->>CLI: SdkSpec CLI->>Prepare: clone the spec and assign every identifier Prepare->>Registry: claim version namespaces, then schemas Prepare->>Registry: claim auth tokens and resource structs Prepare->>Registry: claim inline inputs, responses, params structs Prepare->>Registry: claim channel structs, join and topic functions Registry-->>Prepare: collision-free package-level names Prepare-->>CLI: prepared spec plus registry alt lang is go CLI->>Emitters: models, resources, namespace, auth, client, channels Emitters-->>Disk: flat package sources else lang is contract-tests-go CLI->>Prepare: prepareGoSpec runs again Note over Prepare,Registry: the fixed claim order reproduces identical names CLI->>Emitters: REST, channel, and SSE stream test files Emitters-->>Disk: sibling test package sources end Disk-->>CLI: files written, stale generated files removedBackend structure
classDiagram class GoBackend { +generateGo(spec, options) GeneratedFiles +prepareGoSpec(spec) PreparedGoSpec +writeGoFiles(files, cleanDirs) void } class GoNameRegistry { +claim(key, preferred) string +lookup(key) string +has(key) bool +nameTaken(name) bool } class TypeMap { +typeRefToGo(ref, resolveRef, runtimePrefix) string +goFieldType(field, resolveRef, runtimePrefix) string +goPointer(base) string +goJSONTag(field) string +renderGoFile(pkg, imports, body) string } class ResponseType { +goResponseShape(op) GoResponseShape +goInlineInputName(className, opName) string +goInlineResponseName(className, opName) string +goParamsStructName(className, opName) string } class ModelEmitter { +emitGoModelsFile(pkg, schemas, registry) string +emitGoStruct(cb, name, fields, registry) void } class ResourceEmitter { +emitGoResourceFile(pkg, resource, registry) string +buildResourceMembers(resource) GoResourceMembers +buildOperationGoNames(op, resource) GoOperationNames +goReturnType(op, registry) string } class ChannelEmitter { +emitGoChannelFile(pkg, channel, registry) string +buildChannelMembers(channel) GoChannelMembers +goChannelJoinParams(pattern, params) SplitParams } class ClientEmitter { +emitGoClientFile(pkg, spec) string } class AuthEmitter { +emitGoAuthFile(pkg, spec, registry) string +buildAuthMethodNames(ops) NameList } class GoContractTests { +emitGoContractTests(spec, options) GeneratedFiles +goAccessorChain(versionSet, call) string } class GoValues { +goTypedValue(ref, name, hoistName, ctx) string +goBodyValue(body, inputName, ctx) string +goParamsValue(op, ctx) string } GoBackend --> GoNameRegistry : owns GoBackend --> ModelEmitter : uses GoBackend --> ResourceEmitter : uses GoBackend --> ChannelEmitter : uses GoBackend --> ClientEmitter : uses GoBackend --> AuthEmitter : uses ModelEmitter --> TypeMap : uses ResourceEmitter --> TypeMap : uses ResourceEmitter --> ResponseType : uses ChannelEmitter --> TypeMap : uses GoValues --> TypeMap : uses GoContractTests --> GoBackend : reuses prepareGoSpec GoContractTests --> ResourceEmitter : reuses member naming GoContractTests --> ResponseType : uses GoContractTests --> GoValues : usesScope
Backend-only, and additive. New files under
src/backends/go/and three new files undersrc/backends/contract-tests/. The only shared code touched iscontract-tests/value-generator.ts(a"go"arm added to the existing language switches),contract-tests/index.ts(dispatch),frontend/config.ts(the new optionalgoblock), andsrc/index.ts(two new--langcases). No TypeScript, Python, or Swift emitter was modified.Risk
Low. Nothing on an existing code path changes shape:
value-generator.tsedits add"go"branches and lift a repeatedemptyDictternary intoemptyDictLiteral(lang), which returns the identical literal fortypescript,python, andswift. The existing 333 generator tests cover those paths and are unchanged and green.FrontendConfig.gois optional; specs and configs that omit it behave exactly as before.--langcases are unreachable unless requested by name.The blast radius of a Go-specific bug is confined to the Go output, which no repo consumes until
archastro-golands and the generator is published.User impact
None for existing SDK consumers — no generated TypeScript, Python, or Swift byte changes. For SDK authors, the CLI gains
--lang goand--lang contract-tests-go, documented in both READMEs along with thegoconfig block.Testing
In-repo:
packages/sdk-generator/__tests__/backends/go.test.ts— 20 tests over identifier casing and Go initialisms, keyword and predeclared-name escaping, registry collision behavior, the type map's pointer rules and JSON tags, model/union emission, the full generated file set, and the contract-test emitter. Two of those assertions guard invariants that only bite at Go compile time, so they are worth naming: every generatedTestXxxfunction name is unique across the whole emitted package, and a test file imports the SDK package if and only if it names a type from it (an unused import is a Go compile error).Full workspace suite is green:
Canonical end-to-end proof — not in this repo, and deliberately so. A code generator's output only becomes executable inside a consuming SDK repo, so there is no honest end-to-end boundary to cross here; the tests above are structural assertions over emitted source strings. The real proof lives in the companion
archastro-gorepo, where this branch's output was generated and run:Those 945 tests are the generated contract suite —
contracttests/v1_*_test.go,channels_*_test.go,streams_*_test.go— driving the generated SDK over real process and network boundaries: a Prism mock subprocess servingspecs/platform-openapi.jsonfor every REST operation and its documented error codes, and the@archastro/channel-harnessservice subprocess over a real WebSocket for the Phoenix channel joins, pushes, server pushes, and leaves, plus a real SSE response for the streaming operations. It is the same harness the TypeScript, Python, and Swift suites drive; there is no in-process shortcut.gofmt -l,go vet ./..., andgo build ./...are clean on that output.That run is reproducible from this branch: regenerating
archastro-gofrom this commit produces byte-identical output to what was tested (verified bydiff -ragainst a pre-regeneration snapshot).Edge cases exercised while getting there — each surfaced as a real failure before being fixed:
nil, which marshals to JSONnulland Prism rejects. They are now empty literals of the declared type.hoistInlineObjectspass the emitter does.Not covered: the Go emitters are not exercised against a spec with multiple API versions or a top-level
oneOfschema, because the platform spec has neither. Union emission has unit coverage only.Follow-ups and known issues
archastro-golands separately. Itspackage.jsonpins@archastro/sdk-generator@latest, sonpm cithere cannot emit Go until this is merged and released. The version here is deliberately left at0.7.3— bumping belongs to arelease:commit, not this one.AgentCreateInputMetadatais emitted verbatim, so a spec schema with that literal name would produce a duplicate type declaration. The platform spec does not hit it, and hoisted names are long enough that a collision is unlikely, but it is a shared latent gap in both backends worth closing the next time either is touched.oneOfunions decode loosely. With no discriminator, the Go decoder sets the first variant thatjson.Unmarshalaccepts — andencoding/jsonaccepts almost anything into a struct.Rawis always populated, so nothing is lost, but variant selection is best-effort. Swift has the same limitation viatry?.