Skip to content

Commit

Permalink
Merge pull request #2459 from nestjs/feat/readonly-visitors
Browse files Browse the repository at this point in the history
feat(plugin): introduce readonly visitors
  • Loading branch information
kamilmysliwiec authored Jun 12, 2023
2 parents 23bbc5d + f02bfda commit 3951241
Show file tree
Hide file tree
Showing 19 changed files with 663 additions and 115 deletions.
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

0 comments on commit 3951241

Please sign in to comment.