diff --git a/packages/plugins/openapi/src/rpc-generator.ts b/packages/plugins/openapi/src/rpc-generator.ts index 1a8f3630e..0adb3ed8c 100644 --- a/packages/plugins/openapi/src/rpc-generator.ts +++ b/packages/plugins/openapi/src/rpc-generator.ts @@ -1,7 +1,7 @@ // Inspired by: https://github.com/omar-dulaimi/prisma-trpc-generator import { PluginError, PluginOptions, analyzePolicies, requireOption, resolvePath } from '@zenstackhq/sdk'; -import { DataModel, Model, isDataModel } from '@zenstackhq/sdk/ast'; +import { DataModel, Model, TypeDef, TypeDefField, TypeDefFieldType, isDataModel, isEnum, isTypeDef } from '@zenstackhq/sdk/ast'; import { AggregateOperationSupport, addMissingInputObjectTypesForAggregate, @@ -649,12 +649,27 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase { for (const _enum of [...(this.dmmf.schema.enumTypes.model ?? []), ...this.dmmf.schema.enumTypes.prisma]) { schemas[upperCaseFirst(_enum.name)] = this.generateEnumComponent(_enum); } + + // Also add enums from AST that might not be in DMMF (e.g., only used in TypeDefs) + for (const enumDecl of this.model.declarations.filter(isEnum)) { + if (!schemas[upperCaseFirst(enumDecl.name)]) { + schemas[upperCaseFirst(enumDecl.name)] = { + type: 'string', + enum: enumDecl.fields.map(f => f.name) + }; + } + } // data models for (const model of this.dmmf.datamodel.models) { schemas[upperCaseFirst(model.name)] = this.generateEntityComponent(model); } + // type defs + for (const typeDef of this.model.declarations.filter(isTypeDef)) { + schemas[upperCaseFirst(typeDef.name)] = this.generateTypeDefComponent(typeDef); + } + for (const input of this.inputObjectTypes) { schemas[upperCaseFirst(input.name)] = this.generateInputComponent(input); } @@ -737,7 +752,7 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase { const required: string[] = []; for (const field of model.fields) { - properties[field.name] = this.generateField(field); + properties[field.name] = this.generateField(field, model.name); if (field.isRequired && !(field.relationName && field.isList)) { required.push(field.name); } @@ -750,7 +765,22 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase { return result; } - private generateField(def: { kind: DMMF.FieldKind; type: string; isList: boolean; isRequired: boolean }) { + private generateField(def: { kind: DMMF.FieldKind; type: string; isList: boolean; isRequired: boolean; name?: string }, modelName?: string) { + // For Json fields, check if there's a corresponding TypeDef in the original model + if (def.kind === 'scalar' && def.type === 'Json' && modelName && def.name) { + const dataModel = this.model.declarations.find(d => isDataModel(d) && d.name === modelName) as DataModel; + if (dataModel) { + const field = dataModel.fields.find(f => f.name === def.name); + if (field?.type.reference?.ref && isTypeDef(field.type.reference.ref)) { + // This Json field references a TypeDef + return this.wrapArray( + this.wrapNullable(this.ref(field.type.reference.ref.name, true), !def.isRequired), + def.isList + ); + } + } + } + switch (def.kind) { case 'scalar': return this.wrapArray(this.prismaTypeToOpenAPIType(def.type, !def.isRequired), def.isList); @@ -816,6 +846,47 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase { return result; } + private generateTypeDefComponent(typeDef: TypeDef): OAPI.SchemaObject { + const schema: OAPI.SchemaObject = { + type: 'object', + description: `The "${typeDef.name}" TypeDef`, + properties: typeDef.fields.reduce((acc, field) => { + acc[field.name] = this.generateTypeDefField(field); + return acc; + }, {} as Record), + }; + + const required = typeDef.fields.filter((f) => !f.type.optional).map((f) => f.name); + if (required.length > 0) { + schema.required = required; + } + + return schema; + } + + private generateTypeDefField(field: TypeDefField): OAPI.ReferenceObject | OAPI.SchemaObject { + return this.wrapArray( + this.wrapNullable(this.typeDefFieldTypeToOpenAPISchema(field.type), field.type.optional), + field.type.array + ); + } + + private typeDefFieldTypeToOpenAPISchema(type: TypeDefFieldType): OAPI.ReferenceObject | OAPI.SchemaObject { + // For references to other types (TypeDef, Enum, Model) + if (type.reference?.ref) { + return this.ref(type.reference.ref.name, true); + } + + // For scalar types, reuse the existing mapping logic + // Note: Json type is handled as empty schema for consistency + return match(type.type) + .with('Json', () => ({} as OAPI.SchemaObject)) + .otherwise((t) => { + // Delegate to prismaTypeToOpenAPIType for all other scalar types + return this.prismaTypeToOpenAPIType(String(t), false); + }); + } + private setInputRequired(fields: readonly DMMF.SchemaArg[], result: OAPI.NonArraySchemaObject) { const required = fields.filter((f) => f.isRequired).map((f) => f.name); if (required.length > 0) { @@ -839,7 +910,12 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase { .with(P.union('Boolean', 'True'), () => ({ type: 'boolean' })) .with('DateTime', () => ({ type: 'string', format: 'date-time' })) .with('Bytes', () => ({ type: 'string', format: 'byte' })) - .with(P.union('JSON', 'Json'), () => ({})) + .with(P.union('JSON', 'Json'), () => { + // For Json fields, check if there's a specific TypeDef reference + // Otherwise, return empty schema for arbitrary JSON + const isTypeDefType = this.model.declarations.some(d => isTypeDef(d) && d.name === type); + return isTypeDefType ? this.ref(type, false) : {}; + }) .otherwise((type) => this.ref(type.toString(), false)); return this.wrapNullable(result, nullable); diff --git a/packages/plugins/openapi/tests/baseline/rpc-type-coverage-3.0.0.baseline.yaml b/packages/plugins/openapi/tests/baseline/rpc-type-coverage-3.0.0.baseline.yaml index d4aa6f8cf..eaea71827 100644 --- a/packages/plugins/openapi/tests/baseline/rpc-type-coverage-3.0.0.baseline.yaml +++ b/packages/plugins/openapi/tests/baseline/rpc-type-coverage-3.0.0.baseline.yaml @@ -19,16 +19,33 @@ components: - decimal - boolean - bytes + - json + - plainJson SortOrder: type: string enum: - asc - desc + NullableJsonNullValueInput: + type: string + enum: + - DbNull + - JsonNull + JsonNullValueInput: + type: string + enum: + - JsonNull QueryMode: type: string enum: - default - insensitive + JsonNullValueFilter: + type: string + enum: + - DbNull + - JsonNull + - AnyNull NullsOrder: type: string enum: @@ -60,6 +77,11 @@ components: type: string format: byte nullable: true + json: + allOf: + - $ref: '#/components/schemas/Meta' + nullable: true + plainJson: {} required: - id - string @@ -69,6 +91,15 @@ components: - float - decimal - boolean + - plainJson + Meta: + type: object + description: The "Meta" TypeDef + properties: + something: + type: string + required: + - something FooWhereInput: type: object properties: @@ -129,6 +160,10 @@ components: - type: string format: byte nullable: true + json: + $ref: '#/components/schemas/JsonNullableFilter' + plainJson: + $ref: '#/components/schemas/JsonFilter' FooOrderByWithRelationInput: type: object properties: @@ -152,6 +187,12 @@ components: oneOf: - $ref: '#/components/schemas/SortOrder' - $ref: '#/components/schemas/SortOrderInput' + json: + oneOf: + - $ref: '#/components/schemas/SortOrder' + - $ref: '#/components/schemas/SortOrderInput' + plainJson: + $ref: '#/components/schemas/SortOrder' FooWhereUniqueInput: type: object properties: @@ -210,6 +251,10 @@ components: - type: string format: byte nullable: true + json: + $ref: '#/components/schemas/JsonNullableFilter' + plainJson: + $ref: '#/components/schemas/JsonFilter' FooScalarWhereWithAggregatesInput: type: object properties: @@ -270,6 +315,10 @@ components: - type: string format: byte nullable: true + json: + $ref: '#/components/schemas/JsonNullableWithAggregatesFilter' + plainJson: + $ref: '#/components/schemas/JsonWithAggregatesFilter' FooCreateInput: type: object properties: @@ -296,6 +345,14 @@ components: type: string format: byte nullable: true + json: + oneOf: + - $ref: '#/components/schemas/NullableJsonNullValueInput' + - {} + plainJson: + oneOf: + - $ref: '#/components/schemas/JsonNullValueInput' + - {} required: - string - int @@ -304,6 +361,7 @@ components: - float - decimal - boolean + - plainJson FooUpdateInput: type: object properties: @@ -348,6 +406,14 @@ components: format: byte - $ref: '#/components/schemas/NullableBytesFieldUpdateOperationsInput' nullable: true + json: + oneOf: + - $ref: '#/components/schemas/NullableJsonNullValueInput' + - {} + plainJson: + oneOf: + - $ref: '#/components/schemas/JsonNullValueInput' + - {} FooCreateManyInput: type: object properties: @@ -374,6 +440,14 @@ components: type: string format: byte nullable: true + json: + oneOf: + - $ref: '#/components/schemas/NullableJsonNullValueInput' + - {} + plainJson: + oneOf: + - $ref: '#/components/schemas/JsonNullValueInput' + - {} required: - string - int @@ -382,6 +456,7 @@ components: - float - decimal - boolean + - plainJson FooUpdateManyMutationInput: type: object properties: @@ -426,6 +501,14 @@ components: format: byte - $ref: '#/components/schemas/NullableBytesFieldUpdateOperationsInput' nullable: true + json: + oneOf: + - $ref: '#/components/schemas/NullableJsonNullValueInput' + - {} + plainJson: + oneOf: + - $ref: '#/components/schemas/JsonNullValueInput' + - {} StringFilter: type: object properties: @@ -642,6 +725,72 @@ components: format: byte - $ref: '#/components/schemas/NestedBytesNullableFilter' nullable: true + JsonNullableFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + nullable: true + array_ends_with: + nullable: true + array_contains: + nullable: true + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + JsonFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + nullable: true + array_ends_with: + nullable: true + array_contains: + nullable: true + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' SortOrderInput: type: object properties: @@ -931,6 +1080,84 @@ components: $ref: '#/components/schemas/NestedBytesNullableFilter' _max: $ref: '#/components/schemas/NestedBytesNullableFilter' + JsonNullableWithAggregatesFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + nullable: true + array_ends_with: + nullable: true + array_contains: + nullable: true + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + _count: + $ref: '#/components/schemas/NestedIntNullableFilter' + _min: + $ref: '#/components/schemas/NestedJsonNullableFilter' + _max: + $ref: '#/components/schemas/NestedJsonNullableFilter' + JsonWithAggregatesFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + nullable: true + array_ends_with: + nullable: true + array_contains: + nullable: true + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + _count: + $ref: '#/components/schemas/NestedIntFilter' + _min: + $ref: '#/components/schemas/NestedJsonFilter' + _max: + $ref: '#/components/schemas/NestedJsonFilter' StringFieldUpdateOperationsInput: type: object properties: @@ -1537,6 +1764,72 @@ components: - type: integer - $ref: '#/components/schemas/NestedIntNullableFilter' nullable: true + NestedJsonNullableFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + nullable: true + array_ends_with: + nullable: true + array_contains: + nullable: true + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + NestedJsonFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + nullable: true + array_ends_with: + nullable: true + array_contains: + nullable: true + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' FooSelect: type: object properties: @@ -1558,6 +1851,10 @@ components: type: boolean bytes: type: boolean + json: + type: boolean + plainJson: + type: boolean FooCountAggregateInput: type: object properties: @@ -1579,6 +1876,10 @@ components: type: boolean bytes: type: boolean + json: + type: boolean + plainJson: + type: boolean _all: type: boolean FooAvgAggregateInput: @@ -1694,6 +1995,9 @@ components: type: string format: byte nullable: true + json: + nullable: true + plainJson: {} _count: allOf: - $ref: '#/components/schemas/FooCountAggregateOutputType' @@ -1723,6 +2027,7 @@ components: - float - decimal - boolean + - plainJson FooCountAggregateOutputType: type: object properties: @@ -1744,6 +2049,10 @@ components: type: integer bytes: type: integer + json: + type: integer + plainJson: + type: integer _all: type: integer required: @@ -1756,6 +2065,8 @@ components: - decimal - boolean - bytes + - json + - plainJson - _all FooAvgAggregateOutputType: type: object @@ -1957,8 +2268,6 @@ components: $ref: '#/components/schemas/FooSelect' where: $ref: '#/components/schemas/FooWhereInput' - meta: - $ref: '#/components/schemas/_Meta' orderBy: oneOf: - $ref: '#/components/schemas/FooOrderByWithRelationInput' @@ -1971,6 +2280,8 @@ components: type: integer skip: type: integer + meta: + $ref: '#/components/schemas/_Meta' FooUpdateArgs: type: object required: diff --git a/packages/plugins/openapi/tests/baseline/rpc-type-coverage-3.1.0.baseline.yaml b/packages/plugins/openapi/tests/baseline/rpc-type-coverage-3.1.0.baseline.yaml index f2d3b4c0e..5f3e59136 100644 --- a/packages/plugins/openapi/tests/baseline/rpc-type-coverage-3.1.0.baseline.yaml +++ b/packages/plugins/openapi/tests/baseline/rpc-type-coverage-3.1.0.baseline.yaml @@ -19,16 +19,33 @@ components: - decimal - boolean - bytes + - json + - plainJson SortOrder: type: string enum: - asc - desc + NullableJsonNullValueInput: + type: string + enum: + - DbNull + - JsonNull + JsonNullValueInput: + type: string + enum: + - JsonNull QueryMode: type: string enum: - default - insensitive + JsonNullValueFilter: + type: string + enum: + - DbNull + - JsonNull + - AnyNull NullsOrder: type: string enum: @@ -61,6 +78,11 @@ components: - type: 'null' - type: string format: byte + json: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/Meta' + plainJson: {} required: - id - string @@ -70,6 +92,15 @@ components: - float - decimal - boolean + - plainJson + Meta: + type: object + description: The "Meta" TypeDef + properties: + something: + type: string + required: + - something FooWhereInput: type: object properties: @@ -130,6 +161,10 @@ components: - type: string format: byte - type: 'null' + json: + $ref: '#/components/schemas/JsonNullableFilter' + plainJson: + $ref: '#/components/schemas/JsonFilter' FooOrderByWithRelationInput: type: object properties: @@ -153,6 +188,12 @@ components: oneOf: - $ref: '#/components/schemas/SortOrder' - $ref: '#/components/schemas/SortOrderInput' + json: + oneOf: + - $ref: '#/components/schemas/SortOrder' + - $ref: '#/components/schemas/SortOrderInput' + plainJson: + $ref: '#/components/schemas/SortOrder' FooWhereUniqueInput: type: object properties: @@ -211,6 +252,10 @@ components: - type: string format: byte - type: 'null' + json: + $ref: '#/components/schemas/JsonNullableFilter' + plainJson: + $ref: '#/components/schemas/JsonFilter' FooScalarWhereWithAggregatesInput: type: object properties: @@ -271,6 +316,10 @@ components: - type: string format: byte - type: 'null' + json: + $ref: '#/components/schemas/JsonNullableWithAggregatesFilter' + plainJson: + $ref: '#/components/schemas/JsonWithAggregatesFilter' FooCreateInput: type: object properties: @@ -298,6 +347,14 @@ components: - type: 'null' - type: string format: byte + json: + oneOf: + - $ref: '#/components/schemas/NullableJsonNullValueInput' + - {} + plainJson: + oneOf: + - $ref: '#/components/schemas/JsonNullValueInput' + - {} required: - string - int @@ -306,6 +363,7 @@ components: - float - decimal - boolean + - plainJson FooUpdateInput: type: object properties: @@ -350,6 +408,14 @@ components: format: byte - $ref: '#/components/schemas/NullableBytesFieldUpdateOperationsInput' - type: 'null' + json: + oneOf: + - $ref: '#/components/schemas/NullableJsonNullValueInput' + - {} + plainJson: + oneOf: + - $ref: '#/components/schemas/JsonNullValueInput' + - {} FooCreateManyInput: type: object properties: @@ -377,6 +443,14 @@ components: - type: 'null' - type: string format: byte + json: + oneOf: + - $ref: '#/components/schemas/NullableJsonNullValueInput' + - {} + plainJson: + oneOf: + - $ref: '#/components/schemas/JsonNullValueInput' + - {} required: - string - int @@ -385,6 +459,7 @@ components: - float - decimal - boolean + - plainJson FooUpdateManyMutationInput: type: object properties: @@ -429,6 +504,14 @@ components: format: byte - $ref: '#/components/schemas/NullableBytesFieldUpdateOperationsInput' - type: 'null' + json: + oneOf: + - $ref: '#/components/schemas/NullableJsonNullValueInput' + - {} + plainJson: + oneOf: + - $ref: '#/components/schemas/JsonNullValueInput' + - {} StringFilter: type: object properties: @@ -648,6 +731,84 @@ components: format: byte - $ref: '#/components/schemas/NestedBytesNullableFilter' - type: 'null' + JsonNullableFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + oneOf: + - type: 'null' + - {} + array_ends_with: + oneOf: + - type: 'null' + - {} + array_contains: + oneOf: + - type: 'null' + - {} + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + JsonFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + oneOf: + - type: 'null' + - {} + array_ends_with: + oneOf: + - type: 'null' + - {} + array_contains: + oneOf: + - type: 'null' + - {} + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' SortOrderInput: type: object properties: @@ -940,6 +1101,96 @@ components: $ref: '#/components/schemas/NestedBytesNullableFilter' _max: $ref: '#/components/schemas/NestedBytesNullableFilter' + JsonNullableWithAggregatesFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + oneOf: + - type: 'null' + - {} + array_ends_with: + oneOf: + - type: 'null' + - {} + array_contains: + oneOf: + - type: 'null' + - {} + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + _count: + $ref: '#/components/schemas/NestedIntNullableFilter' + _min: + $ref: '#/components/schemas/NestedJsonNullableFilter' + _max: + $ref: '#/components/schemas/NestedJsonNullableFilter' + JsonWithAggregatesFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + oneOf: + - type: 'null' + - {} + array_ends_with: + oneOf: + - type: 'null' + - {} + array_contains: + oneOf: + - type: 'null' + - {} + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + _count: + $ref: '#/components/schemas/NestedIntFilter' + _min: + $ref: '#/components/schemas/NestedJsonFilter' + _max: + $ref: '#/components/schemas/NestedJsonFilter' StringFieldUpdateOperationsInput: type: object properties: @@ -1556,6 +1807,84 @@ components: - type: integer - $ref: '#/components/schemas/NestedIntNullableFilter' - type: 'null' + NestedJsonNullableFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + oneOf: + - type: 'null' + - {} + array_ends_with: + oneOf: + - type: 'null' + - {} + array_contains: + oneOf: + - type: 'null' + - {} + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + NestedJsonFilter: + type: object + properties: + equals: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' + path: + type: array + items: + type: string + mode: + $ref: '#/components/schemas/QueryMode' + string_contains: + type: string + string_starts_with: + type: string + string_ends_with: + type: string + array_starts_with: + oneOf: + - type: 'null' + - {} + array_ends_with: + oneOf: + - type: 'null' + - {} + array_contains: + oneOf: + - type: 'null' + - {} + lt: {} + lte: {} + gt: {} + gte: {} + not: + oneOf: + - {} + - $ref: '#/components/schemas/JsonNullValueFilter' FooSelect: type: object properties: @@ -1577,6 +1906,10 @@ components: type: boolean bytes: type: boolean + json: + type: boolean + plainJson: + type: boolean FooCountAggregateInput: type: object properties: @@ -1598,6 +1931,10 @@ components: type: boolean bytes: type: boolean + json: + type: boolean + plainJson: + type: boolean _all: type: boolean FooAvgAggregateInput: @@ -1714,6 +2051,11 @@ components: - type: 'null' - type: string format: byte + json: + oneOf: + - type: 'null' + - {} + plainJson: {} _count: oneOf: - type: 'null' @@ -1743,6 +2085,7 @@ components: - float - decimal - boolean + - plainJson FooCountAggregateOutputType: type: object properties: @@ -1764,6 +2107,10 @@ components: type: integer bytes: type: integer + json: + type: integer + plainJson: + type: integer _all: type: integer required: @@ -1776,6 +2123,8 @@ components: - decimal - boolean - bytes + - json + - plainJson - _all FooAvgAggregateOutputType: type: object @@ -1999,8 +2348,6 @@ components: $ref: '#/components/schemas/FooSelect' where: $ref: '#/components/schemas/FooWhereInput' - meta: - $ref: '#/components/schemas/_Meta' orderBy: oneOf: - $ref: '#/components/schemas/FooOrderByWithRelationInput' @@ -2013,6 +2360,8 @@ components: type: integer skip: type: integer + meta: + $ref: '#/components/schemas/_Meta' FooUpdateArgs: type: object required: diff --git a/packages/plugins/openapi/tests/openapi-rpc.test.ts b/packages/plugins/openapi/tests/openapi-rpc.test.ts index 930341ffb..c61e01b1a 100644 --- a/packages/plugins/openapi/tests/openapi-rpc.test.ts +++ b/packages/plugins/openapi/tests/openapi-rpc.test.ts @@ -377,6 +377,10 @@ plugin openapi { specVersion = '${specVersion}' } +type Meta { + something String +} + model Foo { id String @id @default(cuid()) @@ -388,6 +392,8 @@ model Foo { decimal Decimal boolean Boolean bytes Bytes? + json Meta? @json + plainJson Json @@allow('all', true) } @@ -411,6 +417,107 @@ model Foo { } }); + it('complex TypeDef structures', async () => { + for (const specVersion of ['3.0.0', '3.1.0']) { + const { model, dmmf, modelFile } = await loadZModelAndDmmf(` +plugin openapi { + provider = '${normalizePath(path.resolve(__dirname, '../dist'))}' + specVersion = '${specVersion}' +} + +enum Status { + PENDING + APPROVED + REJECTED +} + +type Address { + street String + city String + country String + zipCode String? +} + +type ContactInfo { + email String + phone String? + addresses Address[] +} + +type ReviewItem { + id String + status Status + reviewer ContactInfo + score Int + comments String[] + metadata Json? +} + +type ComplexData { + reviews ReviewItem[] + primaryContact ContactInfo + tags String[] + settings Json +} + +model Product { + id String @id @default(cuid()) + name String + data ComplexData @json + simpleJson Json + + @@allow('all', true) +} + `); + + const { name: output } = tmp.fileSync({ postfix: '.yaml' }); + + const options = buildOptions(model, modelFile, output); + await generate(model, options, dmmf); + + await OpenAPIParser.validate(output); + + const parsed = YAML.parse(fs.readFileSync(output, 'utf-8')); + expect(parsed.openapi).toBe(specVersion); + + // Verify all TypeDefs are generated + expect(parsed.components.schemas.Address).toBeDefined(); + expect(parsed.components.schemas.ContactInfo).toBeDefined(); + expect(parsed.components.schemas.ReviewItem).toBeDefined(); + expect(parsed.components.schemas.ComplexData).toBeDefined(); + + // Verify enum reference in TypeDef + expect(parsed.components.schemas.ReviewItem.properties.status.$ref).toBe('#/components/schemas/Status'); + + // Json field inside a TypeDef should remain generic (wrapped with nullable since it's optional) + // OpenAPI 3.1 uses oneOf with null type, while 3.0 uses nullable: true + if (specVersion === '3.1.0') { + expect(parsed.components.schemas.ReviewItem.properties.metadata).toEqual({ + oneOf: [ + { type: 'null' }, + {} + ] + }); + } else { + expect(parsed.components.schemas.ReviewItem.properties.metadata).toEqual({ nullable: true }); + } + + // Verify nested TypeDef references + expect(parsed.components.schemas.ContactInfo.properties.addresses.type).toBe('array'); + expect(parsed.components.schemas.ContactInfo.properties.addresses.items.$ref).toBe('#/components/schemas/Address'); + + // Verify array of complex objects + expect(parsed.components.schemas.ComplexData.properties.reviews.type).toBe('array'); + expect(parsed.components.schemas.ComplexData.properties.reviews.items.$ref).toBe('#/components/schemas/ReviewItem'); + + // Verify the Product model references the ComplexData TypeDef + expect(parsed.components.schemas.Product.properties.data.$ref).toBe('#/components/schemas/ComplexData'); + + // Verify plain Json field remains generic + expect(parsed.components.schemas.Product.properties.simpleJson).toEqual({}); + } + }); + it('full-text search', async () => { const { model, dmmf, modelFile } = await loadZModelAndDmmf(` generator js {