├── src ├── core │ ├── __tests__ │ │ ├── async-api-explorer.spec.ts │ │ ├── contract-builder.spec.ts │ │ └── async-api-scanner.spec.ts │ ├── async-api-transformer.ts │ ├── operation-object-factory.ts │ ├── exploreAsyncApiOperationMetadata.ts │ ├── async-api-scanner.ts │ ├── document-builder.ts │ └── async-api-explorer.ts ├── interfaces │ ├── index.ts │ ├── async-api-reference-object.interface.ts │ ├── async-api-external-docs.interface.ts │ ├── async-api-discriminator.interface.ts │ ├── async-api-channels.interface.ts │ ├── async-api-servers.interface.ts │ ├── async-api-security-schemes.interface.ts │ ├── doc │ │ ├── denormalized-doc-resolvers.interface.ts │ │ └── denormalized-doc.interface.ts │ ├── server-variables.interface.ts │ ├── async-api-param-object.interface.ts │ ├── async-api-examples.interface.ts │ ├── oauth2-flow.interface.ts │ ├── async-api-oauth2-flow.interface.ts │ ├── async-api-components.interface.ts │ ├── async-api-tags.interface.ts │ ├── async-api-bindings.interface.ts │ ├── async-api-traits.interface.ts │ ├── api-server-options.interface.ts │ ├── async-api-template-options.interface.ts │ ├── async-operation-options.interface.ts │ ├── async-api-scanning-options.interface.ts │ ├── async-api-contract.interface.ts │ ├── async-api-operation.interface.ts │ ├── security-scheme.interface.ts │ ├── async-api-channel.interface.ts │ ├── async-api-schema-object.interface.ts │ └── async-api-message-metadata.interface.ts ├── types │ ├── oauth2-scope-key.type.ts │ ├── async-api-channel-params.type.ts │ ├── security-scheme-in.type.ts │ └── api-server-security.type.ts ├── utils │ ├── validate-path.ts │ ├── strip-last-slash.ts │ └── getSchemaPath.util.ts ├── services │ ├── contract-parser.ts │ ├── __tests__ │ │ ├── contract-parser.spec.ts │ │ └── async-api-generator.spec.ts │ ├── consumer-object-factory.ts │ └── async-api-generator.ts ├── fixtures │ └── contract-fixture.ts ├── constants.ts ├── enums │ └── async-api-security-type.enum.ts ├── decorators │ ├── async-property.decorator.ts │ ├── async-channel.decorator.ts │ ├── async-consumer.decorator.ts │ ├── async-publisher.decorator.ts │ └── helpers.ts └── async-api-module.ts ├── e2e ├── fastify-app │ ├── users │ │ ├── dto │ │ │ └── update-user.dto.ts │ │ ├── user.service.ts │ │ ├── user.module.ts │ │ └── user.controller.ts │ └── app.module.ts ├── jest-e2e.json └── fastify.e2e-spec.ts ├── nest-cli.json ├── .prettierrc ├── tsconfig.build.json ├── test └── jest-e2e.json ├── .github ├── renovate.json └── workflows │ └── ci.yml ├── tsconfig.json ├── .gitignore ├── .eslintrc.js ├── LICENSE ├── README.md └── package.json /src/core/__tests__/async-api-explorer.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | /** @todo Export all interfaces **/ 2 | -------------------------------------------------------------------------------- /e2e/fastify-app/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | export class UpdateUserDto {} 2 | -------------------------------------------------------------------------------- /src/types/oauth2-scope-key.type.ts: -------------------------------------------------------------------------------- 1 | export type OAuth2ScopeKeyType = string 2 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": false 5 | } -------------------------------------------------------------------------------- /src/interfaces/async-api-reference-object.interface.ts: -------------------------------------------------------------------------------- 1 | export interface AsyncApiReferenceObject { 2 | $ref: string 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /e2e/fastify-app/users/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class UserService {} 5 | -------------------------------------------------------------------------------- /src/interfaces/async-api-external-docs.interface.ts: -------------------------------------------------------------------------------- 1 | export interface AsyncApiExternalDocs { 2 | description: string 3 | url: string 4 | } 5 | -------------------------------------------------------------------------------- /src/interfaces/async-api-discriminator.interface.ts: -------------------------------------------------------------------------------- 1 | export interface AsyncApiDiscriminator { 2 | propertyName: string 3 | mapping?: Record 4 | } 5 | -------------------------------------------------------------------------------- /src/interfaces/async-api-channels.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiChannel } from './async-api-channel.interface' 2 | 3 | export interface AsyncApiChannels { 4 | [name: string]: AsyncApiChannel 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/async-api-servers.interface.ts: -------------------------------------------------------------------------------- 1 | import { ApiServerOptions } from './api-server-options.interface' 2 | 3 | export interface AsyncApiServers { 4 | [name: string]: ApiServerOptions 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/async-api-security-schemes.interface.ts: -------------------------------------------------------------------------------- 1 | import { SecurityScheme } from './security-scheme.interface' 2 | 3 | export interface AsyncApiSecuritySchemes { 4 | [name: string]: SecurityScheme 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/doc/denormalized-doc-resolvers.interface.ts: -------------------------------------------------------------------------------- 1 | export interface DenormalizedDocResolvers { 2 | root: Function[] 3 | security: Function[] 4 | tags: Function[] 5 | operations: Function[] 6 | } 7 | -------------------------------------------------------------------------------- /e2e/fastify-app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { UserModule } from './users/user.module' 3 | 4 | @Module({ 5 | imports: [UserModule], 6 | }) 7 | export class ApplicationModule {} 8 | -------------------------------------------------------------------------------- /src/interfaces/server-variables.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ServerVariables { 2 | [key: string]: { 3 | enum?: string[] 4 | default: string 5 | description?: string 6 | examples?: string[] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/validate-path.ts: -------------------------------------------------------------------------------- 1 | /** @see https://github.com/nestjs/swagger/blob/master/lib/utils/validate-path.util.ts **/ 2 | export const validatePath = (inputPath: string): string => 3 | inputPath.charAt(0) !== '/' ? '/' + inputPath : inputPath 4 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "semanticCommits": true, 3 | "packageRules": [{ 4 | "depTypeList": ["devDependencies"], 5 | "automerge": true 6 | }], 7 | "extends": [ 8 | "config:base" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /e2e/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "verbose": true, 7 | "transform": { 8 | "^.+\\.(t|j)s$": "ts-jest" 9 | } 10 | } -------------------------------------------------------------------------------- /src/interfaces/async-api-param-object.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiSchemaObject } from './async-api-schema-object.interface' 2 | 3 | export class AsyncApiParamObject { 4 | description?: string 5 | location: string 6 | schema: AsyncApiSchemaObject 7 | } 8 | -------------------------------------------------------------------------------- /src/services/contract-parser.ts: -------------------------------------------------------------------------------- 1 | import jsyaml from 'js-yaml' 2 | import { AsyncApiContract } from '../interfaces/async-api-contract.interface' 3 | 4 | export class ContractParser { 5 | parse(contract: AsyncApiContract) { 6 | return jsyaml.dump(contract) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/async-api-examples.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ExampleObject { 2 | summary?: string 3 | description?: string 4 | value?: any 5 | externalValue?: string 6 | } 7 | 8 | export interface AsyncApiExamples { 9 | [key: string]: ExampleObject 10 | } 11 | -------------------------------------------------------------------------------- /src/interfaces/oauth2-flow.interface.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2ScopeKeyType } from '../types/oauth2-scope-key.type' 2 | 3 | export class OAuth2Flow { 4 | authorizationUrl: string 5 | tokenUrl: string 6 | refreshUrl?: string 7 | scopes: { [scope: string]: OAuth2ScopeKeyType } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/strip-last-slash.ts: -------------------------------------------------------------------------------- 1 | /** @author https://github.com/nestjs/swagger/blob/master/lib/utils/strip-last-slash.util.ts **/ 2 | export function stripLastSlash(path: string): string { 3 | return path && path[path.length - 1] === '/' 4 | ? path.slice(0, path.length - 1) 5 | : path 6 | } 7 | -------------------------------------------------------------------------------- /src/interfaces/async-api-oauth2-flow.interface.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2Flow } from './oauth2-flow.interface' 2 | 3 | export interface AsyncApiOAuth2Flow { 4 | implicit?: Omit 5 | password?: OAuth2Flow 6 | clientCredentials?: OAuth2Flow 7 | authorizationCode?: OAuth2Flow 8 | } 9 | -------------------------------------------------------------------------------- /e2e/fastify-app/users/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { UserController } from './user.controller' 3 | import { UserService } from './user.service' 4 | 5 | @Module({ 6 | controllers: [UserController], 7 | providers: [UserService], 8 | }) 9 | export class UserModule {} 10 | -------------------------------------------------------------------------------- /src/interfaces/async-api-components.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiSecuritySchemes } from './async-api-security-schemes.interface' 2 | import { AsyncApiChannels } from './async-api-channels.interface' 3 | 4 | export interface AsyncApiComponents { 5 | securitySchemes?: AsyncApiSecuritySchemes 6 | [key: string]: any 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/async-api-tags.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiExternalDocs } from './async-api-external-docs.interface' 2 | 3 | /** @see https://www.asyncapi.com/docs/specifications/2.0.0#tagsObject **/ 4 | export interface AsyncApiTags { 5 | name: string 6 | description: string 7 | externalDocs?: AsyncApiExternalDocs 8 | } 9 | -------------------------------------------------------------------------------- /src/types/async-api-channel-params.type.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiReferenceObject } from '../interfaces/async-api-reference-object.interface' 2 | import { AsyncApiParamObject } from '../interfaces/async-api-param-object.interface' 3 | 4 | export type AsyncApiChannelParamsType = 5 | | AsyncApiReferenceObject 6 | | AsyncApiParamObject 7 | -------------------------------------------------------------------------------- /src/types/security-scheme-in.type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Available options for security scheme property "in" 3 | * @see https://www.asyncapi.com/docs/specifications/2.0.0#oauthFlowObject 4 | */ 5 | export type SecuritySchemeIn = 6 | | 'user' 7 | | 'password' 8 | | 'apiKey' 9 | | 'query' 10 | | 'header' 11 | | 'cookie' 12 | | 'httpApiKey' 13 | -------------------------------------------------------------------------------- /src/interfaces/doc/denormalized-doc.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiContract } from '../async-api-contract.interface' 2 | import { AsyncApiOperation } from '../async-api-operation.interface' 3 | import { AsyncApiChannel } from '../async-api-channel.interface' 4 | 5 | export interface DenormalizedDoc extends Partial { 6 | root?: { name: string } & AsyncApiChannel 7 | operations?: { pub: AsyncApiOperation; sub: AsyncApiOperation } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "esModuleInterop": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/fixtures/contract-fixture.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiContract } from '../interfaces/async-api-contract.interface' 2 | 3 | /** 4 | * Creates the basic contract aka document for AsyncAPI 5 | */ 6 | export const createContractBase = (): AsyncApiContract => ({ 7 | asyncapi: '2.0.0', 8 | defaultContentType: 'application/json', 9 | info: { 10 | title: '', 11 | version: '1.0.0', 12 | description: '', 13 | }, 14 | channels: {}, 15 | components: {}, 16 | }) 17 | -------------------------------------------------------------------------------- /src/types/api-server-security.type.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2ScopeKeyType } from './oauth2-scope-key.type' 2 | 3 | interface SecurityDictionary { 4 | [scope: string]: OAuth2ScopeKeyType[] 5 | } 6 | 7 | /** 8 | * Represents an array of scopes required for the server security but its only required in OAuth2Flow 9 | * If you use other server security such as user-password then you simply pass an empty array 10 | */ 11 | export type ApiServerSecurity = Array 12 | -------------------------------------------------------------------------------- /src/interfaces/async-api-bindings.interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://www.asyncapi.com/docs/specifications/2.0.0#messageBindingsObject 3 | * @todo Must implement each protocol binding interface 4 | */ 5 | export interface AsyncApiBindings { 6 | http?: any 7 | ws?: any 8 | kafka?: any 9 | amqp?: any 10 | amqp1?: any 11 | mqtt?: any 12 | mqtt5?: any 13 | nats?: any 14 | jms?: any 15 | sns?: any 16 | sqs?: any 17 | stomp?: any 18 | redis?: any 19 | } 20 | -------------------------------------------------------------------------------- /src/interfaces/async-api-traits.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiTags } from './async-api-tags.interface' 2 | import { AsyncApiExternalDocs } from './async-api-external-docs.interface' 3 | import { AsyncApiBindings } from './async-api-bindings.interface' 4 | 5 | export interface AsyncApiTraits { 6 | operationId?: string 7 | summary?: string 8 | description?: string 9 | tags?: AsyncApiTags 10 | externalDocs?: AsyncApiExternalDocs 11 | bindings?: AsyncApiBindings 12 | } 13 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DECORATORS_PREFIX = 'asyncapi' 2 | export const METADATA_FACTORY_NAME = '_ASYNCAPI_METADATA_FACTORY' 3 | export const DECORATORS = { 4 | CHANNEL: `${DECORATORS_PREFIX}/channel`, 5 | MESSAGE: `${DECORATORS_PREFIX}/message`, 6 | SUB: `${DECORATORS_PREFIX}/sub`, 7 | PUB: `${DECORATORS_PREFIX}/pub`, 8 | API_MODEL_PROPERTIES: `${DECORATORS_PREFIX}/modelProperties`, 9 | API_MODEL_PROPERTIES_ARRAY: `${DECORATORS_PREFIX}/modelPropertiesArray`, 10 | } 11 | -------------------------------------------------------------------------------- /src/enums/async-api-security-type.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://www.asyncapi.com/docs/specifications/2.0.0#securitySchemeObject 3 | */ 4 | export enum AsyncApiSecurityType { 5 | UserPassword = 'userPassword', 6 | ApiKey = 'apiKey', 7 | X509 = 'X509', 8 | SymmetricEncryption = 'symmetricEncryption', 9 | AsymmetricEncryption = 'asymmetricEncryption', 10 | HttpApiKey = 'httpApiKey', 11 | Http = 'http', 12 | OAuth2 = 'oauth2', 13 | OpenIdConnect = 'openIdConnect', 14 | } 15 | -------------------------------------------------------------------------------- /src/interfaces/api-server-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { ApiServerSecurity } from '../types/api-server-security.type' 2 | import { ServerVariables } from './server-variables.interface' 3 | 4 | /** @see https://www.asyncapi.com/docs/specifications/2.0.0#servers-object-example **/ 5 | export interface ApiServerOptions { 6 | url: string 7 | protocol: string 8 | protocolVersion?: string 9 | variables?: ServerVariables 10 | description?: string 11 | security?: ApiServerSecurity 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/getSchemaPath.util.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { isString } from '@nestjs/common/utils/shared.utils' 3 | 4 | export function getSchemaPath(model: string | Function): string { 5 | const modelName = isString(model) ? model : model && model.name 6 | return `#/components/schemas/${modelName}` 7 | } 8 | 9 | export function refs(...models: Function[]) { 10 | return models.map((item) => ({ 11 | $ref: getSchemaPath(item.name), 12 | })) 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /.asyncapi 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /src/interfaces/async-api-template-options.interface.ts: -------------------------------------------------------------------------------- 1 | /** @see https://github.com/asyncapi/html-template#supported-parameters **/ 2 | export interface AsyncApiTemplateOptions { 3 | /** @default byTagsNoRoot **/ 4 | sidebarOrganization?: 'byTags' | 'byTagsNoRoot' 5 | /** @example /docs **/ 6 | baseHref?: string 7 | /** @default true **/ 8 | singleFile?: boolean 9 | /** @example asyncapi.html **/ 10 | outFilename?: string 11 | /** 12 | * @description Generates output HTML as PDF 13 | * @default false 14 | */ 15 | pdf?: boolean 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | extends: [ 8 | 'plugin:prettier/recommended', 9 | ], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true, 14 | }, 15 | rules: { 16 | '@typescript-eslint/interface-name-prefix': 'off', 17 | '@typescript-eslint/explicit-function-return-type': 'off', 18 | '@typescript-eslint/explicit-module-boundary-types': 'off', 19 | '@typescript-eslint/no-explicit-any': 'off', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/interfaces/async-operation-options.interface.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { AsyncApiOperation } from './async-api-operation.interface' 3 | import { Type } from '@nestjs/common' 4 | import { AsyncApiExamples } from './async-api-examples.interface' 5 | import { AsyncApiDiscriminator } from './async-api-discriminator.interface' 6 | 7 | export interface AsyncApiOperationOptions 8 | extends Omit { 9 | message: { 10 | name: string 11 | payload: { 12 | type: Type | Function | [Function] | string 13 | discriminator?: AsyncApiDiscriminator 14 | examples?: AsyncApiExamples 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/decorators/async-property.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createPropertyDecorator } from './helpers' 2 | import { DECORATORS } from '../constants' 3 | import { Type } from '@nestjs/common' 4 | 5 | interface AsyncPropertyMetadata { 6 | description?: string 7 | type: Type 8 | format?: string 9 | required?: string[] 10 | } 11 | 12 | /** 13 | * Decorates a model property to be included in async api documentation 14 | * (If the property is not decorated, it will not be included) 15 | * @param metadata 16 | * @constructor 17 | */ 18 | export function AsyncProperty( 19 | metadata: AsyncPropertyMetadata, 20 | ): PropertyDecorator { 21 | return createPropertyDecorator(DECORATORS.API_MODEL_PROPERTIES, metadata) 22 | } 23 | -------------------------------------------------------------------------------- /src/services/__tests__/contract-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { ContractParser } from '../contract-parser' 2 | import { DocumentBuilder } from '../../core/document-builder' 3 | 4 | describe('ContractParser', () => { 5 | const parser = new ContractParser() 6 | it('should parse custom contract to yaml', () => { 7 | const contract = new DocumentBuilder() 8 | .setTitle('Test Parse') 9 | .setDescription('test') 10 | .setVersion('1.0.0') 11 | .addServer('production', { 12 | protocol: 'nosql', 13 | url: 'mongodb://localhost:277777', 14 | description: 'Mongodb', 15 | protocolVersion: '2.0.0', 16 | }) 17 | .build() 18 | expect(parser.parse(contract)).toBeDefined() 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /e2e/fastify-app/users/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common' 2 | import { AsyncConsumer } from '../../../src/decorators/async-consumer.decorator' 3 | import { UpdateUserDto } from './dto/update-user.dto' 4 | import { AsyncPublisher } from '../../../src/decorators/async-publisher.decorator' 5 | 6 | @Controller() 7 | export class UserController { 8 | // TODO: Needs review 9 | // @AsyncConsumer('users/update', UpdateUserDto) 10 | handleUserUpdate(dto: UpdateUserDto) { 11 | console.log(dto) 12 | } 13 | 14 | // TODO: Needs review 15 | // @AsyncConsumer('users/changed', undefined) 16 | // @AsyncPublisher('users/reviewed', undefined) 17 | handleSomethingAndPublish() { 18 | console.log('test') 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/interfaces/async-api-scanning-options.interface.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | /** 3 | * Options for AsyncApi Module (kept similar with Swagger module) 4 | */ 5 | export interface AsyncApiScanningOptions { 6 | /** 7 | * List of modules to include in the specification 8 | */ 9 | include?: Function[] 10 | 11 | /** 12 | * If `true`, asyncapi will also load routes from the modules imported by `include` modules 13 | */ 14 | deepScanRoutes?: boolean 15 | 16 | /** 17 | * Custom operationIdFactory that will be used to generate the `operationId` 18 | * based on the `controllerKey` and `methodKey` 19 | * @default () => controllerKey_methodKey 20 | */ 21 | operationIdFactory?: (controllerKey: string, methodKey: string) => string 22 | } 23 | -------------------------------------------------------------------------------- /src/interfaces/async-api-contract.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiServers } from './async-api-servers.interface' 2 | import { AsyncApiComponents } from './async-api-components.interface' 3 | import { AsyncApiChannels } from './async-api-channels.interface' 4 | 5 | export interface AsyncApiContract { 6 | asyncapi: string 7 | id?: string 8 | /** @default application/json **/ 9 | defaultContentType?: string 10 | info: { 11 | title: string 12 | version: string 13 | description: string 14 | license?: { name: string; url: string } 15 | termsOfService?: string 16 | contact?: { name: string; url: string; email: string } 17 | } 18 | servers?: AsyncApiServers 19 | components: AsyncApiComponents 20 | channels: AsyncApiChannels 21 | tags?: any[] 22 | externalDocs?: any 23 | } 24 | -------------------------------------------------------------------------------- /src/decorators/async-channel.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createMixedDecorator, createPropertyDecorator } from './helpers' 2 | import { DECORATORS } from '../constants' 3 | import { Type } from '@nestjs/common' 4 | 5 | export interface AsyncChannelOptions { 6 | name: string 7 | description?: string 8 | bindings?: Record 9 | } 10 | 11 | /** 12 | * Decorates a model property to be included in async api documentation 13 | * (If the property is not decorated, it will not be included) 14 | * @constructor 15 | * @param optionsOrName 16 | */ 17 | export function AsyncChannel(optionsOrName: string | AsyncChannelOptions) { 18 | const metadata = 19 | typeof optionsOrName === 'string' ? { name: optionsOrName } : optionsOrName 20 | return createMixedDecorator(DECORATORS.CHANNEL, metadata) 21 | } 22 | -------------------------------------------------------------------------------- /src/interfaces/async-api-operation.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiExternalDocs } from './async-api-external-docs.interface' 2 | import { AsyncApiTags } from './async-api-tags.interface' 3 | import { AsyncApiBindings } from './async-api-bindings.interface' 4 | import { AsyncApiMessage } from './async-api-message-metadata.interface' 5 | import { AsyncApiTraits } from './async-api-traits.interface' 6 | import { AsyncApiReferenceObject } from './async-api-reference-object.interface' 7 | 8 | export class AsyncApiOperation { 9 | summary?: string 10 | /** @default we generate **/ 11 | operationId?: string 12 | description?: string 13 | tags?: AsyncApiTags 14 | externalDocs?: AsyncApiExternalDocs 15 | bindings?: AsyncApiBindings 16 | traits?: AsyncApiTraits 17 | message?: AsyncApiMessage | AsyncApiReferenceObject 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '*.md' 7 | pull_request: 8 | paths-ignore: 9 | - '*.md' 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | node-version: [12, 14, 16] 18 | os: [macos-latest, ubuntu-latest, windows-latest] 19 | 20 | steps: 21 | - uses: actions/checkout@v2.3.4 22 | 23 | - name: Use Node.js 24 | uses: actions/setup-node@v2.2.0 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Install Dependencies 29 | run: | 30 | npm install --ignore-scripts 31 | - name: Build 32 | run: | 33 | npm run build 34 | - name: Run Tests 35 | run: | 36 | npm test 37 | -------------------------------------------------------------------------------- /src/interfaces/security-scheme.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiSecurityType } from '../enums/async-api-security-type.enum' 2 | import { AsyncApiOAuth2Flow } from './async-api-oauth2-flow.interface' 3 | import { SecuritySchemeIn } from '../types/security-scheme-in.type' 4 | 5 | /** 6 | * Represents the OAuth flow object from Async API for security scheme 7 | * @see https://www.asyncapi.com/docs/specifications/2.0.0#oauthFlowObject 8 | */ 9 | export interface SecurityScheme { 10 | type: AsyncApiSecurityType 11 | description?: string 12 | in?: SecuritySchemeIn 13 | /** 14 | * This is only necessary when we use type http 15 | * @default http 16 | * @see https://tools.ietf.org/html/rfc7235#section-5.1 17 | */ 18 | scheme?: string 19 | bearerFormat?: string 20 | name?: string 21 | flows?: AsyncApiOAuth2Flow 22 | openIdConnectUrl?: string 23 | [key: string]: any 24 | } 25 | -------------------------------------------------------------------------------- /src/services/consumer-object-factory.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiOperation } from '../interfaces/async-api-operation.interface' 2 | import { AsyncApiOperationOptions } from '../interfaces/async-operation-options.interface' 3 | 4 | export class ConsumerObjectFactory { 5 | create( 6 | options: AsyncApiOperationOptions, 7 | operationId: string, 8 | ): AsyncApiOperation { 9 | /** @todo Handle first if the metadata is a SchemaObject or a message type **/ 10 | const metadata = { 11 | description: options?.description || '', 12 | } 13 | //console.log({ metadata, options, operationId }) 14 | //If we have more than 1 type than we have a schema 15 | return { 16 | ...metadata, 17 | operationId, 18 | message: { 19 | description: 'Dump', 20 | summary: 'Testing', 21 | // $ref: '#/components/messages/TEST', 22 | }, 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/interfaces/async-api-channel.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiBindings } from './async-api-bindings.interface' 2 | import { AsyncApiOperation } from './async-api-operation.interface' 3 | import { AsyncApiChannelParamsType } from '../types/async-api-channel-params.type' 4 | 5 | export interface AsyncApiChannel { 6 | /** 7 | * Reference to a schema name, with nestjs-asyncapi you just need to refer the schema name 8 | * but you have to ensure that the schema is registered 9 | * @example "#/components/schemas/user" => "user" 10 | * @todo Check if we can use type linking and the type with decorator AsyncSchema so we can link automatically 11 | * but probably we would need to validate whether the type has or not metadata 12 | */ 13 | $ref?: string 14 | description?: string 15 | subscribe?: AsyncApiOperation 16 | publish?: AsyncApiOperation 17 | parameters?: AsyncApiChannelParamsType 18 | bindings?: AsyncApiBindings 19 | } 20 | -------------------------------------------------------------------------------- /src/decorators/async-consumer.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common' 2 | import { createMethodDecorator, createMixedDecorator } from './helpers' 3 | import { DECORATORS } from '../constants' 4 | import { AsyncApiOperation } from '../interfaces/async-api-operation.interface' 5 | import { AsyncApiReferenceObject } from '../interfaces/async-api-reference-object.interface' 6 | import { AsyncApiSchemaObject } from '../interfaces/async-api-schema-object.interface' 7 | import { AsyncApiOperationOptions } from '../interfaces/async-operation-options.interface' 8 | 9 | export interface MessageOneOf { 10 | oneOf: (AsyncApiSchemaObject | AsyncApiReferenceObject)[] 11 | } 12 | 13 | /** 14 | * 15 | * @see https://www.asyncapi.com/docs/specifications/2.0.0#definitionsConsumer 16 | * @constructor 17 | * @param options 18 | */ 19 | export function AsyncConsumer( 20 | options: AsyncApiOperationOptions, 21 | ): MethodDecorator { 22 | return createMixedDecorator(DECORATORS.SUB, options) 23 | } 24 | -------------------------------------------------------------------------------- /src/core/async-api-transformer.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiChannels } from '../interfaces/async-api-channels.interface' 2 | import { DenormalizedDoc } from '../interfaces/doc/denormalized-doc.interface' 3 | import { AsyncApiChannel } from '../interfaces/async-api-channel.interface' 4 | 5 | export class AsyncApiTransformer { 6 | public normalizeChannels( 7 | denormalizedDocs: DenormalizedDoc[], 8 | ): Record<'channels', AsyncApiChannels> { 9 | const flatChannels = denormalizedDocs.map((d: DenormalizedDoc) => { 10 | const key = d.root.name 11 | const value = { 12 | description: d.root.description, 13 | bindings: d.root.bindings, 14 | parameters: d.root.parameters, 15 | subscribe: d.operations.sub, 16 | publish: d.operations.pub, 17 | } as AsyncApiChannel 18 | return { key, value } 19 | }) 20 | const channels = flatChannels.reduce((acc, it) => { 21 | acc[it.key] = it.value 22 | return acc 23 | }, {}) 24 | 25 | return { channels: channels } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/decorators/async-publisher.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common' 2 | import { createMethodDecorator, createMixedDecorator } from './helpers' 3 | import { DECORATORS } from '../constants' 4 | import { AsyncApiOperationOptions } from '../interfaces/async-operation-options.interface' 5 | 6 | export class AsyncBaseMessage { 7 | description?: string 8 | /** @default true **/ 9 | additionalProperties?: boolean 10 | } 11 | 12 | export class AsyncConsumerMetadata extends AsyncBaseMessage {} 13 | 14 | /** 15 | * 16 | * @param name 17 | * @see https://www.asyncapi.com/docs/specifications/2.0.0#definitionsConsumer 18 | * @constructor 19 | */ 20 | /* 21 | export function AsyncPublisher( 22 | payload: Type | Type[], 23 | metadata?: AsyncConsumerMetadata, 24 | ): MethodDecorator { 25 | return createMethodDecorator(DECORATORS.PUBLISHER, { 26 | payload, 27 | metadata, 28 | }) 29 | } 30 | */ 31 | 32 | export function AsyncPublisher( 33 | options: AsyncApiOperationOptions, 34 | ): MethodDecorator { 35 | return createMixedDecorator(DECORATORS.PUB, options) 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Enigma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/core/__tests__/contract-builder.spec.ts: -------------------------------------------------------------------------------- 1 | import { DocumentBuilder } from '../document-builder' 2 | import { createContractBase } from '../../fixtures/contract-fixture' 3 | import { AsyncApiContract } from '../../interfaces/async-api-contract.interface' 4 | 5 | describe('ContractBuilder', () => { 6 | it('should be defined', () => { 7 | const builder = new DocumentBuilder() 8 | expect(builder).toBeInstanceOf(DocumentBuilder) 9 | expect(builder.build()).toBeDefined() 10 | }) 11 | 12 | it('should throw error when attempting to add invalid URL', () => { 13 | try { 14 | new DocumentBuilder().setTermsOfService('test invalid') 15 | } catch (e) { 16 | expect(e).toBeDefined() 17 | } 18 | }) 19 | 20 | it('should create a valid contract', () => { 21 | const contract = new DocumentBuilder() 22 | .setTitle('test') 23 | .setVersion('1.0.0') 24 | .build() 25 | const baseFixture = createContractBase() 26 | expect(contract).toEqual({ 27 | ...baseFixture, 28 | info: { 29 | ...baseFixture.info, 30 | title: 'test', 31 | version: '1.0.0', 32 | }, 33 | } as AsyncApiContract) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /e2e/fastify.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core' 2 | import { 3 | FastifyAdapter, 4 | NestFastifyApplication, 5 | } from '@nestjs/platform-fastify' 6 | import { ApplicationModule } from './fastify-app/app.module' 7 | import { DocumentBuilder } from '../src/core/document-builder' 8 | import { AsyncApiModule } from '../src/async-api-module' 9 | 10 | describe('Fastify Adapter', () => { 11 | let app: NestFastifyApplication 12 | let builder: DocumentBuilder 13 | 14 | beforeEach(async () => { 15 | app = await NestFactory.create( 16 | ApplicationModule, 17 | new FastifyAdapter(), 18 | { logger: false }, 19 | ) 20 | 21 | builder = new DocumentBuilder() 22 | .setTitle('Users example at Swagger') 23 | .setDescription('The cats API description') 24 | .setVersion('1.0') 25 | }) 26 | 27 | it('should produce a valid Async API 2.0.0 contract', async () => { 28 | const contract = AsyncApiModule.createContract(app, builder.build()) 29 | const doc = JSON.stringify(contract, null, 2) 30 | }) 31 | 32 | it('should setup the route', async () => { 33 | const contract = AsyncApiModule.createContract(app, builder.build()) 34 | await app.init() 35 | await AsyncApiModule.setup('/asyncapi', app, builder.build()) 36 | await expect( 37 | app.getHttpAdapter().getInstance().ready(), 38 | ).resolves.toBeDefined() 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/interfaces/async-api-schema-object.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiExternalDocs } from './async-api-external-docs.interface' 2 | import { AsyncApiReferenceObject } from './async-api-reference-object.interface' 3 | 4 | /** 5 | * @see https://www.asyncapi.com/docs/specifications/2.0.0#properties 6 | * @todo Still missing properties 7 | * **/ 8 | export interface AsyncApiSchemaObject { 9 | discriminator: string 10 | /** @default false **/ 11 | deprecated?: boolean 12 | externalDocs?: AsyncApiExternalDocs 13 | nullable?: boolean 14 | readOnly?: boolean 15 | writeOnly?: boolean 16 | xml?: any 17 | example?: any 18 | examples?: any[] 19 | type?: string 20 | allOf?: (AsyncApiSchemaObject | AsyncApiReferenceObject)[] 21 | oneOf?: (AsyncApiSchemaObject | AsyncApiReferenceObject)[] 22 | anyOf?: (AsyncApiSchemaObject | AsyncApiReferenceObject)[] 23 | not?: AsyncApiSchemaObject | AsyncApiReferenceObject 24 | items?: AsyncApiSchemaObject | AsyncApiReferenceObject 25 | properties?: Record 26 | additionalProperties?: 27 | | AsyncApiSchemaObject 28 | | AsyncApiReferenceObject 29 | | boolean 30 | description?: string 31 | format?: string 32 | default?: any 33 | title?: string 34 | multipleOf?: number 35 | maximum?: number 36 | exclusiveMaximum?: boolean 37 | minimum?: number 38 | exclusiveMinimum?: boolean 39 | maxLength?: number 40 | minLength?: number 41 | pattern?: string 42 | maxItems?: number 43 | minItems?: number 44 | uniqueItems?: boolean 45 | maxProperties?: number 46 | minProperties?: number 47 | required?: string[] 48 | enum?: any[] 49 | } 50 | -------------------------------------------------------------------------------- /src/services/__tests__/async-api-generator.spec.ts: -------------------------------------------------------------------------------- 1 | import { DocumentBuilder } from '../../core/document-builder' 2 | import { AsyncApiGenerator } from '../async-api-generator' 3 | 4 | describe('AsyncApiGenerator', () => { 5 | const generator = new AsyncApiGenerator() 6 | it('should generate the documentation files', async () => { 7 | const contract = new DocumentBuilder() 8 | .setTitle('Jest app') 9 | .setDescription('Test app') 10 | .setVersion('1.0.0') 11 | .addUserPasswordSecurityScheme('DefaultUserPwd') 12 | .addOAuth2SecurityScheme('OAuth2', { 13 | implicit: { 14 | authorizationUrl: 'https://example.com/api/oauth/dialog', 15 | scopes: { 16 | 'app:write': 'permission to modify', 17 | 'app:read': 'read permission', 18 | }, 19 | }, 20 | }) 21 | .addServer('production', { 22 | url: 'mqtt://localhost:123456', 23 | protocol: 'mqtt', 24 | description: 'Rabbitmq test', 25 | security: [ 26 | { DefaultUserPwd: [] }, 27 | { OAuth2: ['app:write', 'app:read'] }, 28 | ], 29 | }) 30 | .setLicense('MIT', 'https://mywebsite.com') 31 | .build() 32 | 33 | /** @todo Remove this and messages, will be replaced with reflection **/ 34 | contract.channels = { 35 | signUp: { 36 | subscribe: { 37 | summary: 'Test', 38 | message: { 39 | $ref: '#/components/messages/signupMessage', 40 | }, 41 | }, 42 | }, 43 | } as any 44 | 45 | contract['components'] = { 46 | ...contract['components'], 47 | messages: { 48 | signupMessage: { 49 | payload: { 50 | type: 'object', 51 | properties: { 52 | displayName: { 53 | type: 'string', 54 | description: 'The user name', 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | } 61 | 62 | const result = await generator.generate(contract) 63 | expect(result).toBeDefined() 64 | }, 999999) 65 | }) 66 | -------------------------------------------------------------------------------- /src/core/operation-object-factory.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { omit } from 'lodash' 3 | import { AsyncApiOperation } from '../interfaces/async-api-operation.interface' 4 | import { getSchemaPath } from '../utils/getSchemaPath.util' 5 | import { AsyncApiOperationOptions } from '../interfaces/async-operation-options.interface' 6 | import { SchemaObjectFactory } from '@nestjs/swagger/dist/services/schema-object-factory' 7 | import { SwaggerTypesMapper } from '@nestjs/swagger/dist/services/swagger-types-mapper' 8 | import { ModelPropertiesAccessor } from '@nestjs/swagger/dist/services/model-properties-accessor' 9 | import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface' 10 | 11 | export class OperationObjectFactory { 12 | private readonly modelPropertiesAccessor = new ModelPropertiesAccessor() 13 | private readonly swaggerTypesMapper = new SwaggerTypesMapper() 14 | private readonly schemaObjectFactory = new SchemaObjectFactory( 15 | this.modelPropertiesAccessor, 16 | this.swaggerTypesMapper, 17 | ) 18 | 19 | create( 20 | operation: AsyncApiOperationOptions, 21 | produces: string[], 22 | schemas: Record, 23 | ): AsyncApiOperation { 24 | const { message } = operation as AsyncApiOperationOptions 25 | const messagePayloadType = message.payload.type as Function 26 | const name = this.schemaObjectFactory.exploreModelSchema( 27 | messagePayloadType, 28 | schemas, 29 | ) 30 | const discriminator = operation.message.payload.discriminator 31 | if (operation.message.payload.discriminator) { 32 | const schema = schemas[name] 33 | if (schema) { 34 | schema.discriminator = discriminator 35 | } 36 | } 37 | 38 | return this.toRefObject(operation, name, produces) 39 | } 40 | 41 | private toRefObject( 42 | operation: AsyncApiOperationOptions, 43 | name: string, 44 | produces: string[], 45 | ): AsyncApiOperation { 46 | const asyncOperationObject = omit(operation, 'examples') 47 | 48 | return { 49 | ...asyncOperationObject, 50 | message: { 51 | name: operation.message.name, 52 | payload: { 53 | $ref: getSchemaPath(name), 54 | examples: operation.message.payload.examples, 55 | }, 56 | }, 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/core/exploreAsyncApiOperationMetadata.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { Type } from '@nestjs/common' 3 | import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface' 4 | import { OperationObjectFactory } from './operation-object-factory' 5 | import { AsyncApiOperationOptions } from '../interfaces/async-operation-options.interface' 6 | import { DECORATORS } from '../constants' 7 | 8 | const operationObjectFactory = new OperationObjectFactory() 9 | 10 | export const exploreAsyncApiOperationMetadata = ( 11 | schemas: Record, 12 | _schemaRefsStack: [], 13 | instance: object, 14 | prototype: Type, 15 | method: object, 16 | ) => { 17 | console.log(method) 18 | /** 19 | * @todo we might need to validate first if he has or not 20 | * @todo We need to add default pub or sub otherwise async api throws an error 21 | */ 22 | const subMetadata: AsyncApiOperationOptions = exploreAsyncapiSubMetadata( 23 | instance, 24 | prototype, 25 | method, 26 | ) 27 | 28 | const pubMetadata: AsyncApiOperationOptions = exploreAsyncapiPubMetadata( 29 | instance, 30 | prototype, 31 | method, 32 | ) 33 | 34 | let pubObject = {} 35 | if (pubMetadata) { 36 | pubObject = { 37 | pub: { 38 | ...pubMetadata, 39 | ...operationObjectFactory.create( 40 | pubMetadata, 41 | ['application/json'], 42 | schemas, 43 | ), 44 | }, 45 | } 46 | } 47 | 48 | let subObject = {} 49 | if (subMetadata) { 50 | subObject = { 51 | sub: { 52 | ...subMetadata, 53 | ...operationObjectFactory.create( 54 | subMetadata, 55 | ['application/json'], 56 | schemas, 57 | ), 58 | }, 59 | } 60 | } 61 | 62 | const result = { ...pubObject, ...subObject } 63 | return result 64 | } 65 | 66 | export const exploreAsyncapiPubMetadata = ( 67 | _instance: object, 68 | _prototype: Type, 69 | method: object, 70 | ) => { 71 | const result = Reflect.getMetadata(DECORATORS.PUB, method) 72 | return result 73 | } 74 | export const exploreAsyncapiSubMetadata = ( 75 | _instance: object, 76 | _prototype: Type, 77 | method: object, 78 | ) => { 79 | const result = Reflect.getMetadata(DECORATORS.SUB, method) 80 | return result 81 | } 82 | -------------------------------------------------------------------------------- /src/core/__tests__/async-api-scanner.spec.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiScanner } from '../async-api-scanner' 2 | import { Test } from '@nestjs/testing' 3 | import { Controller, INestApplication, Injectable } from '@nestjs/common' 4 | import { AsyncConsumer } from '../../decorators/async-consumer.decorator' 5 | import { AsyncProperty } from '../../decorators/async-property.decorator' 6 | import { ContractParser } from '../../services/contract-parser' 7 | import { createContractBase } from '../../fixtures/contract-fixture' 8 | import { AsyncChannel } from '../../decorators/async-channel.decorator' 9 | import { ApiProperty } from '@nestjs/swagger' 10 | import { AsyncPublisher } from '../../decorators/async-publisher.decorator' 11 | 12 | describe('AsyncApiScanner', () => { 13 | let app: INestApplication 14 | let scanner: AsyncApiScanner 15 | 16 | beforeAll(async () => { 17 | class TestMessageDto { 18 | @ApiProperty({ type: String, description: 'Testing from async' }) 19 | id: string 20 | } 21 | 22 | @Injectable() 23 | @AsyncChannel('test') 24 | class UserController { 25 | @AsyncConsumer({ 26 | message: { name: 'hey', payload: { type: TestMessageDto } }, 27 | }) 28 | handleConsume() { 29 | return true 30 | } 31 | 32 | /* @AsyncPublisher('userWillPublish', null) 33 | @AsyncConsumer('userSigned', { message: TestMessageDto }) 34 | consumeAndPublish() { 35 | return 'test' 36 | }*/ 37 | methodThatHasNoDecorators() {} 38 | } 39 | const moduleRef = await Test.createTestingModule({ 40 | providers: [UserController], 41 | }).compile() 42 | app = moduleRef.createNestApplication() 43 | await app.init() 44 | scanner = new AsyncApiScanner() 45 | }) 46 | 47 | it('should be defined', () => { 48 | expect(app).toBeDefined() 49 | expect(scanner).toBeDefined() 50 | }) 51 | 52 | it('should find handleConsume consumer', () => { 53 | const result = scanner.scanApplication(app, {}) 54 | //console.log(util.inspect(result, null, 99, true)) 55 | // console.log(result) 56 | const parser = new ContractParser() 57 | let contract = createContractBase() 58 | contract = { 59 | ...contract, 60 | components: result.components, 61 | channels: result.channels, 62 | } 63 | //console.log(parser.parse(contract)) 64 | console.log(result) 65 | expect(result).toBeDefined() 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/interfaces/async-api-message-metadata.interface.ts: -------------------------------------------------------------------------------- 1 | import { AsyncApiExternalDocs } from './async-api-external-docs.interface' 2 | import { AsyncApiTags } from './async-api-tags.interface' 3 | import { AsyncApiBindings } from './async-api-bindings.interface' 4 | import { AsyncApiExamples } from './async-api-examples.interface' 5 | import { AsyncApiReferenceObject } from './async-api-reference-object.interface' 6 | import { AsyncApiDiscriminator } from './async-api-discriminator.interface' 7 | 8 | export interface XmlObject { 9 | name?: string 10 | namespace?: string 11 | prefix?: string 12 | attribute?: boolean 13 | wrapped?: boolean 14 | } 15 | 16 | export interface SchemaObject { 17 | nullable?: boolean 18 | discriminator?: AsyncApiDiscriminator 19 | readOnly?: boolean 20 | writeOnly?: boolean 21 | xml?: XmlObject 22 | externalDocs?: AsyncApiExternalDocs 23 | example?: any 24 | examples?: any[] 25 | deprecated?: boolean 26 | type?: string 27 | allOf?: (SchemaObject | AsyncApiReferenceObject)[] 28 | oneOf?: (SchemaObject | AsyncApiReferenceObject)[] 29 | anyOf?: (SchemaObject | AsyncApiReferenceObject)[] 30 | not?: SchemaObject | AsyncApiReferenceObject 31 | items?: SchemaObject | AsyncApiReferenceObject 32 | properties?: Record 33 | additionalProperties?: SchemaObject | AsyncApiReferenceObject | boolean 34 | description?: string 35 | format?: string 36 | default?: any 37 | title?: string 38 | multipleOf?: number 39 | maximum?: number 40 | exclusiveMaximum?: boolean 41 | minimum?: number 42 | exclusiveMinimum?: boolean 43 | maxLength?: number 44 | minLength?: number 45 | pattern?: string 46 | maxItems?: number 47 | minItems?: number 48 | uniqueItems?: boolean 49 | maxProperties?: number 50 | minProperties?: number 51 | required?: string[] 52 | enum?: any[] 53 | } 54 | 55 | export interface AsyncCorrelationObject { 56 | description?: string 57 | location: string 58 | } 59 | 60 | export interface AsyncTagObject { 61 | name: string 62 | description?: string 63 | externalDocs?: AsyncApiExternalDocs 64 | } 65 | 66 | export interface AsyncApiMessageTraitObject { 67 | headers?: SchemaObject 68 | correlationId?: AsyncCorrelationObject 69 | /** @see https://www.asyncapi.com/docs/specifications/2.0.0#messageObjectSchemaFormatTable **/ 70 | schemaFormat?: string 71 | /** @see https://www.asyncapi.com/docs/specifications/2.0.0#defaultContentTypeString **/ 72 | contentType?: string 73 | name?: string 74 | title?: string 75 | summary?: string 76 | description?: string 77 | tags?: AsyncTagObject[] 78 | externalDocs?: AsyncApiExternalDocs 79 | bindings?: Record 80 | examples?: AsyncApiExamples 81 | } 82 | 83 | export interface AsyncApiMessage extends AsyncApiMessageTraitObject { 84 | payload?: any 85 | traits?: AsyncApiMessageTraitObject 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [AsyncAPI](https://www.asyncapi.com/) module for nestjs 28 | 29 | ## Installation 30 | 31 | Soon, not published yet 32 | 33 | ## Documentation 34 | Soon 35 | 36 | ## Support 37 | 38 | nestjs-asyncapi is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 39 | 40 | ## Stay in touch 41 | 42 | - Author - [Underfisk](https://github.com/underfisk) 43 | - Nestjs website - [https://nestjs.com](https://nestjs.com/) 44 | - Nestjs Twitter - [@nestframework](https://twitter.com/nestframework) 45 | 46 | ## License 47 | 48 | nestjs-asyncapi is [MIT licensed](LICENSE). 49 | -------------------------------------------------------------------------------- /src/decorators/helpers.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isUndefined, negate, pickBy } from 'lodash' 2 | import { DECORATORS, METADATA_FACTORY_NAME } from '../constants' 3 | /** 4 | * @see https://github.com/nestjs/swagger/blob/master/lib/decorators/helpers.ts 5 | */ 6 | 7 | export function createMixedDecorator( 8 | metakey: string, 9 | metadata: T, 10 | ): MethodDecorator & ClassDecorator { 11 | return ( 12 | // eslint-disable-next-line @typescript-eslint/ban-types 13 | target: object, 14 | key?: string | symbol, 15 | descriptor?: TypedPropertyDescriptor, 16 | ): any => { 17 | if (descriptor) { 18 | Reflect.defineMetadata(metakey, metadata, descriptor.value) 19 | return descriptor 20 | } 21 | Reflect.defineMetadata(metakey, metadata, target) 22 | return target 23 | } 24 | } 25 | 26 | export function createMethodDecorator( 27 | metakey: string, 28 | metadata: T, 29 | ): MethodDecorator { 30 | return ( 31 | // eslint-disable-next-line @typescript-eslint/ban-types 32 | target: object, 33 | key: string | symbol, 34 | descriptor: PropertyDescriptor, 35 | ) => { 36 | Reflect.defineMetadata(metakey, metadata, descriptor.value) 37 | return descriptor 38 | } 39 | } 40 | 41 | export function createClassDecorator = any>( 42 | metakey: string, 43 | metadata: T = [] as T, 44 | ): ClassDecorator { 45 | return (target) => { 46 | const prevValue = Reflect.getMetadata(metakey, target) || [] 47 | Reflect.defineMetadata(metakey, [...prevValue, ...metadata], target) 48 | return target 49 | } 50 | } 51 | 52 | export function createPropertyDecorator = any>( 53 | metakey: string, 54 | metadata: T, 55 | overrideExisting = true, 56 | ): PropertyDecorator { 57 | return (target: any, propertyKey: string) => { 58 | const properties = 59 | Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES_ARRAY, target) || [] 60 | 61 | const key = `:${propertyKey}` 62 | if (!properties.includes(key)) { 63 | Reflect.defineMetadata( 64 | DECORATORS.API_MODEL_PROPERTIES_ARRAY, 65 | [...properties, `:${propertyKey}`], 66 | target, 67 | ) 68 | } 69 | const existingMetadata = Reflect.getMetadata(metakey, target, propertyKey) 70 | if (existingMetadata) { 71 | const newMetadata = pickBy(metadata, negate(isUndefined)) 72 | const metadataToSave = overrideExisting 73 | ? { 74 | ...existingMetadata, 75 | ...newMetadata, 76 | } 77 | : { 78 | ...newMetadata, 79 | ...existingMetadata, 80 | } 81 | 82 | Reflect.defineMetadata(metakey, metadataToSave, target, propertyKey) 83 | } else { 84 | const type = 85 | target?.constructor?.[METADATA_FACTORY_NAME]?.()[propertyKey]?.type ?? 86 | Reflect.getMetadata('design:type', target, propertyKey) 87 | 88 | Reflect.defineMetadata( 89 | metakey, 90 | { 91 | type, 92 | ...pickBy(metadata, negate(isUndefined)), 93 | }, 94 | target, 95 | propertyKey, 96 | ) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/async-api-module.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common' 2 | import { AsyncApiContract } from './interfaces/async-api-contract.interface' 3 | import { validatePath } from './utils/validate-path' 4 | import { AsyncApiGenerator } from './services/async-api-generator' 5 | import { AsyncApiTemplateOptions } from './interfaces/async-api-template-options.interface' 6 | import { ContractParser } from './services/contract-parser' 7 | 8 | export class AsyncApiModule { 9 | public static createContract( 10 | app: INestApplication, 11 | config: any, 12 | options?: any, 13 | ) { 14 | /** @todo Create its implementation **/ 15 | /** @todo If there are no channel/messages in components we might need to provide a default to prevent AsyncAPI validation error **/ 16 | } 17 | 18 | public static async setup( 19 | path: string, 20 | app: INestApplication, 21 | contact: AsyncApiContract, 22 | templateOptions?: AsyncApiTemplateOptions, 23 | ) { 24 | const httpAdapter = app.getHttpAdapter() 25 | if (httpAdapter?.getType() === 'fastify') { 26 | return this.setupFastify(path, httpAdapter, contact, templateOptions) 27 | } 28 | return this.setupExpress(path, app, contact, templateOptions) 29 | } 30 | 31 | private static async setupExpress( 32 | path: string, 33 | app: INestApplication, 34 | contract: AsyncApiContract, 35 | templateOptions?: AsyncApiTemplateOptions, 36 | ) { 37 | const httpAdapter = app.getHttpAdapter() 38 | const finalPath = validatePath(path) 39 | const generator = new AsyncApiGenerator(templateOptions) 40 | const html = await generator.generate(contract) 41 | const parser = new ContractParser() 42 | //httpAdapter.useStaticAssets(generator.getStaticAssetsPath()) 43 | httpAdapter.get(finalPath, (req, res) => 44 | //res.send(generator.getIndexHtmlPath()), 45 | res.send(html), 46 | ) 47 | httpAdapter.get(finalPath + '-json', (req, res) => res.json(contract)) 48 | httpAdapter.get(finalPath + '-yml', (req, res) => 49 | res.json(parser.parse(contract)), 50 | ) 51 | } 52 | 53 | private static async setupFastify( 54 | path: string, 55 | httpServer: any, 56 | contract: AsyncApiContract, 57 | templateOptions?: AsyncApiTemplateOptions, 58 | ) { 59 | // Workaround for older versions of the @nestjs/platform-fastify package 60 | // where "isParserRegistered" getter is not defined. 61 | const hasParserGetterDefined = ( 62 | Object.getPrototypeOf( 63 | httpServer, 64 | // eslint-disable-next-line @typescript-eslint/ban-types 65 | ) as Object 66 | ).hasOwnProperty('isParserRegistered') 67 | if (hasParserGetterDefined && !httpServer.isParserRegistered) { 68 | httpServer.registerParserMiddleware() 69 | } 70 | 71 | const finalPath = validatePath(path) 72 | const generator = new AsyncApiGenerator(templateOptions) 73 | const html = await generator.generate(contract) 74 | const parser = new ContractParser() 75 | 76 | httpServer.get(finalPath, (req, res) => { 77 | // res.send(generator.getIndexHtmlPath()) 78 | res.send(html) 79 | }) 80 | httpServer.get(`${finalPath}-json`, (req, res) => { 81 | res.send(contract) 82 | }) 83 | httpServer.get(`${finalPath}-yml`, (req, res) => { 84 | res.send(parser.parse(contract)) 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/services/async-api-generator.ts: -------------------------------------------------------------------------------- 1 | import Generator from '@asyncapi/generator' 2 | import path from 'path' 3 | import { promises as fs } from 'fs' 4 | import { AsyncApiContract } from '../interfaces/async-api-contract.interface' 5 | import { ContractParser } from './contract-parser' 6 | import { AsyncApiTemplateOptions } from '../interfaces/async-api-template-options.interface' 7 | import { Logger } from '@nestjs/common' 8 | import os from 'os' 9 | 10 | /** 11 | * Private interface just to give static typing due '@asyncapi/generator' due to the fact they do not support yet Typescript 12 | * @see https://github.com/asyncapi/generator/blob/master/docs/api.md 13 | */ 14 | interface IGenerator { 15 | templateName: string 16 | targetDir: string 17 | entrypoint?: string 18 | noOverwriteGlobs: string[] 19 | disabledHooks: { [key: string]: string | boolean | string[] } 20 | output: 'string' | 'fs' 21 | forceWrite: boolean 22 | debug: boolean 23 | install: boolean 24 | // eslint-disable-next-line @typescript-eslint/ban-types 25 | templateConfig: object 26 | // eslint-disable-next-line @typescript-eslint/ban-types 27 | hooks: object 28 | templateParams: AsyncApiTemplateOptions 29 | generate: (document: any) => Promise 30 | generateFromURL: (url: string) => Promise 31 | generateFromFile: (path: string) => Promise 32 | generateFromString: (yaml: string, args?: any) => Promise 33 | } 34 | 35 | type HTMLBuildResult = string 36 | /** 37 | * Provides a generate method to download/install all necessary dependencies of async-api 38 | * and generate .asyncapi folder 39 | */ 40 | export class AsyncApiGenerator { 41 | private readonly logger = new Logger(AsyncApiGenerator.name) 42 | private readonly parser = new ContractParser() 43 | private readonly baseUrl = path.resolve(process.cwd()) 44 | private readonly targetFolder = '.asyncapi' 45 | /* private readonly targetDirectory = path.resolve( 46 | path.join(this.baseUrl, this.targetFolder), 47 | )*/ 48 | private readonly generator: IGenerator 49 | 50 | constructor(readonly templateOptions?: AsyncApiTemplateOptions) { 51 | /* this.generator = new Generator( 52 | '@asyncapi/html-template', 53 | this.targetDirectory, 54 | this.templateOptions, 55 | )*/ 56 | this.generator = new Generator('@asyncapi/html-template', os.tmpdir(), { 57 | forceWrite: true, 58 | entrypoint: 'index.html', 59 | output: 'string', 60 | templateParams: { 61 | singleFile: true, 62 | ...templateOptions, 63 | }, 64 | }) 65 | } 66 | 67 | /** 68 | * Generates the documentation files/static assets under .asyncapi folder 69 | * If this method throws any validation error related to async-api, just place the generated YAML 70 | * in async-api playground and see the details 71 | * 72 | * @param contract 73 | */ 74 | public async generate(contract: AsyncApiContract): Promise { 75 | this.logger.log('Parsing AsyncAPI YAML from AsyncApiContract') 76 | const yaml = this.parser.parse(contract) 77 | this.logger.log('Generating yaml template to files') 78 | return await this.generator.generateFromString(yaml, { 79 | resolve: { 80 | file: false, 81 | }, 82 | }) 83 | } 84 | 85 | /* public getIndexHtmlPath() { 86 | return `${this.targetDirectory}/index.html` 87 | }*/ 88 | 89 | /* public getStaticAssetsPath() { 90 | return this.targetDirectory 91 | }*/ 92 | } 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-asyncapi", 3 | "version": "0.0.1", 4 | "description": "Async API module for Nestjs", 5 | "author": "Rodrigo", 6 | "license": "MIT", 7 | "main": "dist/index", 8 | "types": "dist/index", 9 | "engines": { 10 | "node": ">= 14.0.0", 11 | "npm": ">= 6.11.0" 12 | }, 13 | "contributors": [ 14 | { 15 | "name": "Rodrigo", 16 | "author": true 17 | }, 18 | { 19 | "name": "Vlad Betsun", 20 | "email": "vlad.betcun@gmail.com" 21 | } 22 | ], 23 | "bugs": { 24 | "url": "https://github.com/underfisk/nestjs-asyncapi/issues" 25 | }, 26 | "homepage": "https://github.com/underfisk/nestjs-asyncapi#readme", 27 | "scripts": { 28 | "prebuild": "rimraf dist", 29 | "build": "nest build", 30 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 31 | "start": "nest start", 32 | "start:dev": "nest start --watch", 33 | "start:debug": "nest start --debug --watch", 34 | "start:prod": "node dist/main", 35 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 36 | "test": "jest", 37 | "test:watch": "jest --watch", 38 | "test:cov": "jest --coverage", 39 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 40 | "test:e2e": "jest --config e2e/jest-e2e.json" 41 | }, 42 | "peerDependencies": { 43 | "@nestjs/common": "^8.4.4", 44 | "@nestjs/core": "^8.4.4", 45 | "reflect-metadata": "^0.1.13" 46 | }, 47 | "dependencies": { 48 | "@asyncapi/generator": "^1.9.3", 49 | "@asyncapi/html-template": "^0.24.9", 50 | "@nestjs/mapped-types": "^1.0.1", 51 | "js-yaml": "^4.1.0", 52 | "lodash": "^4.17.21", 53 | "rimraf": "^3.0.2", 54 | "rxjs": "^7.5.5", 55 | "validator": "^13.7.0" 56 | }, 57 | "devDependencies": { 58 | "@nestjs/cli": "^8.2.5", 59 | "@nestjs/common": "8.4.4", 60 | "@nestjs/core": "8.4.4", 61 | "@nestjs/platform-express": "8.4.4", 62 | "@nestjs/platform-fastify": "8.4.4", 63 | "@nestjs/schematics": "^8.0.10", 64 | "@nestjs/swagger": "^5.2.1", 65 | "@nestjs/testing": "^8.4.4", 66 | "@types/express": "^4.17.13", 67 | "@types/jest": "^27.4.0", 68 | "@types/js-yaml": "^4.0.5", 69 | "@types/lodash": "^4.14.178", 70 | "@types/node": "^14.14.6", 71 | "@types/supertest": "^2.0.11", 72 | "@types/validator": "^13.7.1", 73 | "@typescript-eslint/eslint-plugin": "^4.6.1", 74 | "@typescript-eslint/parser": "^4.6.1", 75 | "eslint": "^8.14.0", 76 | "eslint-config-prettier": "^8.5.0", 77 | "eslint-plugin-prettier": "^4.0.0", 78 | "jest": "^27.4.7", 79 | "prettier": "^2.6.2", 80 | "reflect-metadata": "^0.1.13", 81 | "supertest": "^6.2.3", 82 | "ts-jest": "^27.1.4", 83 | "ts-loader": "^8.4.0", 84 | "ts-node": "^9.1.1", 85 | "tsconfig-paths": "^3.14.1", 86 | "typescript": "^4.6.4" 87 | }, 88 | "jest": { 89 | "moduleFileExtensions": [ 90 | "js", 91 | "json", 92 | "ts" 93 | ], 94 | "rootDir": "src", 95 | "testRegex": ".*\\.spec\\.ts$", 96 | "transform": { 97 | "^.+\\.(t|j)s$": "ts-jest" 98 | }, 99 | "collectCoverageFrom": [ 100 | "**/*.(t|j)s" 101 | ], 102 | "coverageDirectory": "../coverage", 103 | "testEnvironment": "node" 104 | }, 105 | "volta": { 106 | "node": "16.15.0" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/core/async-api-scanner.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { INestApplication, Type } from '@nestjs/common' 3 | import { MODULE_PATH } from '@nestjs/common/constants' 4 | import { NestContainer } from '@nestjs/core/injector/container' 5 | import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper' 6 | import { Module } from '@nestjs/core/injector/module' 7 | import { extend, flatten, isEmpty, reduce } from 'lodash' 8 | import { stripLastSlash } from '../utils/strip-last-slash' 9 | import { AsyncApiExplorer } from './async-api-explorer' 10 | import { AsyncApiScanningOptions } from '../interfaces/async-api-scanning-options.interface' 11 | import { AsyncApiChannels } from '../interfaces/async-api-channels.interface' 12 | import { AsyncApiSchemaObject } from '../interfaces/async-api-schema-object.interface' 13 | import { AsyncApiTransformer } from './async-api-transformer' 14 | 15 | /** @author https://github.com/nestjs/swagger/blob/master/lib/swagger-scanner.ts **/ 16 | export class AsyncApiScanner { 17 | private readonly transformer = new AsyncApiTransformer() 18 | /*private readonly schemaObjectFactory = new SchemaObjectFactory( 19 | new ModelPropertiesAccessor(), 20 | new SwaggerTypesMapper(), 21 | )*/ 22 | private readonly explorer = new AsyncApiExplorer() 23 | 24 | public scanApplication( 25 | app: INestApplication, 26 | options: AsyncApiScanningOptions, 27 | ) { 28 | const { 29 | deepScanRoutes, 30 | include: includedModules = [], 31 | operationIdFactory, 32 | } = options 33 | 34 | const container: NestContainer = (app as any).container 35 | const modules: Module[] = this.getModules( 36 | container.getModules(), 37 | includedModules, 38 | ) 39 | 40 | const globalPrefix = stripLastSlash(this.getGlobalPrefix(app)) 41 | const denormalizedChannels = modules.map( 42 | ({ components, metatype, relatedModules }) => { 43 | let allComponents = new Map(components) 44 | 45 | if (deepScanRoutes) { 46 | // only load submodules routes if asked 47 | const isGlobal = (module: Type) => 48 | !container.isGlobalModule(module) 49 | 50 | Array.from(relatedModules.values()) 51 | .filter(isGlobal as any) 52 | .map(({ components: relatedComponents }) => relatedComponents) 53 | .forEach((relatedComponents) => { 54 | allComponents = new Map([...allComponents, ...relatedComponents]) 55 | }) 56 | } 57 | const path = metatype 58 | ? Reflect.getMetadata(MODULE_PATH, metatype) 59 | : undefined 60 | 61 | return this.scanModuleComponents( 62 | allComponents as Map, 63 | path, 64 | globalPrefix, 65 | operationIdFactory, 66 | ) 67 | }, 68 | ) 69 | 70 | const schemas = this.explorer.getSchemas() 71 | //this.addExtraModels(schemas, extraModels); 72 | const normalizedChannels = this.transformer.normalizeChannels( 73 | flatten(denormalizedChannels as any), 74 | ) 75 | const components = { 76 | schemas: reduce(schemas, extend) as Record, 77 | } 78 | return { 79 | ...normalizedChannels, 80 | components: components, 81 | } 82 | 83 | /* return { 84 | channels: this.explorer.getChannels(), 85 | components: { 86 | messages: this.explorer.getMessages(), 87 | }, 88 | }*/ 89 | } 90 | 91 | public scanModuleComponents( 92 | components: Map, 93 | modulePath?: string, 94 | globalPrefix?: string, 95 | operationIdFactory?: (controllerKey: string, methodKey: string) => string, 96 | ): AsyncApiChannels { 97 | const denormalizedArray = [...components.values()].map((comp) => 98 | this.explorer.exploreChannel( 99 | comp, 100 | modulePath, 101 | globalPrefix, 102 | operationIdFactory, 103 | ), 104 | ) 105 | 106 | return flatten(denormalizedArray) as any 107 | } 108 | 109 | public getModules( 110 | modulesContainer: Map, 111 | include: Function[], 112 | ): Module[] { 113 | if (!include || isEmpty(include)) { 114 | return [...modulesContainer.values()] 115 | } 116 | return [...modulesContainer.values()].filter(({ metatype }) => 117 | include.some((item) => item === metatype), 118 | ) 119 | } 120 | 121 | private getGlobalPrefix(app: INestApplication): string { 122 | const internalConfigRef = (app as any).config 123 | return (internalConfigRef && internalConfigRef.getGlobalPrefix()) || '' 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/core/document-builder.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common' 2 | import { createContractBase } from '../fixtures/contract-fixture' 3 | import { AsyncApiContract } from '../interfaces/async-api-contract.interface' 4 | import { SecurityScheme } from '../interfaces/security-scheme.interface' 5 | import { AsyncApiSecurityType } from '../enums/async-api-security-type.enum' 6 | import { ApiServerOptions } from '../interfaces/api-server-options.interface' 7 | import { AsyncApiOAuth2Flow } from '../interfaces/async-api-oauth2-flow.interface' 8 | import { SecuritySchemeIn } from '../types/security-scheme-in.type' 9 | import validator from 'validator' 10 | 11 | /** 12 | * DocumentBuilder helps to create a new "contract/document" between nestjs applications and AsyncAPI 13 | * @see https://www.asyncapi.com/docs/getting-started/asyncapi-documents 14 | */ 15 | export class DocumentBuilder { 16 | private readonly logger = new Logger(DocumentBuilder.name) 17 | private readonly contract = createContractBase() 18 | 19 | public setTitle(title: string): DocumentBuilder { 20 | this.contract.info.title = title 21 | return this 22 | } 23 | 24 | public setId(id: string): DocumentBuilder { 25 | this.contract.id = id 26 | return this 27 | } 28 | 29 | public setDescription(description: string): DocumentBuilder { 30 | this.contract.info.description = description 31 | return this 32 | } 33 | 34 | public setVersion(version: string): DocumentBuilder { 35 | this.contract.info.version = version 36 | return this 37 | } 38 | 39 | public setTermsOfService(url: string): DocumentBuilder { 40 | if (!validator.isURL(url)) { 41 | throw new Error(`${url} is not a valid URL`) 42 | } 43 | this.contract.info.termsOfService = url 44 | return this 45 | } 46 | public setLicense(name: string, url: string): DocumentBuilder { 47 | if (!validator.isURL(url)) { 48 | throw new Error(`${url} is not a valid URL`) 49 | } 50 | this.contract.info.license = { name, url } 51 | return this 52 | } 53 | 54 | public setDefaultContentType(contentType: string): DocumentBuilder { 55 | this.contract.defaultContentType = contentType 56 | return this 57 | } 58 | 59 | public setContact(name: string, email: string, url: string): DocumentBuilder { 60 | if (!validator.isURL(url)) { 61 | throw new Error(`${url} is not a valid URL`) 62 | } 63 | if (!validator.isEmail(email)) { 64 | throw new Error(`${url} is not a valid email`) 65 | } 66 | this.contract.info.contact = { name, email, url } 67 | return this 68 | } 69 | 70 | /** 71 | * Binds a new server into our servers dictionary. If the name is specified twice 72 | * it will override the pre-existing one 73 | * @see https://www.asyncapi.com/docs/getting-started/servers 74 | * @param nameOrEnvironment 75 | * @param options 76 | */ 77 | public addServer( 78 | nameOrEnvironment: string, 79 | options: ApiServerOptions, 80 | ): DocumentBuilder { 81 | if (!this.contract.servers) { 82 | this.contract.servers = {} 83 | } 84 | 85 | this.contract.servers[nameOrEnvironment] = options 86 | return this 87 | } 88 | 89 | public addSecurityScheme( 90 | name: string, 91 | scheme: SecurityScheme, 92 | ): DocumentBuilder { 93 | if (!this.contract.components.securitySchemes) { 94 | this.contract.components.securitySchemes = {} 95 | } 96 | 97 | this.contract.components.securitySchemes[name] = scheme 98 | return this 99 | } 100 | 101 | public addUserPasswordSecurityScheme( 102 | name = 'userPassword', 103 | description = '', 104 | ): DocumentBuilder { 105 | return this.addSecurityScheme(name, { 106 | type: AsyncApiSecurityType.UserPassword, 107 | description, 108 | }) 109 | } 110 | 111 | public addApiKeySecurityScheme( 112 | name: string, 113 | $in: SecuritySchemeIn, 114 | description = '', 115 | ): DocumentBuilder { 116 | return this.addSecurityScheme(name, { 117 | type: AsyncApiSecurityType.ApiKey, 118 | in: $in, 119 | description, 120 | }) 121 | } 122 | 123 | public addX509SecurityScheme( 124 | name: string, 125 | description = '', 126 | ): DocumentBuilder { 127 | return this.addSecurityScheme(name, { 128 | type: AsyncApiSecurityType.X509, 129 | description, 130 | }) 131 | } 132 | 133 | public addSymmetricEncryptionSecurityScheme( 134 | name: string, 135 | description = '', 136 | ): DocumentBuilder { 137 | return this.addSecurityScheme(name, { 138 | type: AsyncApiSecurityType.SymmetricEncryption, 139 | description, 140 | }) 141 | } 142 | 143 | /** 144 | * Add a Bearer or Basic http security, this is a shorthand but scheme is kept as a magic string 145 | * due to the fact Async API supports it that way and the developer might want to customize it 146 | * @param name 147 | * @param scheme 148 | * @param description 149 | */ 150 | public addHTTPSecurityScheme( 151 | name: string, 152 | scheme: string, 153 | description = '', 154 | ): DocumentBuilder { 155 | return this.addSecurityScheme(name, { 156 | type: AsyncApiSecurityType.Http, 157 | scheme, 158 | description, 159 | }) 160 | } 161 | 162 | public addHTTPApiKeySecurityScheme( 163 | name: string, 164 | $in: SecuritySchemeIn, 165 | keyName: string, 166 | description = '', 167 | ): DocumentBuilder { 168 | return this.addSecurityScheme(name, { 169 | type: AsyncApiSecurityType.HttpApiKey, 170 | name: keyName, 171 | description, 172 | }) 173 | } 174 | 175 | public addJWTBearerSecurityScheme( 176 | name: string, 177 | description = '', 178 | ): DocumentBuilder { 179 | return this.addSecurityScheme(name, { 180 | type: AsyncApiSecurityType.Http, 181 | scheme: 'bearer', 182 | bearerFormat: 'JWT', 183 | description, 184 | }) 185 | } 186 | 187 | public addOAuth2SecurityScheme( 188 | name: string, 189 | flows: AsyncApiOAuth2Flow, 190 | description = '', 191 | ): DocumentBuilder { 192 | return this.addSecurityScheme(name, { 193 | type: AsyncApiSecurityType.OAuth2, 194 | flows, 195 | description, 196 | }) 197 | } 198 | 199 | public build(): AsyncApiContract { 200 | return this.contract 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/core/async-api-explorer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { Controller } from '@nestjs/common/interfaces' 3 | import { isNil } from '@nestjs/common/utils/shared.utils' 4 | import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper' 5 | import { MetadataScanner } from '@nestjs/core/metadata-scanner' 6 | import { DECORATORS } from '../constants' 7 | import { ConsumerObjectFactory } from '../services/consumer-object-factory' 8 | import { AsyncApiSchemaObject } from '../interfaces/async-api-schema-object.interface' 9 | import { AsyncApiChannels } from '../interfaces/async-api-channels.interface' 10 | import { AsyncApiMessage } from '../interfaces/async-api-message-metadata.interface' 11 | import { Logger, Type } from '@nestjs/common' 12 | import { AsyncApiChannel } from '../interfaces/async-api-channel.interface' 13 | import { DenormalizedDocResolvers } from '../interfaces/doc/denormalized-doc-resolvers.interface' 14 | import { DenormalizedDoc } from '../interfaces/doc/denormalized-doc.interface' 15 | import { exploreAsyncApiOperationMetadata } from './exploreAsyncApiOperationMetadata' 16 | 17 | export class AsyncApiExplorer { 18 | private readonly logger = new Logger(AsyncApiExplorer.name) 19 | private readonly metadataScanner = new MetadataScanner() 20 | private readonly schemas: Record = {} 21 | private messages: Record = {} 22 | private readonly consumerObjectFactory = new ConsumerObjectFactory() 23 | private operationIdFactory = (controllerKey: string, methodKey: string) => 24 | controllerKey ? `${controllerKey}_${methodKey}` : methodKey 25 | 26 | private readonly schemaRefsStack: string[] = [] 27 | 28 | private channels: Record = {} 29 | 30 | /* private createDefaultMessageComponent(): AsyncApiMessage { 31 | return { 32 | description: 33 | 'Default message, please ensure you add a message component in your producer/consumer decorator', 34 | summary: 'Created automatically', 35 | name: 'Default message', 36 | correlationId: '__EMPTY__', 37 | } 38 | }*/ 39 | 40 | private createDefaultChannelComponent(): AsyncApiChannel { 41 | return { 42 | description: 43 | 'Default channel, please ensure you have at least one AsyncConsumer or AsyncPublisher registered', 44 | subscribe: { 45 | summary: 'Default Sub', 46 | description: '', 47 | message: { 48 | summary: 'Empty message', 49 | description: '', 50 | }, 51 | }, 52 | } 53 | } 54 | 55 | private setChannels(channels: Record) { 56 | this.channels = channels 57 | } 58 | 59 | private setMessages(messages: Record) { 60 | this.messages = messages 61 | } 62 | 63 | getSchemas() { 64 | return this.schemas 65 | } 66 | 67 | getChannels() { 68 | return this.channels 69 | } 70 | 71 | getMessages() { 72 | return this.messages 73 | } 74 | 75 | public exploreChannel( 76 | wrapper: InstanceWrapper, 77 | modulePath?: string, 78 | globalPrefix?: string, 79 | operationIdFactory?: (controllerKey: string, methodKey: string) => string, 80 | ) { 81 | if (operationIdFactory) { 82 | this.operationIdFactory = operationIdFactory 83 | } 84 | const { instance, metatype } = wrapper 85 | 86 | if ( 87 | !instance || 88 | !metatype || 89 | !Reflect.getMetadataKeys(metatype).find((x) => x === DECORATORS.CHANNEL) 90 | ) { 91 | return [] 92 | } 93 | 94 | const prototype = Object.getPrototypeOf(instance) 95 | 96 | console.log({ 97 | instance, 98 | metatype, 99 | prototype, 100 | }) 101 | const documentResolvers: DenormalizedDocResolvers = { 102 | root: [exploreAsyncApiOperationMetadata], 103 | security: [], 104 | tags: [], 105 | operations: [exploreAsyncApiOperationMetadata], 106 | } 107 | return this.generateDenormalizedDocument( 108 | metatype as Type, 109 | prototype, 110 | instance, 111 | documentResolvers, 112 | modulePath, 113 | globalPrefix, 114 | ) 115 | 116 | /* const scanResult = this.metadataScanner.scanFromPrototype( 117 | instance, 118 | prototype, 119 | (name) => { 120 | const handler = prototype[name] 121 | console.log('Handler name -> ' + name) 122 | console.log('Typeof the handler -> ' + typeof handler) 123 | const consumerMetadata = Reflect.getMetadata( 124 | DECORATORS.CONSUMER, 125 | handler, 126 | ) 127 | const publisherMetadata = Reflect.getMetadata( 128 | DECORATORS.PUBLISHER, 129 | handler, 130 | ) 131 | 132 | console.log({ 133 | consumerMetadata, 134 | publisherMetadata, 135 | }) 136 | 137 | if (isNil(consumerMetadata) && isNil(publisherMetadata)) { 138 | return 139 | } 140 | 141 | const channelComponent = this.createChannelComponent( 142 | consumerMetadata, 143 | publisherMetadata, 144 | this.getOperationId(instance, handler), 145 | ) 146 | 147 | const messages = this.createMessagesFromMetadata( 148 | consumerMetadata, 149 | publisherMetadata, 150 | ) 151 | return { channelComponent, messageComponent: messages } 152 | }, 153 | ) 154 | const channels = scanResult.reduce((p, e) => { 155 | return { 156 | ...p, 157 | ...e.channelComponent, 158 | } 159 | }, {} as AsyncApiChannels) 160 | 161 | const messages = scanResult.reduce((p, e) => { 162 | return { 163 | ...p, 164 | ...e.messageComponent, 165 | } 166 | }, {} as any) 167 | //Ensure we have at least 1 message 168 | if (Object.keys(messages).length === 0) { 169 | this.logger.warn( 170 | 'No messages component detected, please ensure you load a message or AsyncAPI throws error', 171 | ) 172 | this.setMessages({ DEFAULT: this.createDefaultMessageComponent() }) 173 | } else { 174 | this.setMessages(messages) 175 | } 176 | 177 | //Ensure we have at least 1 channel 178 | if (Object.keys(channels).length === 0) { 179 | this.setChannels({ DEFAULT: this.createDefaultChannelComponent() }) 180 | } else { 181 | this.setChannels(channels) 182 | } 183 | 184 | return prototype*/ 185 | } 186 | 187 | private getOperationId(instance: object, method: Function): string { 188 | return this.operationIdFactory( 189 | instance.constructor?.name || '', 190 | method.name, 191 | ) 192 | } 193 | 194 | private createChannelComponent( 195 | consumerMetadata: any, 196 | publisherMetadata: any, 197 | operationId: string, 198 | ) { 199 | const component = {} 200 | if (consumerMetadata) { 201 | /** 202 | * @todo We need somehow to also register the message component if its not registered yet 203 | * If type is undefined/null or anything not supported we show create a custom object to say that 204 | * **/ 205 | component[consumerMetadata.channelName] = { 206 | ...(component[consumerMetadata.channelName] || {}), 207 | subscribe: this.consumerObjectFactory.create( 208 | consumerMetadata.options, 209 | operationId + '-consumer', 210 | ), 211 | } 212 | } 213 | 214 | if (publisherMetadata) { 215 | component[consumerMetadata.channelName] = { 216 | ...(component[consumerMetadata.channelName] || {}), 217 | /** @todo Publisher object factory **/ 218 | publish: this.consumerObjectFactory.create( 219 | consumerMetadata.options, 220 | operationId + '-publisher', 221 | ), 222 | } 223 | } 224 | 225 | return component 226 | } 227 | 228 | private createMessagePayload() { 229 | return { 230 | type: 'object', 231 | properties: { 232 | yourProperties: 'etcetc', 233 | }, 234 | } 235 | } 236 | 237 | private isMessageOneOf(type: any) { 238 | return !isNil(type.oneOf) 239 | } 240 | 241 | private isMessageSchemaRef(type: any) { 242 | return !isNil(type.schema) 243 | } 244 | 245 | private isMessageClass(type: any) { 246 | const metadata = Reflect.getMetadata(DECORATORS.MESSAGE, type) 247 | return !isNil(metadata) 248 | } 249 | 250 | private createMessagesFromMetadata( 251 | consumerMetadata: any, 252 | publisherMetadata: any, 253 | ) { 254 | const messageComponent = { 255 | /* ['TEST']: { 256 | description: 'Test', 257 | payload: { 258 | type: 'object', 259 | properties: { 260 | displayName: { 261 | type: 'string', 262 | description: 'your name', 263 | }, 264 | }, 265 | }, 266 | },*/ 267 | } 268 | if (consumerMetadata?.options?.message) { 269 | const { options } = consumerMetadata 270 | console.log({ 271 | consumerMetadata, 272 | message: options.message, 273 | proto: Object.getPrototypeOf(options.message), 274 | isOneOf: this.isMessageOneOf(options.message), 275 | isMessageClass: this.isMessageClass(options.message), 276 | isRef: this.isMessageSchemaRef(options.message), 277 | }) 278 | 279 | if (this.isMessageClass(options.message)) { 280 | const messageMetadata = Reflect.getMetadata( 281 | DECORATORS.MESSAGE, 282 | options.message, 283 | ) 284 | 285 | //let prototype = Object.getPrototypeOf(options.message) 286 | /* const fields: any[] = [] 287 | do { 288 | const childFields = 289 | Reflect.getOwnMetadata( 290 | DECORATORS.API_MODEL_PROPERTIES, 291 | prototype, 292 | ) ?? [] 293 | 294 | //fields.push(...childFields) 295 | } while ( 296 | (prototype = Reflect.getPrototypeOf(prototype)) && 297 | prototype !== Object.prototype && 298 | prototype 299 | )*/ 300 | 301 | /** @todo If there are no properties at all we have to insert one manually **/ 302 | /** @todo Additional properties option will come from message class **/ 303 | } else if (this.isMessageClass(options.message)) { 304 | } else if (this.isMessageOneOf(options.message)) { 305 | } else { 306 | this.logger.warn( 307 | 'Provided message is not a valid type: ' + 308 | options.message + 309 | '. Expected schema reference, AsyncMessage class or oneOf schema reference', 310 | ) 311 | } 312 | } 313 | 314 | return messageComponent 315 | } 316 | 317 | private generateDenormalizedDocument( 318 | metatype: Type, 319 | prototype: Type, 320 | instance: object, 321 | documentResolvers: DenormalizedDocResolvers, 322 | _modulePath?: string, 323 | _globalPrefix?: string, 324 | ): DenormalizedDoc[] { 325 | const denormalizedChannels = this.metadataScanner.scanFromPrototype< 326 | any, 327 | DenormalizedDoc 328 | >(instance, prototype, (name) => { 329 | const targetCallback = prototype[name] 330 | const methodMetadata = documentResolvers.root.reduce((_metadata, fn) => { 331 | const channelMetadata = fn(metatype) 332 | return { 333 | root: Object.assign(channelMetadata, { name: channelMetadata.name }), 334 | operations: documentResolvers.operations.reduce((_metadata, opFn) => { 335 | return opFn( 336 | this.schemas, 337 | this.schemaRefsStack, 338 | instance, 339 | prototype, 340 | targetCallback, 341 | ) 342 | }, {}), 343 | } 344 | }, {}) 345 | return methodMetadata 346 | }) 347 | 348 | return denormalizedChannels 349 | } 350 | } 351 | --------------------------------------------------------------------------------