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>
325 lines
15 KiB
Markdown
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).
|