Skip to content

feat(sdk/go): add baseline hand-written Go SDK for v3 meters#4644

Draft
gergely-kurucz-konghq wants to merge 2 commits into
mainfrom
feat/go-sdk-baseline
Draft

feat(sdk/go): add baseline hand-written Go SDK for v3 meters#4644
gergely-kurucz-konghq wants to merge 2 commits into
mainfrom
feat/go-sdk-baseline

Conversation

@gergely-kurucz-konghq

Copy link
Copy Markdown
Contributor

What

Adds a baseline hand-written Go SDK for the OpenMeter v3 API as a new standalone module (github.com/openmeterio/openmeter/sdk/go). Implements the three meter endpoints — Get, List, Query (+ QueryCSV) — as a worked reference of the target SDK shape.

Why

The existing generated client (api/client/go) is a 1.7MB oapi-codegen monolith targeting the v1 cloud spec. We want an idiomatic, dependency-light SDK whose shape can later be reproduced by a TypeSpec emitter. This PR establishes that gold-standard shape against real endpoints before automating it.

How

  • net/http public surface — no third-party type leaks. hashicorp/go-retryablehttp provides retries but is fully hidden behind the *http.Client seam (StandardClient()), so WithHTTPClient swaps transport cleanly.
  • Resource-grouped opsclient.Meters.Get / List / Query / QueryCSV.
  • Query-string serialization — the three OpenAPI styles on list-meters: deepObject page, form sort, nested deepObject filter.
  • Typed APIError — decodes the RFC7807 problem body (status, title, detail, correlation ID).
  • Auth at request-build time — bearer token survives an injected *http.Client.
  • Models cherry-picked from oapi-codegen output, then hand-curated (DateTimetime.Time, Numericstring, pagination counts→int).

Verification: unit tests via httptest (no network) + an env-gated TestLive. Verified against a local server (unauthenticated) and https://dev.openmeter.cloud/api/v3 (bearer token) — success, auth, and 401 error paths all confirmed.

Note: standalone module, not in any go.work; the main go build ./... does not cover it. CI wiring is a follow-up.

Hand-written, idiomatic Go SDK as a reference shape for a future TypeSpec
emitter. Standalone module (github.com/openmeterio/openmeter/sdk/go).

- net/http public surface; go-retryablehttp hidden behind *http.Client
- resource-grouped ops: Meters.Get / List / Query / QueryCSV
- query-string serialization (deepObject page/filter, form sort)
- typed APIError; bearer auth set at request-build time (survives client injection)
- unit tests via httptest + env-gated TestLive (verified vs local and dev cloud)
@gergely-kurucz-konghq gergely-kurucz-konghq added kind/feature New feature or request release-note/ignore Ignore this change when generating release notes labels Jul 3, 2026
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 93ab68c3-4285-4530-8300-a819cd4525e1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/go-sdk-baseline

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a new standalone Go SDK module (github.com/openmeterio/openmeter/sdk/go) as a hand-written, idiomatic reference implementation for the OpenMeter v3 API, covering the three meter endpoints (Get, List, Query/QueryCSV) with typed errors, functional options, and a hidden retry transport.

  • Client shape: net/http-only public surface with resource-grouped operations (client.Meters.*), bearer token set at request-build time, and a swappable *http.Client seam that hides go-retryablehttp from callers.
  • Query-string serialization: explicit handling of three OpenAPI styles (deepObject page, form sort, nested deepObject filter) with unit-test coverage per style.
  • Error handling: typed *APIError decoding RFC 7807 problem bodies with a RawBody fallback for non-conforming responses.

Confidence Score: 5/5

Safe to merge as a new standalone module; it adds no changes to existing code paths and is not wired into CI yet.

The change is entirely additive — a new module under sdk/go with no integration into the main module or CI. The core HTTP plumbing, error decoding, and query-string serialization are all covered by httptest-based unit tests. The findings are style-level nits (pointer-to-slice conventions, a misleading comment, unbounded body read) that don't affect correctness of the current endpoints.

No files require special attention; models.go and query.go have minor style issues worth tidying before the shape is codified as the TypeSpec emitter target.

Important Files Changed

