Skip to content

Commit e2823f5

Browse files
committed
feat: implement OpenAPI 3.1 patternProperties feature
1 parent aa0bfaa commit e2823f5

File tree

3 files changed

+96
-21
lines changed

3 files changed

+96
-21
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,16 @@ interface IRSchemaObject
174174
* @default 'or'
175175
*/
176176
logicalOperator?: 'and' | 'or';
177+
/**
178+
* When type is `object`, `patternProperties` can be used to define a schema
179+
* for properties that match a specific regex pattern.
180+
*/
181+
patternProperties?: Record<string, IRSchemaObject>;
177182
/**
178183
* When type is `object`, `properties` will contain a map of its properties.
179184
*/
180185
properties?: Record<string, IRSchemaObject>;
186+
181187
/**
182188
* The names of `properties` can be validated against a schema, irrespective
183189
* 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: 39 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,
@@ -781,6 +801,16 @@ const parseOneOf = ({
781801
return irSchema;
782802
};
783803

804+
const parsePatternProperties = ({
805+
context,
806+
schema,
807+
state,
808+
}: {
809+
context: IR.Context;
810+
schema: SchemaWithRequired<SchemaObject, 'patternProperties'>;
811+
state: SchemaState;
812+
}): IR.SchemaObject => parseObject({ context, schema, state });
813+
784814
const parseRef = ({
785815
context,
786816
schema,
@@ -1064,6 +1094,14 @@ export const schemaToIrSchema = ({
10641094
});
10651095
}
10661096

1097+
if (schema.patternProperties) {
1098+
return parsePatternProperties({
1099+
context,
1100+
schema: schema as SchemaWithRequired<SchemaObject, 'patternProperties'>,
1101+
state,
1102+
});
1103+
}
1104+
10671105
// infer object based on the presence of properties
10681106
if (schema.type || schema.properties) {
10691107
return parseType({

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

Lines changed: 51 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) {
@@ -291,6 +320,8 @@ const objectTypeToIdentifier = ({
291320
}
292321
}
293322

323+
// removed duplicate legacy block
324+
294325
return tsc.typeInterfaceNode({
295326
indexKey,
296327
indexProperty,

0 commit comments

Comments
 (0)