Skip to content

refactor(api): session responses use explicit DTOs; published OpenAPI schema finally matches the wire#1024

Merged
acouvreur merged 2 commits into
mainfrom
refactor/api-session-dto
Jul 11, 2026
Merged

refactor(api): session responses use explicit DTOs; published OpenAPI schema finally matches the wire#1024
acouvreur merged 2 commits into
mainfrom
refactor/api-session-dto

Conversation

@acouvreur

Copy link
Copy Markdown
Member

Finding

Stage 2 of the InstanceInfo/session split (stage 1: #1023). This one targets the wire-format facet: the API contract was the domain struct, and the published contract was already wrong.

The blocking endpoint returned an ad-hoc map with the domain object inside:

c.JSON(http.StatusOK, map[string]any{"session": sessionState})

whose bytes were shaped by a custom SessionState.MarshalJSON - but swag generates the OpenAPI schema from struct tags, not custom marshalers. The two have disagreed since day one:

published schema said the wire actually carried
instances a map of name -> entry an array of {instance, error} entries
status (absent) "ready" / "not-ready"
error an empty object a string

Anyone building a reverse-proxy plugin against the published openapi.json parsed the wrong shape. SessionResponse/ThemesResponse existed only to feed annotations; handlers returned ad-hoc maps, so nothing could keep them in sync. And every future field on the domain struct silently became API surface.

Discovery

Found during the API-layer design review by diffing SessionState.MarshalJSON's output against the sablier.SessionState schema in the committed openapi.json.

Fix

  • NewSessionResponse maps the domain session into explicit DTOs (SessionResponse -> SessionStateResponse -> InstanceEntryResponse), and the handler returns the annotated type. The themes endpoint likewise returns the real ThemesResponse. Schema and wire are now generated from the same types - they cannot drift again.
  • SessionState.MarshalJSON is deleted: the domain type no longer carries wire concerns. Its tests move to the API package against the DTO.
  • openapi.json regenerated: it now tells the truth (array + status + string error). This changes the published spec, not the wire - the direction that fixes consumers.

Wire compatibility

Byte-shape unchanged, with one deliberate improvement: instance entries are now sorted by name instead of map-iteration random. Ordering was never part of the contract and non-determinism was only ever a nuisance.

The inner instance object intentionally still serializes the domain InstanceInfo - trimming it to "the stable fields plugins need" is a breaking change that belongs with the API-versioning work, and stage 3 (versioned session record) is the prerequisite for dropping the flat fields.

Tests

  • TestNewSessionResponse - sorted entries, error-string mapping, status aggregation, empty-session-as-[].
  • TestSessionResponseWireShape - pins array + status + omitted-empty-error, so the historical contract is enforced by test rather than by accident.
  • Migrated the two former MarshalJSON tests; full API + core suites and golangci-lint pass; make check-generate green.

Docs

openapi.json is the only doc artifact affected (regenerated; now accurate). No labels/config/behavior change.

…truct

The blocking endpoint answered with an ad-hoc map{"session": sessionState}
whose bytes were shaped by a custom SessionState.MarshalJSON, while the
OpenAPI schema was generated from the struct tags. The two had drifted
apart from day one: the published schema said `instances` was a map of
name to entry and had no `status` property, while the wire carried an
ARRAY of {instance, error} entries plus `status`. Anyone building a
reverse-proxy plugin against the published contract parsed the wrong
shape. The SessionResponse/ThemesResponse types existed only to feed the
annotations; nothing kept them in sync with what handlers actually wrote.

The handlers now build and return the annotated DTOs (NewSessionResponse,
ThemesResponse), so the generated schema is derived from the same types
that produce the bytes and cannot drift again. The wire shape is unchanged
except that instance entries are now sorted by name instead of
map-iteration random (ordering was never part of the contract).

SessionState loses its MarshalJSON: the domain type no longer carries wire
concerns, which is stage 2 of the InstanceInfo/session split (stage 1:
#1023). Its marshalling tests move to the API package against the DTO,
joined by a wire-shape pin so the array+status contract is enforced by
test rather than by accident.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 11, 2026
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
┌───────────────────────────────────────────────────────────────────────────────────────┐
│ Diff between sablier and sablier                                                      │
├─────────┬─────────────────────────────────────────────┬──────────┬──────────┬─────────┤
│ PERCENT │ NAME                                        │ OLD SIZE │ NEW SIZE │ DIFF    │
├─────────┼─────────────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.90%  │ go.yaml.in/yaml/v2                          │ 275 kB   │ 277 kB   │ +2.5 kB │
│ +0.32%  │ github.com/sablierapp/sablier               │ 654 kB   │ 656 kB   │ +2.1 kB │
│ +0.04%  │ go.opentelemetry.io/otel                    │ 1.1 MB   │ 1.1 MB   │ +427 B  │
│ +0.01%  │ runtime                                     │ 3.3 MB   │ 3.3 MB   │ +275 B  │
│ +0.03%  │ encoding                                    │ 418 kB   │ 418 kB   │ +134 B  │
│ +0.16%  │ golang.org/x/crypto                         │ 64 kB    │ 64 kB    │ +101 B  │
│ +1.00%  │ go.shape                                    │ 4.8 kB   │ 4.8 kB   │ +48 B   │
│ +0.14%  │ vendor/golang.org/x/net/http2/hpack         │ 35 kB    │ 35 kB    │ +47 B   │
│ +0.46%  │ vendor/golang.org/x/sys/cpu                 │ 6.4 kB   │ 6.4 kB   │ +29 B   │
│ +0.01%  │ k8s.io/klog/v2                              │ 124 kB   │ 124 kB   │ +16 B   │
│ +0.00%  │ crypto                                      │ 1.9 MB   │ 1.9 MB   │ +8 B    │
│ +0.00%  │ reflect                                     │ 340 kB   │ 340 kB   │ +8 B    │
│ +0.01%  │ github.com/spf13/viper                      │ 73 kB    │ 73 kB    │ +7 B    │
│ +0.00%  │ github.com/google/gnostic-models            │ 1.6 MB   │ 1.6 MB   │ +5 B    │
│ +0.00%  │ k8s.io/api                                  │ 17 MB    │ 17 MB    │ +3 B    │
│ +0.00%  │ k8s.io/apimachinery                         │ 1.8 MB   │ 1.8 MB   │ +3 B    │
│ +0.00%  │ github.com/emicklei/go-restful/v3           │ 134 kB   │ 134 kB   │ +1 B    │
│ +0.00%  │ github.com/spf13/cobra                      │ 239 kB   │ 239 kB   │ +1 B    │
│ +0.00%  │ google.golang.org/grpc                      │ 1.2 MB   │ 1.2 MB   │ +1 B    │
│ +0.00%  │ text/template                               │ 293 kB   │ 293 kB   │ +1 B    │
│ -0.00%  │ net                                         │ 1.7 MB   │ 1.7 MB   │ -1 B    │
│ -0.00%  │ go.mongodb.org/mongo-driver/v2              │ 674 kB   │ 674 kB   │ -2 B    │
│ -0.02%  │ golang.org/x/sys                            │ 46 kB    │ 46 kB    │ -10 B   │
│ -0.31%  │ internal/cpu                                │ 6.1 kB   │ 6.1 kB   │ -19 B   │
│ -0.00%  │ golang.org/x/net                            │ 902 kB   │ 902 kB   │ -22 B   │
│ -0.11%  │ vendor/golang.org/x/net/idna                │ 22 kB    │ 22 kB    │ -25 B   │
│ -0.09%  │ k8s.io/utils                                │ 32 kB    │ 32 kB    │ -30 B   │
│ -0.01%  │ go.yaml.in/yaml/v3                          │ 312 kB   │ 312 kB   │ -33 B   │
│ -0.00%  │ k8s.io/client-go                            │ 14 MB    │ 14 MB    │ -39 B   │
│ -0.02%  │ github.com/gin-gonic/gin                    │ 337 kB   │ 337 kB   │ -56 B   │
│ -0.14%  │ vendor/golang.org/x/crypto/chacha20poly1305 │ 71 kB    │ 71 kB    │ -101 B  │
│ -0.08%  │ sigs.k8s.io/json                            │ 173 kB   │ 173 kB   │ -134 B  │
│ -0.48%  │ go.opentelemetry.io/auto/sdk                │ 89 kB    │ 88 kB    │ -427 B  │
│ -0.80%  │ gopkg.in/yaml.v3                            │ 306 kB   │ 304 kB   │ -2.4 kB │
├─────────┼─────────────────────────────────────────────┼──────────┼──────────┼─────────┤
│ -0.00%  │ .rodata                                     │ 2.6 MB   │ 2.6 MB   │ -39 B   │
├─────────┼─────────────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.01%  │ sablier                                     │ 62 MB    │ 62 MB    │ +4.1 kB │
│         │ sablier                                     │          │          │         │
└─────────┴─────────────────────────────────────────────┴──────────┴──────────┴─────────┘

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed! | 781 tests in 103.295s

View HTML Test Report

@sonarqubecloud

Copy link
Copy Markdown

@acouvreur acouvreur merged commit 5b9fcb0 into main Jul 11, 2026
9 checks passed
@acouvreur acouvreur deleted the refactor/api-session-dto branch July 11, 2026 15:06
acouvreur added a commit that referenced this pull request Jul 11, 2026
Both stores serialized the raw domain InstanceInfo as the persisted session
entry, making the storage schema "whatever the domain struct happens to
marshal as": every field added to InstanceInfo silently became persisted
state with no version to gate evolution and no way to recognize records
written by an older release.

Sessions are now wrapped in a SessionRecord envelope ({"v":1,"instance":
{...}}) owned next to the Store interface. Its unmarshaler transparently
upgrades pre-versioning payloads (bare InstanceInfo documents), so live
sessions in valkey and state.json snapshots survive the upgrade instead of
being dropped. Foreign keys sharing the valkey keyspace keep the previous
tolerance: they decode to a record with an empty instance name and are
skipped by the existing name check.

The Store interface is unchanged (Get/Put still speak InstanceInfo), so the
core and providers are untouched; this is stage 3 of the InstanceInfo split
(stage 1: #1023, stage 2: #1024) and the prerequisite for slimming what
sessions actually persist, which will be a schema-version bump instead of a
silent break.

Migration coverage: record round-trip and legacy-upgrade unit tests, an
inmemory legacy-snapshot load test, and valkey testcontainers tests reading
a legacy payload through Get and Range and asserting new entries carry the
version. The sabliercmd storage fixtures already use the legacy shape, so
the state-file load tests now exercise the upgrade end to end.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant