Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(plugin): introduce readonly visitors #2459

Merged
merged 9 commits into from
Jun 12, 2023
19 changes: 18 additions & 1 deletion e2e/api-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@
"/api/cats/bulk": {
"get": {
"operationId": "CatsController_findAllBulk",
"summary": "Find all cats in bulk",
"parameters": [
{
"name": "header",
Expand All @@ -607,7 +608,17 @@
],
"responses": {
"200": {
"description": ""
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Cat"
}
}
}
}
}
},
"tags": [
Expand Down Expand Up @@ -992,6 +1003,7 @@
"type": "object",
"properties": {
"name": {
"description": "Name of the cat",
"type": "string"
},
"age": {
Expand Down Expand Up @@ -1080,6 +1092,11 @@
"description": "The breed of the Cat"
},
"_tags": {
"description": "Tags of the cat",
"example": [
"tag1",
"tag2"
],
"type": "array",
"items": {
"type": "string"
Expand Down
44 changes: 43 additions & 1 deletion e2e/validate-schema.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { writeFileSync } from 'fs';
import { OpenAPIV3 } from 'openapi-types';
import { join } from 'path';
import * as SwaggerParser from 'swagger-parser';
import {
Expand All @@ -12,7 +13,6 @@ import {
import { ApplicationModule } from './src/app.module';
import { Cat } from './src/cats/classes/cat.class';
import { TagDto } from './src/cats/dto/tag.dto';
import { OpenAPIV3 } from 'openapi-types';

describe('Validate OpenAPI schema', () => {
let app: INestApplication;
Expand Down Expand Up @@ -49,6 +49,48 @@ describe('Validate OpenAPI schema', () => {
});

it('should produce a valid OpenAPI 3.0 schema', async () => {
SwaggerModule.loadPluginMetadata({
metadata: {
'@nestjs/swagger': {
models: [
[
require('./src/cats/classes/cat.class'),
{
Cat: {
tags: {
description: 'Tags of the cat',
example: ['tag1', 'tag2']
}
}
}
],
[
require('./src/cats/dto/create-cat.dto'),
{
CreateCatDto: {
name: {
description: 'Name of the cat'
}
}
}
]
],
controllers: [
[
require('./src/cats/cats.controller'),
{
CatsController: {
findAllBulk: {
type: [require('./src/cats/classes/cat.class').Cat],
summary: 'Find all cats in bulk'
}
}
}
]
]
}
}
});
const document = SwaggerModule.createDocument(app, options);

const doc = JSON.stringify(document, null, 2);
Expand Down
8 changes: 6 additions & 2 deletions lib/decorators/api-operation.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const defaultOperationOptions: ApiOperationOptions = {
summary: ''
};

export function ApiOperation(options: ApiOperationOptions): MethodDecorator {
export function ApiOperation(
options: ApiOperationOptions,
{ overrideExisting } = { overrideExisting: true }
): MethodDecorator {
return createMethodDecorator(
DECORATORS.API_OPERATION,
pickBy(
Expand All @@ -18,6 +21,7 @@ export function ApiOperation(options: ApiOperationOptions): MethodDecorator {
...options
} as ApiOperationOptions,
negate(isUndefined)
)
),
{ overrideExisting }
);
}
23 changes: 16 additions & 7 deletions lib/decorators/api-response.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { HttpStatus, Type } from '@nestjs/common';
import { omit } from 'lodash';
import { DECORATORS } from '../constants';
import {
ReferenceObject,
ResponseObject,
SchemaObject,
ReferenceObject
SchemaObject
} from '../interfaces/open-api-spec.interface';
import { getTypeIsArrayTuple } from './helpers';

Expand All @@ -26,7 +26,8 @@ export interface ApiResponseSchemaHost
export type ApiResponseOptions = ApiResponseMetadata | ApiResponseSchemaHost;

export function ApiResponse(
options: ApiResponseOptions
options: ApiResponseOptions,
{ overrideExisting } = { overrideExisting: true }
): MethodDecorator & ClassDecorator {
const [type, isArray] = getTypeIsArrayTuple(
(options as ApiResponseMetadata).type,
Expand All @@ -46,8 +47,14 @@ export function ApiResponse(
descriptor?: TypedPropertyDescriptor<any>
): any => {
if (descriptor) {
const responses =
Reflect.getMetadata(DECORATORS.API_RESPONSE, descriptor.value) || {};
const responses = Reflect.getMetadata(
DECORATORS.API_RESPONSE,
descriptor.value
);

if (responses && !overrideExisting) {
return descriptor;
}
Reflect.defineMetadata(
DECORATORS.API_RESPONSE,
{
Expand All @@ -58,8 +65,10 @@ export function ApiResponse(
);
return descriptor;
}
const responses =
Reflect.getMetadata(DECORATORS.API_RESPONSE, target) || {};
const responses = Reflect.getMetadata(DECORATORS.API_RESPONSE, target);
if (responses && !overrideExisting) {
return descriptor;
}
Reflect.defineMetadata(
DECORATORS.API_RESPONSE,
{
Expand Down
15 changes: 14 additions & 1 deletion lib/decorators/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,26 @@ import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants';

export function createMethodDecorator<T = any>(
metakey: string,
metadata: T
metadata: T,
{ overrideExisting } = { overrideExisting: true }
): MethodDecorator {
return (
target: object,
key: string | symbol,
descriptor: PropertyDescriptor
) => {
if (typeof metadata === 'object') {
const prevValue = Reflect.getMetadata(metakey, descriptor.value);
if (prevValue && !overrideExisting) {
return descriptor;
}
Reflect.defineMetadata(
metakey,
{ ...prevValue, ...metadata },
descriptor.value
);
return descriptor;
}
Reflect.defineMetadata(metakey, metadata, descriptor.value);
return descriptor;
};
Expand Down
48 changes: 47 additions & 1 deletion lib/explorers/api-operation.explorer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,54 @@
import { Type } from '@nestjs/common';
import { DECORATORS } from '../constants';
import { ApiOperation } from '../decorators/api-operation.decorator';
import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants';

export const exploreApiOperationMetadata = (
instance: object,
prototype: Type<unknown>,
method: object
) => Reflect.getMetadata(DECORATORS.API_OPERATION, method);
) => {
applyMetadataFactory(prototype);
return Reflect.getMetadata(DECORATORS.API_OPERATION, method);
};

function applyMetadataFactory(prototype: Type<unknown>) {
const classPrototype = prototype;
do {
if (!prototype.constructor) {
return;
}
if (!prototype.constructor[METADATA_FACTORY_NAME]) {
continue;
}
const metadata = prototype.constructor[METADATA_FACTORY_NAME]();
const methodKeys = Object.keys(metadata);
methodKeys.forEach((key) => {
const operationMeta = {};
const { summary, deprecated, tags } = metadata[key];

applyIfNotNil(operationMeta, 'summary', summary);
applyIfNotNil(operationMeta, 'deprecated', deprecated);
applyIfNotNil(operationMeta, 'tags', tags);

if (Object.keys(operationMeta).length === 0) {
return;
}
ApiOperation(operationMeta, { overrideExisting: false })(
classPrototype,
key,
Object.getOwnPropertyDescriptor(classPrototype, key)
);
});
} while (
(prototype = Reflect.getPrototypeOf(prototype) as Type<any>) &&
prototype !== Object.prototype &&
prototype
);
}

function applyIfNotNil(target: Record<string, any>, key: string, value: any) {
if (value !== undefined && value !== null) {
target[key] = value;
}
}
38 changes: 37 additions & 1 deletion lib/explorers/api-response.explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { HTTP_CODE_METADATA, METHOD_METADATA } from '@nestjs/common/constants';
import { isEmpty } from '@nestjs/common/utils/shared.utils';
import { get, mapValues, omit } from 'lodash';
import { DECORATORS } from '../constants';
import { ApiResponseMetadata } from '../decorators';
import { ApiResponse, ApiResponseMetadata } from '../decorators';
import { SchemaObject } from '../interfaces/open-api-spec.interface';
import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants';
import { ResponseObjectFactory } from '../services/response-object-factory';
import { mergeAndUniq } from '../utils/merge-and-uniq.util';

Expand Down Expand Up @@ -32,6 +33,8 @@ export const exploreApiResponseMetadata = (
prototype: Type<unknown>,
method: Function
) => {
applyMetadataFactory(prototype);

const responses = Reflect.getMetadata(DECORATORS.API_RESPONSE, method);
if (responses) {
const classProduces = Reflect.getMetadata(
Expand Down Expand Up @@ -84,3 +87,36 @@ const mapResponsesToSwaggerResponses = (
);
return mapValues(openApiResponses, omitParamType);
};

function applyMetadataFactory(prototype: Type<unknown>) {
const classPrototype = prototype;
do {
if (!prototype.constructor) {
return;
}
if (!prototype.constructor[METADATA_FACTORY_NAME]) {
continue;
}
const metadata = prototype.constructor[METADATA_FACTORY_NAME]();
const methodKeys = Object.keys(metadata);
methodKeys.forEach((key) => {
const { summary, deprecated, tags, ...meta } = metadata[key];

if (Object.keys(meta).length === 0) {
return;
}
if (meta.status === undefined) {
meta.status = getStatusCode(classPrototype[key]);
}
ApiResponse(meta, { overrideExisting: false })(
classPrototype,
key,
Object.getOwnPropertyDescriptor(classPrototype, key)
);
});
} while (
(prototype = Reflect.getPrototypeOf(prototype) as Type<any>) &&
prototype !== Object.prototype &&
prototype
);
}
6 changes: 6 additions & 0 deletions lib/extra/swagger-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,9 @@ export function PickType() {
export function getSchemaPath() {
return () => '';
}
export function before() {
return () => '';
}
export function ReadonlyVisitor() {
return class {};
}
3 changes: 1 addition & 2 deletions lib/plugin/compiler-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import * as ts from 'typescript';
import { mergePluginOptions } from './merge-options';
import { isFilenameMatched } from './utils/is-filename-matched.util';
import { ControllerClassVisitor } from './visitors/controller-class.visitor';
import { ModelClassVisitor } from './visitors/model-class.visitor';

const modelClassVisitor = new ModelClassVisitor();
const controllerClassVisitor = new ControllerClassVisitor();
const isFilenameMatched = (patterns: string[], filename: string) =>
patterns.some((path) => filename.includes(path));

export const before = (options?: Record<string, any>, program?: ts.Program) => {
options = mergePluginOptions(options);
Expand Down
1 change: 1 addition & 0 deletions lib/plugin/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './compiler-plugin';
export * from './visitors/readonly.visitor';
5 changes: 4 additions & 1 deletion lib/plugin/merge-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface PluginOptions {
dtoKeyOfComment?: string;
controllerKeyOfComment?: string;
introspectComments?: boolean;
readonly?: boolean;
pathToSource?: string;
}

const defaultOptions: PluginOptions = {
Expand All @@ -15,7 +17,8 @@ const defaultOptions: PluginOptions = {
classValidatorShim: true,
dtoKeyOfComment: 'description',
controllerKeyOfComment: 'description',
introspectComments: false
introspectComments: false,
readonly: false
};

export const mergePluginOptions = (
Expand Down
Loading