/** * Writes apps/api/schema.gql and apps/api/openapi.json without starting the * API or touching a database: `npx nx run api:contracts`. * * The running API writes the same files on start in development; this is for * when no database is at hand — a CI job, a laptop without Docker running. */ import 'reflect-metadata'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { InjectionToken, Module, Provider, Type } from '@nestjs/common'; import { MODULE_METADATA } from '@nestjs/common/constants'; import { NestFactory } from '@nestjs/core'; import { GRAPHQL_SDL_FILE_HEADER, GraphQLSchemaBuilderModule, GraphQLSchemaFactory, } from '@nestjs/graphql'; import { IdentityPresentersModule } from '@task-stream/api/identity/presenters'; import { TrackerPresentersModule } from '@task-stream/api/tracker/presenters'; import { lexicographicSortSchema, printSchema } from 'graphql'; import { AppModule } from '../src/app/app.module'; import { buildOpenApiDocument } from '../src/app/openapi'; /** The GraphQL resolvers a presenters module declares. */ function resolversOf(module: Type): Type[] { const providers: unknown[] = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, module) ?? []; return providers.filter( (provider): provider is Type => typeof provider === 'function' && // `@Resolver()` without a type (subscriptions) sets no type, only a name. Reflect.getMetadataKeys(provider).some((key) => String(key).startsWith('graphql:resolver'), ), ); } async function graphqlSchema(): Promise { const app = await NestFactory.create(GraphQLSchemaBuilderModule, { logger: false, }); await app.init(); const schema = await app .get(GraphQLSchemaFactory) .create([ ...resolversOf(IdentityPresentersModule), ...resolversOf(TrackerPresentersModule), ]); await app.close(); // Same output as GraphQLModule's autoSchemaFile with sortSchema. return GRAPHQL_SDL_FILE_HEADER + printSchema(lexicographicSortSchema(schema)); } /** The REST controllers a module declares. */ function controllersOf(module: Type): Type[] { return Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, module) ?? []; } /** * Empty stand-ins for whatever the controllers' constructors ask for: Swagger * reads the controllers' metadata and never calls them. */ function standIns(controllers: Type[]): Provider[] { const tokens = new Set(); for (const controller of controllers) { const types: unknown[] = Reflect.getMetadata('design:paramtypes', controller) ?? []; const injected: { index: number; param: unknown }[] = Reflect.getMetadata('self:paramtypes', controller) ?? []; types.forEach((type, index) => tokens.add(injected.find((i) => i.index === index)?.param ?? type), ); } return [...tokens].map((token) => ({ provide: token as InjectionToken, useValue: {}, })); } async function openApi(): Promise { // Only the controllers, with stand-ins for their dependencies: nothing // connects to Postgres, Mongo, Redis or Pireon. const controllers = [ ...controllersOf(AppModule), ...controllersOf(IdentityPresentersModule), ...controllersOf(TrackerPresentersModule), ]; @Module({ controllers, providers: standIns(controllers) }) class ContractsModule {} const app = await NestFactory.create(ContractsModule, { logger: false }); app.setGlobalPrefix('api'); const document = buildOpenApiDocument(app, { pireonBaseUrl: process.env['PIREON_BASE_URL'] || 'https://oauth.pireon.dev', scopes: (process.env['PIREON_SCOPES'] || 'users:read') .split(/[\s,]+/) .filter(Boolean), passwordGrant: process.env['PIREON_PASSWORD_GRANT'] !== 'false', }); return JSON.stringify(document, null, 2) + '\n'; } async function main() { const root = process.cwd(); writeFileSync(join(root, 'apps/api/schema.gql'), await graphqlSchema()); writeFileSync(join(root, 'apps/api/openapi.json'), await openApi()); } main().then( () => process.exit(0), (error) => { console.error(error); process.exit(1); }, );