feat(api): REST API with OpenAPI for other apps, behind Pireon OAuth

A REST adapter next to GraphQL (presenters/http) that calls the same
application services, so validation, permissions and domain errors are the
same: projects (members, board, stats, activity), issues (search, workflow,
assignee, release, links, comments, history), comments, releases and users.
Projects and issues can be addressed by id or key (WEB, WEB-12).

- Swagger UI at /api/docs, document at /api/docs-json and apps/api/openapi.json
  (rewritten by `nx serve api`, like schema.gql) for client generation.
- Every route but health/auth/webhook needs a Pireon token: the document
  declares Pireon OAuth2 (authorization code + PKCE, password) through the
  /api/auth/token proxy, and a bearer scheme for apps with their own client.
- configureApp() shares main.ts setup with the e2e, which now also covers REST.
- SWAGGER_ENABLED / OPENAPI_FILE config; docs 03/05 and library READMEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:26:47 +02:00
parent b2c9be72c3
commit 341241a52f
36 changed files with 7386 additions and 42 deletions

View File

@@ -1,11 +1,10 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import {
IdentityProvider,
InvalidAccessTokenError,
} from '@task-stream/api/identity/application';
import { User, UserFactory } from '@task-stream/api/identity/domain';
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
import { Client, createClient } from 'graphql-ws';
import WebSocket from 'ws';
@@ -16,9 +15,11 @@ process.env['MONGO_URL'] ??= 'mongodb://localhost:27017/taskstream_e2e';
process.env['REDIS_URL'] ??= 'redis://localhost:6379';
process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
process.env['GRAPHQL_SCHEMA_FILE'] = '';
process.env['OPENAPI_FILE'] = '';
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
const { AppModule } = require('../src/app/app.module');
const { configureApp } = require('../src/app/configure');
/** Pireon is replaced by fixed tokens: the rest of the stack is real. */
const users = new UserFactory();
@@ -71,6 +72,25 @@ describe('task-stream (e2e)', () => {
return body.data;
}
/** REST call under /api; returns status and parsed body. */
async function rest<T = Record<string, any>>( // eslint-disable-line @typescript-eslint/no-explicit-any
token: string | null,
method: string,
path: string,
body?: object,
): Promise<{ status: number; body: T }> {
const response = await fetch(`${url}/api${path}`, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
}
/** The read model is eventually consistent: poll until it catches up. */
async function eventually<T>(
read: () => Promise<T>,
@@ -123,10 +143,7 @@ describe('task-stream (e2e)', () => {
.useValue(fakePireon)
.compile();
app = moduleRef.createNestApplication({ rawBody: true });
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true }),
);
app.useGlobalFilters(new DomainErrorFilter());
configureApp(app);
await app.listen(0);
url = await app.getUrl();
url = url.replace('[::1]', 'localhost');
@@ -405,4 +422,139 @@ describe('task-stream (e2e)', () => {
expect(anonymous.received).toHaveLength(0);
expect(anonymous.errors).toHaveLength(1);
});
it('exposes the same use cases over REST, documented with OpenAPI', async () => {
// --- The contract other apps generate clients from ---
const docs = await rest(null, 'GET', '/docs-json');
expect(docs.status).toBe(200);
expect(Object.keys(docs.body['paths'])).toEqual(
expect.arrayContaining([
'/api/projects',
'/api/projects/{project}/issues',
'/api/issues/{issue}/status',
'/api/releases/{releaseId}/publish',
'/api/users/me',
]),
);
expect(Object.keys(docs.body['components']['securitySchemes'])).toEqual(
expect.arrayContaining(['pireon-oauth', 'pireon']),
);
// --- Every consumer goes through Pireon's OAuth ---
expect((await rest(null, 'GET', '/projects')).status).toBe(401);
expect((await rest('not-a-token', 'GET', '/projects')).status).toBe(401);
expect((await rest('token-ana', 'GET', '/users/me')).body).toMatchObject({
id: '101',
name: 'Ana (lead)',
});
// --- Project and members, addressed by key ---
const key = 'R' + Date.now().toString(36).slice(-5).toUpperCase();
const created = await rest('token-ana', 'POST', '/projects', {
key,
name: 'REST project',
});
expect(created.status).toBe(201);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
expect(
(
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '103',
role: 'MEMBER',
})
).status,
).toBe(201);
// --- Tickets: create, workflow, relations, comments ---
const invalid = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'NOPE',
title: '',
});
expect(invalid.status).toBe(400);
const be = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Endpoint de cupones',
area: 'BACKEND',
});
const qa = await rest('token-qa', 'POST', `/projects/${key}/issues`, {
type: 'QA',
title: 'Probar cupones',
});
expect(be.body).toMatchObject({ key: `${key}-1`, version: 1 });
const beKey = be.body['key'] as string;
const qaKey = qa.body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${beKey}`),
(r) => r.status === 200,
);
const skip = await rest('token-ana', 'PUT', `/issues/${beKey}/status`, {
status: 'DONE',
});
expect(skip.status).toBe(422);
expect(skip.body).toMatchObject({ code: 'BUSINESS_RULE' });
expect(
(
await rest('token-ana', 'PUT', `/issues/${beKey}/status`, {
status: 'TODO',
})
).status,
).toBe(200);
expect(
(
await rest('token-ana', 'PUT', `/issues/${beKey}/assignee`, {
assigneeId: '103',
})
).status,
).toBe(200);
expect(
(
await rest('token-qa', 'POST', `/issues/${qaKey}/links`, {
type: 'TESTS',
targetIssue: beKey,
})
).status,
).toBe(201);
expect(
(
await rest('token-qa', 'POST', `/issues/${beKey}/comments`, {
body: 'Lo pruebo en cuanto esté en QA',
})
).status,
).toBe(201);
const issue = await eventually(
() => rest('token-ana', 'GET', `/issues/${beKey}`),
(r) => r.body?.['status'] === 'TODO' && r.body?.['links']?.length === 1,
);
expect(issue.body).toMatchObject({
key: beKey,
assignee: { id: '103' },
links: [{ type: 'TESTS', direction: 'INWARD', issueKey: qaKey }],
allowedTransitions: expect.arrayContaining(['IN_PROGRESS']),
});
const search = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?status=TODO,BACKLOG&type=FEATURE`,
);
expect(search.body).toMatchObject({ total: 1, items: [{ key: beKey }] });
const comments = await eventually(
() => rest('token-ana', 'GET', `/issues/${beKey}/comments`),
(r) => r.body?.['total'] === 1,
);
expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } });
// --- Same authorisation as GraphQL ---
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
404,
);
});
});

5176
apps/api/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"@nestjs/graphql": "^13.4.5",
"@nestjs/mongoose": "^11.0.4",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.7",
"@nestjs/typeorm": "^11.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",

View File

@@ -33,4 +33,9 @@ export const configSchema = Joi.object({
GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
// REST + OpenAPI for other apps: Swagger UI at /api/docs, JSON at /api/docs-json.
SWAGGER_ENABLED: Joi.boolean().default(true),
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
});

View File

@@ -0,0 +1,13 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
import { setupOpenApi } from './openapi';
/** Everything main.ts sets on the app, shared with the e2e tests. */
export function configureApp(app: INestApplication): void {
app.setGlobalPrefix('api');
app.enableCors();
app.enableShutdownHooks();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new DomainErrorFilter());
setupOpenApi(app);
}

View File

@@ -5,12 +5,19 @@ import {
ServiceUnavailableException,
} from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import {
ApiOkResponse,
ApiOperation,
ApiServiceUnavailableResponse,
ApiTags,
} from '@nestjs/swagger';
import { Public } from '@task-stream/api/identity/presenters';
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
import Redis from 'ioredis';
import { Connection } from 'mongoose';
import { DataSource } from 'typeorm';
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
@@ -21,6 +28,13 @@ export class HealthController {
@Public()
@Get()
@ApiOperation({ summary: 'API and databases status' })
@ApiOkResponse({
schema: {
example: { status: 'ok', postgres: 'up', mongo: 'up', redis: 'up' },
},
})
@ApiServiceUnavailableResponse({ description: 'A database is down' })
async check() {
const checks = await Promise.allSettled([
this.postgres.query('SELECT 1'),

158
apps/api/src/app/openapi.ts Normal file
View File

@@ -0,0 +1,158 @@
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, 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();
/**
* 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 pireon =
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev';
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
.split(/[\s,]+/)
.filter(Boolean);
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,
},
...(config.get<boolean>('PIREON_PASSWORD_GRANT')
? {
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 = [];
}
}
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; }
`,
});
}

View File

@@ -1,22 +1,19 @@
import { Logger, ValidationPipe } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
import { AppModule } from './app/app.module';
import { configureApp } from './app/configure';
async function bootstrap() {
// rawBody: needed to verify Pireon webhook signatures.
const app = await NestFactory.create(AppModule, { rawBody: true });
app.setGlobalPrefix('api');
app.enableCors();
app.enableShutdownHooks();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new DomainErrorFilter());
configureApp(app);
const port = process.env['PORT'] || 3000;
await app.listen(port);
Logger.log(
`🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`,
);
Logger.log(`📘 REST: http://localhost:${port}/api/docs (OpenAPI)`);
Logger.log(`❤️ Health: http://localhost:${port}/api/health`);
}