Files
task-stream/docs/03-api.md
Josep Piguillem 341241a52f 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>
2026-10-03 15:26:47 +02:00

325 lines
15 KiB
Markdown

# 3. API
API GraphQL en `/graphql` (HTTP para queries y mutations, WebSocket `graphql-ws` para subscriptions) más unos pocos
endpoints REST bajo `/api`. El esquema completo se genera en `apps/api/schema.gql`.
## 3.1 Modelo de dominio (contexto `tracker`)
```mermaid
classDiagram
class Project {
key: "TS"
name
members: (userId, role)[]
addMember() changeMemberRole() removeMember() updateDetails()
}
class Issue {
key: "TS-42"
type · area · priority · status
assigneeId · releaseId · labels · figmaUrl
links: (type, target)[]
changeStatus() assign() linkTo() unlink() setRelease() updateDetails()
}
class Comment {
body · authorId
edit() delete()
}
class Release {
name · status · targetDate
update() ship() archive()
}
Project "1" --> "*" Issue
Issue "1" --> "*" Comment
Project "1" --> "*" Release
Release "0..1" <-- "*" Issue
Issue "*" --> "*" Issue : links
```
### Roles por proyecto
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
| -------------------------------- | --- | --------------------------------- | -------------------------------------------- |
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
| `ADMIN` | ✅ | ✅ | ✅ |
| `MEMBER` | ✅ | ✅ | ❌ |
| `VIEWER` | ✅ | ❌ | ❌ |
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
el proyecto existe.
### Workflow de un ticket
```mermaid
stateDiagram-v2
[*] --> BACKLOG
BACKLOG --> TODO
BACKLOG --> IN_PROGRESS
BACKLOG --> CANCELLED
TODO --> BACKLOG
TODO --> IN_PROGRESS
TODO --> CANCELLED
IN_PROGRESS --> TODO
IN_PROGRESS --> IN_REVIEW
IN_PROGRESS --> READY_FOR_QA
IN_PROGRESS --> CANCELLED
IN_REVIEW --> IN_PROGRESS
IN_REVIEW --> READY_FOR_QA
IN_REVIEW --> DONE
READY_FOR_QA --> IN_QA
READY_FOR_QA --> IN_PROGRESS
IN_QA --> DONE
IN_QA --> QA_FAILED
QA_FAILED --> IN_PROGRESS
QA_FAILED --> TODO
DONE --> TODO
DONE --> IN_PROGRESS
CANCELLED --> BACKLOG
```
- `QA_FAILED` y `CANCELLED` exigen un **motivo**, que queda en el historial.
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
- `Issue.allowedTransitions` en GraphQL devuelve los estados permitidos desde el actual.
### Otras reglas
- **Proyecto**: clave de 2 a 10 caracteres alfanuméricos que empieza por letra y es única (los tickets se numeran
`CLAVE-1`, `CLAVE-2`…).
- **Asignación**: solo a miembros del proyecto. Para añadir miembros, el usuario debe existir y estar activo en Pireon.
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
### Eventos de dominio
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
| Agregado | Eventos |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
## 3.2 GraphQL
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`).
### Queries
| Query | Devuelve |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro |
| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
| `release(id)` | Una release. Campos calculados: `progress`, `issues` |
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
### Mutations
Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations |
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
| Comentarios | `addComment`, `editComment`, `deleteComment` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
### Subscriptions
| Subscription | Se emite cuando |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `releaseChanged(projectId)` | Cambia una release |
| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto |
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
### Ejemplos
```graphql
mutation {
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
id
key
version
}
}
mutation {
createIssue(input: { projectId: "…", type: BUG, title: "El botón de pagar no responde en Safari", area: FRONTEND, priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
id
key
version
}
}
mutation {
changeIssueStatus(input: { issueId: "…", status: QA_FAILED, reason: "Sigue fallando en Safari 17" }) {
version
}
}
mutation {
linkIssues(input: { issueId: "<FE>", type: DEPENDS_ON, targetIssueId: "<BE>" }) {
version
}
}
query {
project(idOrKey: "SHOP") {
stats {
total
open
byStatus {
key
count
}
byAssignee {
user {
name
}
count
}
}
board {
status
total
issues {
key
title
assignee {
name
}
}
}
releases {
name
status
progress {
percent
}
}
}
issue(idOrKey: "SHOP-1") {
status
allowedTransitions
links {
type
direction
issueKey
}
comments {
items {
author {
name
}
body
}
}
history {
summary
occurredAt
}
}
}
```
```ts
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'ws://localhost:3000/graphql',
connectionParams: { authorization: `Bearer ${token}` },
});
client.subscribe(
{
query: 'subscription($p: ID!) { issueChanged(projectId: $p) { action version issue { key status } } }',
variables: { p: projectId },
},
{ next: console.log, error: console.error, complete: () => {} },
);
```
### Errores
Los errores de dominio llegan como errores GraphQL con `extensions.code`:
| Código | Significado |
| ----------------- | -------------------------------------------------------------- |
| `UNAUTHENTICATED` | Falta el token o Pireon lo rechaza |
| `NOT_FOUND` | No existe o no eres miembro |
| `FORBIDDEN` | No tienes permiso (p. ej. un `VIEWER` modificando) |
| `VALIDATION` | Datos inválidos (clave de proyecto, URL de Figma…) |
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
## 3.3 REST con OpenAPI (para otras apps)
Los mismos casos de uso que GraphQL, como API REST documentada con OpenAPI 3.0, para que otras apps de la plataforma
la consuman o generen un cliente. Son otro adaptador de entrada (`presenters/http`) que llama a los mismos
`*.service.ts`: misma validación, permisos y errores.
| | |
| ----------------- | ----------------------------------------------------------------------------------- |
| Swagger UI | `/api/docs` (con «Authorize» contra Pireon) |
| Documento OpenAPI | `/api/docs-json` (y `/api/docs-yaml`) |
| Fichero exportado | `apps/api/openapi.json`: lo reescribe `nx serve api` al arrancar, como `schema.gql` |
### Autenticación: siempre a través de Pireon
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth` y el
webhook, exigen `Authorization: Bearer <token de Pireon>` (el `AuthGuard` global, igual que en GraphQL). El documento
declara dos esquemas:
- **`pireon-oauth`** (OAuth2): _Authorization Code + PKCE_ contra `oauth.pireon.dev/oauth/authorize` y, si
`PIREON_PASSWORD_GRANT=true`, _password_. El intercambio de tokens va por `POST /api/auth/token` (Pireon no tiene
CORS en `/oauth/token` y el `client_secret` se queda en el servidor). Para usar «Authorize» en Swagger UI, registra
`<api>/api/docs/oauth2-redirect.html` como redirect URI del cliente en Pireon.
- **`pireon`** (Bearer): para una app que ya tiene un token de Pireon, por ejemplo con su propio cliente OAuth.
Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como en la web.
### Recursos
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `…/board`, `…/stats`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
| Releases | `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para
tiempo real, las subscriptions de GraphQL.
### Errores
`{ statusCode, code?, message }`: `400` entrada no válida (`message` es una lista), `401` sin token o token rechazado
por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (reintentar) y `422` regla de negocio (p. ej. una
transición que el workflow no permite).
### Generar un cliente en otra app
```sh
npx openapi-typescript apps/api/openapi.json -o src/task-stream-api.d.ts # tipos
# o un cliente completo: openapi-generator, orval, ng-openapi-gen…
```
Ver también [Autenticación](05-autenticacion.md).