Skip to content

Commit 4e498bf

Browse files
committed
feat(sdk): add v3 Go client with typespec emitter
1 parent 7d6a4f7 commit 4e498bf

116 files changed

Lines changed: 21835 additions & 1806 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/e2e/SKILL.md

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: e2e
3-
description: Write end-to-end tests for OpenMeter against a live server. Use when adding tests under e2e/ that exercise API endpoints over HTTP (v1 generated SDK or v3 raw HTTP).
3+
description: Write end-to-end tests for OpenMeter against a live server. Use when adding tests under e2e/ that exercise API endpoints over HTTP (v1 generated SDK or v3 SDK).
44
user-invocable: true
55
argument-hint: "[feature or scenario to test]"
66
allowed-tools: Read, Edit, Write, Bash, Grep, Glob, Agent
@@ -21,7 +21,7 @@ Both live in `e2e/` and share the build tag, environment, and skip-when-unset co
2121
| Style | When to use | Client | Reference |
2222
|-------|-------------|--------|-----------|
2323
| **v1 SDK** | Endpoint has generated Go SDK coverage (ingest, meters, subjects, customers, v1 plans, entitlements) | `initClient(t) *api.ClientWithResponses` from `setup_test.go` | `e2e_test.go`, `entitlement_test.go`, `multisubject_test.go` |
24-
| **v3 raw HTTP** | Endpoint lives under `/api/v3/...` (no SDK yet) — plans, addons, plan-addons, v3 meter query, etc. | `newV3Client(t) *v3Client` from `v3helpers_test.go` | `plans_v3_test.go`, `addons_v3_test.go`, `planaddons_v3_test.go` |
24+
| **v3 SDK** | Endpoint lives under `/api/v3/...` — plans, addons, plan-addons, v3 meter query, billing invoices, customer credits, etc. | `newV3Client(t) *v3Client` from `v3helpers_test.go`. `v3Client` embeds the generated `*v3sdk.Client` (`api/v3/client`), so tests call SDK services directly — no per-endpoint wrappers | `plans_v3_test.go`, `addons_v3_test.go`, `planaddons_v3_test.go` |
2525

2626
Mixed files are fine — e.g., a v3 test that needs a v1 feature can call `initClient(t)` for the feature setup and `newV3Client(t)` for the assertion.
2727

@@ -71,7 +71,10 @@ For v1 tests, use `ulid.Make().String()` or a `fmt.Sprintf("%s_%d", prefix, time
7171
Default server page size is 20. When a test creates a fixture and then lists to locate it, bump the page size or the fresh row may sit past page 1 on a busy DB:
7272

7373
```go
74-
c.ListPlans(withPageSize(1000)) // v3 helper
74+
// v3
75+
c.Plans.List(t.Context(), v3sdk.PlanListParams{
76+
Page: &v3sdk.PageParams{Size: lo.ToPtr(1000)},
77+
})
7578
// v1: pass page_size via the generated params struct
7679
```
7780

@@ -81,15 +84,15 @@ The server trims trailing zeros and canonicalizes decimals on round-trip: `"0.10
8184

8285
### Per-request timeout
8386

84-
The v3 harness wraps every request in a 30s context (`v3RequestTimeout` in `v3helpers_test.go`). A server-side hang surfaces in seconds instead of eating the whole 10-minute `go test` deadline. Keep that bound when adding new wrappers.
87+
The generated v3 SDK (`api/v3/client`, `defaultRequestTimeout` in `transport.go`) applies a 30s deadline to any call whose context carries none. Tests just pass `t.Context()` — no per-call timeout plumbing needed. The one exception is `c.doMalformedRequest(...)`, the raw HTTP escape hatch, which builds its own inline 30s context since it bypasses the SDK transport entirely. A server-side hang still surfaces in seconds instead of eating the whole 10-minute `go test` deadline.
8588

8689
### Context
8790

8891
Use `t.Context()` in e2e tests too — it ties cancellation to the test harness and matches the rest of the repo.
8992

9093
## v1 SDK style
9194

92-
The generated client at `api/client/go` exposes `<Endpoint>WithResponse` methods that return typed response structs with `StatusCode()`, `JSON200`, `JSON201`, etc. Shared helpers in `e2e/helpers.go` wrap the common multi-step flows (create customer + subject, lookup meter by slug, v3 meter query that pre-dates the full v3 harness).
95+
The generated client at `api/client/go` exposes `<Endpoint>WithResponse` methods that return typed response structs with `StatusCode()`, `JSON200`, `JSON201`, etc. Shared helpers in `e2e/helpers.go` wrap the common multi-step flows (create customer + subject, lookup meter by slug).
9396

