Skip to content

feat(sdk-generator): Go SDK + contract-test backends - #57

Merged
calvin-archastro merged 3 commits into
mainfrom
features/calvin-archastro-24-07-2026-go-sdk
Jul 24, 2026
Merged

feat(sdk-generator): Go SDK + contract-test backends#57
calvin-archastro merged 3 commits into
mainfrom
features/calvin-archastro-24-07-2026-go-sdk

Conversation

@calvin-archastro

@calvin-archastro calvin-archastro commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review on ArchCode

What changed

Adds a Go target to @archastro/sdk-generator: --lang go emits a typed Go SDK, --lang contract-tests-go emits the matching go test contract 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 why FrontendConfig gains a go block 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 same GoNameRegistry as 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 …Params struct rather than an optional-argument tail. Path and scope parameters stay positional after ctx 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 omitempty can 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 -w over the output as part of regeneration and gate CI on gofmt -l being empty — the same split protoc-gen-go uses between emission and go/format.

Two commits ahead of the Go work were carried over from a branch whose PR already merged: attach unversioned paths to the default version and MIT 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 removed
Loading

Backend 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 : uses
Loading

Scope

Backend-only, and additive. New files under src/backends/go/ and three new files under src/backends/contract-tests/. The only shared code touched is contract-tests/value-generator.ts (a "go" arm added to the existing language switches), contract-tests/index.ts (dispatch), frontend/config.ts (the new optional go block), and src/index.ts (two new --lang cases). No TypeScript, Python, or Swift emitter was modified.

Risk

Low. Nothing on an existing code path changes shape:

  • The value-generator.ts edits add "go" branches and lift a repeated emptyDict ternary into emptyDictLiteral(lang), which returns the identical literal for typescript, python, and swift. The existing 333 generator tests cover those paths and are unchanged and green.
  • FrontendConfig.go is optional; specs and configs that omit it behave exactly as before.
  • The two new --lang cases 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-go lands 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 go and --lang contract-tests-go, documented in both READMEs along with the go config 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 generated TestXxx function 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:

npm test
  @archastro/channel-harness   6 files,  63 tests passed
  @archastro/sdk-generator     9 files, 333 tests passed

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-go repo, where this branch's output was generated and run:

ARCHASTRO_RUN_CHANNEL_CONTRACT_TESTS=1 go test -race ./...
ok  github.com/ArchAstro/archastro-go/contracttests   4.182s   (945 tests, 0 skipped)
ok  github.com/ArchAstro/archastro-go/platform        1.582s   (40 tests)

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 serving specs/platform-openapi.json for every REST operation and its documented error codes, and the @archastro/channel-harness service 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 ./..., and go build ./... are clean on that output.

That run is reproducible from this branch: regenerating archastro-go from this commit produces byte-identical output to what was tested (verified by diff -r against a pre-regeneration snapshot).

Edge cases exercised while getting there — each surfaced as a real failure before being fixed:

  • Optional query parameters typed as maps were dereferenced as if they were pointers. Pointer-ness is now read from the declared field type rather than inferred from optionality.
  • Contract-test values for required map and inline-object fields were emitted as nil, which marshals to JSON null and Prism rejects. They are now empty literals of the declared type.
  • Typed values for hoisted inline inputs were built from the pre-hoist field list, so nested struct types and pointer-ness disagreed with the emitted struct. The value builder now runs the same hoistInlineObjects pass the emitter does.

Not covered: the Go emitters are not exercised against a spec with multiple API versions or a top-level oneOf schema, because the platform spec has neither. Union emission has unit coverage only.

Follow-ups and known issues

  • archastro-go lands separately. Its package.json pins @archastro/sdk-generator@latest, so npm ci there cannot emit Go until this is merged and released. The version here is deliberately left at 0.7.3 — bumping belongs to a release: commit, not this one.
  • Hoisted inline struct names bypass the registry, exactly as they do in the Swift backend: a hoisted name such as AgentCreateInputMetadata is 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.
  • Untagged oneOf unions decode loosely. With no discriminator, the Go decoder sets the first variant that json.Unmarshal accepts — and encoding/json accepts almost anything into a struct. Raw is always populated, so nothing is lost, but variant selection is best-effort. Swift has the same limitation via try?.

calvin-archastro and others added 3 commits July 24, 2026 14:43
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>
@calvin-archastro
calvin-archastro merged commit 2b83823 into main Jul 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant