nestjs / swagger

OpenAPI (Swagger) module for Nest framework (node.js) :earth_americas:
https://nestjs.com
MIT License
1.68k stars 464 forks source link

Model-Level Description #2671

Open keinsell opened 11 months ago

keinsell commented 11 months ago

Is there an existing issue that is already proposing this?

Is your feature request related to a problem? Please describe it

During writing documentation for some OpenAPI Specification I've noticed there are missing features related to model documentation, in terms of complex applications it would be helpful for end-users to explain for what specific model is responsible and how to properly build one. OpenAPI 3.0 itself have support for description of models sadly Nest.js do not.

Additional case is no feature for renaming models, if I have class RegisterAccountDto it's a little frustrating I cannot remove Dto suffix for user as this is irrelevant information for him.

Describe the solution you'd like

// THIS
@ApiModel({ name: "AccountPayload", description: "DTO used for creation of new account" })
// ---
export class CreateAccount {
    /**
     * Represents the unique identifier of an entity.
     *
     * @typedef {string} Id
     */
    @ApiProperty({
        name:        "id",
        description: "The account's unique identifier",
    }) id: string;
    /**
     * Represents an email address.
     * @typedef {string} email
     */
    @ApiProperty({
        name:        "email",
        description: "The account's email address",
    }) email: string;
    /**
     * Indicates whether the email associated with a user account has been verified.
     *
     * @type {boolean}
     */
    @ApiProperty({
        name:        "emailVerified",
        description: "Indicates whether the email associated with a user account has been verified",
    }) emailVerified: boolean;
    /**
     * The password variable is a string that represents a user's password.
     *
     * @type {string}
     */
    @ApiProperty({
        name:        "password",
        description: "The account's password",
    }) password: string;
    /**
     * Represents a username.
     * @typedef {string} username
     */
    @ApiProperty({
        name:        "username",
        description: "The account's username",
    }) username: string;
}

Teachability, documentation, adoption, migration strategy

No response

What is the motivation / use case for changing the behavior?

User Experience and Documentation of complex/large-scale applications

keinsell commented 11 months ago

Implemented such functionality on my own, but I would be happy to see one delivered out-of-box by library.

/**
 * Experimental method to add API model functionality to the OpenAPI document.
 *
 * @param {OpenAPIObject} document - The OpenAPI document to add model functionality to.
 * @return {void}
 */
function experimentalAddApiModelFunctionality(document: OpenAPIObject): void {

    const modelMetadataStore = getMetadataStore();

    if (document.components) {
        for (const definitionKey in document.components.schemas) {
            const metatype = modelMetadataStore[definitionKey];

            if (metatype) {
                if (metatype.name) {
                    document.components.schemas[metatype.name] = document.components.schemas[definitionKey];
                    delete document.components.schemas[definitionKey];
                }

                if (metatype.description) {
                    (
                        document.components.schemas[metatype.name ?? definitionKey] as any
                    ).description = metatype.description;
                }
            }
        }
    }

    // Experimental
    const updateSchema = (schema: any) => {
        if (schema && schema.$ref) {
            const refArray     = schema.$ref.split('/');
            const originalName = refArray.pop();
            const metatype     = modelMetadataStore[originalName];

            if (metatype && metatype.name) {
                refArray.push(metatype.name);
                schema.$ref = refArray.join('/');
            }
        }
    };

    // Update Swagger Paths
    for (const pathKey in document.paths) {
        for (const methodKey in document.paths[pathKey]) {
            const operation = document.paths[pathKey][methodKey];

            if (operation && operation.parameters) {
                for (const param of operation.parameters) {
                    updateSchema(param.schema); // references under parameters can be updated
                }
            }

            if (operation && operation.requestBody && operation.requestBody.content) {
                for (const mediaTypeKey in operation.requestBody.content) {
                    const schema = operation.requestBody.content[mediaTypeKey].schema;
                    updateSchema(schema); // references under request bodies can be updated
                }
            }

            if (operation && operation.responses) {
                for (const responseKey in operation.responses) {
                    const contentType = operation.responses[responseKey]?.content;

                    for (const mediaTypeKey in contentType) {
                        const schema = contentType[mediaTypeKey].schema;
                        updateSchema(schema); // references under responses can be updated.
                    }
                }
            }
        }
    }
}
import {SetMetadata} from '@nestjs/common';

const modelMetadataStore = {};

