Skip to content

Commit

Permalink
fix: properly handle nullable fields in openapi generator
Browse files Browse the repository at this point in the history
  • Loading branch information
ymc9 committed Dec 25, 2023
1 parent dd80a64 commit 4858409
Show file tree
Hide file tree
Showing 18 changed files with 14,726 additions and 450 deletions.
3 changes: 3 additions & 0 deletions packages/plugins/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
"change-case": "^4.1.2",
"lower-case-first": "^2.0.2",
"openapi-types": "^12.1.0",
"semver": "^7.3.8",
"tiny-invariant": "^1.3.1",
"ts-pattern": "^4.3.0",
"upper-case-first": "^2.0.2",
"yaml": "^2.2.1",
"zod": "^3.22.4",
Expand All @@ -41,6 +43,7 @@
"devDependencies": {
"@readme/openapi-parser": "^2.4.0",
"@types/pluralize": "^0.0.29",
"@types/semver": "^7.3.13",
"@types/tmp": "^0.2.3",
"@zenstackhq/testtools": "workspace:*",
"pluralize": "^8.0.0",
Expand Down
40 changes: 40 additions & 0 deletions packages/plugins/openapi/src/generator-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { Model } from '@zenstackhq/sdk/ast';
import type { OpenAPIV3_1 as OAPI } from 'openapi-types';
import { fromZodError } from 'zod-validation-error';
import { SecuritySchemesSchema } from './schema';
import semver from 'semver';

export abstract class OpenAPIGeneratorBase {
protected readonly DEFAULT_SPEC_VERSION = '3.1.0';

constructor(protected model: Model, protected options: PluginOptions, protected dmmf: DMMF.Document) {}

abstract generate(): string[];
Expand All @@ -25,6 +28,43 @@ export abstract class OpenAPIGeneratorBase {
}
}

protected wrapNullable(
schema: OAPI.ReferenceObject | OAPI.SchemaObject,
isNullable: boolean
): OAPI.ReferenceObject | OAPI.SchemaObject {
if (!isNullable) {
return schema;
}

const specVersion = this.getOption('specVersion', this.DEFAULT_SPEC_VERSION);

// https://stackoverflow.com/questions/48111459/how-to-define-a-property-that-can-be-string-or-null-in-openapi-swagger
// https://stackoverflow.com/questions/40920441/how-to-specify-a-property-can-be-null-or-a-reference-with-swagger
if (semver.gte(specVersion, '3.1.0')) {
// OAPI 3.1.0 and above has native 'null' type
if ((schema as OAPI.BaseSchemaObject).oneOf) {
// merge into existing 'oneOf'
return { oneOf: [...(schema as OAPI.BaseSchemaObject).oneOf!, { type: 'null' }] };
} else {
// wrap into a 'oneOf'
return { oneOf: [{ type: 'null' }, schema] };
}
} else {
if ((schema as OAPI.ReferenceObject).$ref) {
// nullable $ref needs to be represented as: { allOf: [{ $ref: ... }], nullable: true }
return {
allOf: [schema],
nullable: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
} else {
// nullable scalar: { type: ..., nullable: true }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { ...schema, nullable: true } as any;
}
}
}

protected array(itemType: OAPI.SchemaObject | OAPI.ReferenceObject) {
return { type: 'array', items: itemType } as const;
}
Expand Down
90 changes: 39 additions & 51 deletions packages/plugins/openapi/src/rest-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import {
resolvePath,
} from '@zenstackhq/sdk';
import { DataModel, DataModelField, DataModelFieldType, Enum, isDataModel, isEnum } from '@zenstackhq/sdk/ast';
import * as fs from 'fs';
import fs from 'fs';
import { lowerCaseFirst } from 'lower-case-first';
import type { OpenAPIV3_1 as OAPI } from 'openapi-types';
import * as path from 'path';
import path from 'path';
import pluralize from 'pluralize';
import invariant from 'tiny-invariant';
import { P, match } from 'ts-pattern';
import YAML from 'yaml';
import { name } from '.';
import { OpenAPIGeneratorBase } from './generator-base';
Expand Down Expand Up @@ -49,7 +50,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
}

const openapi: OAPI.Document = {
openapi: this.getOption('specVersion', '3.1.0'),
openapi: this.getOption('specVersion', this.DEFAULT_SPEC_VERSION),
info: {
title: this.getOption('title', 'ZenStack Generated API'),
version: this.getOption('version', '1.0.0'),
Expand Down Expand Up @@ -483,9 +484,8 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
schema = this.fieldTypeToOpenAPISchema(field.type);
}
}
if (array) {
schema = { type: 'array', items: schema };
}

schema = this.wrapArray(schema, array);

return {
name: name === 'id' ? 'filter[id]' : `filter[${field.name}${name}]`,
Expand Down Expand Up @@ -576,10 +576,10 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
description: 'Pagination information',
required: ['first', 'last', 'prev', 'next'],
properties: {
first: this.nullable({ type: 'string', description: 'Link to the first page' }),
last: this.nullable({ type: 'string', description: 'Link to the last page' }),
prev: this.nullable({ type: 'string', description: 'Link to the previous page' }),
next: this.nullable({ type: 'string', description: 'Link to the next page' }),
first: this.wrapNullable({ type: 'string', description: 'Link to the first page' }, true),
last: this.wrapNullable({ type: 'string', description: 'Link to the last page' }, true),
prev: this.wrapNullable({ type: 'string', description: 'Link to the previous page' }, true),
next: this.wrapNullable({ type: 'string', description: 'Link to the next page' }, true),
},
},
_errors: {
Expand Down Expand Up @@ -634,7 +634,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
type: 'object',
description: 'A to-one relationship',
properties: {
data: this.nullable(this.ref('_resourceIdentifier')),
data: this.wrapNullable(this.ref('_resourceIdentifier'), true),
},
},
_toOneRelationshipWithLinks: {
Expand All @@ -643,7 +643,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
description: 'A to-one relationship with links',
properties: {
links: this.ref('_relationLinks'),
data: this.nullable(this.ref('_resourceIdentifier')),
data: this.wrapNullable(this.ref('_resourceIdentifier'), true),
},
},
_toManyRelationship: {
Expand Down Expand Up @@ -680,13 +680,16 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
},
_toOneRelationshipRequest: {
description: 'Input for manipulating a to-one relationship',
...this.nullable({
type: 'object',
required: ['data'],
properties: {
data: this.ref('_resourceIdentifier'),
...this.wrapNullable(
{
type: 'object',
required: ['data'],
properties: {
data: this.ref('_resourceIdentifier'),
},
},
}),
true
),
},
_toManyRelationshipResponse: {
description: 'Response for a to-many relationship',
Expand Down Expand Up @@ -841,7 +844,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
const fields = model.fields.filter((f) => !isIdField(f));

const attributes: Record<string, OAPI.SchemaObject> = {};
const relationships: Record<string, OAPI.ReferenceObject> = {};
const relationships: Record<string, OAPI.ReferenceObject | OAPI.SchemaObject> = {};

const required: string[] = [];

Expand All @@ -853,7 +856,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
} else {
relType = field.type.array ? '_toManyRelationshipWithLinks' : '_toOneRelationshipWithLinks';
}
relationships[field.name] = this.ref(relType);
relationships[field.name] = this.wrapNullable(this.ref(relType), field.type.optional);
} else {
attributes[field.name] = this.generateField(field);
if (
Expand Down Expand Up @@ -911,48 +914,33 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
}

private generateField(field: DataModelField) {
return this.wrapArray(this.fieldTypeToOpenAPISchema(field.type), field.type.array);
}

private get specVersion() {
return this.getOption('specVersion', '3.0.0');
return this.wrapArray(
this.wrapNullable(this.fieldTypeToOpenAPISchema(field.type), field.type.optional),
field.type.array
);
}

private fieldTypeToOpenAPISchema(type: DataModelFieldType): OAPI.ReferenceObject | OAPI.SchemaObject {
switch (type.type) {
case 'String':
return { type: 'string' };
case 'Int':
case 'BigInt':
return { type: 'integer' };
case 'Float':
return { type: 'number' };
case 'Decimal':
return this.oneOf({ type: 'number' }, { type: 'string' });
case 'Boolean':
return { type: 'boolean' };
case 'DateTime':
return { type: 'string', format: 'date-time' };
case 'Bytes':
return { type: 'string', format: 'byte', description: 'Base64 encoded byte array' };
case 'Json':
return {};
default: {
return match(type.type)
.with('String', () => ({ type: 'string' }))
.with(P.union('Int', 'BigInt'), () => ({ type: 'integer' }))
.with('Float', () => ({ type: 'number' }))
.with('Decimal', () => this.oneOf({ type: 'number' }, { type: 'string' }))
.with('Boolean', () => ({ type: 'boolean' }))
.with('DateTime', () => ({ type: 'string', format: 'date-time' }))
.with('Bytes', () => ({ type: 'string', format: 'byte', description: 'Base64 encoded byte array' }))
.with('Json', () => ({}))
.otherwise((t) => {
const fieldDecl = type.reference?.ref;
invariant(fieldDecl);
invariant(fieldDecl, `Type ${t} is not a model reference`);
return this.ref(fieldDecl?.name);
}
}
});
}

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

private nullable(schema: OAPI.SchemaObject | OAPI.ReferenceObject) {
return this.specVersion === '3.0.0' ? { ...schema, nullable: true } : this.oneOf(schema, { type: 'null' });
}

private parameter(type: string) {
return { $ref: `#/components/parameters/${type}` };
}
Expand Down
64 changes: 32 additions & 32 deletions packages/plugins/openapi/src/rpc-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { lowerCaseFirst } from 'lower-case-first';
import type { OpenAPIV3_1 as OAPI } from 'openapi-types';
import * as path from 'path';
import invariant from 'tiny-invariant';
import { match, P } from 'ts-pattern';
import { upperCaseFirst } from 'upper-case-first';
import YAML from 'yaml';
import { name } from '.';
Expand Down Expand Up @@ -62,7 +63,7 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
this.pruneComponents(paths, components);

const openapi: OAPI.Document = {
openapi: this.getOption('specVersion', '3.1.0'),
openapi: this.getOption('specVersion', this.DEFAULT_SPEC_VERSION),
info: {
title: this.getOption('title', 'ZenStack Generated API'),
version: this.getOption('version', '1.0.0'),
Expand Down Expand Up @@ -710,14 +711,14 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
return result;
}

private generateField(def: { kind: DMMF.FieldKind; type: string; isList: boolean }) {
private generateField(def: { kind: DMMF.FieldKind; type: string; isList: boolean; isRequired: boolean }) {
switch (def.kind) {
case 'scalar':
return this.wrapArray(this.prismaTypeToOpenAPIType(def.type), def.isList);
return this.wrapArray(this.prismaTypeToOpenAPIType(def.type, !def.isRequired), def.isList);

case 'enum':
case 'object':
return this.wrapArray(this.ref(def.type, false), def.isList);
return this.wrapArray(this.wrapNullable(this.ref(def.type, false), !def.isRequired), def.isList);

default:
throw new PluginError(this.options.name, `Unsupported field kind: ${def.kind}`);
Expand All @@ -735,9 +736,18 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
f.location !== 'fieldRefTypes'
)
.map((f) => {
return this.wrapArray(this.prismaTypeToOpenAPIType(f.type), f.isList);
return this.wrapArray(this.prismaTypeToOpenAPIType(f.type, false), f.isList);
});
properties[field.name] = options.length > 1 ? { oneOf: options } : options[0];

let prop = options.length > 1 ? { oneOf: options } : options[0];

// if types include 'Null', make it nullable
prop = this.wrapNullable(
prop,
field.inputTypes.some((f) => f.type === 'Null')
);

properties[field.name] = prop;
}

const result: OAPI.SchemaObject = { type: 'object', properties };
Expand All @@ -752,11 +762,12 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
switch (field.outputType.location) {
case 'scalar':
case 'enumTypes':
outputType = this.prismaTypeToOpenAPIType(field.outputType.type);
outputType = this.prismaTypeToOpenAPIType(field.outputType.type, !!field.isNullable);
break;
case 'outputObjectTypes':
outputType = this.prismaTypeToOpenAPIType(
typeof field.outputType.type === 'string' ? field.outputType.type : field.outputType.type.name
typeof field.outputType.type === 'string' ? field.outputType.type : field.outputType.type.name,
!!field.isNullable
);
break;
}
Expand Down Expand Up @@ -786,30 +797,19 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
}
}

private prismaTypeToOpenAPIType(type: DMMF.ArgType): OAPI.ReferenceObject | OAPI.SchemaObject {
switch (type) {
case 'String':
return { type: 'string' };
case 'Int':
case 'BigInt':
return { type: 'integer' };
case 'Float':
return { type: 'number' };
case 'Decimal':
return this.oneOf({ type: 'string' }, { type: 'number' });
case 'Boolean':
case 'True':
return { type: 'boolean' };
case 'DateTime':
return { type: 'string', format: 'date-time' };
case 'Bytes':
return { type: 'string', format: 'byte' };
case 'JSON':
case 'Json':
return {};
default:
return this.ref(type.toString(), false);
}
private prismaTypeToOpenAPIType(type: DMMF.ArgType, nullable: boolean): OAPI.ReferenceObject | OAPI.SchemaObject {
const result = match(type)
.with('String', () => ({ type: 'string' }))
.with(P.union('Int', 'BigInt'), () => ({ type: 'integer' }))
.with('Float', () => ({ type: 'number' }))
.with('Decimal', () => this.oneOf({ type: 'string' }, { type: 'number' }))
.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'), () => ({}))
.otherwise((type) => this.ref(type.toString(), false));

return this.wrapNullable(result, nullable);
}

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

0 comments on commit 4858409

Please sign in to comment.