docs: documentação de API para desenvolvedores (GLPI -> Redmine)

Endpoints, payloads, cabeçalhos, mapeamento de campos, impersonation,
idempotência e exemplos completos. Material de treino para a equipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gemini 2026-06-29 22:04:42 -03:00
parent 92d1ac044a
commit 4a285f65ac

256
docs/API.md Normal file
View file

@ -0,0 +1,256 @@
# Documentação de API — Plugin Redmine Integration
> Documento de desenvolvedor. Descreve **o que sai do GLPI** e **como entra no Redmine**:
> gatilhos, chamadas à API REST do Redmine, payloads, cabeçalhos e o mapeamento de campos.
> Base de código: `inc/redmineapi.class.php` (cliente HTTP) e `hook.php` (orquestração).
## Índice
1. [Visão geral](#1-visão-geral)
2. [Autenticação e cabeçalhos](#2-autenticação-e-cabeçalhos)
3. [Gatilhos no GLPI](#3-gatilhos-no-glpi)
4. [Resolução do projeto Redmine](#4-resolução-do-projeto-redmine)
5. [Endpoints do Redmine usados](#5-endpoints-do-redmine-usados)
6. [Mapeamento de campos GLPI → Redmine](#6-mapeamento-de-campos-glpi--redmine)
7. [Impersonation (autoria)](#7-impersonation-autoria)
8. [Idempotência e tabelas de estado](#8-idempotência-e-tabelas-de-estado)
9. [Tratamento de erros](#9-tratamento-de-erros)
10. [Exemplos completos](#10-exemplos-completos)
---
## 1. Visão geral
O plugin é um **cliente da API REST do Redmine**. Ele reage a eventos do GLPI
(hooks) e traduz esses eventos em chamadas HTTP ao Redmine.
```
hooks GLPI HTTP (REST/JSON)
GLPI (chamados) ───────────► Plugin (PHP/cURL) ──────────────────► Redmine
inc/redmineapi.class.php
```
- **Sentido único** (GLPI → Redmine) no fluxo automático. O GLPI é a fonte; o
Redmine recebe issues e lançamentos de tempo.
- A única exceção de leitura é a **Sincronização de Metadados** (Redmine → cache local).
---
## 2. Autenticação e cabeçalhos
Todas as chamadas usam a **chave de API de um administrador** do Redmine
(armazenada cifrada com `GLPIKey` em `glpi_plugin_redmine_configs.api_key`).
Cabeçalhos padrão:
| Cabeçalho | Valor | Quando |
|---|---|---|
| `X-Redmine-API-Key` | `<api_key do admin>` | sempre |
| `Content-Type` | `application/json` | POST/PUT |
| `X-Redmine-Switch-User` | `<login do usuário Redmine>` | impersonation (ver §7) |
- Timeout: **5s** por requisição (evita 504).
- Sucesso = HTTP `2xx`. O corpo é JSON.
---
## 3. Gatilhos no GLPI
| Evento GLPI (hook) | Função | Ação no Redmine |
|---|---|---|
| `item_add` / `item_update` em **TicketTask** | `plugin_redmine_tickettask_*` | cria a issue (1ª vez) + lança o tempo |
| `item_update` em **Ticket** | `plugin_redmine_ticket_update` | atualiza o **status** da issue vinculada |
| Aba **Redmine** em Projeto/Entidade (form) | `front/project.form.php`, `front/entity.form.php` | cria/vincula projeto, define categoria, sincroniza metadados |
**Gate do lançamento de tempo:** o processamento da TicketTask só ocorre quando
`task.state == Planning::DONE` (Feito) **e** `actiontime > 0`. Tarefas não
concluídas permanecem apenas no GLPI.
---
## 4. Resolução do projeto Redmine
Antes de criar a issue, o plugin descobre **em qual projeto Redmine** ela entra,
a partir do chamado:
```
Ticket
├─ vinculado a um Projeto GLPI? ──► glpi_plugin_redmine_projects ─► redmine_project_id
│ (projeto via glpi_projecttasks_tickets ou glpi_itils_projects)
└─ senão ──► entities_id do ticket ─► glpi_plugin_redmine_entities ─► redmine_project_id
(match EXATO da entidade, sem herança de árvore)
```
Sem vínculo (nem projeto nem entidade) → **nada é enviado** (log e retorno).
---
## 5. Endpoints do Redmine usados
### Leitura (Sincronizar Metadados)
| Método | Endpoint | Uso |
|---|---|---|
| GET | `/projects.json?limit=100` | lista de projetos (cache + dropdowns) |
| GET | `/trackers.json` | tipos de tarefa |
| GET | `/issue_statuses.json` | status (alvo do mapa de status) |
| GET | `/enumerations/issue_priorities.json` | prioridades |
| GET | `/enumerations/time_entry_activities.json` | atividades de tempo |
| GET | `/roles.json` | papéis (para auto-membership) |
| GET | `/users.json?limit=100&offset=N` | usuários (cache p/ mapeamento; paginado) |
| GET | `/projects/{id}/issue_categories.json` | categorias (por projeto) |
### Escrita
| Método | Endpoint | Uso |
|---|---|---|
| POST | `/projects.json` | criar projeto Redmine (aba Projeto/Entidade) |
| POST | `/issues.json` | **criar a issue** a partir do chamado |
| PUT | `/issues/{id}.json` | sincronizar **status** da issue |
| POST | `/time_entries.json` | **lançar tempo** da TicketTask |
| POST | `/projects/{id}/memberships.json` | adicionar técnico como membro (auto-membership) |
---
## 6. Mapeamento de campos GLPI → Redmine
### Issue (`POST /issues.json`)
| Campo Redmine (`issue.*`) | Origem no GLPI |
|---|---|
| `project_id` | projeto Redmine resolvido (§4) |
| `subject` | `Ticket.name` (truncado a 255, HTML removido) |
| `description` | `Ticket.content` (HTML removido) |
| `tracker_id` | `config.default_tracker_id` |
| `priority_id` | `config.default_priority_id` |
| `status_id` | `config.status_map[Ticket.status]` |
| `category_id` | `default_category_id` do vínculo (projeto/entidade) |
| `watcher_user_ids` | `config.default_watcher_ids` (array) |
| **autor** | técnico **atribuído** ao chamado → `X-Redmine-Switch-User` (§7) |
### Lançamento de tempo (`POST /time_entries.json`)
| Campo Redmine (`time_entry.*`) | Origem no GLPI |
|---|---|
| `issue_id` | issue da issue do chamado |
| `hours` | `TicketTask.actiontime / 3600` (segundos → horas decimais) |
| `comments` | `TicketTask.content` (HTML removido) |
| `activity_id` | `config.default_activity_id` |
| **autor** | quem lançou a task (`TicketTask.users_id`) → `X-Redmine-Switch-User` |
### Status (`PUT /issues/{id}.json`)
| Campo | Origem |
|---|---|
| `status_id` | `config.status_map[Ticket.status]` (no `item_update` do Ticket) |
### Conversões importantes
- **Tempo:** GLPI grava `actiontime` em **segundos** (int); Redmine espera **horas decimais**. `5400 → 1.5`.
- **Subject:** limite de 255 chars no Redmine → truncado.
- **HTML:** descrições do GLPI (TinyMCE) passam por `RichText::getTextFromHtml`.
---
## 7. Impersonation (autoria)
Por padrão, tudo criado via API nasce como o **admin** (dono da chave). Para
atribuir o autor correto, o plugin usa o cabeçalho **`X-Redmine-Switch-User: <login>`**.
**Pré-requisito:** o usuário impersonado precisa ser **membro do projeto Redmine**
com um papel — senão o Redmine retorna **403**. Por isso, antes de impersonar, o
plugin chama `ensureProjectMembership()` (adiciona o técnico com `config.default_role_id`).
**Mapeamento GLPI → usuário Redmine** (`resolveRedmineUser`):
1. **Override manual** (`glpi_plugin_redmine_usermap`), se houver.
2. **Auto-match por e-mail** (e-mail do usuário GLPI = `mail` do usuário Redmine, do cache).
Sem correspondência → cai no admin (fallback, §9).
---
## 8. Idempotência e tabelas de estado
| Tabela | Garante |
|---|---|
| `glpi_plugin_redmine_tickets` (`tickets_id` único) | **1 issue por chamado** (reuso) |
| `glpi_plugin_redmine_tasks` (`tickettasks_id` único) | tempo lançado **1× por task** (sem duplicar) |
| `glpi_plugin_redmine_projects` / `_entities` | vínculo GLPI ↔ projeto Redmine |
| `glpi_plugin_redmine_users` / `_usermap` | cache de usuários + overrides de mapeamento |
Editar horas de uma task já lançada **não** re-sincroniza. Reverter `Feito`→`A fazer`
**não** apaga o time entry.
---
## 9. Tratamento de erros
- **Impersonation falha** (403/412 — usuário sem par no Redmine ou não-membro):
o plugin **refaz a chamada como admin** (não perde a issue/tempo) e registra
aviso em `files/_log/redmine*`.
- **API inacessível / chave inválida:** `syncMetadata()` retorna `false`; criação
de issue/tempo é abortada com log.
- Todo erro HTTP `>= 400` é logado via `Toolbox::logError`.
---
## 10. Exemplos completos
### Criar issue (com impersonation)
```http
POST /issues.json HTTP/1.1
X-Redmine-API-Key: <admin_key>
X-Redmine-Switch-User: joao.silva
Content-Type: application/json
{
"issue": {
"project_id": 3,
"subject": "Erro no servidor de e-mail",
"description": "Cliente relata falha no envio.",
"tracker_id": 1,
"priority_id": 2,
"status_id": 1,
"category_id": 7,
"watcher_user_ids": [5]
}
}
```
Resposta `201`: `{ "issue": { "id": 42, "author": { "id": 9, "name": "João Silva" }, ... } }`
### Lançar tempo (com impersonation)
```http
POST /time_entries.json HTTP/1.1
X-Redmine-API-Key: <admin_key>
X-Redmine-Switch-User: joao.silva
Content-Type: application/json
{
"time_entry": {
"issue_id": 42,
"hours": 1.5,
"activity_id": 1,
"comments": "Reinício do serviço e validação."
}
}
```
### Sincronizar status da issue
```http
PUT /issues/42.json
X-Redmine-API-Key: <admin_key>
Content-Type: application/json
{ "issue": { "status_id": 5 } }
```
### Auto-membership (antes de impersonar)
```http
POST /projects/3/memberships.json
X-Redmine-API-Key: <admin_key>
Content-Type: application/json
{ "membership": { "user_id": 9, "role_ids": [3] } }
```
---
### Resumo do fluxo end-to-end (suporte recorrente / entidade)
1. Técnico marca a TicketTask como **Feito** no GLPI.
2. Hook resolve o projeto Redmine pela **entidade** do chamado.
3. (1ª vez) `POST /issues.json` cria a issue — autor = técnico atribuído (impersonation + auto-membership).
4. `POST /time_entries.json` lança as horas — autor = quem lançou a task.
5. Ao mudar o status do chamado, `PUT /issues/{id}.json` reflete no Redmine.