9497
```go
9598
func TestIngest(t *testing.T) {
@@ -109,9 +112,9 @@ Patterns worth reusing:
109112
- Eventual consistency: `assert.EventuallyWithT(...)` when the test writes an event and then queries the meter (ingestion is async through Kafka).
110113
- Error shape on 4xx: the generated SDK parses into `resp.ApplicationproblemJSON400.Extensions.ValidationErrors[N].Code` for v1 domain validation — see the older `productcatalog_test.go` for examples.
111114

112-
## v3 raw HTTP style
115+
## v3 SDK style
113116

114-
The v3 Go SDK isn't generated yet, so tests build requests from `apiv3.*` structs and decode success bodies themselves. `v3helpers_test.go` owns the HTTP plumbing:
117+
Use `newV3Client(t)` for v3 endpoints. `v3Client` embeds the generated `v3sdk "github.com/openmeterio/openmeter/api/v3/client"` SDK (Go package name `openmeter`), so tests call the service methods directly — `c.Plans.Create(...)`, `c.Addons.Publish(...)`, `c.Customers.Credits.Grants.Create(...)`, `c.Meters.Query(...)`. There are no per-endpoint wrapper methods to maintain; the harness only adds exact-2xx status pinning on top of the SDK and a raw HTTP escape hatch for requests the typed SDK cannot represent.
115118

116119
```go
117120
func TestV3<Entity><Behavior>(t *testing.T) {
@@ -120,24 +123,36 @@ func TestV3<Entity><Behavior>(t *testing.T) {
120123
body := validPlanRequest("descriptive_prefix")
121124
// mutate body as needed...
122125

123-
status, plan, problem := c.CreatePlan(body)
124-
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
126+
plan, err := c.Plans.Create(t.Context(), body)
127+
c.requireStatus(http.StatusCreated, err)
125128
require.NotNil(t, plan)
126129

127-
assert.Equal(t, apiv3.BillingPlanStatusDraft, plan.Status)
130+
assert.Equal(t, v3sdk.PlanStatusDraft, plan.Status)
131+
132+
// Error case: assert the exact status and problem shape.
133+
_, err = c.Plans.Create(t.Context(), invalidBody)
134+
problem := requireProblem(t, err, http.StatusBadRequest)
135+
assertValidationCode(t, problem, "plan_phase_duplicated_key")
128136
}
129137
```
130138

131-
All typed wrappers return `(status, *T, *v3Problem)`:
132-
- `*T` is populated only on the expected 2xx.
133-
- `*v3Problem` is populated only when the response is 4xx/5xx and parses as `application/problem+json`.
134-
- `c.do(method, path, body)` is the low-level escape hatch — returns `(status, raw, *v3Problem)`.
139+
Two call shapes to know:
140+
- **Success**: `c.requireStatus(want int, err error)` asserts `require.NoError(t, err)` and then that the exact HTTP status captured by an injected `RoundTripper` equals `want` — this is how tests distinguish 201 vs 200 vs 204 without the SDK response type carrying a status field. It fails `t` directly; never call it inside `assert.EventuallyWithT` or any helper that receives a `*assert.CollectT` — use `require.NoError(collect, err)` there instead (see `queryMeterV3` and the v1 polling patterns in `e2e_test.go`).
141+
- **Error**: every non-2xx response surfaces as `*v3sdk.APIError` (`v3sdk.AsAPIError(err)`). `requireProblem(t, err, wantStatus)` asserts the status and returns the decoded `*v3Problem` for the three assertion helpers below (`assertValidationCode`, `assertProblemDetail`, `assertInvalidParameterRule`).
142+
- `c.doMalformedRequest(method, path, body) (int, []byte, *v3Problem)` is the raw HTTP escape hatch — use it only for values the typed SDK cannot represent at all, such as an unparseable query parameter or malformed timestamp string. Everything else goes through the SDK.
143+
- `queryMeterV3(t, meterID, v3sdk.MeterQueryRequest) (*v3sdk.MeterQueryResult, error)` builds its own client and returns the raw error instead of failing `t`, so it's safe to call inside `assert.EventuallyWithT` while waiting for ingested events to land.
144+
145+
Delete/detach calls (`c.Plans.Delete`, `c.Addons.Delete`, plan-addon detach) have no response body, so they just return `error` — pin the status the same way: `err := c.Plans.Delete(t.Context(), id); c.requireStatus(http.StatusNoContent, err)`.
146+
147+
SDK unions (`Price`, `RateCardEntitlement`, `UpdateInvoiceLine`, ...) are discriminated types that must be built with their `XFromY(...)` package constructors — e.g. `lo.Must(v3sdk.PriceFromPriceFlat(v3sdk.PriceFlat{Amount: "10"}))` — not by assigning fields to a zero-valued struct. A zero union value marshals as JSON `null` and the server rejects it. The constructor sets the discriminator field; read a union back with its `AsX()` methods (they return pointers) or check the exported `.Type` field directly, as `assertUnitPriceAmount` does.
148+
149+
The constructor only stamps the discriminator of the union value it builds — variant structs NESTED inside the payload are marshaled as-is. Where a variant struct is used directly as a field (e.g. `PriceTier.UnitPrice *PriceUnit` inside a graduated/volume price), set its `Type` explicitly (`v3sdk.PriceUnit{Type: v3sdk.PriceTypeUnit, ...}`) or the wire body carries `"type":""` and the request fails schema validation with a 400 (see `validGraduatedRateCard`).
135150

136-
Delete/Detach wrappers (`DeletePlan`, `DeleteAddon`, `DetachAddon`) have no response body, so they omit the `*T` and return `(status, *v3Problem)`.
151+
Testify trap: SDK integer fields (`Plan.Version`, `Addon.Version`, pagination meta counts, etc.) are typed `int64`/similar, not `int`. `assert.Equal(t, 1, plan.Version)` fails on the type mismatch even when the values match. Use `assert.EqualValues` or a matching literal type instead (see `plans_v3_test.go`'s `assert.EqualValues(t, 1, plan.Version)`).
137152

138153
Extending the harness:
139-
- New endpoint family → add typed wrappers using `decodeTyped[T]` so the `(status, *T, *problem)` contract stays consistent.
140-
- New fixture kind → add a `valid<Thing>Request("prefix")` builder that internally calls `uniqueKey` so callers never have to think about collisions.
154+
- New endpoint family → call the matching `api/v3/client` SDK service directly; no wrapper method to add.
155+
- New fixture kind → add a `valid<Thing>Request("prefix")` builder that internally calls `uniqueKey` so callers never have to think about collisions, and constructs any union fields via their `XFromY(...)` constructor.
141156
- New assertion shape → add `assert<Shape>(t, problem, ...)` next to the existing helpers.
142157

143158
## Error-shape triage (v3)
@@ -182,17 +197,17 @@ func TestV3<Entity>Lifecycle(t *testing.T) {
182197
var entityID string
183198

184199
t.Run("Should create the entity in draft status", func(t *testing.T) {
185-
status, e, problem := c.Create<Entity>(createBody)
186-
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
200+
e, err := c.<Entities>.Create(t.Context(), createBody)
201+
c.requireStatus(http.StatusCreated, err)
187202
require.NotNil(t, e)
188-
entityID = e.Id
203+
entityID = e.ID
189204
})
190205

191206
t.Run("Should publish the entity", func(t *testing.T) {
192207
require.NotEmpty(t, entityID)
193-
status, e, problem := c.Publish<Entity>(entityID)
194-
require.Equal(t, http.StatusOK, status, "problem: %+v", problem)
195-
assert.Equal(t, apiv3.<Entity>StatusActive, e.Status)
208+
e, err := c.<Entities>.Publish(t.Context(), entityID)
209+
c.requireStatus(http.StatusOK, err)
210+
assert.Equal(t, v3sdk.<Entity>StatusActive, e.Status)
196211
})
197212

198213
// ... archive, delete, etc.
@@ -209,12 +224,12 @@ Reference: `e2e/planaddons_v3_test.go` `TestV3PlanAddonAttachStatusMatrix`.
209224
func TestV3<Something>Matrix(t *testing.T) {
210225
cases := []struct {
211226
name string
212-
mutate func(*apiv3.Create<X>Request)
227+
mutate func(*v3sdk.Create<X>Request)
213228
expectedStatus int
214229
expectedCode string // domain-validation code; empty for 2xx or non-PC shapes
215230
expectedDetailIn string // substring of Detail; alternative to expectedCode
216231
}{
217-
{name: "valid baseline → 201", mutate: func(*apiv3.Create<X>Request) {}, expectedStatus: http.StatusCreated},
232+
{name: "valid baseline → 201", mutate: func(*v3sdk.Create<X>Request) {}, expectedStatus: http.StatusCreated},
218233
// ... more rows
219234
}
220235

@@ -225,15 +240,17 @@ func TestV3<Something>Matrix(t *testing.T) {
225240
body := valid<X>Request("matrix")
226241
tc.mutate(&body)
227242

228-
status, got, problem := c.Create<X>(body)
229-
assert.Equal(t, tc.expectedStatus, status, "problem: %+v", problem)
243+
got, err := c.<Xs>.Create(t.Context(), body)
230244

231245
switch {
232246
case tc.expectedCode != "":
247+
problem := requireProblem(t, err, tc.expectedStatus)
233248
assertValidationCode(t, problem, tc.expectedCode)
234249
case tc.expectedDetailIn != "":
250+
problem := requireProblem(t, err, tc.expectedStatus)
235251
assertProblemDetail(t, problem, tc.expectedDetailIn)
236252
default:
253+
c.requireStatus(tc.expectedStatus, err)
237254
require.NotNil(t, got)
238255
}
239256
})
@@ -251,7 +268,7 @@ For async billing flows, make timeout failures self-diagnosing. Log the created
251268

252269
## Testing conventions
253270

254-
- **`require` vs `assert`**: `require` for fatal preconditions (no point continuing), `assert` for soft per-field checks. In table rows, use `assert.Equal(t, tc.expectedStatus, status, "%+v", problem)` for the status check so the subsequent body-shape assertion still fires and surfaces in the same failure. Reserve `require` for lifecycle tests where later steps depend on the earlier status being correct.
271+
- **`require` vs `assert`**: `require` for fatal preconditions (no point continuing), `assert` for soft per-field checks. In table rows, branch on the expected outcome (`requireProblem` for the error rows, `c.requireStatus` for the 2xx rows) rather than asserting status and body separately — see `TestV3PlanAddonAttachStatusMatrix`. Reserve `require` for lifecycle tests where later steps depend on the earlier status being correct.
255272
- **`t.Helper()`** in every helper function — so `require` failures blame the caller.
256273
- **`t.Context()`** over `context.Background()` — cancellation ties to the test.
257274
- **Test naming**: when both v1 and v3 tests live in the same package, prefix v3 tests with `TestV3` to disambiguate (`TestV3PlanLifecycle`, `TestV3AddonVersioningAndAutoArchive`). For single-style packages, the `V3` prefix is unnecessary.
@@ -268,4 +285,5 @@ Captured from real live-server runs. Most are v3-wide; a few call out plans/addo
268285
## Further reading
269286

270287
- **`AGENTS.md`** — repo-wide conventions: toolchain fallback, build tag, `POSTGRES_HOST` for in-process tests, general coding rules.
271-
- **Generated v3 types**`api/v3/api.gen.go` (regenerated by `make gen-api`; don't edit). `BillingPrice` and similar discriminated unions require the `FromBillingPriceXxx` helpers — never build the raw struct by hand.
288+
- **Generated v3 SDK**`api/v3/client` (Go package `openmeter`; regenerated by `make gen-api`; don't edit). This is the SDK `newV3Client(t)` embeds and the only client v3 e2e tests use.
289+
- **Generated v3 server types**`api/v3/api.gen.go` (regenerated by `make gen-api`; don't edit). No longer used by e2e fixtures; the v3 harness and all fixture builders build SDK types (`v3sdk.*`) directly.

.gitattributes

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,9 @@
33
*.{bat,[bB][aA][tT]} text eol=crlf
44

55
ent/db/** linguist-generated
6+
7+
# The v3 Go SDK is emitted by @openmeter/typespec-go; only the hand-written
8+
# wire tests and their fixtures stay reviewable.
9+
api/v3/client/** linguist-generated=true
10+
api/v3/client/*_test.go linguist-generated=false
11+
api/v3/client/testdata/** linguist-generated=false

.github/workflows/ci.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ jobs:
189189
exit 1
190190
fi
191191
192+
- name: Run TypeSpec Go emitter checks
193+
run: |
194+
nix develop --impure .#ci -c pnpm -C api/spec --filter @openmeter/typespec-go run check
195+
192196
generators-javascript-sdk:
193197
name: Code Generators / JavaScript SDK
194198
runs-on: depot-ubuntu-latest-8
@@ -293,6 +297,38 @@ jobs:
293297
exit 1
294298
fi
295299
300+
go-sdk:
301+
name: Go SDK
302+
runs-on: depot-ubuntu-latest-4
303+
304+
steps:
305+
- name: Checkout repository
306+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
307+
with:
308+
persist-credentials: false
309+
310+
- name: Set up Nix
311+
uses: nixbuild/nix-quick-install-action@9f63be77f412a248c9d9a65a4c82cf066cdf8f0c # v35
312+
with:
313+
github_access_token: ${{ secrets.GITHUB_TOKEN }}
314+
nix_conf: |
315+
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
316+
keep-env-derivations = true
317+
keep-outputs = true
318+
319+
- name: Restore Nix store
320+
uses: nix-community/cache-nix-action/restore@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
321+
with:
322+
primary-key: ${{ runner.os }}-openmeter-nix-build-${{ github.ref_name }}-${{ hashFiles('flake.*') }}
323+
restore-prefixes-first-match: |
324+
${{ runner.os }}-openmeter-nix-build-${{ github.ref_name }}-
325+
${{ runner.os }}-openmeter-nix-build-main-${{ hashFiles('flake.*') }}
326+
${{ runner.os }}-openmeter-nix-build-main-
327+
${{ runner.os }}-openmeter-nix-build-
328+
329+
- name: Run Go SDK checks
330+
run: nix develop --impure .#ci -c make test-go-sdk
331+
296332
test:
297333
name: Test
298334
runs-on: depot-ubuntu-latest-8

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ All generated files have `// Code generated by X, DO NOT EDIT.` headers — neve
7979
|---|---|---|
8080
| `api/openapi.yaml`, `api/openapi.cloud.yaml` | TypeSpec in `api/spec/` | `make gen-api` |
8181
| `api/client/javascript/`, `api/client/go/` | OpenAPI spec | `make gen-api` |
82+
| `api/v3/client/` (v3 Go SDK, standalone module) | TypeSpec in `api/spec/` via `@openmeter/typespec-go` | `make gen-api` |
8283
| `api/api.gen.go`, `api/v3/api.gen.go` | OpenAPI spec via oapi-codegen | `make gen-api` |
8384
| `api/client/go/client.gen.go` | OpenAPI spec | `make gen-api` |
8485
| `**/ent/db/` | Ent schema in `openmeter/ent/schema/` | `make generate` |
@@ -181,6 +182,7 @@ nix develop --impure .#ci -c env POSTGRES_HOST=127.0.0.1 go test -tags=dynamic .
181182
| `make test` | Run all tests (parallel: `-p 128 -parallel 16`) |
182183
| `make test-nocache` | Run tests bypassing cache |
183184
| `make test-all` | Run tests including Svix/Redis dependencies |
185+
| `make test-go-sdk` | Build, vet, and test the v3 Go SDK module (`api/v3/client`) |
184186
| `make etoe` | Run e2e tests (requires docker compose dependencies) |
185187

186188
**Running a single package directly:**

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ ENV GOCACHE=/go/cache
2020
ENV GOMODCACHE=/go/pkg/mod
2121

2222
COPY --link go.mod go.sum ./
23+
# go.mod replaces the nested v3 SDK module with this local path, so its
24+
# manifests must exist before `go mod download` can resolve the module graph.
25+
COPY --link api/v3/client/go.mod api/v3/client/go.sum ./api/v3/client/
2326

2427
RUN --mount=type=cache,target=/go/pkg/mod \
2528
--mount=type=cache,target=/go/cache \

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@ test-all: ## Run tests with svix dependencies, bypassing the test cache
246246
./tools/wait-for-compose.sh postgres svix redis
247247
SVIX_HOST="localhost" SVIX_JWT_SECRET="$(SVIX_JWT_SECRET)" go test ${GO_TEST_FLAGS} -count=1 ./...
248248

249+
.PHONY: test-go-sdk
250+
test-go-sdk: ## Build, vet, and test the v3 Go SDK module (api/v3/client)
251+
$(call print-target)
252+
cd api/v3/client && go build ./... && go vet ./... && go test ./...
253+
249254
.PHONY: lint
250255
lint: lint-go lint-api-spec lint-openapi lint-helm ## Run linters
251256
$(call print-target)

api/spec/.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ packages/**/output/
44
packages/**/dist/
55
packages/**/coverage/
66
packages/typespec-typescript/src/static-helpers/lib-files-data.gen.ts
7+
packages/aip-client-go/

0 commit comments

Comments
 (0)