Filename Overview
sdk/go/client.go Entry point and URL resolution logic; resolve correctly handles base-path prefixes and trailing-slash normalization.
sdk/go/transport.go Request construction and execution; doRaw uses unbounded io.ReadAll; default client hides retryablehttp cleanly behind *http.Client.
sdk/go/meters.go Implements Get, List, Query, QueryCSV; deepObject/form query-string serialization is correctly wired; no new issues beyond the already-flagged empty meterID validation.
sdk/go/models.go Data models; *[]string pointer-to-slice on GroupByDimensions, In, Nin is unconventional (parallel to the already-flagged pointer-to-map pattern on Dimensions/Labels).
sdk/go/query.go Query-string serialization helpers; inline sort example (sort=name,-created_at) disagrees with actual test behavior (sort=name,created_at desc).
sdk/go/errors.go Typed RFC 7807 error decoding; best-effort unmarshal with RawBody fallback is clean and well-tested.
sdk/go/option.go Functional option pattern for token, HTTP client, and user-agent; straightforward and idiomatic.
sdk/go/go.mod Standalone module with single direct dependency (go-retryablehttp); module path ergonomics already flagged in a previous thread.
sdk/go/client_test.go Comprehensive httptest-based unit tests covering method, path, headers, auth, content negotiation, error decoding, and transport injection.
sdk/go/live_test.go Env-gated integration test; correctly skipped when OPENMETER_BASE_URL is unset, so it never runs in CI by accident.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant Client
    participant MetersService
    participant transport as newRequest / doRaw
    participant Server as OpenMeter API

    Caller->>Client: New(baseURL, WithToken(...))
    Client-->>Caller: "*Client{Meters: *MetersService}"

    Caller->>MetersService: Meters.List(ctx, MeterListParams)
    MetersService->>transport: newRequest(GET, /openmeter/meters, query, nil, JSON)
    Note over transport: resolve() joins base URL + path<br/>Sets Accept, Authorization, User-Agent
    transport->>Server: "GET /openmeter/meters?page[size]=10&sort=..."
    Server-->>transport: 200 application/json
    transport->>transport: doRaw → io.ReadAll(body)
    alt 2xx
        transport->>transport: doJSON → json.Unmarshal → MeterPagePaginatedResponse
        transport-->>MetersService: "*MeterPagePaginatedResponse"
    else non-2xx
        transport->>transport: newAPIError(statusCode, body)
        transport-->>MetersService: "*APIError"
    end
    MetersService-->>Caller: page, err

    Caller->>MetersService: Meters.Query(ctx, meterID, MeterQueryRequest)
    MetersService->>transport: "newRequest(POST, /openmeter/meters/{id}/query, nil, body, JSON)"
    Note over transport: json.Marshal(MeterQueryRequest) → bytes.NewReader<br/>Sets Content-Type + Accept: application/json
    transport->>Server: "POST /openmeter/meters/{id}/query"
    Server-->>transport: 200 application/json
    transport-->>MetersService: "*MeterQueryResult, nil"

    Caller->>MetersService: Meters.QueryCSV(ctx, meterID, MeterQueryRequest)
    MetersService->>transport: newRequest(POST, .../query, nil, body, CSV)
    Note over transport: Accept: text/csv
    transport->>Server: "POST /openmeter/meters/{id}/query"
    Server-->>transport: 200 text/csv
    transport-->>MetersService: []byte (raw CSV), nil
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant Client
    participant MetersService
    participant transport as newRequest / doRaw
    participant Server as OpenMeter API

    Caller->>Client: New(baseURL, WithToken(...))
    Client-->>Caller: "*Client{Meters: *MetersService}"

    Caller->>MetersService: Meters.List(ctx, MeterListParams)
    MetersService->>transport: newRequest(GET, /openmeter/meters, query, nil, JSON)
    Note over transport: resolve() joins base URL + path<br/>Sets Accept, Authorization, User-Agent
    transport->>Server: "GET /openmeter/meters?page[size]=10&sort=..."
    Server-->>transport: 200 application/json
    transport->>transport: doRaw → io.ReadAll(body)
    alt 2xx
        transport->>transport: doJSON → json.Unmarshal → MeterPagePaginatedResponse
        transport-->>MetersService: "*MeterPagePaginatedResponse"
    else non-2xx
        transport->>transport: newAPIError(statusCode, body)
        transport-->>MetersService: "*APIError"
    end
    MetersService-->>Caller: page, err

    Caller->>MetersService: Meters.Query(ctx, meterID, MeterQueryRequest)
    MetersService->>transport: "newRequest(POST, /openmeter/meters/{id}/query, nil, body, JSON)"
    Note over transport: json.Marshal(MeterQueryRequest) → bytes.NewReader<br/>Sets Content-Type + Accept: application/json
    transport->>Server: "POST /openmeter/meters/{id}/query"
    Server-->>transport: 200 application/json
    transport-->>MetersService: "*MeterQueryResult, nil"

    Caller->>MetersService: Meters.QueryCSV(ctx, meterID, MeterQueryRequest)
    MetersService->>transport: newRequest(POST, .../query, nil, body, CSV)
    Note over transport: Accept: text/csv
    transport->>Server: "POST /openmeter/meters/{id}/query"
    Server-->>transport: 200 text/csv
    transport-->>MetersService: []byte (raw CSV), nil
