Files
task-stream/apps/api/src/app/openapi.ts
Josep Piguillem 0b65c1c912 feat: attachments on tickets and comments
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>
2026-10-08 08:35:33 +02:00

180 lines
6.0 KiB
TypeScript

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>/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 <token>\`.
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<boolean>('SWAGGER_ENABLED')) return;
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
.split(/[\s,]+/)
.filter(Boolean);
const document = buildOpenApiDocument(app, {
pireonBaseUrl:
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev',
scopes,
passwordGrant: !!config.get<boolean>('PIREON_PASSWORD_GRANT'),
});
const file = config.get<string>('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<string>('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; }
`,
});
}