feat: restful style openapi spec generation (#410)

This commit is contained in:
Yiming 2023-05-12 10:48:56 -07:00 committed by GitHub
parent f07ccdded0
commit 4ebaa1fa4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 6089 additions and 100 deletions

View file

@ -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",

View 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;
}
}

View file

@ -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();
}
}

View 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 servers 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,
};
}
}

View file

@ -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 };
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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;
}

View file

@ -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;
}

View file

@ -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
*/

View file

@ -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';
/**

View file

@ -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(

View file

@ -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;
});
}

View file

@ -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==}