Skip to content

Commit 0e422ad

Browse files
authored
fix: properly handle nullable fields in openapi generator (#906)
1 parent dd80a64 commit 0e422ad

18 files changed

+14726
-450
lines changed

packages/plugins/openapi/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
"change-case": "^4.1.2",
3333
"lower-case-first": "^2.0.2",
3434
"openapi-types": "^12.1.0",
35+
"semver": "^7.3.8",
3536
"tiny-invariant": "^1.3.1",
37+
"ts-pattern": "^4.3.0",
3638
"upper-case-first": "^2.0.2",
3739
"yaml": "^2.2.1",
3840
"zod": "^3.22.4",
@@ -41,6 +43,7 @@
4143
"devDependencies": {
4244
"@readme/openapi-parser": "^2.4.0",
4345
"@types/pluralize": "^0.0.29",
46+
"@types/semver": "^7.3.13",
4447
"@types/tmp": "^0.2.3",
4548
"@zenstackhq/testtools": "workspace:*",
4649
"pluralize": "^8.0.0",

packages/plugins/openapi/src/generator-base.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import { Model } from '@zenstackhq/sdk/ast';
44
import type { OpenAPIV3_1 as OAPI } from 'openapi-types';
55
import { fromZodError } from 'zod-validation-error';
66
import { SecuritySchemesSchema } from './schema';
7+
import semver from 'semver';
78

89
export abstract class OpenAPIGeneratorBase {
10+
protected readonly DEFAULT_SPEC_VERSION = '3.1.0';
11+
912
constructor(protected model: Model, protected options: PluginOptions, protected dmmf: DMMF.Document) {}
1013

1114
abstract generate(): string[];
@@ -25,6 +28,43 @@ export abstract class OpenAPIGeneratorBase {
2528
}
2629
}
2730

31+
protected wrapNullable(
32+
schema: OAPI.ReferenceObject | OAPI.SchemaObject,
33+
isNullable: boolean
34+
): OAPI.ReferenceObject | OAPI.SchemaObject {
35+
if (!isNullable) {
36+
return schema;
37+
}
38+
39+
const specVersion = this.getOption('specVersion', this.DEFAULT_SPEC_VERSION);
40+
41+
// https://stackoverflow.com/questions/48111459/how-to-define-a-property-that-can-be-string-or-null-in-openapi-swagger
42+
// https://stackoverflow.com/questions/40920441/how-to-specify-a-property-can-be-null-or-a-reference-with-swagger
43+
if (semver.gte(specVersion, '3.1.0')) {
44+
// OAPI 3.1.0 and above has native 'null' type
45+
if ((schema as OAPI.BaseSchemaObject).oneOf) {
46+
// merge into existing 'oneOf'
47+
return { oneOf: [...(schema as OAPI.BaseSchemaObject).oneOf!, { type: 'null' }] };
48+
} else {
49+
// wrap into a 'oneOf'
50+
return { oneOf: [{ type: 'null' }, schema] };
51+
}
52+
} else {
53+
if ((schema as OAPI.ReferenceObject).$ref) {
54+
// nullable $ref needs to be represented as: { allOf: [{ $ref: ... }], nullable: true }
55+
return {
56+
allOf: [schema],
57+
nullable: true,
58+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
59+
} as any;
60+
} else {
61+
// nullable scalar: { type: ..., nullable: true }
62+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
63+
return { ...schema, nullable: true } as any;
64+
}
65+
}
66+
}
67+
2868
protected array(itemType: OAPI.SchemaObject | OAPI.ReferenceObject) {
2969
return { type: 'array', items: itemType } as const;
3070
}

packages/plugins/openapi/src/rest-generator.ts

Lines changed: 39 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ import {
1212
resolvePath,
1313
} from '@zenstackhq/sdk';
1414
import { DataModel, DataModelField, DataModelFieldType, Enum, isDataModel, isEnum } from '@zenstackhq/sdk/ast';
15-
import * as fs from 'fs';
15+
import fs from 'fs';
1616
import { lowerCaseFirst } from 'lower-case-first';
1717
import type { OpenAPIV3_1 as OAPI } from 'openapi-types';
18-
import * as path from 'path';
18+
import path from 'path';
1919
import pluralize from 'pluralize';
2020
import invariant from 'tiny-invariant';
21+
import { P, match } from 'ts-pattern';
2122
import YAML from 'yaml';
2223
import { name } from '.';
2324
import { OpenAPIGeneratorBase } from './generator-base';
@@ -49,7 +50,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
4950
}
5051

5152
const openapi: OAPI.Document = {
52-
openapi: this.getOption('specVersion', '3.1.0'),
53+
openapi: this.getOption('specVersion', this.DEFAULT_SPEC_VERSION),
5354
info: {
5455
title: this.getOption('title', 'ZenStack Generated API'),
5556
version: this.getOption('version', '1.0.0'),
@@ -483,9 +484,8 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
483484
schema = this.fieldTypeToOpenAPISchema(field.type);
484485
}
485486
}
486-
if (array) {
487-
schema = { type: 'array', items: schema };
488-
}
487+
488+
schema = this.wrapArray(schema, array);
489489

490490
return {
491491
name: name === 'id' ? 'filter[id]' : `filter[${field.name}${name}]`,
@@ -576,10 +576,10 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
576576
description: 'Pagination information',
577577
required: ['first', 'last', 'prev', 'next'],
578578
properties: {
579-
first: this.nullable({ type: 'string', description: 'Link to the first page' }),
580-
last: this.nullable({ type: 'string', description: 'Link to the last page' }),
581-
prev: this.nullable({ type: 'string', description: 'Link to the previous page' }),
582-
next: this.nullable({ type: 'string', description: 'Link to the next page' }),
579+
first: this.wrapNullable({ type: 'string', description: 'Link to the first page' }, true),
580+
last: this.wrapNullable({ type: 'string', description: 'Link to the last page' }, true),
581+
prev: this.wrapNullable({ type: 'string', description: 'Link to the previous page' }, true),
582+
next: this.wrapNullable({ type: 'string', description: 'Link to the next page' }, true),
583583
},
584584
},
585585
_errors: {
@@ -634,7 +634,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
634634
type: 'object',
635635
description: 'A to-one relationship',
636636
properties: {
637-
data: this.nullable(this.ref('_resourceIdentifier')),
637+
data: this.wrapNullable(this.ref('_resourceIdentifier'), true),
638638
},
639639
},
640640
_toOneRelationshipWithLinks: {
@@ -643,7 +643,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
643643
description: 'A to-one relationship with links',
644644
properties: {
645645
links: this.ref('_relationLinks'),
646-
data: this.nullable(this.ref('_resourceIdentifier')),
646+
data: this.wrapNullable(this.ref('_resourceIdentifier'), true),
647647
},
648648
},
649649
_toManyRelationship: {
@@ -680,13 +680,16 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
680680
},
681681
_toOneRelationshipRequest: {
682682
description: 'Input for manipulating a to-one relationship',
683-
...this.nullable({
684-
type: 'object',
685-
required: ['data'],
686-
properties: {
687-
data: this.ref('_resourceIdentifier'),
683+
...this.wrapNullable(
684+
{
685+
type: 'object',
686+
required: ['data'],
687+
properties: {
688+
data: this.ref('_resourceIdentifier'),
689+
},
688690
},
689-
}),
691+
true
692+
),
690693
},
691694
_toManyRelationshipResponse: {
692695
description: 'Response for a to-many relationship',
@@ -841,7 +844,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
841844
const fields = model.fields.filter((f) => !isIdField(f));
842845

843846
const attributes: Record<string, OAPI.SchemaObject> = {};
844-
const relationships: Record<string, OAPI.ReferenceObject> = {};
847+
const relationships: Record<string, OAPI.ReferenceObject | OAPI.SchemaObject> = {};
845848

846849
const required: string[] = [];
847850

@@ -853,7 +856,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
853856
} else {
854857
relType = field.type.array ? '_toManyRelationshipWithLinks' : '_toOneRelationshipWithLinks';
855858
}
856-
relationships[field.name] = this.ref(relType);
859+
relationships[field.name] = this.wrapNullable(this.ref(relType), field.type.optional);
857860
} else {
858861
attributes[field.name] = this.generateField(field);
859862
if (
@@ -911,48 +914,33 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
911914
}
912915

913916
private generateField(field: DataModelField) {
914-
return this.wrapArray(this.fieldTypeToOpenAPISchema(field.type), field.type.array);
915-
}
916-
917-
private get specVersion() {
918-
return this.getOption('specVersion', '3.0.0');
917+
return this.wrapArray(
918+
this.wrapNullable(this.fieldTypeToOpenAPISchema(field.type), field.type.optional),
919+
field.type.array
920+
);
919921
}
920922

921923
private fieldTypeToOpenAPISchema(type: DataModelFieldType): OAPI.ReferenceObject | OAPI.SchemaObject {
922-
switch (type.type) {
923-
case 'String':
924-
return { type: 'string' };
925-
case 'Int':
926-
case 'BigInt':
927-
return { type: 'integer' };
928-
case 'Float':
929-
return { type: 'number' };
930-
case 'Decimal':
931-
return this.oneOf({ type: 'number' }, { type: 'string' });
932-
case 'Boolean':
933-
return { type: 'boolean' };
934-
case 'DateTime':
935-
return { type: 'string', format: 'date-time' };
936-
case 'Bytes':
937-
return { type: 'string', format: 'byte', description: 'Base64 encoded byte array' };
938-
case 'Json':
939-
return {};
940-
default: {
924+
return match(type.type)
925+
.with('String', () => ({ type: 'string' }))
926+
.with(P.union('Int', 'BigInt'), () => ({ type: 'integer' }))
927+
.with('Float', () => ({ type: 'number' }))
928+
.with('Decimal', () => this.oneOf({ type: 'number' }, { type: 'string' }))
929+
.with('Boolean', () => ({ type: 'boolean' }))
930+
.with('DateTime', () => ({ type: 'string', format: 'date-time' }))
931+
.with('Bytes', () => ({ type: 'string', format: 'byte', description: 'Base64 encoded byte array' }))
932+
.with('Json', () => ({}))
933+
.otherwise((t) => {
941934
const fieldDecl = type.reference?.ref;
942-
invariant(fieldDecl);
935+
invariant(fieldDecl, `Type ${t} is not a model reference`);
943936
return this.ref(fieldDecl?.name);
944-
}
945-
}
937+
});
946938
}
947939

948940
private ref(type: string) {
949941
return { $ref: `#/components/schemas/${type}` };
950942
}
951943

952-
private nullable(schema: OAPI.SchemaObject | OAPI.ReferenceObject) {
953-
return this.specVersion === '3.0.0' ? { ...schema, nullable: true } : this.oneOf(schema, { type: 'null' });
954-
}
955-
956944
private parameter(type: string) {
957945
return { $ref: `#/components/parameters/${type}` };
958946
}

packages/plugins/openapi/src/rpc-generator.ts

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { lowerCaseFirst } from 'lower-case-first';
1616
import type { OpenAPIV3_1 as OAPI } from 'openapi-types';
1717
import * as path from 'path';
1818
import invariant from 'tiny-invariant';
19+
import { match, P } from 'ts-pattern';
1920
import { upperCaseFirst } from 'upper-case-first';
2021
import YAML from 'yaml';
2122
import { name } from '.';
@@ -62,7 +63,7 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
6263
this.pruneComponents(paths, components);
6364

6465
const openapi: OAPI.Document = {
65-
openapi: this.getOption('specVersion', '3.1.0'),
66+
openapi: this.getOption('specVersion', this.DEFAULT_SPEC_VERSION),
6667
info: {
6768
title: this.getOption('title', 'ZenStack Generated API'),
6869
version: this.getOption('version', '1.0.0'),
@@ -710,14 +711,14 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
710711
return result;
711712
}
712713

713-
private generateField(def: { kind: DMMF.FieldKind; type: string; isList: boolean }) {
714+
private generateField(def: { kind: DMMF.FieldKind; type: string; isList: boolean; isRequired: boolean }) {
714715
switch (def.kind) {
715716
case 'scalar':
716-
return this.wrapArray(this.prismaTypeToOpenAPIType(def.type), def.isList);
717+
return this.wrapArray(this.prismaTypeToOpenAPIType(def.type, !def.isRequired), def.isList);
717718

718719
case 'enum':
719720
case 'object':
720-
return this.wrapArray(this.ref(def.type, false), def.isList);
721+
return this.wrapArray(this.wrapNullable(this.ref(def.type, false), !def.isRequired), def.isList);
721722

722723
default:
723724
throw new PluginError(this.options.name, `Unsupported field kind: ${def.kind}`);
@@ -735,9 +736,18 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
735736
f.location !== 'fieldRefTypes'
736737
)
737738
.map((f) => {
738-
return this.wrapArray(this.prismaTypeToOpenAPIType(f.type), f.isList);
739+
return this.wrapArray(this.prismaTypeToOpenAPIType(f.type, false), f.isList);
739740
});
740-
properties[field.name] = options.length > 1 ? { oneOf: options } : options[0];
741+
742+
let prop = options.length > 1 ? { oneOf: options } : options[0];
743+
744+
// if types include 'Null', make it nullable
745+
prop = this.wrapNullable(
746+
prop,
747+
field.inputTypes.some((f) => f.type === 'Null')
748+
);
749+
750+
properties[field.name] = prop;
741751
}
742752

743753
const result: OAPI.SchemaObject = { type: 'object', properties };
@@ -752,11 +762,12 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
752762
switch (field.outputType.location) {
753763
case 'scalar':
754764
case 'enumTypes':
755-
outputType = this.prismaTypeToOpenAPIType(field.outputType.type);
765+
outputType = this.prismaTypeToOpenAPIType(field.outputType.type, !!field.isNullable);
756766
break;
757767
case 'outputObjectTypes':
758768
outputType = this.prismaTypeToOpenAPIType(
759-
typeof field.outputType.type === 'string' ? field.outputType.type : field.outputType.type.name
769+
typeof field.outputType.type === 'string' ? field.outputType.type : field.outputType.type.name,
770+
!!field.isNullable
760771
);
761772
break;
762773
}
@@ -786,30 +797,19 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
786797
}
787798
}
788799

789-
private prismaTypeToOpenAPIType(type: DMMF.ArgType): OAPI.ReferenceObject | OAPI.SchemaObject {
790-
switch (type) {
791-
case 'String':
792-
return { type: 'string' };
793-
case 'Int':
794-
case 'BigInt':
795-
return { type: 'integer' };
796-
case 'Float':
797-
return { type: 'number' };
798-
case 'Decimal':
799-
return this.oneOf({ type: 'string' }, { type: 'number' });
800-
case 'Boolean':
801-
case 'True':
802-
return { type: 'boolean' };
803-
case 'DateTime':
804-
return { type: 'string', format: 'date-time' };
805-
case 'Bytes':
806-
return { type: 'string', format: 'byte' };
807-
case 'JSON':
808-
case 'Json':
809-
return {};
810-
default:
811-
return this.ref(type.toString(), false);
812-
}
800+
private prismaTypeToOpenAPIType(type: DMMF.ArgType, nullable: boolean): OAPI.ReferenceObject | OAPI.SchemaObject {
801+
const result = match(type)
802+
.with('String', () => ({ type: 'string' }))
803+
.with(P.union('Int', 'BigInt'), () => ({ type: 'integer' }))
804+
.with('Float', () => ({ type: 'number' }))
805+
.with('Decimal', () => this.oneOf({ type: 'string' }, { type: 'number' }))
806+
.with(P.union('Boolean', 'True'), () => ({ type: 'boolean' }))
807+
.with('DateTime', () => ({ type: 'string', format: 'date-time' }))
808+
.with('Bytes', () => ({ type: 'string', format: 'byte' }))
809+
.with(P.union('JSON', 'Json'), () => ({}))
810+
.otherwise((type) => this.ref(type.toString(), false));
811+
812+
return this.wrapNullable(result, nullable);
813813
}
814814

815815
private ref(type: string, rooted = true, description?: string): OAPI.ReferenceObject {

0 commit comments

Comments
 (0)