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>
180 lines
6.0 KiB
TypeScript
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; }
|
|
`,
|
|
});
|
|
}
|