Skip to content

Commit c84763b

Browse files
authored
Merge pull request #2523 from hey-api/feature/openapi-ts-contribution
feat: implement OpenAPI 3.1 `patternProperties` feature
2 parents 995f107 + 666ccc5 commit c84763b

File tree

8 files changed

+309
-21
lines changed

8 files changed

+309
-21
lines changed

.changeset/two-humans-rescue.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 `patternProperties` in OpenAPI 3.1

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ describe(`OpenAPI ${version}`, () => {
4444
};
4545

4646
const scenarios = [
47+
{
48+
config: createConfig({
49+
input: 'pattern-properties.json',
50+
output: 'pattern-properties',
51+
}),
52+
description: 'handles pattern properties',
53+
},
4754
{
4855
config: createConfig({
4956
input: 'additional-properties-false.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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
export type PatternPropertiesTest = {
4+
id?: string;
5+
metadata?: MetadataObject;
6+
};
7+
8+
export type MetadataObject = {
9+
name?: string;
10+
description?: string;
11+
[key: string]: Array<string> | string | {
12+
value?: string;
13+
enabled?: boolean;
14+
} | undefined;
15+
};
16+
17+
export type NestedPatternObject = {
18+
base?: string;
19+
[key: string]: {
20+
[key: string]: string;
21+
} | string | undefined;
22+
};
23+
24+
export type UnionPatternObject = {
25+
type?: 'user' | 'admin' | 'guest';
26+
[key: string]: ({
27+
[key: string]: unknown;
28+
} & {
29+
level?: number;
30+
}) | (string | number) | ('user' | 'admin' | 'guest') | undefined;
31+
};
32+
33+
export type PatternPropertiesResponse = {
34+
success?: boolean;
35+
data?: MetadataObject;
36+
};
37+
38+
export type PostPatternTestData = {
39+
body: PatternPropertiesTest;
40+
path?: never;
41+
query?: never;
42+
url: '/pattern-test';
43+
};
44+
45+
export type PostPatternTestResponses = {
46+
/**
47+
* Success
48+
*/
49+
200: PatternPropertiesResponse;
50+
};
51+
52+
export type PostPatternTestResponse = PostPatternTestResponses[keyof PostPatternTestResponses];
53+
54+
export type ClientOptions = {
55+
baseUrl: `${string}://${string}` | (string & {});
56+
};
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "OpenAPI 3.1.0 pattern properties example",
5+
"version": "1"
6+
},
7+
"paths": {
8+
"/pattern-test": {
9+
"post": {
10+
"summary": "Test pattern properties",
11+
"requestBody": {
12+
"required": true,
13+
"content": {
14+
"application/json": {
15+
"schema": {
16+
"$ref": "#/components/schemas/PatternPropertiesTest"
17+
}
18+
}
19+
}
20+
},
21+
"responses": {
22+
"200": {
23+
"description": "Success",
24+
"content": {
25+
"application/json": {
26+
"schema": {
27+
"$ref": "#/components/schemas/PatternPropertiesResponse"
28+
}
29+
}
30+
}
31+
}
32+
}
33+
}
34+
}
35+
},
36+
"components": {
37+
"schemas": {
38+
"PatternPropertiesTest": {
39+
"type": "object",
40+
"properties": {
41+
"id": {
42+
"type": "string"
43+
},
44+
"metadata": {
45+
"$ref": "#/components/schemas/MetadataObject"
46+
}
47+
},
48+
"additionalProperties": false
49+
},
50+
"MetadataObject": {
51+
"type": "object",
52+
"properties": {
53+
"name": {
54+
"type": "string"
55+
},
56+
"description": {
57+
"type": "string"
58+
}
59+
},
60+
"patternProperties": {
61+
"^meta_": {
62+
"type": "string",
63+
"description": "Any property starting with 'meta_' must be a string"
64+
},
65+
"^config_": {
66+
"type": "object",
67+
"properties": {
68+
"value": {
69+
"type": "string"
70+
},
71+
"enabled": {
72+
"type": "boolean"
73+
}
74+
},
75+
"additionalProperties": false
76+
},
77+
"^tag_[a-zA-Z0-9_]+$": {
78+
"type": "string",
79+
"description": "Tag properties must match pattern and be strings"
80+
},
81+
"^[0-9]+_item$": {
82+
"type": "array",
83+
"items": {
84+
"type": "string"
85+
},
86+
"description": "Numbered item properties must be arrays of strings"
87+
}
88+
},
89+
"additionalProperties": false
90+
},
91+
"NestedPatternObject": {
92+
"type": "object",
93+
"properties": {
94+
"base": {
95+
"type": "string"
96+
}
97+
},
98+
"patternProperties": {
99+
"^nested_": {
100+
"type": "object",
101+
"patternProperties": {
102+
"^sub_": {
103+
"type": "string"
104+
}
105+
},
106+
"additionalProperties": false
107+
}
108+
},
109+
"additionalProperties": false
110+
},
111+
"UnionPatternObject": {
112+
"type": "object",
113+
"properties": {
114+
"type": {
115+
"type": "string",
116+
"enum": ["user", "admin", "guest"]
117+
}
118+
},
119+
"patternProperties": {
120+
"^user_": {
121+
"oneOf": [
122+
{
123+
"type": "string"
124+
},
125+
{
126+
"type": "number"
127+
}
128+
]
129+
},
130+
"^admin_": {
131+
"allOf": [
132+
{
133+
"type": "object"
134+
},
135+
{
136+
"properties": {
137+
"level": {
138+
"type": "number",
139+
"minimum": 1,
140+
"maximum": 10
141+
}
142+
}
143+
}
144+
]
145+
}
146+
},
147+
"additionalProperties": false
148+
},
149+
"PatternPropertiesResponse": {
150+
"type": "object",
151+
"properties": {
152+
"success": {
153+
"type": "boolean"
154+
},
155+
"data": {
156+
"$ref": "#/components/schemas/MetadataObject"
157+
}
158+
},
159+
"additionalProperties": false
160+
}
161+
}
162+
}
163+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,10 +255,16 @@ interface IRSchemaObject
255255
* @default 'or'
256256
*/
257257
logicalOperator?: 'and' | 'or';
258+
/**
259+
* When type is `object`, `patternProperties` can be used to define a schema
260+
* for properties that match a specific regex pattern.
261+
*/
262+
patternProperties?: Record<string, IRSchemaObject>;
258263
/**
259264
* When type is `object`, `properties` will contain a map of its properties.
260265
*/
261266
properties?: Record<string, IRSchemaObject>;
267+
262268
/**
263269
* The names of `properties` can be validated against a schema, irrespective
264270
* of their values. This can be useful if you don't want to enforce specific

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,9 @@ const parseObject = ({
295295
const isEmptyObjectInAllOf =
296296
state.inAllOf &&
297297
schema.additionalProperties === false &&
298-
(!schema.properties || Object.keys(schema.properties).length === 0);
298+
(!schema.properties || Object.keys(schema.properties).length === 0) &&
299+
(!schema.patternProperties ||
300+
Object.keys(schema.patternProperties).length === 0);
299301

300302
if (!isEmptyObjectInAllOf) {
301303
irSchema.additionalProperties = {
@@ -311,6 +313,24 @@ const parseObject = ({
311313
irSchema.additionalProperties = irAdditionalPropertiesSchema;
312314
}
313315

316+
if (schema.patternProperties) {
317+
const patternProperties: Record<string, IR.SchemaObject> = {};
318+
319+
for (const pattern in schema.patternProperties) {
320+
const patternSchema = schema.patternProperties[pattern]!;
321+
const irPatternSchema = schemaToIrSchema({
322+
context,
323+
schema: patternSchema,
324+
state,
325+
});
326+
patternProperties[pattern] = irPatternSchema;
327+
}
328+
329+
if (Object.keys(patternProperties).length) {
330+
irSchema.patternProperties = patternProperties;
331+
}
332+
}
333+
314334
if (schema.propertyNames) {
315335
irSchema.propertyNames = schemaToIrSchema({
316336
context,

packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -246,14 +246,41 @@ const objectTypeToIdentifier = ({
246246
}
247247
}
248248

249-
if (
250-
schema.additionalProperties &&
251-
(schema.additionalProperties.type !== 'never' || !indexPropertyItems.length)
252-
) {
253-
if (schema.additionalProperties.type === 'never') {
254-
indexPropertyItems = [schema.additionalProperties];
255-
} else {
256-
indexPropertyItems.unshift(schema.additionalProperties);
249+
// include pattern value schemas into the index union
250+
if (schema.patternProperties) {
251+
for (const pattern in schema.patternProperties) {
252+
const ir = schema.patternProperties[pattern]!;
253+
indexPropertyItems.unshift(ir);
254+
}
255+
}
256+
257+
const hasPatterns =
258+
!!schema.patternProperties &&
259+
Object.keys(schema.patternProperties).length > 0;
260+
261+
const addPropsRaw = schema.additionalProperties;
262+
const addPropsObj =
263+
addPropsRaw !== false && addPropsRaw
264+
? (addPropsRaw as IR.SchemaObject)
265+
: undefined;
266+
const shouldCreateIndex =
267+
hasPatterns ||
268+
(!!addPropsObj &&
269+
(addPropsObj.type !== 'never' || !indexPropertyItems.length));
270+
271+
if (shouldCreateIndex) {
272+
// only inject additionalProperties when it’s not "never"
273+
const addProps = addPropsObj;
274+
if (addProps && addProps.type !== 'never') {
275+
indexPropertyItems.unshift(addProps);
276+
} else if (
277+
!hasPatterns &&
278+
!indexPropertyItems.length &&
279+
addProps &&
280+
addProps.type === 'never'
281+
) {
282+
// keep "never" only when there are NO patterns and NO explicit properties
283+
indexPropertyItems = [addProps];
257284
}
258285

259286
if (hasOptionalProperties) {
@@ -265,18 +292,20 @@ const objectTypeToIdentifier = ({
265292
indexProperty = {
266293
isRequired: !schema.propertyNames,
267294
name: 'key',
268-
type: schemaToType({
269-
onRef,
270-
plugin,
271-
schema:
272-
indexPropertyItems.length === 1
273-
? indexPropertyItems[0]!
274-
: {
275-
items: indexPropertyItems,
276-
logicalOperator: 'or',
277-
},
278-
state,
279-
}),
295+
type:
296+
indexPropertyItems.length === 1
297+
? schemaToType({
298+
onRef,
299+
plugin,
300+
schema: indexPropertyItems[0]!,
301+
state,
302+
})
303+
: schemaToType({
304+
onRef,
305+
plugin,
306+
schema: { items: indexPropertyItems, logicalOperator: 'or' },
307+
state,
308+
}),
280309
};
281310

282311
if (schema.propertyNames?.$ref) {

0 commit comments

Comments
 (0)