import { INestApplication } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger'; import { PIREON_BEARER, PIREON_OAUTH, } from '@task-stream/api/identity/presenters'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; export const API_VERSION = '1.0.0'; const DESCRIPTION = ` REST API of **Pireon TaskStream** for other apps: projects, tickets (bugs, features, UI changes, tasks and QA), workflow, comments, relations and releases. ### Authentication Every endpoint (except \`/auth\`, \`/health\` and the webhook) needs a **Pireon access token**, so any app or user that consumes it goes through Pireon's OAuth: - **Authorization Code + PKCE** against \`oauth.pireon.dev/oauth/authorize\` (the "Authorize" button here uses it; register \`/api/docs/oauth2-redirect.html\` as a redirect URI of the client). - An app with its own Pireon OAuth client can get tokens from Pireon directly and send them as \`Authorization: Bearer \`. What you can see and change depends on your role in each project. ### Writes are eventually consistent Commands answer \`{ id, key?, version }\` as soon as the event is stored. Reads come from a read model that catches up in milliseconds: a \`GET\` right after a write may still show the previous version (or 404 right after a create). ### Real time For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws). `.trim(); export interface OpenApiSettings { pireonBaseUrl: string; scopes: string[]; passwordGrant: boolean; } /** The OpenAPI document of the REST controllers. Needs no database. */ export function buildOpenApiDocument( app: INestApplication, settings: OpenApiSettings, ): OpenAPIObject { const pireon = settings.pireonBaseUrl; const scopes = settings.scopes; const scopeDocs = Object.fromEntries( scopes.map((scope) => [ scope, scope === 'users:read' ? 'Search Pireon users' : scope, ]), ); // Token requests go through the API's proxy: Pireon's /oauth/token has no // CORS and the client secret stays on the server. const tokenUrl = '/api/auth/token'; const document = SwaggerModule.createDocument( app, new DocumentBuilder() .setTitle('Pireon TaskStream API') .setDescription(DESCRIPTION) .setVersion(API_VERSION) .addServer('/', 'This server (the paths already include /api)') .addOAuth2( { type: 'oauth2', description: 'Pireon OAuth2 (Laravel Passport)', flows: { authorizationCode: { authorizationUrl: `${pireon}/oauth/authorize`, tokenUrl, refreshUrl: tokenUrl, scopes: scopeDocs, }, ...(settings.passwordGrant ? { password: { tokenUrl, refreshUrl: tokenUrl, scopes: scopeDocs, }, } : {}), }, }, PIREON_OAUTH, ) .addBearerAuth( { type: 'http', scheme: 'bearer', description: 'A Pireon access token obtained elsewhere', }, PIREON_BEARER, ) .addTag('Projects', 'Projects, members, board, stats and activity') .addTag( 'Issues', 'Tickets: search, create, workflow, relations, comments', ) .addTag('Comments', 'Edit and delete comments') .addTag('Releases', 'Versions that group tickets') .addTag('Users', 'The signed-in user and the Pireon directory') .addTag('Auth', 'Pireon OAuth token proxy') .addTag('Health', 'Liveness of the API and its databases') .addTag('Webhooks', 'Called by Pireon') .build(), ); // Operations without a security requirement are the public ones (@Public(): // health, auth, webhook). Say so explicitly instead of leaving it undefined. for (const path of Object.values(document.paths)) { for (const operation of [ path.get, path.post, path.put, path.patch, path.delete, ]) { if (operation && !operation.security) operation.security = []; } } return document; } /** * Builds the OpenAPI document from the REST controllers, serves Swagger UI and * (in development) writes it to OPENAPI_FILE so other apps can generate clients. */ export function setupOpenApi(app: INestApplication): void { const config = app.get(ConfigService); if (!config.get('SWAGGER_ENABLED')) return; const scopes = (config.get('PIREON_SCOPES') ?? '') .split(/[\s,]+/) .filter(Boolean); const document = buildOpenApiDocument(app, { pireonBaseUrl: config.get('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev', scopes, passwordGrant: !!config.get('PIREON_PASSWORD_GRANT'), }); const file = config.get('OPENAPI_FILE'); if (file) { writeFileSync( join(process.cwd(), file), JSON.stringify(document, null, 2) + '\n', ); } SwaggerModule.setup('api/docs', app, document, { jsonDocumentUrl: 'api/docs-json', yamlDocumentUrl: 'api/docs-yaml', customSiteTitle: 'Pireon TaskStream API', swaggerOptions: { persistAuthorization: true, initOAuth: { clientId: config.get('PIREON_CLIENT_ID') || undefined, scopes, usePkceWithAuthorizationCodeGrant: true, }, }, // The Pireon look, as in the web app. customCss: ` @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'); .swagger-ui, .swagger-ui .info .title, .swagger-ui .opblock-tag { font-family: Inter, system-ui, sans-serif; } .swagger-ui .topbar { display: none; } .swagger-ui .info .title { background: linear-gradient(100deg, #a347e1, #1879bf); -webkit-background-clip: text; background-clip: text; color: transparent; } .swagger-ui .btn.authorize { color: #a347e1; border-color: #a347e1; } .swagger-ui .btn.authorize svg { fill: #a347e1; } `, }); }