export const ApiModel = ({
    name,
    description,
}: { name?: string; description?: string } = {}): ClassDecorator => {
    return (target: Function) => {

        SetMetadata('API_MODEL_METADATA', {
            name,
            description,
        })(target);

        modelMetadataStore[target.name] = {
            name,
            description,
        };
    };
};

export function getMetadataStore() {
    return modelMetadataStore;
}
joeskeen commented 8 months ago

@keinsell where do you call the experimentalAddApiModelFunctionality function?

keinsell commented 8 months ago

@joeskeen I've a dedicated function which I run at the bootstrapping of application.

export async function buildSwaggerDocumentation(app : INestApplication) : Promise<void>
  {
     const logger = new Logger( 'doc:swagger' )

     const swaggerConfig = new DocumentBuilder()
     .setTitle( __config.get( 'SERVICE_NAME' ) )
     .setDescription( __config.get( 'SERVICE_DESCRIPTION' ) )
     .setVersion( '1.0' )
     .addTag( 'api' )
     .addBearerAuth( {
                             name         : 'Bearer Token',
                             type         : 'http',
                             scheme       : 'bearer',
                             bearerFormat : 'JWT',
                             description  : 'JWT Authorization header using the Bearer scheme. Example: "Authorization: Bearer <token>"',
                          } )
     .build()

     logger.verbose( `Swagger documentation base built for ${__config.get( 'SERVICE_NAME' )} service.` )

     const document = SwaggerModule.createDocument( app, swaggerConfig,

                                                                    new DocumentBuilder()
                                                                    // https://stackoverflow.com/questions/59376717/global-headers-for-all-controllers-nestjs-swagger
                                                                    // .addGlobalParameters( { in          : 'header', required    :
                                                                    // true, name        : 'x-request-id', description : 'A unique
                                                                    // identifier assigned to the request. Clients can include this
                                                                    // header' + ' to trace and correlate requests across different
                                                                    // services and systems.', schema      : {type : 'string'},
                                                                    // deprecated  : true, example : nanoid( 128 ), } )
                                                                    .build() as any,
     )

     logger.verbose( `Swagger documentation document built for ${__config.get( 'SERVICE_NAME' )} service.` )

     experimentalAddApiModelFunctionality( document )

     //SwaggerModule.setup(ApplicationConfiguration.openapiDocumentationPath, app, document, {
     // explorer:  true,
     // customCss: new SwaggerTheme('v3').getBuffer("flattop"), // OR newspaper
     //});

     //app.use("/api", apiReference({
     // spec: {
     //     content: document,
     // },
     //}))

     const documentationObjectPath = `${process.cwd()}/src/common/modules/documentation/swagger/public/api/openapi3.json`

     const formattedDocument = await prettier.format( JSON.stringify( document ), {
        parser   : 'json-stringify',
        tabWidth : 2,
     } )

     // Save Swagger Documentation to File
     fs.writeFileSync( documentationObjectPath, formattedDocument )

     logger.verbose( `Swagger documentation was snapshot into ${tildify( documentationObjectPath )}` )
  }
  export async function bootstrap()
  {
     // Bootstrap application
     const app = await NestFactory.create( Container, {
        abortOnError  : false,
        autoFlushLogs : true,
        bufferLogs    : true,
        snapshot      : isDevelopment(),
     } )

     // Implement logger used for bootstrapping and notifying about application state
     const logger = new Logger( 'Bootstrap' )

     await executePrismaRelatedProcesses()

     // Enable graceful shutdown hooks
     app.enableShutdownHooks()

     // Build swagger documentation
     await buildSwaggerDocumentation( app )
     buildCompodocDocumentation()

     // ...
joeskeen commented 8 months ago

@keinsell Thanks for sharing! I went a different route to accomplish the same thing, but for my use case I needed to add custom metadata for both the DTO class and properties to make the schema output include nonstandard properties used by https://github.com/json-editor/json-editor. Here's what I came up with.

I noticed that there is a built-in decorator called ApiExtension which allowed arbitrary metadata to be added; the only problem is, it required the metadata key to start with x-, which for my case wouldn't work, as I needed to do things like headerTemplate for classes and options: {hidden: true} for properties. So I created my own version of this, and made it compatible with classes, methods, and properties.

const DECORATOR_API_EXTENSION = 'swagger/apiExtension';
const DECORATOR_API_MODEL_PROPERTIES_ARRAY = 'swagger/apiModelPropertiesArray';
const DECORATOR_API_MODEL_PROPERTIES = 'swagger/apiModelProperties';
const METADATA_FACTORY_NAME = '_OPENAPI_METADATA_FACTORY';

/* this function taken from the '@nestjs/swagger' source at lib/decorators/api-extension.decorator.ts */

export function ApiExtension(extensionKey: string, extensionProperties: any) {
  /* 
    We do NOT care about using 'x-' prefixes, as this is going to be used to be compatible with 
    @json-editor/json-editor, which does not use 'x-' prefixes. 
  */
  // if (!extensionKey.startsWith('x-')) {
  //   throw new Error(
  //     'Extension key is not prefixed. Please ensure you prefix it with `x-`.',
  //   );
  // }

  const extensionObject = {
    [extensionKey]: { ...extensionProperties },
  };

  return createMixedDecorator(extensionObject);
}

export function ApiExtensions(extensionProperties: Record<string, any>) {
  const extensionObject = {
    ...extensionProperties,
  };

  return createMixedDecorator(extensionObject);
}

export function createMixedDecorator<T = any>(
  metadata: T,
  overrideExisting = true,
): MethodDecorator & ClassDecorator & PropertyDecorator {
  return (
    target: object,
    propertyKey?: string,
    descriptor?: TypedPropertyDescriptor<any>,
  ): any => {
    const isMethod = !!descriptor;
    const isProperty = !isMethod && !!propertyKey;
    const isClass = !isMethod && !isProperty;
    if (isMethod) {
      const metadataKey = DECORATOR_API_EXTENSION;
      let targetMetadata: any;
      if (Array.isArray(metadata)) {
        const existingMetadata =
          Reflect.getMetadata(metadataKey, descriptor.value) || [];
        targetMetadata = overrideExisting
          ? metadata
          : [...existingMetadata, ...metadata];
      } else {
        const existingMetadata =
          Reflect.getMetadata(metadataKey, descriptor.value) || {};
        targetMetadata = overrideExisting
          ? metadata
          : { ...existingMetadata, ...metadata };
      }
      Reflect.defineMetadata(metadataKey, targetMetadata, descriptor.value);
      return descriptor;
    } else if (isClass) {
      const metadataKey = DECORATOR_API_EXTENSION;
      Reflect.defineMetadata(metadataKey, metadata, target);
      return target;
    } else if (isProperty) {
      const metadataKey = DECORATOR_API_MODEL_PROPERTIES;
      const properties =
        Reflect.getMetadata(DECORATOR_API_MODEL_PROPERTIES_ARRAY, target) || [];

      const key = `:${propertyKey}`;
      if (!properties.includes(key)) {
        Reflect.defineMetadata(
          DECORATOR_API_MODEL_PROPERTIES_ARRAY,
          [...properties, key],
          target,
        );
      }
      const existingMetadata = Reflect.getMetadata(
        metadataKey,
        target,
        propertyKey,
      );
      if (existingMetadata) {
        const newMetadata = withoutUndefinedProperties(metadata);
        const metadataToSave = overrideExisting
          ? {
              ...existingMetadata,
              ...newMetadata,
            }
          : {
              ...newMetadata,
              ...existingMetadata,
            };

        Reflect.defineMetadata(
          metadataKey,
          metadataToSave,
          target,
          propertyKey,
        );
      } else {
        const type =
          target?.constructor?.[METADATA_FACTORY_NAME]?.()[propertyKey]?.type ??
          Reflect.getMetadata('design:type', target, propertyKey);

        Reflect.defineMetadata(
          metadataKey,
          {
            type,
            ...withoutUndefinedProperties(metadata),
          },
          target,
          propertyKey,
        );
      }
    }
  };
}

export function withoutUndefinedProperties<T extends Record<string, any>>(
  object: T,
): Partial<T> {
  return Object.keys(object)
    .filter((key) => object[key] !== undefined)
    .reduce((acc, key) => {
      acc[key] = object[key];
      return acc;
    }, {});
}

Usage is as follows:

@ApiExtensions({
  name: MyDto.name,
  description: 'A collection of data.',
  headerTemplate: '<%= self.name %> data',
  additionalProperties: false,
})
export class MyDto {
  @ApiProperty({
    description:
      'The globally-unique identifier of this DTO',
    type: String,
    required: false,
    example: 'ielnvidjem934j',
  })
  @ApiExtension('options', { hidden: true })
  id?: string;

  ...
}

I'm thinking it would be beneficial to have this library allow for non-x- properties in the @ApiExtension() decorator, even if you have to pass another parameter such as { strict: false }. This would allow for flexibility to extend your schemas to meet the needs of the application / other libraries. It would also be nice if the built-in @ApiExtension() decorator supported properties, as the implementation in the library currently only supports classes and methods.