zenstack/packages/plugins/openapi/src/generator-base.ts
Yiming 420df87bf1
merge v2 to dev (#1281)
Co-authored-by: Augustin <43639468+Azzerty23@users.noreply.github.com>
Co-authored-by: Jonathan Stevens <jonathan.stevens@resnovas.com>
Co-authored-by: Jonathan S <jonathan.stevens@eventiva.co.uk>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: ErikMCM <70036542+ErikMCM@users.noreply.github.com>
Co-authored-by: Jason Kleinberg <ustice@gmail.com>
Co-authored-by: Jonathan S <punk.gift9475@alias.org.uk>
Co-authored-by: Jiasheng <jiashengguo@outlook.com>
2024-04-24 21:19:41 +08:00

156 lines
5.9 KiB
TypeScript

import { PluginError, getDataModels, hasAttribute, type PluginOptions, type PluginResult } from '@zenstackhq/sdk';
import { Model } from '@zenstackhq/sdk/ast';
import type { DMMF } from '@zenstackhq/sdk/prisma';
import type { OpenAPIV3_1 as OAPI } from 'openapi-types';
import semver from 'semver';
import { fromZodError } from 'zod-validation-error';
import { name } from '.';
import { SecuritySchemesSchema } from './schema';
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(): PluginResult;
protected get includedModels() {
return getDataModels(this.model).filter((d) => !hasAttribute(d, '@@openapi.ignore'));
}
protected wrapArray(
schema: OAPI.ReferenceObject | OAPI.SchemaObject,
isArray: boolean
): OAPI.ReferenceObject | OAPI.SchemaObject {
if (isArray) {
return { type: 'array', items: schema };
} else {
return schema;
}
}
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;
}
protected oneOf(...schemas: (OAPI.SchemaObject | OAPI.ReferenceObject)[]) {
return { oneOf: schemas };
}
protected allOf(...schemas: (OAPI.SchemaObject | OAPI.ReferenceObject)[]) {
return { allOf: schemas };
}
protected getOption<T = string>(name: string): T | undefined;
protected getOption<T = string, D extends T = T>(name: string, defaultValue: D): T;
protected getOption<T = string>(name: string, defaultValue?: T): T | undefined {
const value = this.options[name];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return value === undefined ? defaultValue : value;
}
protected generateSecuritySchemes() {
const securitySchemes = this.getOption<Record<string, string>[]>('securitySchemes');
if (securitySchemes) {
const parsed = SecuritySchemesSchema.safeParse(securitySchemes);
if (!parsed.success) {
throw new PluginError(name, `"securitySchemes" option is invalid: ${fromZodError(parsed.error)}`);
}
return parsed.data;
}
return undefined;
}
protected pruneComponents(paths: OAPI.PathsObject, components: OAPI.ComponentsObject) {
const schemas = components.schemas;
if (schemas) {
const roots = new Set<string>();
for (const path of Object.values(paths)) {
this.collectUsedComponents(path, roots);
}
// build a transitive closure for all reachable schemas from roots
const allUsed = new Set<string>(roots);
let todo = [...allUsed];
while (todo.length > 0) {
const curr = new Set<string>(allUsed);
Object.entries(schemas)
.filter(([key]) => todo.includes(key))
.forEach(([, value]) => {
this.collectUsedComponents(value, allUsed);
});
todo = [...allUsed].filter((e) => !curr.has(e));
}
// prune unused schemas
Object.keys(schemas).forEach((key) => {
if (!allUsed.has(key)) {
delete schemas[key];
}
});
}
}
private collectUsedComponents(value: unknown, allUsed: Set<string>) {
if (!value) {
return;
}
if (Array.isArray(value)) {
value.forEach((item) => {
this.collectUsedComponents(item, allUsed);
});
} else if (typeof value === 'object') {
Object.entries(value).forEach(([subKey, subValue]) => {
if (subKey === '$ref') {
const ref = subValue as string;
const name = ref.split('/').pop();
if (name && !allUsed.has(name)) {
allUsed.add(name);
}
} else {
this.collectUsedComponents(subValue, allUsed);
}
});
}
}
}