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

Model-Level Description #2671

Closed
1 task done
keinsell opened this issue Oct 23, 2023 · 7 comments
Closed
1 task done

Model-Level Description #2671

keinsell opened this issue Oct 23, 2023 · 7 comments
Labels

Comments

@keinsell
Copy link

keinsell commented Oct 23, 2023

Is there an existing issue that is already proposing this?

  • I have searched the existing issues

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
Copy link
Author

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
Copy link

@keinsell where do you call the experimentalAddApiModelFunctionality function?

@keinsell
Copy link
Author

@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
Copy link

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

@kamilmysliwiec
Copy link
Member

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.

Fixed here #2427

Would you like to create a PR that allows setting other attributes as well?

@ccaspers
Copy link
Contributor

ccaspers commented Dec 3, 2024

I created a PR that adds support for a description: #3198

@kamilmysliwiec
Copy link
Member

Let's track this here #3198

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants