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:
@@ -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
5176
apps/api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
13
apps/api/src/app/configure.ts
Normal file
13
apps/api/src/app/configure.ts
Normal 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);
|
||||
}
|
||||
@@ -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
158
apps/api/src/app/openapi.ts
Normal 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; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user