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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user