mirror of
https://github.com/zenstackhq/zenstack
synced 2026-05-24 10:08:55 +00:00
feat: restful style openapi spec generation (#410)
This commit is contained in:
parent
f07ccdded0
commit
4ebaa1fa4a
14 changed files with 6089 additions and 100 deletions
|
|
@ -40,6 +40,7 @@
|
|||
"@readme/openapi-parser": "^2.4.0",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/lower-case-first": "^1.0.1",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/tmp": "^0.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.0",
|
||||
"@typescript-eslint/parser": "^5.54.0",
|
||||
|
|
@ -47,6 +48,7 @@
|
|||
"copyfiles": "^2.4.1",
|
||||
"eslint": "^8.35.0",
|
||||
"jest": "^29.5.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"tmp": "^0.2.1",
|
||||
"ts-jest": "^29.0.5",
|
||||
|
|
|
|||
60
packages/plugins/openapi/src/generator-base.ts
Normal file
60
packages/plugins/openapi/src/generator-base.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { DMMF } from '@prisma/generator-helper';
|
||||
import { PluginError, PluginOptions, getDataModels, hasAttribute } from '@zenstackhq/sdk';
|
||||
import { Model } from '@zenstackhq/sdk/ast';
|
||||
import type { OpenAPIV3_1 as OAPI } from 'openapi-types';
|
||||
import { SecuritySchemesSchema } from './schema';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
|
||||
export abstract class OpenAPIGeneratorBase {
|
||||
constructor(protected model: Model, protected options: PluginOptions, protected dmmf: DMMF.Document) {}
|
||||
|
||||
abstract generate(): string[];
|
||||
|
||||
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 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(`"securitySchemes" option is invalid: ${fromZodError(parsed.error)}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
import { DMMF } from '@prisma/generator-helper';
|
||||
import { PluginOptions } from '@zenstackhq/sdk';
|
||||
import { Model } from '@zenstackhq/sdk/ast';
|
||||
import { OpenAPIGenerator } from './generator';
|
||||
import { RESTfulOpenAPIGenerator } from './rest-generator';
|
||||
import { RPCOpenAPIGenerator } from './rpc-generator';
|
||||
|
||||
export const name = 'OpenAPI';
|
||||
|
||||
export default async function run(model: Model, options: PluginOptions, dmmf: DMMF.Document) {
|
||||
return new OpenAPIGenerator(model, options, dmmf).generate();
|
||||
const flavor = options.flavor ? (options.flavor as string) : 'restful';
|
||||
if (flavor === 'restful') {
|
||||
return new RESTfulOpenAPIGenerator(model, options, dmmf).generate();
|
||||
} else {
|
||||
return new RPCOpenAPIGenerator(model, options, dmmf).generate();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
903
packages/plugins/openapi/src/rest-generator.ts
Normal file
903
packages/plugins/openapi/src/rest-generator.ts
Normal file
|
|
@ -0,0 +1,903 @@
|
|||
// Inspired by: https://github.com/omar-dulaimi/prisma-trpc-generator
|
||||
|
||||
import { DMMF } from '@prisma/generator-helper';
|
||||
import {
|
||||
AUXILIARY_FIELDS,
|
||||
PluginError,
|
||||
analyzePolicies,
|
||||
getDataModels,
|
||||
isForeignKeyField,
|
||||
isIdField,
|
||||
isRelationshipField,
|
||||
} from '@zenstackhq/sdk';
|
||||
import { DataModel, DataModelField, DataModelFieldType, Enum, isDataModel, isEnum } from '@zenstackhq/sdk/ast';
|
||||
import * as fs from 'fs';
|
||||
import { lowerCaseFirst } from 'lower-case-first';
|
||||
import type { OpenAPIV3_1 as OAPI } from 'openapi-types';
|
||||
import * as path from 'path';
|
||||
import pluralize from 'pluralize';
|
||||
import invariant from 'tiny-invariant';
|
||||
import YAML from 'yaml';
|
||||
import { OpenAPIGeneratorBase } from './generator-base';
|
||||
import { getModelResourceMeta } from './meta';
|
||||
|
||||
type Policies = ReturnType<typeof analyzePolicies>;
|
||||
|
||||
/**
|
||||
* Generates RESTful style OpenAPI specification.
|
||||
*/
|
||||
export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
|
||||
private warnings: string[] = [];
|
||||
|
||||
generate() {
|
||||
const output = this.getOption('output', '');
|
||||
if (!output) {
|
||||
throw new PluginError('"output" option is required');
|
||||
}
|
||||
|
||||
const components = this.generateComponents();
|
||||
const paths = this.generatePaths();
|
||||
|
||||
// generate security schemes, and root-level security
|
||||
components.securitySchemes = this.generateSecuritySchemes();
|
||||
let security: OAPI.Document['security'] | undefined = undefined;
|
||||
if (components.securitySchemes && Object.keys(components.securitySchemes).length > 0) {
|
||||
security = Object.keys(components.securitySchemes).map((scheme) => ({ [scheme]: [] }));
|
||||
}
|
||||
|
||||
const openapi: OAPI.Document = {
|
||||
openapi: this.getOption('specVersion', '3.1.0'),
|
||||
info: {
|
||||
title: this.getOption('title', 'ZenStack Generated API'),
|
||||
version: this.getOption('version', '1.0.0'),
|
||||
description: this.getOption('description'),
|
||||
summary: this.getOption('summary'),
|
||||
},
|
||||
tags: this.includedModels.map((model) => {
|
||||
const meta = getModelResourceMeta(model);
|
||||
return {
|
||||
name: lowerCaseFirst(model.name),
|
||||
description: meta?.tagDescription ?? `${model.name} operations`,
|
||||
};
|
||||
}),
|
||||
paths,
|
||||
components,
|
||||
security,
|
||||
};
|
||||
|
||||
const ext = path.extname(output);
|
||||
if (ext && (ext.toLowerCase() === '.yaml' || ext.toLowerCase() === '.yml')) {
|
||||
fs.writeFileSync(output, YAML.stringify(openapi));
|
||||
} else {
|
||||
fs.writeFileSync(output, JSON.stringify(openapi, undefined, 2));
|
||||
}
|
||||
|
||||
return this.warnings;
|
||||
}
|
||||
|
||||
private generatePaths(): OAPI.PathsObject {
|
||||
let result: OAPI.PathsObject = {};
|
||||
|
||||
const includeModelNames = this.includedModels.map((d) => d.name);
|
||||
|
||||
for (const model of this.dmmf.datamodel.models) {
|
||||
if (includeModelNames.includes(model.name)) {
|
||||
const zmodel = this.model.declarations.find(
|
||||
(d) => isDataModel(d) && d.name === model.name
|
||||
) as DataModel;
|
||||
if (zmodel) {
|
||||
result = {
|
||||
...result,
|
||||
...this.generatePathsForModel(model, zmodel),
|
||||
} as OAPI.PathsObject;
|
||||
} else {
|
||||
this.warnings.push(`Unable to load ZModel definition for: ${model.name}}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private generatePathsForModel(model: DMMF.Model, zmodel: DataModel): OAPI.PathItemObject | undefined {
|
||||
const result: Record<string, OAPI.PathItemObject> = {};
|
||||
|
||||
// analyze access policies to determine default security
|
||||
const policies = analyzePolicies(zmodel);
|
||||
|
||||
let prefix = this.getOption('prefix', '');
|
||||
if (prefix.endsWith('/')) {
|
||||
prefix = prefix.substring(0, prefix.length - 1);
|
||||
}
|
||||
|
||||
// GET /resource
|
||||
// POST /resource
|
||||
result[`${prefix}/${lowerCaseFirst(model.name)}`] = {
|
||||
get: this.makeResourceList(zmodel, policies),
|
||||
post: this.makeResourceCreate(zmodel, policies),
|
||||
};
|
||||
|
||||
// GET /resource/{id}
|
||||
// PATCH /resource/{id}
|
||||
// DELETE /resource/{id}
|
||||
result[`${prefix}/${lowerCaseFirst(model.name)}/{id}`] = {
|
||||
get: this.makeResourceFetch(zmodel, policies),
|
||||
patch: this.makeResourceUpdate(zmodel, policies),
|
||||
delete: this.makeResourceDelete(zmodel, policies),
|
||||
};
|
||||
|
||||
// paths for related resources and relationships
|
||||
for (const field of zmodel.fields) {
|
||||
const relationDecl = field.type.reference?.ref;
|
||||
if (!isDataModel(relationDecl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// GET /resource/{id}/field
|
||||
const relatedDataPath = `${prefix}/${lowerCaseFirst(model.name)}/{id}/${field.name}`;
|
||||
let container = result[relatedDataPath];
|
||||
if (!container) {
|
||||
container = result[relatedDataPath] = {};
|
||||
}
|
||||
container.get = this.makeRelatedFetch(zmodel, field, relationDecl);
|
||||
|
||||
const relationshipPath = `${prefix}/${lowerCaseFirst(model.name)}/{id}/relationships/${field.name}`;
|
||||
container = result[relationshipPath];
|
||||
if (!container) {
|
||||
container = result[relationshipPath] = {};
|
||||
}
|
||||
// GET /resource/{id}/relationships/field
|
||||
container.get = this.makeRelationshipFetch(zmodel, field, policies);
|
||||
// PATCH /resource/{id}/relationships/field
|
||||
container.patch = this.makeRelationshipUpdate(zmodel, field, policies);
|
||||
if (field.type.array) {
|
||||
// POST /resource/{id}/relationships/field
|
||||
container.post = this.makeRelationshipCreate(zmodel, field, policies);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private makeResourceList(model: DataModel, policies: Policies) {
|
||||
return {
|
||||
operationId: `list-${model.name}`,
|
||||
description: `List "${model.name}" resources`,
|
||||
tags: [lowerCaseFirst(model.name)],
|
||||
parameters: [
|
||||
this.parameter('include'),
|
||||
this.parameter('sort'),
|
||||
this.parameter('page-offset'),
|
||||
this.parameter('page-limit'),
|
||||
...this.generateFilterParameters(model),
|
||||
],
|
||||
responses: {
|
||||
'200': this.success(`${model.name}ListResponse`),
|
||||
'403': this.forbidden(),
|
||||
},
|
||||
security: policies.read === true ? [] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private makeResourceCreate(model: DataModel, policies: Policies) {
|
||||
return {
|
||||
operationId: `create-${model.name}`,
|
||||
description: `Create a "${model.name}" resource`,
|
||||
tags: [lowerCaseFirst(model.name)],
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/vnd.api+json': {
|
||||
schema: this.ref(`${model.name}CreateRequest`),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'201': this.success(`${model.name}Response`),
|
||||
'403': this.forbidden(),
|
||||
},
|
||||
security: policies.create === true ? [] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private makeResourceFetch(model: DataModel, policies: Policies) {
|
||||
return {
|
||||
operationId: `fetch-${model.name}`,
|
||||
description: `Fetch a "${model.name}" resource`,
|
||||
tags: [lowerCaseFirst(model.name)],
|
||||
parameters: [this.parameter('id'), this.parameter('include')],
|
||||
responses: {
|
||||
'200': this.success(`${model.name}Response`),
|
||||
'403': this.forbidden(),
|
||||
'404': this.notFound(),
|
||||
},
|
||||
security: policies.read === true ? [] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private makeRelatedFetch(model: DataModel, field: DataModelField, relationDecl: DataModel) {
|
||||
const policies = analyzePolicies(relationDecl);
|
||||
const parameters: OAPI.OperationObject['parameters'] = [this.parameter('id'), this.parameter('include')];
|
||||
if (field.type.array) {
|
||||
parameters.push(
|
||||
this.parameter('sort'),
|
||||
this.parameter('page-offset'),
|
||||
this.parameter('page-limit'),
|
||||
...this.generateFilterParameters(model)
|
||||
);
|
||||
}
|
||||
const result = {
|
||||
operationId: `fetch-${model.name}-related-${field.name}`,
|
||||
description: `Fetch the related "${field.name}" resource for "${model.name}"`,
|
||||
tags: [lowerCaseFirst(model.name)],
|
||||
parameters,
|
||||
responses: {
|
||||
'200': this.success(
|
||||
field.type.array ? `${relationDecl.name}ListResponse` : `${relationDecl.name}Response`
|
||||
),
|
||||
'403': this.forbidden(),
|
||||
'404': this.notFound(),
|
||||
},
|
||||
security: policies.read === true ? [] : undefined,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
private makeResourceUpdate(model: DataModel, policies: Policies) {
|
||||
return {
|
||||
operationId: `update-${model.name}`,
|
||||
description: `Update a "${model.name}" resource`,
|
||||
tags: [lowerCaseFirst(model.name)],
|
||||
parameters: [this.parameter('id')],
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/vnd.api+json': {
|
||||
schema: this.ref(`${model.name}UpdateRequest`),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': this.success(`${model.name}Response`),
|
||||
'403': this.forbidden(),
|
||||
'404': this.notFound(),
|
||||
},
|
||||
security: policies.update === true ? [] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private makeResourceDelete(model: DataModel, policies: Policies) {
|
||||
return {
|
||||
operationId: `delete-${model.name}`,
|
||||
description: `Delete a "${model.name}" resource`,
|
||||
tags: [lowerCaseFirst(model.name)],
|
||||
parameters: [this.parameter('id')],
|
||||
responses: {
|
||||
'200': this.success(),
|
||||
'403': this.forbidden(),
|
||||
'404': this.notFound(),
|
||||
},
|
||||
security: policies.delete === true ? [] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private makeRelationshipFetch(model: DataModel, field: DataModelField, policies: Policies) {
|
||||
const parameters: OAPI.OperationObject['parameters'] = [this.parameter('id')];
|
||||
if (field.type.array) {
|
||||
parameters.push(
|
||||
this.parameter('sort'),
|
||||
this.parameter('page-offset'),
|
||||
this.parameter('page-limit'),
|
||||
...this.generateFilterParameters(model)
|
||||
);
|
||||
}
|
||||
return {
|
||||
operationId: `fetch-${model.name}-relationship-${field.name}`,
|
||||
description: `Fetch the "${field.name}" relationships for a "${model.name}"`,
|
||||
tags: [lowerCaseFirst(model.name)],
|
||||
parameters,
|
||||
responses: {
|
||||
'200': field.type.array
|
||||
? this.success('_toManyRelationshipResponse')
|
||||
: this.success('_toOneRelationshipResponse'),
|
||||
'403': this.forbidden(),
|
||||
'404': this.notFound(),
|
||||
},
|
||||
security: policies.read === true ? [] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private makeRelationshipCreate(model: DataModel, field: DataModelField, policies: Policies) {
|
||||
return {
|
||||
operationId: `create-${model.name}-relationship-${field.name}`,
|
||||
description: `Create new "${field.name}" relationships for a "${model.name}"`,
|
||||
tags: [lowerCaseFirst(model.name)],
|
||||
parameters: [this.parameter('id')],
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/vnd.api+json': {
|
||||
schema: this.ref('_toManyRelationshipRequest'),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': this.success('_toManyRelationshipResponse'),
|
||||
'403': this.forbidden(),
|
||||
'404': this.notFound(),
|
||||
},
|
||||
security: policies.update === true ? [] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private makeRelationshipUpdate(model: DataModel, field: DataModelField, policies: Policies) {
|
||||
return {
|
||||
operationId: `update-${model.name}-relationship-${field.name}`,
|
||||
description: `Update "${field.name}" ${pluralize('relationship', field.type.array ? 2 : 1)} for a "${
|
||||
model.name
|
||||
}"`,
|
||||
tags: [lowerCaseFirst(model.name)],
|
||||
parameters: [this.parameter('id')],
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/vnd.api+json': {
|
||||
schema: field.type.array
|
||||
? this.ref('_toManyRelationshipRequest')
|
||||
: this.ref('_toOneRelationshipRequest'),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': field.type.array
|
||||
? this.success('_toManyRelationshipResponse')
|
||||
: this.success('_toOneRelationshipResponse'),
|
||||
'403': this.forbidden(),
|
||||
'404': this.notFound(),
|
||||
},
|
||||
security: policies.update === true ? [] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private generateFilterParameters(model: DataModel) {
|
||||
const result: OAPI.ParameterObject[] = [];
|
||||
|
||||
for (const field of model.fields) {
|
||||
if (isForeignKeyField(field)) {
|
||||
// no filtering with foreign keys because one can filter
|
||||
// directly on the relationship
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isIdField(field)) {
|
||||
// id filter
|
||||
result.push(this.makeFilterParameter(field, 'id', 'Id filter'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// equality filter
|
||||
result.push(this.makeFilterParameter(field, '', 'Equality filter', field.type.array));
|
||||
|
||||
if (isRelationshipField(field)) {
|
||||
// TODO: how to express nested filters?
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.type.array) {
|
||||
// collection filters
|
||||
result.push(this.makeFilterParameter(field, '$has', 'Collection contains filter'));
|
||||
result.push(this.makeFilterParameter(field, '$hasEvery', 'Collection contains-all filter', true));
|
||||
result.push(this.makeFilterParameter(field, '$hasSome', 'Collection contains-any filter', true));
|
||||
result.push(
|
||||
this.makeFilterParameter(field, '$isEmpty', 'Collection is empty filter', false, {
|
||||
type: 'boolean',
|
||||
})
|
||||
);
|
||||
} else {
|
||||
if (field.type.type && ['Int', 'BigInt', 'Float', 'Decimal', 'DateTime'].includes(field.type.type)) {
|
||||
// comparison filters
|
||||
result.push(this.makeFilterParameter(field, '$lt', 'Less-than filter'));
|
||||
result.push(this.makeFilterParameter(field, '$lte', 'Less-than or equal filter'));
|
||||
result.push(this.makeFilterParameter(field, '$gt', 'Greater-than filter'));
|
||||
result.push(this.makeFilterParameter(field, '$gte', 'Greater-than or equal filter'));
|
||||
}
|
||||
|
||||
if (field.type.type === 'String') {
|
||||
result.push(this.makeFilterParameter(field, '$contains', 'String contains filter'));
|
||||
result.push(
|
||||
this.makeFilterParameter(field, '$icontains', 'String case-insensitive contains filter')
|
||||
);
|
||||
result.push(this.makeFilterParameter(field, '$search', 'String full-text search filter'));
|
||||
result.push(this.makeFilterParameter(field, '$startsWith', 'String startsWith filter'));
|
||||
result.push(this.makeFilterParameter(field, '$endsWith', 'String endsWith filter'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private makeFilterParameter(
|
||||
field: DataModelField,
|
||||
name: string,
|
||||
description: string,
|
||||
array = false,
|
||||
schemaOverride?: OAPI.SchemaObject
|
||||
) {
|
||||
let schema: OAPI.SchemaObject | OAPI.ReferenceObject;
|
||||
|
||||
if (schemaOverride) {
|
||||
schema = schemaOverride;
|
||||
} else {
|
||||
const fieldDecl = field.type.reference?.ref;
|
||||
if (isEnum(fieldDecl)) {
|
||||
schema = this.ref(fieldDecl.name);
|
||||
} else if (isDataModel(fieldDecl)) {
|
||||
schema = { type: 'string' };
|
||||
} else {
|
||||
invariant(field.type.type);
|
||||
schema = this.fieldTypeToOpenAPISchema(field.type);
|
||||
}
|
||||
}
|
||||
if (array) {
|
||||
schema = { type: 'array', items: schema };
|
||||
}
|
||||
|
||||
return {
|
||||
name: name === 'id' ? 'filter[id]' : `filter[${field.name}${name}]`,
|
||||
required: false,
|
||||
description: name === 'id' ? description : `${description} for "${field.name}"`,
|
||||
in: 'query',
|
||||
style: 'form',
|
||||
explode: false,
|
||||
schema,
|
||||
} as OAPI.ParameterObject;
|
||||
}
|
||||
|
||||
private generateComponents() {
|
||||
const schemas: Record<string, OAPI.SchemaObject> = {};
|
||||
const parameters: Record<string, OAPI.ParameterObject> = {};
|
||||
const components: OAPI.ComponentsObject = {
|
||||
schemas,
|
||||
parameters,
|
||||
};
|
||||
|
||||
for (const [name, value] of Object.entries(this.generateSharedComponents())) {
|
||||
schemas[name] = value;
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(this.generateParameters())) {
|
||||
parameters[name] = value;
|
||||
}
|
||||
|
||||
for (const _enum of this.model.declarations.filter((d): d is Enum => isEnum(d))) {
|
||||
schemas[_enum.name] = this.generateEnumComponent(_enum);
|
||||
}
|
||||
|
||||
// data models
|
||||
for (const model of getDataModels(this.model)) {
|
||||
if (!this.includedModels.includes(model)) {
|
||||
continue;
|
||||
}
|
||||
for (const [name, value] of Object.entries(this.generateDataModelComponents(model))) {
|
||||
schemas[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
private generateSharedComponents(): Record<string, OAPI.SchemaObject> {
|
||||
return {
|
||||
_jsonapi: {
|
||||
type: 'object',
|
||||
description: 'An object describing the server’s implementation',
|
||||
properties: {
|
||||
version: { type: 'string' },
|
||||
meta: this.ref('_meta'),
|
||||
},
|
||||
},
|
||||
_meta: {
|
||||
type: 'object',
|
||||
description: 'Meta information about the response',
|
||||
additionalProperties: true,
|
||||
},
|
||||
_resourceIdentifier: {
|
||||
type: 'object',
|
||||
description: 'Identifier for a resource',
|
||||
required: ['type', 'id'],
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
_resource: this.allOf(this.ref('_resourceIdentifier'), {
|
||||
type: 'object',
|
||||
description: 'A resource with attributes and relationships',
|
||||
properties: {
|
||||
attributes: { type: 'object' },
|
||||
relationships: { type: 'object' },
|
||||
},
|
||||
}),
|
||||
_links: {
|
||||
type: 'object',
|
||||
required: ['self'],
|
||||
description: 'Links related to the resource',
|
||||
properties: { self: { type: 'string' } },
|
||||
},
|
||||
_pagination: {
|
||||
type: 'object',
|
||||
description: 'Pagination information',
|
||||
properties: {
|
||||
first: this.nullable({ type: 'string' }),
|
||||
last: this.nullable({ type: 'string' }),
|
||||
prev: this.nullable({ type: 'string' }),
|
||||
next: this.nullable({ type: 'string' }),
|
||||
},
|
||||
},
|
||||
_errors: {
|
||||
type: 'array',
|
||||
description: 'An array of error objects',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
code: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
detail: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
_errorResponse: {
|
||||
type: 'object',
|
||||
required: ['errors'],
|
||||
description: 'An error response',
|
||||
properties: {
|
||||
jsonapi: this.ref('_jsonapi'),
|
||||
errors: this.ref('_errors'),
|
||||
},
|
||||
},
|
||||
_relationLinks: {
|
||||
type: 'object',
|
||||
required: ['self', 'related'],
|
||||
description: 'Links related to a relationship',
|
||||
properties: {
|
||||
self: { type: 'string' },
|
||||
related: { type: 'string' },
|
||||
},
|
||||
},
|
||||
_toOneRelationship: {
|
||||
type: 'object',
|
||||
description: 'A to-one relationship',
|
||||
properties: {
|
||||
data: this.nullable(this.ref('_resourceIdentifier')),
|
||||
},
|
||||
},
|
||||
_toOneRelationshipWithLinks: {
|
||||
type: 'object',
|
||||
required: ['links', 'data'],
|
||||
description: 'A to-one relationship with links',
|
||||
properties: {
|
||||
links: this.ref('_relationLinks'),
|
||||
data: this.nullable(this.ref('_resourceIdentifier')),
|
||||
},
|
||||
},
|
||||
_toManyRelationship: {
|
||||
type: 'object',
|
||||
required: ['data'],
|
||||
description: 'A to-many relationship',
|
||||
properties: {
|
||||
data: this.array(this.ref('_resourceIdentifier')),
|
||||
},
|
||||
},
|
||||
_toManyRelationshipWithLinks: {
|
||||
type: 'object',
|
||||
required: ['links', 'data'],
|
||||
description: 'A to-many relationship with links',
|
||||
properties: {
|
||||
links: this.ref('_pagedRelationLinks'),
|
||||
data: this.array(this.ref('_resourceIdentifier')),
|
||||
},
|
||||
},
|
||||
_pagedRelationLinks: {
|
||||
description: 'Relationship links with pagination information',
|
||||
...this.allOf(this.ref('_pagination'), this.ref('_relationLinks')),
|
||||
},
|
||||
_toManyRelationshipRequest: {
|
||||
type: 'object',
|
||||
required: ['data'],
|
||||
description: 'Input for manipulating a to-many relationship',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: this.ref('_resourceIdentifier'),
|
||||
},
|
||||
},
|
||||
},
|
||||
_toOneRelationshipRequest: {
|
||||
description: 'Input for manipulating a to-one relationship',
|
||||
...this.nullable({
|
||||
type: 'object',
|
||||
required: ['data'],
|
||||
properties: {
|
||||
data: this.ref('_resourceIdentifier'),
|
||||
},
|
||||
}),
|
||||
},
|
||||
_toManyRelationshipResponse: {
|
||||
description: 'Response for a to-many relationship',
|
||||
...this.allOf(this.ref('_toManyRelationshipWithLinks'), {
|
||||
type: 'object',
|
||||
properties: {
|
||||
jsonapi: this.ref('_jsonapi'),
|
||||
},
|
||||
}),
|
||||
},
|
||||
_toOneRelationshipResponse: {
|
||||
description: 'Response for a to-one relationship',
|
||||
...this.allOf(this.ref('_toOneRelationshipWithLinks'), {
|
||||
type: 'object',
|
||||
properties: {
|
||||
jsonapi: this.ref('_jsonapi'),
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private generateParameters(): Record<string, OAPI.ParameterObject> {
|
||||
return {
|
||||
id: {
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
description: 'The resource id',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
include: {
|
||||
name: 'include',
|
||||
in: 'query',
|
||||
description: 'Relationships to include',
|
||||
required: false,
|
||||
style: 'form',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
sort: {
|
||||
name: 'sort',
|
||||
in: 'query',
|
||||
description: 'Fields to sort by',
|
||||
required: false,
|
||||
style: 'form',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
'page-offset': {
|
||||
name: 'page[offset]',
|
||||
in: 'query',
|
||||
description: 'Offset for pagination',
|
||||
required: false,
|
||||
style: 'form',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
'page-limit': {
|
||||
name: 'page[limit]',
|
||||
in: 'query',
|
||||
description: 'Limit for pagination',
|
||||
required: false,
|
||||
style: 'form',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private generateEnumComponent(_enum: Enum): OAPI.SchemaObject {
|
||||
const schema: OAPI.SchemaObject = {
|
||||
type: 'string',
|
||||
description: `The "${_enum.name}" Enum`,
|
||||
enum: _enum.fields.map((f) => f.name),
|
||||
};
|
||||
return schema;
|
||||
}
|
||||
|
||||
private generateDataModelComponents(model: DataModel) {
|
||||
const result: Record<string, OAPI.SchemaObject> = {};
|
||||
result[`${model.name}`] = this.generateModelEntity(model, 'read');
|
||||
|
||||
result[`${model.name}CreateRequest`] = {
|
||||
type: 'object',
|
||||
description: `Input for creating a "${model.name}"`,
|
||||
required: ['data'],
|
||||
properties: {
|
||||
data: this.generateModelEntity(model, 'create'),
|
||||
},
|
||||
};
|
||||
|
||||
result[`${model.name}UpdateRequest`] = {
|
||||
type: 'object',
|
||||
description: `Input for updating a "${model.name}"`,
|
||||
required: ['data'],
|
||||
properties: { data: this.generateModelEntity(model, 'update') },
|
||||
};
|
||||
|
||||
const relationships: Record<string, OAPI.ReferenceObject> = {};
|
||||
for (const field of model.fields) {
|
||||
if (isRelationshipField(field)) {
|
||||
if (field.type.array) {
|
||||
relationships[field.name] = this.ref('_toManyRelationship');
|
||||
} else {
|
||||
relationships[field.name] = this.ref('_toOneRelationship');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result[`${model.name}Response`] = {
|
||||
type: 'object',
|
||||
description: `Response for a "${model.name}"`,
|
||||
required: ['data'],
|
||||
properties: {
|
||||
jsonapi: this.ref('_jsonapi'),
|
||||
data: this.allOf(this.ref(`${model.name}`), {
|
||||
type: 'object',
|
||||
properties: { relationships: { type: 'object', properties: relationships } },
|
||||
}),
|
||||
|
||||
included: {
|
||||
type: 'array',
|
||||
items: this.ref('_resource'),
|
||||
},
|
||||
links: this.ref('_links'),
|
||||
},
|
||||
};
|
||||
|
||||
result[`${model.name}ListResponse`] = {
|
||||
type: 'object',
|
||||
description: `Response for a list of "${model.name}"`,
|
||||
required: ['data', 'links'],
|
||||
properties: {
|
||||
jsonapi: this.ref('_jsonapi'),
|
||||
data: this.array(
|
||||
this.allOf(this.ref(`${model.name}`), {
|
||||
type: 'object',
|
||||
properties: { relationships: { type: 'object', properties: relationships } },
|
||||
})
|
||||
),
|
||||
included: {
|
||||
type: 'array',
|
||||
items: this.ref('_resource'),
|
||||
},
|
||||
links: this.allOf(this.ref('_links'), this.ref('_pagination')),
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private generateModelEntity(model: DataModel, mode: 'read' | 'create' | 'update'): OAPI.SchemaObject {
|
||||
const fields = model.fields.filter((f) => !AUXILIARY_FIELDS.includes(f.name) && !isIdField(f));
|
||||
|
||||
const attributes: Record<string, OAPI.SchemaObject> = {};
|
||||
const relationships: Record<string, OAPI.ReferenceObject> = {};
|
||||
|
||||
const required: string[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (isRelationshipField(field)) {
|
||||
let relType: string;
|
||||
if (mode === 'create' || mode === 'update') {
|
||||
relType = field.type.array ? '_toManyRelationship' : '_toOneRelationship';
|
||||
} else {
|
||||
relType = field.type.array ? '_toManyRelationshipWithLinks' : '_toOneRelationshipWithLinks';
|
||||
}
|
||||
relationships[field.name] = this.ref(relType);
|
||||
} else {
|
||||
attributes[field.name] = this.generateField(field);
|
||||
if (
|
||||
mode === 'create' &&
|
||||
!field.type.optional &&
|
||||
// collection relation fields are implicitly optional
|
||||
!(isDataModel(field.$resolvedType?.decl) && field.type.array)
|
||||
) {
|
||||
required.push(field.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: any = {
|
||||
type: 'object',
|
||||
description: `The "${model.name}" model`,
|
||||
required: ['id', 'type', 'attributes'],
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
id: { type: 'string' },
|
||||
attributes: {
|
||||
type: 'object',
|
||||
required: required.length > 0 ? required : undefined,
|
||||
properties: attributes,
|
||||
},
|
||||
},
|
||||
} satisfies OAPI.SchemaObject;
|
||||
|
||||
if (Object.keys(relationships).length > 0) {
|
||||
result.properties.relationships = {
|
||||
type: 'object',
|
||||
properties: relationships,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private generateField(field: DataModelField) {
|
||||
return this.wrapArray(this.fieldTypeToOpenAPISchema(field.type), field.type.array);
|
||||
}
|
||||
|
||||
private get specVersion() {
|
||||
return this.getOption('specVersion', '3.0.0');
|
||||
}
|
||||
|
||||
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':
|
||||
case 'Decimal':
|
||||
return { type: 'number' };
|
||||
case 'Boolean':
|
||||
return { type: 'boolean' };
|
||||
case 'DateTime':
|
||||
return { type: 'string', format: 'date-time' };
|
||||
case 'Json':
|
||||
return { type: 'object' };
|
||||
default: {
|
||||
const fieldDecl = type.reference?.ref;
|
||||
invariant(fieldDecl);
|
||||
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}` };
|
||||
}
|
||||
|
||||
private forbidden() {
|
||||
return {
|
||||
description: 'Request is forbidden',
|
||||
content: {
|
||||
'application/vnd.api+json': {
|
||||
schema: this.ref('_errorResponse'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private notFound() {
|
||||
return {
|
||||
description: 'Resource is not found',
|
||||
content: {
|
||||
'application/vnd.api+json': {
|
||||
schema: this.ref('_errorResponse'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private success(responseComponent?: string) {
|
||||
return {
|
||||
description: 'Successful operation',
|
||||
content: responseComponent
|
||||
? {
|
||||
'application/vnd.api+json': {
|
||||
schema: this.ref(responseComponent),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,8 @@
|
|||
// Inspired by: https://github.com/omar-dulaimi/prisma-trpc-generator
|
||||
|
||||
import { DMMF } from '@prisma/generator-helper';
|
||||
import {
|
||||
analyzePolicies,
|
||||
AUXILIARY_FIELDS,
|
||||
getDataModels,
|
||||
hasAttribute,
|
||||
PluginError,
|
||||
PluginOptions,
|
||||
} from '@zenstackhq/sdk';
|
||||
import { DataModel, isDataModel, type Model } from '@zenstackhq/sdk/ast';
|
||||
import { analyzePolicies, AUXILIARY_FIELDS, PluginError } from '@zenstackhq/sdk';
|
||||
import { DataModel, isDataModel } from '@zenstackhq/sdk/ast';
|
||||
import {
|
||||
addMissingInputObjectTypesForAggregate,
|
||||
addMissingInputObjectTypesForInclude,
|
||||
|
|
@ -18,29 +11,25 @@ import {
|
|||
AggregateOperationSupport,
|
||||
resolveAggregateOperationSupport,
|
||||
} from '@zenstackhq/sdk/dmmf-helpers';
|
||||
import { lowerCaseFirst } from 'lower-case-first';
|
||||
import * as fs from 'fs';
|
||||
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 YAML from 'yaml';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
import { OpenAPIGeneratorBase } from './generator-base';
|
||||
import { getModelResourceMeta } from './meta';
|
||||
import { SecuritySchemesSchema } from './schema';
|
||||
|
||||
/**
|
||||
* Generates OpenAPI specification.
|
||||
*/
|
||||
export class OpenAPIGenerator {
|
||||
export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
|
||||
private inputObjectTypes: DMMF.InputType[] = [];
|
||||
private outputObjectTypes: DMMF.OutputType[] = [];
|
||||
private usedComponents: Set<string> = new Set<string>();
|
||||
private aggregateOperationSupport: AggregateOperationSupport;
|
||||
private includedModels: DataModel[];
|
||||
private warnings: string[] = [];
|
||||
|
||||
constructor(private model: Model, private options: PluginOptions, private dmmf: DMMF.Document) {}
|
||||
|
||||
generate() {
|
||||
const output = this.getOption('output', '');
|
||||
if (!output) {
|
||||
|
|
@ -50,7 +39,6 @@ export class OpenAPIGenerator {
|
|||
// input types
|
||||
this.inputObjectTypes.push(...this.dmmf.schema.inputObjectTypes.prisma);
|
||||
this.outputObjectTypes.push(...this.dmmf.schema.outputObjectTypes.prisma);
|
||||
this.includedModels = getDataModels(this.model).filter((d) => !hasAttribute(d, '@@openapi.ignore'));
|
||||
|
||||
// add input object types that are missing from Prisma dmmf
|
||||
addMissingInputObjectTypesForModelArgs(this.inputObjectTypes, this.dmmf.datamodel.models);
|
||||
|
|
@ -64,7 +52,7 @@ export class OpenAPIGenerator {
|
|||
const paths = this.generatePaths(components);
|
||||
|
||||
// generate security schemes, and root-level security
|
||||
this.generateSecuritySchemes(components);
|
||||
components.securitySchemes = this.generateSecuritySchemes();
|
||||
let security: OAPI.Document['security'] | undefined = undefined;
|
||||
if (components.securitySchemes && Object.keys(components.securitySchemes).length > 0) {
|
||||
security = Object.keys(components.securitySchemes).map((scheme) => ({ [scheme]: [] }));
|
||||
|
|
@ -103,17 +91,6 @@ export class OpenAPIGenerator {
|
|||
return this.warnings;
|
||||
}
|
||||
|
||||
private generateSecuritySchemes(components: OAPI.ComponentsObject) {
|
||||
const securitySchemes = this.getOption<Record<string, string>[]>('securitySchemes');
|
||||
if (securitySchemes) {
|
||||
const parsed = SecuritySchemesSchema.safeParse(securitySchemes);
|
||||
if (!parsed.success) {
|
||||
throw new PluginError(`"securitySchemes" option is invalid: ${fromZodError(parsed.error)}`);
|
||||
}
|
||||
components.securitySchemes = parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
private pruneComponents(components: OAPI.ComponentsObject) {
|
||||
const schemas = components.schemas;
|
||||
if (schemas) {
|
||||
|
|
@ -551,7 +528,7 @@ export class OpenAPIGenerator {
|
|||
description: 'Invalid request',
|
||||
},
|
||||
'403': {
|
||||
description: 'Forbidden',
|
||||
description: 'Request is forbidden',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -617,15 +594,6 @@ export class OpenAPIGenerator {
|
|||
return this.ref(name);
|
||||
}
|
||||
|
||||
private getOption<T = string>(name: string): T | undefined;
|
||||
private getOption<T = string, D extends T = T>(name: string, defaultValue: D): T;
|
||||
private 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;
|
||||
}
|
||||
|
||||
private generateComponents() {
|
||||
const schemas: Record<string, OAPI.SchemaObject> = {};
|
||||
const components: OAPI.ComponentsObject = {
|
||||
|
|
@ -784,29 +752,10 @@ export class OpenAPIGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
private wrapArray(
|
||||
schema: OAPI.ReferenceObject | OAPI.SchemaObject,
|
||||
isArray: boolean
|
||||
): OAPI.ReferenceObject | OAPI.SchemaObject {
|
||||
if (isArray) {
|
||||
return { type: 'array', items: schema };
|
||||
} else {
|
||||
return schema;
|
||||
}
|
||||
}
|
||||
|
||||
private ref(type: string, rooted = true) {
|
||||
if (rooted) {
|
||||
this.usedComponents.add(type);
|
||||
}
|
||||
return { $ref: `#/components/schemas/${type}` };
|
||||
}
|
||||
|
||||
private array(itemType: unknown) {
|
||||
return { type: 'array', items: itemType };
|
||||
}
|
||||
|
||||
private oneOf(...schemas: unknown[]) {
|
||||
return { oneOf: schemas };
|
||||
}
|
||||
}
|
||||
1727
packages/plugins/openapi/tests/baseline/rest.baseline.yaml
Normal file
1727
packages/plugins/openapi/tests/baseline/rest.baseline.yaml
Normal file
File diff suppressed because it is too large
Load diff
3087
packages/plugins/openapi/tests/baseline/rpc.baseline.yaml
Normal file
3087
packages/plugins/openapi/tests/baseline/rpc.baseline.yaml
Normal file
File diff suppressed because it is too large
Load diff
209
packages/plugins/openapi/tests/openapi-restful.test.ts
Normal file
209
packages/plugins/openapi/tests/openapi-restful.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/// <reference types="@types/jest" />
|
||||
|
||||
import OpenAPIParser from '@readme/openapi-parser';
|
||||
import { getLiteral, getObjectLiteral } from '@zenstackhq/sdk';
|
||||
import { isPlugin, Model, Plugin } from '@zenstackhq/sdk/ast';
|
||||
import { loadZModelAndDmmf } from '@zenstackhq/testtools';
|
||||
import * as fs from 'fs';
|
||||
import * as tmp from 'tmp';
|
||||
import YAML from 'yaml';
|
||||
import generate from '../src';
|
||||
|
||||
describe('Open API Plugin Tests', () => {
|
||||
it('run plugin', async () => {
|
||||
const { model, dmmf, modelFile } = await loadZModelAndDmmf(`
|
||||
plugin openapi {
|
||||
provider = '${process.cwd()}/dist'
|
||||
}
|
||||
|
||||
enum Role {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
email String @unique
|
||||
role Role @default(USER)
|
||||
posts Post[]
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
title String
|
||||
author User? @relation(fields: [authorId], references: [id])
|
||||
authorId String?
|
||||
published Boolean @default(false)
|
||||
viewCount Int @default(0)
|
||||
|
||||
@@openapi.meta({
|
||||
tagDescription: 'Post-related operations'
|
||||
})
|
||||
}
|
||||
|
||||
model Foo {
|
||||
id String @id
|
||||
@@openapi.ignore
|
||||
}
|
||||
|
||||
model Bar {
|
||||
id String @id
|
||||
@@ignore
|
||||
}
|
||||
`);
|
||||
|
||||
const { name: output } = tmp.fileSync({ postfix: '.yaml' });
|
||||
|
||||
const options = buildOptions(model, modelFile, output, '3.1.0');
|
||||
await generate(model, options, dmmf);
|
||||
|
||||
console.log('OpenAPI specification generated:', output);
|
||||
|
||||
const api = await OpenAPIParser.validate(output);
|
||||
|
||||
expect(api.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'user', description: 'User operations' }),
|
||||
expect.objectContaining({ name: 'post', description: 'Post-related operations' }),
|
||||
])
|
||||
);
|
||||
|
||||
expect(api.paths?.['/user']?.['get']).toBeTruthy();
|
||||
expect(api.paths?.['/user']?.['post']).toBeTruthy();
|
||||
expect(api.paths?.['/user']?.['put']).toBeFalsy();
|
||||
expect(api.paths?.['/user/{id}']?.['get']).toBeTruthy();
|
||||
expect(api.paths?.['/user/{id}']?.['patch']).toBeTruthy();
|
||||
expect(api.paths?.['/user/{id}']?.['delete']).toBeTruthy();
|
||||
expect(api.paths?.['/user/{id}/posts']?.['get']).toBeTruthy();
|
||||
expect(api.paths?.['/user/{id}/relationships/posts']?.['get']).toBeTruthy();
|
||||
expect(api.paths?.['/user/{id}/relationships/posts']?.['post']).toBeTruthy();
|
||||
expect(api.paths?.['/user/{id}/relationships/posts']?.['patch']).toBeTruthy();
|
||||
expect(api.paths?.['/post/{id}/relationships/author']?.['get']).toBeTruthy();
|
||||
expect(api.paths?.['/post/{id}/relationships/author']?.['post']).toBeUndefined();
|
||||
expect(api.paths?.['/post/{id}/relationships/author']?.['patch']).toBeTruthy();
|
||||
expect(api.paths?.['/foo']).toBeUndefined();
|
||||
expect(api.paths?.['/bar']).toBeUndefined();
|
||||
|
||||
const parsed = YAML.parse(fs.readFileSync(output, 'utf-8'));
|
||||
expect(parsed.openapi).toBe('3.1.0');
|
||||
const baseline = YAML.parse(fs.readFileSync(`${__dirname}/baseline/rest.baseline.yaml`, 'utf-8'));
|
||||
expect(parsed).toMatchObject(baseline);
|
||||
});
|
||||
|
||||
it('options', async () => {
|
||||
const { model, dmmf, modelFile } = await loadZModelAndDmmf(`
|
||||
plugin openapi {
|
||||
provider = '${process.cwd()}/dist'
|
||||
specVersion = '3.0.0'
|
||||
title = 'My Awesome API'
|
||||
version = '1.0.0'
|
||||
description = 'awesome api'
|
||||
prefix = '/myapi'
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
}
|
||||
`);
|
||||
|
||||
const { name: output } = tmp.fileSync({ postfix: '.yaml' });
|
||||
const options = buildOptions(model, modelFile, output);
|
||||
await generate(model, options, dmmf);
|
||||
|
||||
console.log('OpenAPI specification generated:', output);
|
||||
|
||||
const parsed = YAML.parse(fs.readFileSync(output, 'utf-8'));
|
||||
expect(parsed.openapi).toBe('3.0.0');
|
||||
|
||||
const api = await OpenAPIParser.validate(output);
|
||||
expect(api.info).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'My Awesome API',
|
||||
version: '1.0.0',
|
||||
description: 'awesome api',
|
||||
})
|
||||
);
|
||||
|
||||
expect(api.paths?.['/myapi/user']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('security schemes valid', async () => {
|
||||
const { model, dmmf, modelFile } = await loadZModelAndDmmf(`
|
||||
plugin openapi {
|
||||
provider = '${process.cwd()}/dist'
|
||||
securitySchemes = {
|
||||
myBasic: { type: 'http', scheme: 'basic' },
|
||||
myBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||
myApiKey: { type: 'apiKey', in: 'header', name: 'X-API-KEY' }
|
||||
}
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
posts Post[]
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
authorId String
|
||||
@@allow('read', true)
|
||||
}
|
||||
`);
|
||||
|
||||
const { name: output } = tmp.fileSync({ postfix: '.yaml' });
|
||||
const options = buildOptions(model, modelFile, output);
|
||||
await generate(model, options, dmmf);
|
||||
|
||||
console.log('OpenAPI specification generated:', output);
|
||||
|
||||
const parsed = YAML.parse(fs.readFileSync(output, 'utf-8'));
|
||||
expect(parsed.components.securitySchemes).toEqual(
|
||||
expect.objectContaining({
|
||||
myBasic: { type: 'http', scheme: 'basic' },
|
||||
myBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||
myApiKey: { type: 'apiKey', in: 'header', name: 'X-API-KEY' },
|
||||
})
|
||||
);
|
||||
expect(parsed.security).toEqual(expect.arrayContaining([{ myBasic: [] }, { myBearer: [] }]));
|
||||
|
||||
const api = await OpenAPIParser.validate(output);
|
||||
expect(api.paths?.['/user']?.['get']?.security).toBeUndefined();
|
||||
expect(api.paths?.['/user/{id}/posts']?.['get']?.security).toEqual([]);
|
||||
expect(api.paths?.['/post']?.['get']?.security).toEqual([]);
|
||||
expect(api.paths?.['/post']?.['post']?.security).toBeUndefined();
|
||||
});
|
||||
|
||||
it('security schemes invalid', async () => {
|
||||
const { model, dmmf, modelFile } = await loadZModelAndDmmf(`
|
||||
plugin openapi {
|
||||
provider = '${process.cwd()}/dist'
|
||||
securitySchemes = {
|
||||
myBasic: { type: 'invalid', scheme: 'basic' }
|
||||
}
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
}
|
||||
`);
|
||||
|
||||
const { name: output } = tmp.fileSync({ postfix: '.yaml' });
|
||||
const options = buildOptions(model, modelFile, output);
|
||||
await expect(generate(model, options, dmmf)).rejects.toEqual(
|
||||
expect.objectContaining({ message: expect.stringContaining('"securitySchemes" option is invalid') })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildOptions(model: Model, modelFile: string, output: string, specVersion = '3.0.0') {
|
||||
const optionFields = model.declarations.find((d): d is Plugin => isPlugin(d))?.fields || [];
|
||||
const options: any = { schemaPath: modelFile, output, specVersion, flavor: 'restful' };
|
||||
optionFields.forEach((f) => (options[f.name] = getLiteral(f.value) ?? getObjectLiteral(f.value)));
|
||||
return options;
|
||||
}
|
||||
|
|
@ -83,6 +83,8 @@ model Bar {
|
|||
|
||||
const parsed = YAML.parse(fs.readFileSync(output, 'utf-8'));
|
||||
expect(parsed.openapi).toBe('3.1.0');
|
||||
const baseline = YAML.parse(fs.readFileSync(`${__dirname}/baseline/rpc.baseline.yaml`, 'utf-8'));
|
||||
expect(parsed).toMatchObject(baseline);
|
||||
|
||||
const api = await OpenAPIParser.validate(output);
|
||||
|
||||
|
|
@ -310,7 +312,7 @@ model User {
|
|||
|
||||
function buildOptions(model: Model, modelFile: string, output: string) {
|
||||
const optionFields = model.declarations.find((d): d is Plugin => isPlugin(d))?.fields || [];
|
||||
const options: any = { schemaPath: modelFile, output };
|
||||
const options: any = { schemaPath: modelFile, output, flavor: 'rpc' };
|
||||
optionFields.forEach((f) => (options[f.name] = getLiteral(f.value) ?? getObjectLiteral(f.value)));
|
||||
return options;
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import { AstNode } from 'langium';
|
||||
import { STD_LIB_MODULE_NAME } from './constants';
|
||||
import {
|
||||
DataModel,
|
||||
DataModelField,
|
||||
|
|
@ -10,6 +8,8 @@ import {
|
|||
ReferenceExpr,
|
||||
} from '@zenstackhq/language/ast';
|
||||
import { resolved } from '@zenstackhq/sdk';
|
||||
import { AstNode } from 'langium';
|
||||
import { STD_LIB_MODULE_NAME } from './constants';
|
||||
|
||||
/**
|
||||
* Gets the toplevel Model containing the given node.
|
||||
|
|
@ -29,24 +29,6 @@ export function isFromStdlib(node: AstNode) {
|
|||
return !!model && !!model.$document && model.$document.uri.path.endsWith(STD_LIB_MODULE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets id fields declared at the data model level
|
||||
*/
|
||||
export function getIdFields(model: DataModel) {
|
||||
const idAttr = model.attributes.find((attr) => attr.decl.ref?.name === '@@id');
|
||||
if (!idAttr) {
|
||||
return [];
|
||||
}
|
||||
const fieldsArg = idAttr.args.find((a) => a.$resolvedParam?.name === 'fields');
|
||||
if (!fieldsArg || !isArrayExpr(fieldsArg.value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fieldsArg.value.items
|
||||
.filter((item): item is ReferenceExpr => isReferenceExpr(item))
|
||||
.map((item) => resolved(item.target) as DataModelField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets lists of unique fields declared at the data model level
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import {
|
|||
isLiteralExpr,
|
||||
ReferenceExpr,
|
||||
} from '@zenstackhq/language/ast';
|
||||
import { analyzePolicies, getLiteral } from '@zenstackhq/sdk';
|
||||
import { analyzePolicies, getIdFields, getLiteral } from '@zenstackhq/sdk';
|
||||
import { AstNode, DiagnosticInfo, getDocument, ValidationAcceptor } from 'langium';
|
||||
import { IssueCodes, SCALAR_TYPES } from '../constants';
|
||||
import { AstValidator } from '../types';
|
||||
import { getIdFields, getUniqueFields } from '../utils';
|
||||
import { getUniqueFields } from '../utils';
|
||||
import { validateAttributeApplication, validateDuplicatedDeclarations } from './utils';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
getDataModels,
|
||||
getLiteral,
|
||||
hasAttribute,
|
||||
isIdField,
|
||||
PluginOptions,
|
||||
resolved,
|
||||
saveProject,
|
||||
|
|
@ -22,7 +23,6 @@ import {
|
|||
import { lowerCaseFirst } from 'lower-case-first';
|
||||
import path from 'path';
|
||||
import { CodeBlockWriter, VariableDeclarationKind } from 'ts-morph';
|
||||
import { getIdFields } from '../../language-server/utils';
|
||||
import { ensureNodeModuleFolder, getDefaultOutputFolder } from '../plugin-utils';
|
||||
|
||||
export const name = 'Model Metadata';
|
||||
|
|
@ -158,21 +158,6 @@ function getFieldAttributes(field: DataModelField): RuntimeAttribute[] {
|
|||
.filter((d): d is RuntimeAttribute => !!d);
|
||||
}
|
||||
|
||||
function isIdField(field: DataModelField) {
|
||||
// field-level @id attribute
|
||||
if (field.attributes.some((attr) => attr.decl.ref?.name === '@id')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// model-level @@id attribute with a list of fields
|
||||
const model = field.$container as DataModel;
|
||||
const modelLevelIds = getIdFields(model);
|
||||
if (modelLevelIds.includes(field)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getUniqueConstraints(model: DataModel) {
|
||||
const constraints: Array<{ name: string; fields: string[] }> = [];
|
||||
for (const attr of model.attributes.filter(
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import {
|
|||
isDataModel,
|
||||
isLiteralExpr,
|
||||
isObjectExpr,
|
||||
isReferenceExpr,
|
||||
Model,
|
||||
Reference,
|
||||
ReferenceExpr,
|
||||
} from '@zenstackhq/language/ast';
|
||||
|
||||
/**
|
||||
|
|
@ -122,3 +124,73 @@ export function getAttributeArgLiteral<T extends string | number | boolean>(
|
|||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets id fields declared at the data model level
|
||||
*/
|
||||
export function getIdFields(model: DataModel) {
|
||||
const idAttr = model.attributes.find((attr) => attr.decl.ref?.name === '@@id');
|
||||
if (!idAttr) {
|
||||
return [];
|
||||
}
|
||||
const fieldsArg = idAttr.args.find((a) => a.$resolvedParam?.name === 'fields');
|
||||
if (!fieldsArg || !isArrayExpr(fieldsArg.value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fieldsArg.value.items
|
||||
.filter((item): item is ReferenceExpr => isReferenceExpr(item))
|
||||
.map((item) => resolved(item.target) as DataModelField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the given field is declared as an id field.
|
||||
*/
|
||||
export function isIdField(field: DataModelField) {
|
||||
// field-level @id attribute
|
||||
if (field.attributes.some((attr) => attr.decl.ref?.name === '@id')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// model-level @@id attribute with a list of fields
|
||||
const model = field.$container as DataModel;
|
||||
const modelLevelIds = getIdFields(model);
|
||||
if (modelLevelIds.includes(field)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the given field is a relation field.
|
||||
*/
|
||||
export function isRelationshipField(field: DataModelField) {
|
||||
return isDataModel(field.type.reference?.ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the given field is a relation foreign key field.
|
||||
*/
|
||||
export function isForeignKeyField(field: DataModelField) {
|
||||
const model = field.$container as DataModel;
|
||||
return model.fields.some((f) => {
|
||||
// find @relation attribute
|
||||
const relAttr = f.attributes.find((attr) => attr.decl.ref?.name === '@relation');
|
||||
if (relAttr) {
|
||||
// find "fields" arg
|
||||
const fieldsArg = relAttr.args.find((a) => a.$resolvedParam?.name === 'fields');
|
||||
|
||||
if (fieldsArg && isArrayExpr(fieldsArg.value)) {
|
||||
// find a matching field reference
|
||||
return fieldsArg.value.items.some((item): item is ReferenceExpr => {
|
||||
if (isReferenceExpr(item)) {
|
||||
return item.target.ref === field;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ importers:
|
|||
'@types/lower-case-first':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
'@types/pluralize':
|
||||
specifier: ^0.0.29
|
||||
version: 0.0.29
|
||||
'@types/tmp':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3
|
||||
|
|
@ -147,6 +150,9 @@ importers:
|
|||
jest:
|
||||
specifier: ^29.5.0
|
||||
version: 29.5.0
|
||||
pluralize:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
rimraf:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
|
|
@ -7832,7 +7838,6 @@ packages:
|
|||
/pluralize@8.0.0:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
dev: false
|
||||
|
||||
/postcss@8.4.14:
|
||||
resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==}
|
||||
|
|
|
|||
Loading…
Reference in a new issue