Loading

Reviews (2): Last reviewed commit: "test(sdk/go): cover meter list key filte..." | Re-trigger Greptile

Assert filter[key][eq] round-trips through a real server: exact-match
returns the meter, a bogus key returns none.
Comment thread sdk/go/meters.go
Comment on lines +82 to +83
func (s *MetersService) Get(ctx context.Context, meterID string) (*Meter, error) {
req, err := s.client.newRequest(ctx, http.MethodGet, metersBasePath+"/"+url.PathEscape(meterID), nil, nil, contentTypeJSON)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing empty meterID validation

Get(ctx, "") builds the path /openmeter/meters/ (note trailing slash). Depending on the server's router, that either silently maps to the List endpoint—doJSON then decodes the paginated list envelope {"data":[...],"meta":{...}} into a Meter struct, which succeeds but returns a zero-value struct with no error—or returns a 4xx whose message ("Not Found", etc.) gives no hint the caller forgot to pass an ID. The same latent issue exists in Query and QueryCSV. An upfront check eliminates the ambiguity.

Prompt To Fix With AI
This is a comment left during a code review.
Path: sdk/go/meters.go
Line: 82-83

Comment:
**Missing empty `meterID` validation**

`Get(ctx, "")` builds the path `/openmeter/meters/` (note trailing slash). Depending on the server's router, that either silently maps to the List endpoint—`doJSON` then decodes the paginated list envelope `{"data":[...],"meta":{...}}` into a `Meter` struct, which succeeds but returns a zero-value struct with no error—or returns a 4xx whose message ("Not Found", etc.) gives no hint the caller forgot to pass an ID. The same latent issue exists in `Query` and `QueryCSV`. An upfront check eliminates the ambiguity.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment thread sdk/go/models.go
Comment on lines +36 to +38
// Dimensions maps group-by dimension names to JSONPath expressions.
Dimensions *map[string]string `json:"dimensions,omitempty"`
Labels *map[string]string `json:"labels,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Pointer-to-map is unconventional and complicates the caller

map is already a reference type in Go: a nil map is the idiomatic "not set" value and is correctly omitted by json:",omitempty". Wrapping it in an extra pointer forces callers to double-dereference and breaks the clean "nil = absent" mental model. Replacing *map[string]string with plain map[string]string gives the same JSON serialization behaviour with no change in correctness.

Suggested change
// Dimensions maps group-by dimension names to JSONPath expressions.
Dimensions *map[string]string `json:"dimensions,omitempty"`
Labels *map[string]string `json:"labels,omitempty"`
// Dimensions maps group-by dimension names to JSONPath expressions.
Dimensions map[string]string `json:"dimensions,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Prompt To Fix With AI
This is a comment left during a code review.
Path: sdk/go/models.go
Line: 36-38

Comment:
**Pointer-to-map is unconventional and complicates the caller**

`map` is already a reference type in Go: a nil map is the idiomatic "not set" value and is correctly omitted by `json:",omitempty"`. Wrapping it in an extra pointer forces callers to double-dereference and breaks the clean "nil = absent" mental model. Replacing `*map[string]string` with plain `map[string]string` gives the same JSON serialization behaviour with no change in correctness.

```suggestion
	// Dimensions maps group-by dimension names to JSONPath expressions.
	Dimensions map[string]string `json:"dimensions,omitempty"`
	Labels     map[string]string `json:"labels,omitempty"`
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Comment thread sdk/go/models.go
Comment on lines +85 to +88
// MeterQueryFilters filters a meter query by dimension values.
type MeterQueryFilters struct {
Dimensions *map[string]QueryFilterStringMapItem `json:"dimensions,omitempty"`
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Same pointer-to-map concern as Meter.Dimensions/Labels. A plain map[string]QueryFilterStringMapItem is nil-safe, already a reference type, and omitted by omitempty when nil—no pointer needed.

Suggested change
// MeterQueryFilters filters a meter query by dimension values.
type MeterQueryFilters struct {
Dimensions *map[string]QueryFilterStringMapItem `json:"dimensions,omitempty"`
}
// MeterQueryFilters filters a meter query by dimension values.
type MeterQueryFilters struct {
Dimensions map[string]QueryFilterStringMapItem `json:"dimensions,omitempty"`
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: sdk/go/models.go
Line: 85-88

Comment:
Same pointer-to-map concern as `Meter.Dimensions`/`Labels`. A plain `map[string]QueryFilterStringMapItem` is nil-safe, already a reference type, and omitted by `omitempty` when nil—no pointer needed.

```suggestion
// MeterQueryFilters filters a meter query by dimension values.
type MeterQueryFilters struct {
	Dimensions map[string]QueryFilterStringMapItem `json:"dimensions,omitempty"`
}
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Comment thread sdk/go/go.mod
@@ -0,0 +1,7 @@
module github.com/openmeterio/openmeter/sdk/go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Module path forces a mandatory import alias

The last segment of github.com/openmeterio/openmeter/sdk/go is the reserved keyword go, so import "github.com/openmeterio/openmeter/sdk/go" is a compile-time error. Every consumer must write import openmeter "github.com/openmeterio/openmeter/sdk/go". The README calls this out, but it is a persistent ergonomic friction point. A module path like github.com/openmeterio/openmeter/sdk/go/openmeter (with a nested package) would let callers use the natural package name without an alias.

Prompt To Fix With AI
This is a comment left during a code review.
Path: sdk/go/go.mod
Line: 1

Comment:
**Module path forces a mandatory import alias**

The last segment of `github.com/openmeterio/openmeter/sdk/go` is the reserved keyword `go`, so `import "github.com/openmeterio/openmeter/sdk/go"` is a compile-time error. Every consumer must write `import openmeter "github.com/openmeterio/openmeter/sdk/go"`. The README calls this out, but it is a persistent ergonomic friction point. A module path like `github.com/openmeterio/openmeter/sdk/go/openmeter` (with a nested package) would let callers use the natural package name without an alias.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment thread sdk/go/transport.go
Comment on lines +24 to +31
func defaultHTTPClient() *http.Client {
rc := retryablehttp.NewClient()
rc.RetryMax = 3
rc.RetryWaitMin = 500 * time.Millisecond
rc.RetryWaitMax = 5 * time.Second
rc.Logger = nil // silence the default stdout logger
return rc.StandardClient()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Default client retries POST requests on 5xx

retryablehttp's default CheckRetry policy retries any HTTP method—including POST—on 5xx responses and connection errors. The current POST endpoint (/meters/{id}/query) is a read, so this is safe today. However, if the SDK later grows write operations and callers rely on the default transport, duplicate side effects can occur silently on retried 5xx responses. Consider overriding CheckRetry to restrict automatic retries to idempotent methods (GET, HEAD), or at least document the retry-on-POST behaviour prominently.

Prompt To Fix With AI
This is a comment left during a code review.
Path: sdk/go/transport.go
Line: 24-31

Comment:
**Default client retries POST requests on 5xx**

`retryablehttp`'s default `CheckRetry` policy retries any HTTP method—including POST—on 5xx responses and connection errors. The current POST endpoint (`/meters/{id}/query`) is a read, so this is safe today. However, if the SDK later grows write operations and callers rely on the default transport, duplicate side effects can occur silently on retried 5xx responses. Consider overriding `CheckRetry` to restrict automatic retries to idempotent methods (`GET`, `HEAD`), or at least document the retry-on-POST behaviour prominently.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/feature New feature or request release-note/ignore Ignore this change when generating release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant