Skip to content

Commit d83f294

Browse files
committed
feat(calendar): flatten merged-feed response to nullish fields
Reshapes the merged-feed response (`hotReleaseResponseSchema`, used by `media` and `releases/hot`) from a `z.union([movie, show])` to a single flat object with every field nullish. OpenAPI codegen renders a union `oneOf` as a model with all fields required, so consumers generated a wrong schema (movie/show/episode all non-null); nullish fields generate a correct optional-field model, and callers null-check to discriminate. Documents the convention in AGENTS.md. Bumps to 0.4.25.
1 parent e0ff7d6 commit d83f294

3 files changed

Lines changed: 60 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# trakt-api agent rules
2+
3+
Conventions for authoring the ts-rest + zod API contract. The OpenAPI spec and
4+
consumer models/clients are generated from these schemas, so schema shape is a
5+
public artifact, not just internal typing.
6+
7+
## Response schemas
8+
9+
### Polymorphic / multi-shape responses: one flat object, all fields nullish
10+
11+
When an endpoint returns entries of more than one entity shape - a merged feed
12+
(e.g. calendar `media` / `releases/hot` returning movies AND episodes), or any
13+
route whose response varies by a `type=movie|show|episode|...` param - model the
14+
response as a SINGLE object with **every shape-specific field nullish**. Do NOT
15+
use `z.union([...])`.
16+
17+
```ts
18+
// ❌ BAD: union -> OpenAPI `oneOf` -> codegen emits a model with ALL fields
19+
// required, so consumers get a wrong schema.
20+
export const feedResponseSchema = z.union([movieEntrySchema, showEntrySchema]);
21+
22+
// ✅ GOOD: one flat object, shape-specific fields nullish -> codegen emits a
23+
// correct all-optional model. Discriminate by shape at runtime.
24+
export const feedResponseSchema = z.object({
25+
released: z.string().nullish(),
26+
movie: movieResponseSchema.nullish(),
27+
first_aired: z.string().nullish(),
28+
episode: episodeResponseSchema.nullish(),
29+
show: showResponseSchema.nullish(),
30+
});
31+
```
32+
33+
**Why:** OpenAPI codegen turns a union `oneOf` into a single model with every
34+
member's fields marked required, so a downstream consumer sees `movie`, `show`,
35+
`episode`, `season`, etc. all as non-null and its generated model is wrong.
36+
Nullish fields on one object generate a correct optional-field model; the caller
37+
null-checks (`if (entry.movie) ...`) to discriminate.
38+
39+
Single-shape responses stay fully required - only widen to nullish the fields
40+
that are genuinely absent for some variant.
41+
42+
Reference:
43+
`projects/api/src/contracts/calendars/schema/response/hotReleaseResponseSchema.ts`.

projects/api/deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@trakt/api",
33
"exports": "./src/index.ts",
4-
"version": "0.4.24",
4+
"version": "0.4.25",
55
"imports": {
66
"@anatine/zod-openapi": "npm:@anatine/zod-openapi@^2.2.6",
77
"@std/testing": "jsr:@std/testing@^1.0.5",
Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
import { movieResponseSchema } from '../../../_internal/response/movieResponseSchema.ts';
2+
import { showResponseSchema } from '../../../_internal/response/showResponseSchema.ts';
13
import { z } from '../../../_internal/z.ts';
2-
import { calendarMovieResponseSchema } from './calendarMovieResponseSchema.ts';
3-
import { calendarShowResponseSchema } from './calendarShowListResponseSchema.ts';
4+
import { calendarEpisodeResponseSchema } from './calendarEpisodeResponseSchema.ts';
45

56
/**
6-
* A single entry in the merged hot-releases feed: either an upcoming movie or
7-
* an upcoming episode. Discriminate by shape — movie entries carry `movie`,
8-
* episode entries carry `episode`/`show`.
7+
* A single entry in a merged calendar feed (media, hot releases): an upcoming
8+
* movie OR an upcoming episode. Modeled as one flat object with every field
9+
* nullish rather than a `z.union` — OpenAPI codegen turns a union `oneOf` into
10+
* a model with all fields required, so consumers get a wrong schema. With
11+
* nullish fields the generated model is correct; discriminate by shape (movie
12+
* entries carry `movie`, episode entries carry `episode`/`show`).
913
*/
10-
export const hotReleaseResponseSchema = z.union([
11-
calendarMovieResponseSchema,
12-
calendarShowResponseSchema,
13-
]);
14+
export const hotReleaseResponseSchema = z.object({
15+
released: z.string().nullish(),
16+
movie: movieResponseSchema.nullish(),
17+
first_aired: z.string().nullish(),
18+
episode: calendarEpisodeResponseSchema.nullish(),
19+
show: showResponseSchema.nullish(),
20+
});

0 commit comments

Comments
 (0)