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:
@@ -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