Open keinsell opened 1 year 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;
}
@keinsell where do you call the experimentalAddApiModelFunctionality
function?
@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()
// ...
@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.
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 https://github.com/nestjs/swagger/pull/2427
Would you like to create a PR that allows setting other attributes as well?
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 removeDto
suffix for user as this is irrelevant information for him.Describe the solution you'd like
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