Files are uploaded over REST, stored on disk (a volume in Docker) and recorded on the ticket as events; the bytes are written first, so no attachment ever points at a missing file. Only images, video, audio, PDF and plain text are shown inline, everything with nosniff and a sandbox CSP. The web shows thumbnails, uploads by drag and drop or by pasting a screenshot, and attaches files to new comments. Also adds `nx run api:contracts`, which writes schema.gql and openapi.json without starting the API or any database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
4.0 KiB
TypeScript
118 lines
4.0 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<unknown>();
|
|
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<string> {
|
|
// 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);
|
|
},
|
|
);
|