Skip to content

Commit 60deb25

Browse files
authored
Merge pull request #2153 from hey-api/feat/object-property-names
fix(parser): handle propertyNames keyword
2 parents ced57a7 + b272bd9 commit 60deb25

File tree

11 files changed

+170
-55
lines changed

11 files changed

+170
-55
lines changed

.changeset/selfish-vans-heal.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): handle `propertyNames` keyword

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,14 @@ describe(`OpenAPI ${version}`, () => {
508508
description:
509509
'sets correct logical operator and brackets on object with properties and oneOf composition',
510510
},
511+
{
512+
config: createConfig({
513+
input: 'object-property-names.yaml',
514+
output: 'object-property-names',
515+
}),
516+
description:
517+
'sets correct index signature type on object with property names',
518+
},
511519
{
512520
config: createConfig({
513521
input: 'operation-204.json',
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
export * from './types.gen';
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
export type Foo = 'foo' | 'bar';
4+
5+
export type Bar = {
6+
[key in Foo]?: string;
7+
};
8+
9+
export type ClientOptions = {
10+
baseUrl: `${string}://${string}` | (string & {});
11+
};

packages/openapi-ts-tests/test/openapi-ts.config.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ export default defineConfig(() => {
5151
// 'invalid',
5252
// 'servers-entry.yaml',
5353
// ),
54-
path: path.resolve(__dirname, 'spec', '3.1.x', 'type-format.yaml'),
54+
path: path.resolve(
55+
__dirname,
56+
'spec',
57+
'3.1.x',
58+
'object-property-names.yaml',
59+
),
5560
// path: 'http://localhost:4000/',
5661
// path: 'https://get.heyapi.dev/',
5762
// path: 'https://get.heyapi.dev/hey-api/backend?branch=main&version=1.0.0',
@@ -114,9 +119,9 @@ export default defineConfig(() => {
114119
// operationId: false,
115120
// responseStyle: 'data',
116121
// throwOnError: true,
117-
transformer: '@hey-api/transformers',
122+
// transformer: '@hey-api/transformers',
118123
// transformer: true,
119-
validator: 'zod',
124+
// validator: 'zod',
120125
},
121126
{
122127
// bigInt: true,
@@ -140,17 +145,17 @@ export default defineConfig(() => {
140145
},
141146
{
142147
exportFromIndex: true,
143-
name: '@tanstack/react-query',
148+
// name: '@tanstack/react-query',
144149
},
145150
{
146151
// comments: false,
147152
// exportFromIndex: true,
148-
name: 'valibot',
153+
// name: 'valibot',
149154
},
150155
{
151156
// comments: false,
152157
// exportFromIndex: true,
153-
name: 'zod',
158+
// name: 'zod',
154159
},
155160
],
156161
// useOptions: false,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
openapi: 3.1.1
2+
info:
3+
title: OpenAPI 3.1.1 object property names example
4+
version: 1
5+
components:
6+
schemas:
7+
Foo:
8+
enum:
9+
- foo
10+
- bar
11+
type: string
12+
Bar:
13+
additionalProperties:
14+
type: string
15+
propertyNames:
16+
$ref: '#/components/schemas/Foo'
17+
type: object

packages/openapi-ts/src/compiler/typedef.ts

Lines changed: 87 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import ts from 'typescript';
33
import { validTypescriptIdentifierRegExp } from '../utils/regexp';
44
import {
55
createKeywordTypeNode,
6+
createMappedTypeNode,
67
createParameterDeclaration,
78
createStringLiteral,
89
createTypeNode,
10+
createTypeParameterDeclaration,
911
createTypeReferenceNode,
1012
} from './types';
1113
import {
@@ -51,13 +53,26 @@ const maybeNullable = ({
5153
* @returns ts.TypeLiteralNode | ts.TypeUnionNode
5254
*/
5355
export const createTypeInterfaceNode = ({
56+
indexKey,
5457
indexProperty,
5558
isNullable,
5659
properties,
5760
useLegacyResolution,
5861
}: {
62+
/**
63+
* Adds an index key type.
64+
*
65+
* @example
66+
* ```ts
67+
* type IndexKey = {
68+
* [key in Foo]: string
69+
* }
70+
* ```
71+
*/
72+
indexKey?: string;
5973
/**
6074
* Adds an index signature if defined.
75+
*
6176
* @example
6277
* ```ts
6378
* type IndexProperty = {
@@ -72,59 +87,84 @@ export const createTypeInterfaceNode = ({
7287
}) => {
7388
const propertyTypes: Array<ts.TypeNode> = [];
7489

75-
const members: Array<ts.TypeElement> = properties.map((property) => {
76-
const modifiers: readonly ts.Modifier[] | undefined = property.isReadOnly
77-
? [createModifier({ keyword: 'readonly' })]
78-
: undefined;
79-
80-
const questionToken: ts.QuestionToken | undefined =
81-
property.isRequired !== false
82-
? undefined
83-
: ts.factory.createToken(ts.SyntaxKind.QuestionToken);
84-
85-
const type: ts.TypeNode | undefined = createTypeNode(property.type);
86-
propertyTypes.push(type);
87-
88-
const signature = ts.factory.createPropertySignature(
89-
modifiers,
90-
useLegacyResolution ||
91-
(typeof property.name === 'string' &&
92-
property.name.match(validTypescriptIdentifierRegExp)) ||
93-
(typeof property.name !== 'string' && ts.isPropertyName(property.name))
94-
? property.name
95-
: createStringLiteral({ text: property.name }),
96-
questionToken,
97-
type,
98-
);
99-
100-
addLeadingComments({
101-
comments: property.comment,
102-
node: signature,
103-
});
104-
105-
return signature;
106-
});
107-
108-
if (indexProperty) {
109-
const modifiers: readonly ts.Modifier[] | undefined =
110-
indexProperty.isReadOnly
90+
const members: Array<ts.TypeElement | ts.MappedTypeNode> = properties.map(
91+
(property) => {
92+
const modifiers: readonly ts.Modifier[] | undefined = property.isReadOnly
11193
? [createModifier({ keyword: 'readonly' })]
11294
: undefined;
113-
const indexSignature = ts.factory.createIndexSignature(
114-
modifiers,
115-
[
116-
createParameterDeclaration({
95+
96+
const questionToken: ts.QuestionToken | undefined =
97+
property.isRequired !== false
98+
? undefined
99+
: ts.factory.createToken(ts.SyntaxKind.QuestionToken);
100+
101+
const type: ts.TypeNode | undefined = createTypeNode(property.type);
102+
propertyTypes.push(type);
103+
104+
const signature = ts.factory.createPropertySignature(
105+
modifiers,
106+
useLegacyResolution ||
107+
(typeof property.name === 'string' &&
108+
property.name.match(validTypescriptIdentifierRegExp)) ||
109+
(typeof property.name !== 'string' &&
110+
ts.isPropertyName(property.name))
111+
? property.name
112+
: createStringLiteral({ text: property.name }),
113+
questionToken,
114+
type,
115+
);
116+
117+
addLeadingComments({
118+
comments: property.comment,
119+
node: signature,
120+
});
121+
122+
return signature;
123+
},
124+
);
125+
126+
let isIndexMapped = false;
127+
128+
if (indexProperty) {
129+
if (!properties.length && indexKey) {
130+
const indexSignature = createMappedTypeNode({
131+
questionToken: ts.factory.createToken(ts.SyntaxKind.QuestionToken),
132+
type: createKeywordTypeNode({ keyword: 'string' }),
133+
typeParameter: createTypeParameterDeclaration({
134+
constraint: createTypeReferenceNode({ typeName: indexKey }),
117135
name: createIdentifier({ text: String(indexProperty.name) }),
118-
type: createKeywordTypeNode({ keyword: 'string' }),
119136
}),
120-
],
121-
createTypeNode(indexProperty.type),
122-
);
123-
members.push(indexSignature);
137+
});
138+
members.push(indexSignature);
139+
isIndexMapped = true;
140+
} else {
141+
const modifiers: ReadonlyArray<ts.Modifier> | undefined =
142+
indexProperty.isReadOnly
143+
? [createModifier({ keyword: 'readonly' })]
144+
: undefined;
145+
const indexSignature = ts.factory.createIndexSignature(
146+
modifiers,
147+
[
148+
createParameterDeclaration({
149+
name: createIdentifier({ text: String(indexProperty.name) }),
150+
type: createKeywordTypeNode({ keyword: 'string' }),
151+
}),
152+
],
153+
createTypeNode(indexProperty.type),
154+
);
155+
members.push(indexSignature);
156+
}
124157
}
125158

126-
const node = ts.factory.createTypeLiteralNode(members);
127-
return maybeNullable({ isNullable, node });
159+
const node = isIndexMapped
160+
? members[0]!
161+
: // @ts-expect-error
162+
ts.factory.createTypeLiteralNode(members);
163+
return maybeNullable({
164+
isNullable,
165+
// @ts-expect-error
166+
node,
167+
});
128168
};
129169

130170
/**

packages/openapi-ts/src/ir/types.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ interface IRSchemaObject
177177
* When type is `object`, `properties` will contain a map of its properties.
178178
*/
179179
properties?: Record<string, IRSchemaObject>;
180+
/**
181+
* The names of `properties` can be validated against a schema, irrespective
182+
* of their values. This can be useful if you don't want to enforce specific
183+
* properties, but you want to make sure that the names of those properties
184+
* follow a specific convention.
185+
*/
186+
propertyNames?: IRSchemaObject;
180187
/**
181188
* Each schema eventually resolves into `type`.
182189
*/

packages/openapi-ts/src/openApi/3.1.x/parser/graph.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ const collectSchemaDependencies = (
8080
collectSchemaDependencies(item, dependencies);
8181
}
8282
}
83+
84+
if (schema.propertyNames && typeof schema.propertyNames === 'object') {
85+
collectSchemaDependencies(schema.propertyNames, dependencies);
86+
}
8387
};
8488

8589
export const createGraph = ({

packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,14 @@ const parseObject = ({
324324
irSchema.additionalProperties = irAdditionalPropertiesSchema;
325325
}
326326

327+
if (schema.propertyNames) {
328+
irSchema.propertyNames = schemaToIrSchema({
329+
context,
330+
schema: schema.propertyNames,
331+
state,
332+
});
333+
}
334+
327335
if (schema.required) {
328336
irSchema.required = schema.required;
329337
}

0 commit comments

Comments
 (0)