diff --git a/.changeset/two-humans-rescue.md b/.changeset/two-humans-rescue.md new file mode 100644 index 0000000000..98f3d708a5 --- /dev/null +++ b/.changeset/two-humans-rescue.md @@ -0,0 +1,5 @@ +--- +'@hey-api/openapi-ts': patch +--- + +fix(parser): handle `patternProperties` in OpenAPI 3.1 diff --git a/packages/openapi-ts-tests/main/test/3.1.x.test.ts b/packages/openapi-ts-tests/main/test/3.1.x.test.ts index 3dfb21499b..4534e8eb90 100644 --- a/packages/openapi-ts-tests/main/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/main/test/3.1.x.test.ts @@ -44,6 +44,13 @@ describe(`OpenAPI ${version}`, () => { }; const scenarios = [ + { + config: createConfig({ + input: 'pattern-properties.json', + output: 'pattern-properties', + }), + description: 'handles pattern properties', + }, { config: createConfig({ input: 'additional-properties-false.json', diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/pattern-properties/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/pattern-properties/index.ts new file mode 100644 index 0000000000..56bade120a --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/pattern-properties/index.ts @@ -0,0 +1,2 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; \ No newline at end of file diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/pattern-properties/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/pattern-properties/types.gen.ts new file mode 100644 index 0000000000..717c93b03d --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/pattern-properties/types.gen.ts @@ -0,0 +1,56 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type PatternPropertiesTest = { + id?: string; + metadata?: MetadataObject; +}; + +export type MetadataObject = { + name?: string; + description?: string; + [key: string]: Array | string | { + value?: string; + enabled?: boolean; + } | undefined; +}; + +export type NestedPatternObject = { + base?: string; + [key: string]: { + [key: string]: string; + } | string | undefined; +}; + +export type UnionPatternObject = { + type?: 'user' | 'admin' | 'guest'; + [key: string]: ({ + [key: string]: unknown; + } & { + level?: number; + }) | (string | number) | ('user' | 'admin' | 'guest') | undefined; +}; + +export type PatternPropertiesResponse = { + success?: boolean; + data?: MetadataObject; +}; + +export type PostPatternTestData = { + body: PatternPropertiesTest; + path?: never; + query?: never; + url: '/pattern-test'; +}; + +export type PostPatternTestResponses = { + /** + * Success + */ + 200: PatternPropertiesResponse; +}; + +export type PostPatternTestResponse = PostPatternTestResponses[keyof PostPatternTestResponses]; + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; \ No newline at end of file diff --git a/packages/openapi-ts-tests/specs/3.1.x/pattern-properties.json b/packages/openapi-ts-tests/specs/3.1.x/pattern-properties.json new file mode 100644 index 0000000000..36cd51a3c4 --- /dev/null +++ b/packages/openapi-ts-tests/specs/3.1.x/pattern-properties.json @@ -0,0 +1,163 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI 3.1.0 pattern properties example", + "version": "1" + }, + "paths": { + "/pattern-test": { + "post": { + "summary": "Test pattern properties", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatternPropertiesTest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatternPropertiesResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PatternPropertiesTest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/MetadataObject" + } + }, + "additionalProperties": false + }, + "MetadataObject": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^meta_": { + "type": "string", + "description": "Any property starting with 'meta_' must be a string" + }, + "^config_": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "^tag_[a-zA-Z0-9_]+$": { + "type": "string", + "description": "Tag properties must match pattern and be strings" + }, + "^[0-9]+_item$": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Numbered item properties must be arrays of strings" + } + }, + "additionalProperties": false + }, + "NestedPatternObject": { + "type": "object", + "properties": { + "base": { + "type": "string" + } + }, + "patternProperties": { + "^nested_": { + "type": "object", + "patternProperties": { + "^sub_": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "UnionPatternObject": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["user", "admin", "guest"] + } + }, + "patternProperties": { + "^user_": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "^admin_": { + "allOf": [ + { + "type": "object" + }, + { + "properties": { + "level": { + "type": "number", + "minimum": 1, + "maximum": 10 + } + } + } + ] + } + }, + "additionalProperties": false + }, + "PatternPropertiesResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$ref": "#/components/schemas/MetadataObject" + } + }, + "additionalProperties": false + } + } + } +} diff --git a/packages/openapi-ts/src/ir/types.d.ts b/packages/openapi-ts/src/ir/types.d.ts index 496bd5f7d8..d838ab47a5 100644 --- a/packages/openapi-ts/src/ir/types.d.ts +++ b/packages/openapi-ts/src/ir/types.d.ts @@ -174,10 +174,16 @@ interface IRSchemaObject * @default 'or' */ logicalOperator?: 'and' | 'or'; + /** + * When type is `object`, `patternProperties` can be used to define a schema + * for properties that match a specific regex pattern. + */ + patternProperties?: Record; /** * When type is `object`, `properties` will contain a map of its properties. */ properties?: Record; + /** * The names of `properties` can be validated against a schema, irrespective * of their values. This can be useful if you don't want to enforce specific diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts index c5a89bd678..9199670ac0 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts @@ -295,7 +295,9 @@ const parseObject = ({ const isEmptyObjectInAllOf = state.inAllOf && schema.additionalProperties === false && - (!schema.properties || Object.keys(schema.properties).length === 0); + (!schema.properties || Object.keys(schema.properties).length === 0) && + (!schema.patternProperties || + Object.keys(schema.patternProperties).length === 0); if (!isEmptyObjectInAllOf) { irSchema.additionalProperties = { @@ -311,6 +313,24 @@ const parseObject = ({ irSchema.additionalProperties = irAdditionalPropertiesSchema; } + if (schema.patternProperties) { + const patternProperties: Record = {}; + + for (const pattern in schema.patternProperties) { + const patternSchema = schema.patternProperties[pattern]!; + const irPatternSchema = schemaToIrSchema({ + context, + schema: patternSchema, + state, + }); + patternProperties[pattern] = irPatternSchema; + } + + if (Object.keys(patternProperties).length) { + irSchema.patternProperties = patternProperties; + } + } + if (schema.propertyNames) { irSchema.propertyNames = schemaToIrSchema({ context, diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts index 05a0875746..50168e61aa 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts @@ -246,14 +246,41 @@ const objectTypeToIdentifier = ({ } } - if ( - schema.additionalProperties && - (schema.additionalProperties.type !== 'never' || !indexPropertyItems.length) - ) { - if (schema.additionalProperties.type === 'never') { - indexPropertyItems = [schema.additionalProperties]; - } else { - indexPropertyItems.unshift(schema.additionalProperties); + // include pattern value schemas into the index union + if (schema.patternProperties) { + for (const pattern in schema.patternProperties) { + const ir = schema.patternProperties[pattern]!; + indexPropertyItems.unshift(ir); + } + } + + const hasPatterns = + !!schema.patternProperties && + Object.keys(schema.patternProperties).length > 0; + + const addPropsRaw = schema.additionalProperties; + const addPropsObj = + addPropsRaw !== false && addPropsRaw + ? (addPropsRaw as IR.SchemaObject) + : undefined; + const shouldCreateIndex = + hasPatterns || + (!!addPropsObj && + (addPropsObj.type !== 'never' || !indexPropertyItems.length)); + + if (shouldCreateIndex) { + // only inject additionalProperties when it’s not "never" + const addProps = addPropsObj; + if (addProps && addProps.type !== 'never') { + indexPropertyItems.unshift(addProps); + } else if ( + !hasPatterns && + !indexPropertyItems.length && + addProps && + addProps.type === 'never' + ) { + // keep "never" only when there are NO patterns and NO explicit properties + indexPropertyItems = [addProps]; } if (hasOptionalProperties) { @@ -265,18 +292,20 @@ const objectTypeToIdentifier = ({ indexProperty = { isRequired: !schema.propertyNames, name: 'key', - type: schemaToType({ - onRef, - plugin, - schema: - indexPropertyItems.length === 1 - ? indexPropertyItems[0]! - : { - items: indexPropertyItems, - logicalOperator: 'or', - }, - state, - }), + type: + indexPropertyItems.length === 1 + ? schemaToType({ + onRef, + plugin, + schema: indexPropertyItems[0]!, + state, + }) + : schemaToType({ + onRef, + plugin, + schema: { items: indexPropertyItems, logicalOperator: 'or' }, + state, + }), }; if (schema.propertyNames?.$ref) {