Skip to content

fix: properly handle nullable fields in openapi generator #906

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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