Skip to content

Commit dfe0418

Browse files
authored
Merge pull request #2793 from hey-api/copilot/fix-request-types-schema
Fix writeOnly schema properties missing from request types in nested schemas
2 parents bd18df9 + 1f59665 commit dfe0418

File tree

9 files changed

+220
-3
lines changed

9 files changed

+220
-3
lines changed

.changeset/nervous-eyes-pay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hey-api/openapi-ts": patch
3+
---
4+
5+
fix(parser): writeOnly schema properties missing from request types in nested schemas

packages/openapi-ts-tests/main/test/3.1.x.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,22 @@ describe(`OpenAPI ${version}`, () => {
647647
}),
648648
description: 'handles read-only and write-only types',
649649
},
650+
{
651+
config: createConfig({
652+
input: 'transforms-read-write-nested.yaml',
653+
output: 'transforms-read-write-nested',
654+
plugins: ['@hey-api/typescript'],
655+
}),
656+
description: 'handles write-only types in nested schemas',
657+
},
658+
{
659+
config: createConfig({
660+
input: 'transforms-read-write-response.yaml',
661+
output: 'transforms-read-write-response',
662+
plugins: ['@hey-api/typescript'],
663+
}),
664+
description: 'handles read-only types in nested response schemas',
665+
},
650666
{
651667
config: createConfig({
652668
input: 'ref-type.json',
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
export type * from './types.gen';
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
export type ClientOptions = {
4+
baseUrl: `${string}://${string}` | (string & {});
5+
};
6+
7+
export type CreateItemRequest = {
8+
payload: PayloadWritable;
9+
};
10+
11+
export type Payload = {
12+
kind: 'jpeg';
13+
};
14+
15+
export type PayloadWritable = {
16+
kind: 'jpeg';
17+
/**
18+
* Data required on write
19+
*/
20+
encoded: string;
21+
};
22+
23+
export type ItemCreateData = {
24+
body: CreateItemRequest;
25+
path?: never;
26+
query?: never;
27+
url: '/items';
28+
};
29+
30+
export type ItemCreateResponses = {
31+
/**
32+
* Created
33+
*/
34+
201: unknown;
35+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
export type * from './types.gen';
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
export type ClientOptions = {
4+
baseUrl: `${string}://${string}` | (string & {});
5+
};
6+
7+
export type ItemListResponse = {
8+
items: Array<Item>;
9+
};
10+
11+
export type Item = {
12+
/**
13+
* Server-generated ID
14+
*/
15+
readonly id: string;
16+
name: string;
17+
/**
18+
* Server-generated timestamp
19+
*/
20+
readonly created_at?: string;
21+
};
22+
23+
export type ItemWritable = {
24+
name: string;
25+
};
26+
27+
export type ItemListData = {
28+
body?: never;
29+
path?: never;
30+
query?: never;
31+
url: '/items';
32+
};
33+
34+
export type ItemListResponses = {
35+
/**
36+
* Success
37+
*/
38+
200: ItemListResponse;
39+
};
40+
41+
export type ItemListResponse2 = ItemListResponses[keyof ItemListResponses];

packages/openapi-ts/src/openApi/shared/transforms/readWrite.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ type WalkArgs = {
464464
inSchema: boolean;
465465
node: unknown;
466466
path: ReadonlyArray<string | number>;
467+
visited?: Set<string>;
467468
};
468469

469470
/**
@@ -474,10 +475,12 @@ type WalkArgs = {
474475
* @param split - The split mapping (from splitSchemas)
475476
*/
476477
export const updateRefsInSpec = ({
478+
graph,
477479
logger,
478480
spec,
479481
split,
480482
}: {
483+
graph: Graph;
481484
logger: Logger;
482485
spec: unknown;
483486
split: Omit<SplitSchemas, 'schemas'>;
@@ -491,6 +494,7 @@ export const updateRefsInSpec = ({
491494
inSchema,
492495
node,
493496
path,
497+
visited = new Set(),
494498
}: WalkArgs): void => {
495499
if (node instanceof Array) {
496500
node.forEach((item, index) =>
@@ -500,6 +504,7 @@ export const updateRefsInSpec = ({
500504
inSchema,
501505
node: item,
502506
path: [...path, index],
507+
visited,
503508
}),
504509
);
505510
} else if (node && typeof node === 'object') {
@@ -519,6 +524,21 @@ export const updateRefsInSpec = ({
519524
} else if (mapping?.write === nextPointer) {
520525
nextContext = 'write';
521526
}
527+
} else {
528+
// Not a split variant - check graph for the schema's scopes
529+
const nodeInfo = graph.nodes.get(nextPointer);
530+
if (nodeInfo?.scopes) {
531+
// If schema has only write scope, use write context
532+
// If schema has only read scope, use read context
533+
// If schema has both or neither, leave context as-is (null)
534+
const hasRead = nodeInfo.scopes.has('read');
535+
const hasWrite = nodeInfo.scopes.has('write');
536+
if (hasWrite && !hasRead) {
537+
nextContext = 'write';
538+
} else if (hasRead && !hasWrite) {
539+
nextContext = 'read';
540+
}
541+
}
522542
}
523543
}
524544

@@ -535,6 +555,7 @@ export const updateRefsInSpec = ({
535555
inSchema: false,
536556
node: (node as Record<string, unknown>)[key],
537557
path: [...path, key],
558+
visited,
538559
});
539560
}
540561
return;
@@ -555,6 +576,7 @@ export const updateRefsInSpec = ({
555576
inSchema: false,
556577
node: value,
557578
path: [...path, key],
579+
visited,
558580
});
559581
continue;
560582
}
@@ -565,6 +587,7 @@ export const updateRefsInSpec = ({
565587
inSchema: false,
566588
node: value,
567589
path: [...path, key],
590+
visited,
568591
});
569592
continue;
570593
}
@@ -577,6 +600,7 @@ export const updateRefsInSpec = ({
577600
inSchema: true,
578601
node: param.schema,
579602
path: [...path, key, index, 'schema'],
603+
visited,
580604
});
581605
}
582606
// Also handle content (OpenAPI 3.x)
@@ -587,6 +611,7 @@ export const updateRefsInSpec = ({
587611
inSchema: false,
588612
node: param.content,
589613
path: [...path, key, index, 'content'],
614+
visited,
590615
});
591616
}
592617
});
@@ -608,6 +633,7 @@ export const updateRefsInSpec = ({
608633
inSchema: false,
609634
node: (value as Record<string, unknown>)[headerKey],
610635
path: [...path, key, headerKey],
636+
visited,
611637
});
612638
}
613639
continue;
@@ -622,15 +648,20 @@ export const updateRefsInSpec = ({
622648
inSchema: true,
623649
node: value,
624650
path: [...path, key],
651+
visited,
625652
});
626653
} else if (key === '$ref' && typeof value === 'string') {
627654
// Prefer exact match first
628655
const map = split.mapping[value];
629656
if (map) {
630-
if (map.read && (!nextContext || nextContext === 'read')) {
657+
if (nextContext === 'read' && map.read) {
631658
(node as Record<string, unknown>)[key] = map.read;
632-
} else if (map.write && (!nextContext || nextContext === 'write')) {
659+
} else if (nextContext === 'write' && map.write) {
633660
(node as Record<string, unknown>)[key] = map.write;
661+
} else if (!nextContext && map.read) {
662+
// For schemas with no context (unused in operations), default to read variant
663+
// This ensures $refs in unused schemas don't point to removed originals
664+
(node as Record<string, unknown>)[key] = map.read;
634665
}
635666
}
636667
} else {
@@ -640,6 +671,7 @@ export const updateRefsInSpec = ({
640671
inSchema,
641672
node: value,
642673
path: [...path, key],
674+
visited,
643675
});
644676
}
645677
}
@@ -679,6 +711,6 @@ export const readWriteTransform = ({
679711
const originalSchemas = captureOriginalSchemas(spec, logger);
680712
const split = splitSchemas({ config, graph, logger, spec });
681713
insertSplitSchemasIntoSpec({ logger, spec, split });
682-
updateRefsInSpec({ logger, spec, split });
714+
updateRefsInSpec({ graph, logger, spec, split });
683715
removeOriginalSplitSchemas({ logger, originalSchemas, spec, split });
684716
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
openapi: 3.0.3
2+
info:
3+
title: writeOnly repro
4+
version: 1.0.0
5+
paths:
6+
/items:
7+
post:
8+
operationId: item_create
9+
requestBody:
10+
required: true
11+
content:
12+
application/json:
13+
schema:
14+
$ref: '#/components/schemas/CreateItemRequest'
15+
responses:
16+
'201':
17+
description: Created
18+
components:
19+
schemas:
20+
CreateItemRequest:
21+
type: object
22+
required:
23+
- payload
24+
properties:
25+
payload:
26+
$ref: '#/components/schemas/Payload'
27+
Payload:
28+
type: object
29+
required:
30+
- kind
31+
- encoded
32+
properties:
33+
kind:
34+
type: string
35+
enum: [jpeg]
36+
encoded:
37+
type: string
38+
writeOnly: true
39+
description: Data required on write
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
openapi: 3.0.3
2+
info:
3+
title: readOnly response test
4+
version: 1.0.0
5+
paths:
6+
/items:
7+
get:
8+
operationId: item_list
9+
responses:
10+
'200':
11+
description: Success
12+
content:
13+
application/json:
14+
schema:
15+
$ref: '#/components/schemas/ItemListResponse'
16+
components:
17+
schemas:
18+
ItemListResponse:
19+
type: object
20+
required:
21+
- items
22+
properties:
23+
items:
24+
type: array
25+
items:
26+
$ref: '#/components/schemas/Item'
27+
Item:
28+
type: object
29+
required:
30+
- id
31+
- name
32+
properties:
33+
id:
34+
type: string
35+
readOnly: true
36+
description: Server-generated ID
37+
name:
38+
type: string
39+
created_at:
40+
type: string
41+
format: date-time
42+
readOnly: true
43+
description: Server-generated timestamp

0 commit comments

Comments
 (0)