From 3df8db146427f917b751dbb024106685a9f5b201 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 21 Oct 2026 11:55:35 -0300 Subject: [PATCH 1/2] docs: adiciona setup orientado a agentes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md com layout, regras que economizam tempo e índice de docs - docs/setup.md: install e testes via Docker, exemplos, CI, versionamento e release - docs/sdk.md: client, endpoints, DSL de models, mutators, exceções e testes - docs/gotchas.md: armadilhas de ambiente, SDK e processo - skill ipag-sdk-php-execution em .claude/skills - CHANGELOG.md iniciando o registro a partir da próxima versão - .gitattributes com export-ignore para os arquivos de agente --- .../skills/ipag-sdk-php-execution/SKILL.md | 427 ++++++++++++++++++ .gitattributes | 5 + CHANGELOG.md | 7 + CLAUDE.md | 96 ++++ docs/gotchas.md | 186 ++++++++ docs/sdk.md | 254 +++++++++++ docs/setup.md | 142 ++++++ 7 files changed, 1117 insertions(+) create mode 100644 .claude/skills/ipag-sdk-php-execution/SKILL.md create mode 100644 .gitattributes create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 docs/gotchas.md create mode 100644 docs/sdk.md create mode 100644 docs/setup.md diff --git a/.claude/skills/ipag-sdk-php-execution/SKILL.md b/.claude/skills/ipag-sdk-php-execution/SKILL.md new file mode 100644 index 0000000..3dc46f2 --- /dev/null +++ b/.claude/skills/ipag-sdk-php-execution/SKILL.md @@ -0,0 +1,427 @@ +--- +name: ipag-sdk-php-execution +description: > + Use este skill sempre que uma task iPag for executada neste projeto + (ipag-sdk-php, o SDK PHP público da API iPag): bug fix em model ou endpoint, + campo ou endpoint novo, compatibilidade de versão de PHP, release do SDK. + Cobre o ciclo completo, do pickup no Jira até a tag publicada. Inclui as + particularidades deste repo: ele é público no GitHub (nada interno em + branch, commit, PR, teste ou changelog), o host não tem PHP (tudo via + Docker) e a correção só chega ao consumidor depois de tag e de atualização + da dependência do lado dele. + Triggers: "pegar essa task", "bora começar", "vamos implementar", + "executar a IPAG-XXXX", "corrigir esse bug no SDK", "adicionar campo no + model", "adicionar endpoint", "publicar versão do SDK", "abrir PR", + "finalizar a task". +--- + +# Skill — Execução de tasks no ipag-sdk-php + +Equivalente, para **este** projeto, do skill genérico de execução iPag. É +autossuficiente e não depende de outro skill estar carregado. + +Contexto técnico não se repete aqui: está em `CLAUDE.md`, `docs/setup.md`, +`docs/sdk.md` e `docs/gotchas.md` (caminhos relativos à raiz do repo). Leia o +`docs/gotchas.md` antes de mexer em dependências ou release. + +--- + +## O que define tudo: repo público e entrega por tag + +| | | +|---|---| +| Host | **GitHub** `ipagdevs/ipag-sdk-php`, **público** | +| Branch principal | `master` (**nunca** `develop`: ver gotchas) | +| PR | GitHub. O `gh` **não está instalado** neste host e não há MCP do GitHub, então devolva o link. | +| Distribuição | Packagist, a partir das **tags**. O `composer.json` não tem `version`. | +| Consumidores | integrações de lojistas via Composer e o módulo Magento 2 (`ipagdevs/magento2`), que fixa a versão exata | + +Duas consequências valem para todos os passos: + +1. **Nada interno vai para o repo.** Número de task Jira, link do Jira, nome + ou ID de estabelecimento, CPF/CNPJ ou nome de cliente final e trecho de log + de produção não entram em branch, commit, PR, issue, teste, exemplo ou + `CHANGELOG.md`. O detalhe interno fica no Jira, que é privado. +2. **Merge não é entrega.** A correção só existe para o consumidor depois da + tag (Passo 8), e só chega a ele quando ele atualiza a dependência. + +--- + +## Passo 1 — Pegar a task no Jira + +Via MCP Atlassian, antes de tocar em código: + +1. **Status em andamento.** Se estiver em "To Do", transicione + (`getTransitionsForJiraIssue` → `transitionJiraIssue`). +2. **Data de início.** Preencha com a data atual se estiver vazia. +3. **Story points.** Estime e preencha se não houver. A escala é **Fibonacci: + 1, 3, 5, 8, 13**, nunca 2. + + Para os dois campos, confirme o field id via `getJiraIssue` + (`fields: ["*all"]`) antes de gravar. Não assuma o id. +4. **Leia as issues vinculadas.** Task de bug costuma nascer de um caso de + suporte em outro projeto, e é lá que estão a reprodução, os logs e quem + precisa ser avisado no fim (Passo 8). + +**Se o MCP Atlassian estiver fora do ar**, peça ao usuário o export da task (e +das vinculadas) num arquivo e siga com ele. O que seria gravado no Jira +(status, story points, comentários) vira texto pronto, que você devolve ao +usuário ou grava quando o MCP voltar. Não trave a execução por isso. + +Task tipo `[Review]`: ver "Casos especiais", sem seguir os passos 2 a 8. + +--- + +## Passo 2 — Classificar a task + +| Tipo de task | Onde mexe | Release | +|---|---|---| +| Bug em model ou mutator | `src/Model/**`, `tests/Model/**` | PATCH | +| Bug em endpoint ou HTTP | `src/Endpoint/**`, `src/Core/**`, `src/Http/**`, `tests/Endpoint/**` | PATCH | +| Compatibilidade de PHP | onde o CI acusar | PATCH | +| Campo novo em model | model, teste, exemplo, `README.md` | MINOR | +| Endpoint ou método público novo | endpoint, models, `IpagClient`, testes, exemplo, `README.md` | MINOR | +| Quebra de compatibilidade | **alinhe com o usuário antes** | MAJOR | +| Só docs ou setup de agente | `CLAUDE.md`, `docs/`, `.claude/` | nenhuma | + +Uma correção de bug que **muda o valor serializado** (ex.: o documento deixa +de ser completado com zeros) continua sendo PATCH, mas registre no +`CHANGELOG.md` o que muda para quem dependia do comportamento antigo. + +Confirme o estado antes de começar: + +```bash +git status -sb +git fetch origin && git log --oneline -1 origin/master +# o vendor/ bate com o lock? (só lê; "0 installs, 0 updates" = em dia) +docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ + -v "$PWD":/app:ro -w /app composer:2 install --dry-run --no-interaction +``` + +Se o `vendor/` estiver atrás do lock, ou com dono `root`, reinstale antes de +confiar em qualquer resultado de teste ([gotchas](../../../docs/gotchas.md#vendor-e-phpunitcache-com-dono-root)). + +--- + +## Passo 3 — Criar a branch + +```bash +git checkout master && git pull origin master +git checkout -b {tipo}/IIP-{issue}-{descricaoEmCamelCase} +``` + +**O `{issue}` não é o número da task Jira.** O repo é público, e o número +interno não vai para ele. Por isso o prefixo é `IIP-{n}` ("**I**pag +**I**mprovement **P**urpose"), onde `{n}` é o número de uma **issue pública +no GitHub** (`ipagdevs/ipag-sdk-php/issues/{n}`) que descreve o propósito. +É a mesma convenção do módulo Magento 2, adotada em 2026-09 pelo mesmo motivo. + +Prefixos: `fix/` e `feat/` para código, e `docs/` ou `chore/` para o que não +muda o pacote. O histórico tem `feature/IPAG-6787-...` e +`feature/minorFixesPR8`, que são anteriores à convenção e **não** devem ser +seguidos. + +Sem `gh` nem MCP do GitHub, **o usuário cria a issue manualmente**: + +1. Gere título e corpo em Markdown, **com conteúdo público**: o comportamento + do SDK, um exemplo com dado sintético e o esperado contra o obtido. Sem + cliente, sem estabelecimento, sem log de produção e sem link do Jira. +2. Devolva o texto, ou a URL já preenchida: + `https://github.com/ipagdevs/ipag-sdk-php/issues/new?title=...&body=...` + (URL-encoded). +3. Peça o número de volta e só então crie a branch (ou renomeie, com + `git branch -m`, a que já estiver em uso). + +### Consolidação por propósito + +Quando várias tasks Jira formam um propósito único, use **uma** branch e +**uma** issue para o propósito, acumulando os commits de todas. Antes do PR, +squashe num commit só (o GitHub deste repo faz merge commit, não squash, então +o que estiver no PR entra na `master` como está): + +```bash +git checkout master && git pull origin master +git checkout -b {tipo}/IIP-{n}-{proposito} +git merge --squash +git commit -m "..." +git diff origin/master...HEAD --stat # só o que é deste propósito? +``` + +Propósitos diferentes vão em PRs diferentes, mesmo que nasçam da mesma task. +Por exemplo, setup de agente e correção de bug: o PR de release deve conter só +o que entra no release. + +--- + +## Passo 4 — Planejar antes de codar + +Para tasks não triviais, alinhe a abordagem com o usuário antes de escrever +código. Depois, **antes de executar**, poste o plano aprovado como comentário +na task Jira (`addCommentToJiraIssue`): causa raiz, abordagem, bump previsto +(PATCH/MINOR/MAJOR) e como verificar. O comentário é privado e pode ter os +detalhes do caso real. + +Se a task pede "revisar se outros lugares têm a mesma lógica", faça o +levantamento **no plano**, com a lista de arquivos, e não descubra no meio da +implementação. Ex.: a validação de documento com `Assert ... or() ...` aparece +em `Customer`, `Holder` e `Seller` ([gotchas](../../../docs/gotchas.md#or-do-assert-prioriza-o-lado-direito)). + +--- + +## Passo 5 — Implementar + +- **Sintaxe de PHP 7.4.** A lista do que não pode está em + [gotchas](../../../docs/gotchas.md#o-piso-é-o-php-74-os-polyfills-são-só-de-função). +- **Não rode `composer update` nem `composer require`** sem que a task peça uma + mudança de dependência. Se o lock mudar só no `plugin-api-version`, reverta. +- **A semântica do campo vem de [developers.ipag.com.br](https://developers.ipag.com.br)** + (tipo, formato, máscara, default), não do SDK. +- **Siga o padrão do código vizinho:** os mutators, os accessors tipados, os + models v2 que filtram nulos e o ID na query. Como adicionar endpoint ou + campo está em [sdk.md](../../../docs/sdk.md#adicionar-um-endpoint). +- **Arquivos novos com LF.** Não normalize CRLF de arquivos existentes na + mesma PR. +- **O `README.md` acompanha a API pública:** método, endpoint ou campo novo + ganha uma seção no mesmo PR. +- **Commits:** semânticos, em PT-BR e no presente (`fix: corrige ...`, + `feat: adiciona ...`, `docs:`, `chore:`, `test:`). Corpo em bullets quando + houver mais de uma mudança. **Sem** número de task Jira e **sem** o trailer + `Co-Authored-By: Claude ...`, que o time não usa. + +--- + +## Passo 6 — Testar + +Este repo **tem** suíte real (PHPUnit 9.6, sem rede) e CI em PHP 7.4, 8.3 e +8.4. A tarefa só está pronta com as três versões verdes **localmente**: + +```bash +for v in 7.4 8.3 8.4; do + docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app -w /app php:$v-cli vendor/bin/phpunit || break +done +``` + +Arquivo ou teste único: `... vendor/bin/phpunit tests/Model/CustomerTest.php` +ou `--filter `. Os outros comandos estão em +[setup.md](../../../docs/setup.md#comandos). + +**Em bug, reproduza antes de corrigir.** + +1. Escreva o teste que falha, com dado sintético que tenha a mesma + característica do caso real. +2. Veja-o falhar e só então corrija. + +Para uma reprodução rápida fora da suíte, use um script avulso montado +read-only, que não suja o repo: + +```bash +docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app:ro -v :/s:ro \ + php:7.4-cli php /s/repro.php /app # o script faz require "$argv[1]/vendor/autoload.php" +``` + +O que os testes precisam cobrir: + +- **Model:** construtor, setter, getter e `jsonSerialize()`, com o caso feliz, + os casos de borda pedidos pela task e o erro de validação + (`MutatorAttributeException`/`\TypeError`). +- **Endpoint:** se a mudança toca rota, verbo, query ou body, verifique a + **request** com o middleware de histórico do Guzzle + ([sdk.md § Testes](../../../docs/sdk.md#testes)). Os testes existentes não + fazem isso, e uma rota errada passaria no CI. +- **Dados:** CPF/CNPJ sintéticos válidos, gerados pelo dígito verificador. + Nunca o documento do caso real + ([gotchas](../../../docs/gotchas.md#dado-de-produção-não-entra-no-repo)). + +**Validação contra a API** (quando a task espera comportamento da API, ex. +"a API retorna o documento com a máscara X"): + +1. Rode um exemplo de `examples/` contra a **sandbox**, com credenciais + editadas localmente em `examples/config.php`. +2. Desfaça a edição antes de qualquer `git add` (`git diff --quiet examples/config.php`). +3. Sem credenciais de sandbox, diga isso explicitamente e deixe a validação + pendente. Não a dê como feita com base só no teste unitário. + +Registre o resultado como comentário na task Jira **quando houver consenso** +de que a implementação está finalizada, não no meio de uma iteração. + +### Índice por sintoma + +O `docs/gotchas.md` é organizado por causa. Abaixo está o mesmo conteúdo +indexado pelo **sintoma**: + +- **A suíte inteira falha no PHP 8.4 com `Errors: 461` e deprecation em + `sebastian/*`:** o `vendor/` está mais velho que o lock. Reinstale. +- **`Permission denied` no `composer install`:** o `vendor/` tem dono `root`, + de um container antigo. +- **O teste passa, mas o endpoint chama a rota errada:** os testes não + verificam a request. +- **Passa no 7.4 e falha no 8.x:** é deprecation virando erro (`null` em + função interna, parâmetro implicitamente anulável). +- **Falha no 7.4 e passa no 8.x:** sintaxe de PHP 8 no código. +- **`getStatusCode()` vem `null` na `HttpClientException`:** use `getCode()`. +- **`TypeError` num setter ou `"1"` rejeitado:** a tipagem do schema é + estrita. +- **`null` no JSON enviado:** o model é v1, que não filtra nulos. +- **O documento sai diferente do que entrou (zeros a mais, CPF virando + CNPJ):** é o `str_pad` e a precedência do `or()` em `kubinyete/assertation`. +- **`Expected value from mutator ...` ou `ArgumentCountError` ao setar um + atributo:** o nome do atributo colide com um método do model. +- **`The environment must be valid`:** o ambiente está fora da whitelist exata + (ex. `http://api.ipag.test`). +- **`The "-r" option does not exist` ao apagar o `vendor/`:** na imagem + `composer:2`, `rm` é alias de `composer remove`. Use `php:7.4-cli rm -rf ...`. + +Lista completa em `docs/gotchas.md`. + +--- + +## Passo 7 — Abrir PR + +```bash +git diff origin/master...HEAD --stat # escopo: só este propósito +git diff --quiet examples/config.php # sem credencial +git push -u origin # imprime o link .../pull/new/ +``` + +- **Base: `master`.** O PR roda o CI nas três versões. +- **Devolva ao usuário** o link de criação que o `git push` imprime, com + sugestão de título e descrição. Não dá para criar via CLI ou MCP. +- **Descrição pública:** + - o que muda e por quê, com exemplo sintético; + - como foi testado; + - bump previsto; + - `Relates to #{n}`, com a issue pública. Use `Closes #{n}` só se o PR + encerra o propósito inteiro. + + Sem link do Jira e sem dado do caso real. +- **Não abra o PR por conta própria:** espere o usuário sinalizar que o + propósito está pronto. +- **O `CHANGELOG.md` vai no próprio PR** (Passo 8, item 1). Assim o review + cobre o texto do release. + +--- + +## Passo 8 — Publicar a versão + +Toda task de código (`fix/`, `feat/`) termina em release, a não ser que o +usuário decida acumular mudanças para um release conjunto. O processo completo +está em [setup.md § Versionamento e release](../../../docs/setup.md#versionamento-e-release). +O resumo: + +1. **No PR**, nova entrada no topo do `CHANGELOG.md`: + `## vX.Y.Z - AAAA-MM-DD`, com bullets em PT-BR escritos para o + **consumidor** (o que muda no uso, não o que mudou no código). +2. **Depois do merge**, crie uma tag **anotada** no merge commit da `master`. + **O push da tag publica para todos os consumidores, então só faça com o ok + explícito do usuário:** + + ```bash + git checkout master && git pull origin master + git log -1 --oneline # é o merge commit do PR? + git tag -a vX.Y.Z -m "Release vX.Y.Z" + git push origin vX.Y.Z + ``` + + Não repita os desvios do histórico (tag sem `v`, lightweight, fora de merge + commit). Tag publicada não se move. +3. **GitHub Release:** devolva ao usuário o link + `https://github.com/ipagdevs/ipag-sdk-php/releases/new?tag=vX.Y.Z` com o + texto da entrada do `CHANGELOG.md`. +4. **Packagist:** confira se a versão apareceu em + `https://packagist.org/packages/ipagdevs/ipag-sdk-php`. Se não aparecer, + avise o usuário. Atualizar lá exige acesso de mantenedor. +5. **Avise quem depende:** + - Se a task nasceu de um caso de suporte, prepare o aviso: versão, o que + corrige e o comando de atualização + (`composer require ipagdevs/ipag-sdk-php:^X.Y.Z`). Poste-o como + comentário na issue de origem, após confirmar com o usuário. + - Se a correção importa para o módulo Magento 2, registre que ele fixa a + versão exata e precisa de PR próprio no repo dele (ver "Casos + especiais"). + +--- + +## Padrão de comentários no Jira + +Use `addCommentToJiraIssue` em **Markdown** (`##`, `-`, `` `código` ``, +`**negrito**`), com `contentFormat: "markdown"`. Nunca misture wiki markup +(`h2.`, `{{...}}`, `*bold*`) com `contentFormat: markdown`, porque sai +literal. Para corrigir um comentário já postado, reenvie com o mesmo +`commentId`. + +Comentário de auditoria prematuro (postado antes do consenso) se **apaga** e +dá lugar ao final. Não empilhe um novo "que supera o anterior". + +--- + +## Casos especiais + +### Task só de docs ou setup de agente +Não gera release nem entrada no `CHANGELOG.md`. Um PR com prefixo `docs/` ou +`chore/`. Esses arquivos ficam fora do pacote publicado pelo `export-ignore` +do `.gitattributes`. Se criar outro arquivo que não deve ir para o Packagist, +inclua-o lá. + +### Atualizar o consumidor (módulo Magento 2) +O módulo fixa a versão exata do SDK, então a correção não chega sozinha. +Atualizá-lo é **outra task, em outro repo**, com o skill de execução do +ambiente Magento. Ao propor isso, liste o que muda entre a versão que ele fixa +e a nova (`git log --oneline ..vX.Y.Z`), porque o salto pode trazer +releases intermediários. + +### Task tipo Review +O entregável é um único comentário de review na própria task, sem alterar +código. Diffe contra o merge-base, nunca direto contra a `master`: + +```bash +git fetch origin +base=$(git merge-base origin/master origin/) +git diff $base..origin/ +``` + +Rode a suíte nas três versões na branch revisada. Poste o review em Markdown, +estruturado por severidade: ✅ Verificado OK / 🔴 Pontos a resolver / +🟡 Arquitetura (não bloqueante) / ⚪ Nits. Confira também se a branch respeita +"nada interno no repo". + +### Código vindo da `develop` +A `develop` diverge da `master` e tem accessors que colidem com ela. Se algo +de lá for pedido, traga por cherry-pick para uma branch nova a partir da +`master`, resolvendo a colisão de nomes no plano. Nunca faça merge da +`develop`. + +--- + +## Manter este skill vivo + +Gotcha novo descoberto durante a execução (comando, permissão, padrão de +dados, armadilha de infra) entra **na mesma PR da task**. Vai aqui se for sobre +*como executar tasks*, e em `docs/gotchas.md` se for sobre *como o SDK se +comporta*. Escreva em termos públicos: o repo é aberto. + +--- + +## Checklist antes de considerar a task pronta + +- [ ] A task está "In Progress", com data de início e story points desde o começo (ou o texto ficou pendente para o usuário, se o MCP estiver fora)? +- [ ] As issues vinculadas foram lidas (reprodução e quem avisar no fim)? +- [ ] O `vendor/` foi conferido contra o lock antes de confiar nos testes? +- [ ] A branch saiu da `master` e segue `{tipo}/IIP-{issue}-{desc}`, com issue pública criada? +- [ ] Se a task é uma entre várias do mesmo propósito, os commits estão acumulando na branch única do propósito? +- [ ] O plano foi postado no Jira antes de codar (tasks não triviais), com a lista de lugares afetados? +- [ ] O bug foi reproduzido por um teste que falhava antes da correção? +- [ ] O código é PHP 7.4 e a suíte está verde em 7.4, 8.3 e 8.4 localmente? +- [ ] Se mexeu em rota, verbo, query ou body, há um teste que inspeciona a request? +- [ ] Os dados de teste e exemplo são sintéticos, sem nada do caso real? +- [ ] O `composer.lock` está intocado, ou a mudança nele é intencional? +- [ ] Os commits estão sem número Jira e sem o trailer `Co-Authored-By: Claude ...`? +- [ ] O `examples/config.php` está sem credencial? +- [ ] O `README.md` foi atualizado, se a API pública mudou? +- [ ] A entrada do `CHANGELOG.md` está no PR, com o bump certo (PATCH/MINOR/MAJOR)? +- [ ] Antes do PR, o histórico foi squashado e o escopo conferido contra `origin/master`? +- [ ] O PR referencia a issue pública (`Relates to #{n}`) e não tem nada interno? +- [ ] A validação contra a API foi feita na sandbox, ou declarada como pendente, e registrada no Jira após o consenso? +- [ ] Depois do merge, com o ok do usuário, a tag anotada foi criada no merge commit, com GitHub Release e Packagist conferidos? +- [ ] Quem depende foi avisado (caso de suporte; módulo Magento, se for o caso)? +- [ ] Os links de PR, issue e release foram devolvidos ao usuário? +- [ ] O gotcha novo descoberto na execução foi registrado (aqui ou em `docs/gotchas.md`)? diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9e333f3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Arquivos de desenvolvimento e de agente: ficam no repo, fora do pacote publicado +/.claude export-ignore +/CLAUDE.md export-ignore +/docs export-ignore +/.gitattributes export-ignore diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..38ebb2a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +Todas as mudanças notáveis neste projeto serão documentadas neste arquivo. + +O formato é baseado em [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/spec/v2.0.0.html). + +As versões até `v1.4.1` não têm entrada aqui: consulte as [tags](https://github.com/ipagdevs/ipag-sdk-php/tags) e o histórico de commits. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4c50a69 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +SDK PHP da API iPag, publicado no Packagist como +[`ipagdevs/ipag-sdk-php`](https://github.com/ipagdevs/ipag-sdk-php). É uma +biblioteca, sem aplicação para subir: o trabalho aqui é editar `src/`, testar +com PHPUnit e publicar uma tag. O `README.md` é a documentação **do +consumidor** e não descreve como o SDK funciona por dentro. Isso fica em +`docs/`. + +**O repositório é público.** Tudo o que for commitado (código, testes, +exemplos, mensagens, PRs e estes docs) fica visível para qualquer pessoa. +Número de task interna, dado de estabelecimento e documento ou log de +produção não entram aqui. + +## Layout + +| Caminho | O que é | +|---|---| +| `src/Core/` | `IpagClient` (ponto de entrada e accessors dos endpoints), `Client`, `Endpoint` (base dos recursos), `IpagEnvironment`, `Enums/` (classes com constantes, não enums nativos) | +| `src/Endpoint/` | Uma classe por recurso da API (`$location` define a rota) | +| `src/Model/` | Payloads de **request**, construídos sobre a DSL de `src/Model/Schema/` | +| `src/Http/` | `GuzzleHttpClient` e `Response`, que é o que os endpoints retornam | +| `src/Exception/` | `HttpClientException` (4xx), `HttpServerException` (5xx), `HttpTransferException` (rede), `ParseException` | +| `src/Support/` | Credenciais e providers de antifraude e adquirente (também são `Model`) | +| `src/Mock/` | `IpagClientMock`, usado pelos testes. Vai no pacote publicado. | +| `tests/` | PHPUnit 9.6, sem rede (Guzzle `MockHandler`) | +| `examples/` | Scripts que fazem chamadas **reais** à sandbox (`examples/config.php`) | +| `CHANGELOG.md` | Uma entrada por release, a partir da primeira versão depois de `v1.4.1` | +| `docs/`, `.claude/skills/`, `CLAUDE.md` | Docs para agentes e devs. Ficam fora do pacote publicado (`export-ignore` no `.gitattributes`). | + +## Regras que economizam tempo + +1. **O piso é o PHP 7.4.** O `composer.json` fixa `config.platform.php=7.4.33` + e o CI roda 7.4, 8.3 e 8.4. Não use sintaxe de PHP 8.0 ou mais nova (union + types, `mixed`, `match`, `?->`, named args, promoção no construtor, `enum`, + `readonly`), nem rode `composer update` sem o pin. +2. **O host não tem PHP nem Composer: tudo roda via `docker run` com imagens + oficiais.** O `Dockerfile`, o `docker-compose.yml` e o `.devcontainer/` estão + no `.gitignore` e não existem num clone. Os comandos validados estão em + [setup.md](docs/setup.md#comandos). +3. **Rode a suíte nas três versões antes do PR.** O `phpunit.xml` transforma + deprecation em erro (`convertDeprecationsToExceptions`), e o que passa no 7.4 + pode quebrar no 8.4. +4. **Os testes não verificam a request.** Nenhum teste confere URL, verbo, + query ou body enviados. Um `$location` ou um verbo errado passa no CI. Se a + mudança mexe nisso, o teste precisa inspecionar a request + ([sdk.md](docs/sdk.md#testes)). +5. **Endpoints retornam `Http\Response`, não models.** Os models só montam o + payload de request. Para ler a resposta, use `getData()` ou + `getParsedPath('a.b')`. +6. **Mutator é um método com o nome exato do atributo** (`cpf_cnpj()`, + `zipcode()`). Um atributo novo com o mesmo nome de um método existente vira + mutator sem aviso. +7. **A semântica de um campo vem da documentação da API** + ([developers.ipag.com.br](https://developers.ipag.com.br)), nunca do próprio + SDK, que não documenta formato nem default. +8. **Nunca use `origin/develop` como base.** Ela tem commits não mergeados que + colidem com a `master` (os mesmos accessors `subscriptionV2()` e + `paymentLinksV2()` retornando classes diferentes). Todo PR vai para a + `master`. +9. **Quem publica a versão é a tag.** O `composer.json` não tem `version`, e + cada release precisa de entrada no `CHANGELOG.md` e de uma tag anotada no + merge commit ([setup.md](docs/setup.md#versionamento-e-release)). + +## Docs + +| Doc | Vá aqui quando | +|---|---| +| [docs/setup.md](docs/setup.md) | for instalar dependências, rodar testes ou exemplos, entender o CI, versionar ou publicar | +| [docs/sdk.md](docs/sdk.md) | for ler ou alterar o SDK: client, endpoints, DSL de models, mutators, exceções, testes, como adicionar endpoint ou campo | +| [docs/gotchas.md](docs/gotchas.md) | antes de mexer em ambiente ou release, e quando algo não fizer sentido | + +O fluxo de execução de tasks (do pickup ao release) está na skill +[`ipag-sdk-php-execution`](.claude/skills/ipag-sdk-php-execution/SKILL.md). + +Fontes canônicas que os docs **não** replicam: +- a documentação da API ([developers.ipag.com.br](https://developers.ipag.com.br)), para campos e regras; +- `composer.json`/`composer.lock`, para versões; +- `.github/workflows/ci-tests.yml`, para o CI; +- `README.md`, para a API pública do SDK. + +## Estado de referência + +`master` em `103e055` (tag `v1.4.1`), `composer.lock` com Guzzle `7.10.0`, +PHPUnit `9.6.34` e `kubinyete/assertation` `1.0.4`. A suíte tinha 461 testes, +todos passando em PHP 7.4, 8.3 e 8.4. + +Se algo não bater, revalide: + +```bash +git rev-parse --short HEAD && git describe --tags +git status --short +``` + +Ao mudar `composer.json`, `phpunit.xml`, `.github/workflows/*` ou `src/Core/*`, +atualize o doc correspondente. diff --git a/docs/gotchas.md b/docs/gotchas.md new file mode 100644 index 0000000..eea3f54 --- /dev/null +++ b/docs/gotchas.md @@ -0,0 +1,186 @@ +← [CLAUDE.md](../CLAUDE.md) · [setup](setup.md) · [sdk](sdk.md) · **gotchas** + +# Armadilhas + +Só o que não se descobre lendo o código. Os bugs latentes estão em +[sdk.md § Notas de leitura](sdk.md#notas-de-leitura). + +## Ambiente + +### O compose local não existe num clone + +`Dockerfile`, `docker-compose.yml`, `.docker/` e `.devcontainer/` estão no +`.gitignore` (`Dockerfile`, `*docker*`, `.devcontainer`). Onde existem, são +arquivos pessoais: o serviço tem nome de dev, a imagem é PHP 7.4 com Xdebug em +`start_with_request=yes` e o `XDEBUG_CONFIG` usa sintaxe do Xdebug 2. Use os +comandos `docker run` de [setup.md](setup.md#comandos), que funcionam em +qualquer clone. + +### `vendor/` e `.phpunit.cache` com dono `root` + +Um `composer install` rodado como root num container deixa `vendor/` e +`.phpunit.cache/` com dono `root`. Os sintomas são dois: + +- o `composer install` com `-u` falha com `Permission denied`; +- o `vendor/` envelhece sem ninguém perceber. O lock evolui, mas ninguém + consegue reinstalar. + +Com o `vendor/` velho, o PHPUnit antigo emite deprecation no PHP 8.4, e a +suíte inteira cai com `Tests: 461, Assertions: 0, Errors: 461` +(`Implicitly marking parameter ... as nullable` em `sebastian/cli-parser`). Não +é bug do SDK, é o `vendor/`. + +```bash +# o vendor/ bate com o lock? (só lê, nada é alterado) +docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ + -v "$PWD":/app:ro -w /app composer:2 install --dry-run --no-interaction +# "0 installs, 0 updates" = em dia + +# reinstalar: apaga como root (o dono atual) e instala com o seu usuário. +# Use a imagem php: na composer:2, "rm" é alias de "composer remove". +docker run --rm -v "$PWD":/app -w /app php:7.4-cli rm -rf vendor .phpunit.cache +docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ + -v "$PWD":/app -w /app composer:2 install --no-interaction +``` + +O `.phpunit.cache` com dono `root` não quebra nada: o PHPUnit só deixa de +gravar o cache de resultados. + +### `composer.lock` muda sem ninguém pedir + +- Um Composer mais novo reescreve o `plugin-api-version` no lock. Se essa for a + única mudança no lock, reverta (`git checkout composer.lock`). +- `config.platform.php=7.4.33` e o `audit.ignore` do `composer.json` vieram + juntos em `97d439b`, que fez o downgrade dos pacotes para manter o PHP 7.4. + Não remova nenhum dos dois sem uma task que trate disso. Sem o pin, um + `composer update` resolve pacotes que exigem PHP 8, e o job 7.4 do CI quebra. + +### O piso é o PHP 7.4, os polyfills são só de função + +`symfony/polyfill-php80` e `polyfill-php81` trazem **funções** +(`str_contains`, `array_is_list`, que é usada em `SchemaRelationAttribute`), +nunca sintaxe. + +| Não pode | Pode (7.4) | +|---|---| +| union types, `mixed`, retorno `static`, `match`, `?->`, named args, promoção no construtor, `enum`, `readonly`, vírgula final em **lista de parâmetros** | propriedades tipadas, arrow functions, `??=`, vírgula final em **chamada** | + +### Deprecation é erro + +Com `convertDeprecationsToExceptions="true"`, um aviso do PHP 8.x derruba o +teste. As duas classes que já apareceram (corrigidas em `686bd49` e `4db912a`): + +- **`null` passado para função interna** (8.1). Os mutators de data precisam de + `if (is_null($value)) return null;` antes de `\DateTime::createFromFormat`, e + os de string, de `$value ?? ''` antes de `preg_replace`. +- **Parâmetro implicitamente anulável** (8.4). Use `?string $x = null`, nunca + `string $x = null`. + +### 18 arquivos estão com CRLF + +Os arquivos de v2 que entraram em `23bb290` (em `src/`, `tests/` e `examples/`) +estão commitados com CRLF (`git ls-files --eol | grep crlf`). Os demais estão +com LF, e o repo não tem regra de `eol` no `.gitattributes`. Crie arquivos +novos com LF. Normalizar os existentes no meio de outra task gera um diff de +arquivo inteiro e esconde a mudança real: isso merece um PR próprio. + +### `examples/config.php` é versionado + +Para rodar um exemplo é preciso editá-lo com credenciais reais, e ele é +rastreado num repo público. Antes de qualquer `git add`, confira que +`git diff --quiet examples/config.php` não retorna nada. + +## SDK + +### Os testes não veem a request + +Os testes de endpoint enfileiram respostas no `MockHandler` e conferem só o que +voltou. Nenhum deles inspeciona URL, verbo, query ou body. Um `$location` +errado, `_PUT` no lugar de `_POST` ou o ID no path em vez da query passam no +CI. Quando a mudança toca nisso, use o middleware de histórico +([sdk.md § Testes](sdk.md#testes)). + +### `getStatusCode()` é `null` na `HttpClientException` + +O `Endpoint::request()` re-embrulha todo 4xx numa `HttpClientException` nova, +passando `statusCode` e `statusMessage` como `null`. Use `getCode()` ou +`getResponse()->getStatusCode()`. 5xx e erro de rede não passam por isso e +mantêm o status. + +### `headers` no Guzzle derruba o User-Agent + +O `GuzzleHttpClient` junta o default com a config usando um `array_merge` +**raso**. Como o `IpagClient` passa uma chave `headers`, o default +`User-Agent: IPag SDK for PHP` é descartado. O mesmo vale para quem passar +`headers` ao montar o client. + +### `IpagEnvironment::LOCAL` não tem esquema + +A whitelist aceita só o literal `'api.ipag.test'`. Quem passa +`http://api.ipag.test` recebe `UnexpectedValueException`, e sem esquema o +Guzzle trata a URL inteira como path relativo. Nos testes isso não aparece, +porque o `MockHandler` nunca resolve a URL. + +### Models v1 mandam `null` + +O `jsonSerialize()` do `Model` base emite todos os atributos, inclusive os +nulos. Um `update` de `Customer` só com `name` envia os outros 15 campos como +`null`. Os models v2 filtram os nulos, mas os v1 aninhados neles não. + +### `or()` do Assert prioriza o lado direito + +A validação de documento vem de `kubinyete/assertation`. Em +`Assert::value($v)->asCpf(false)->or()->asCnpj(false)->get()`, o `get()` +devolve o resultado do **último** ramo, se ele for válido, e só cai no anterior +se não for. E `asCpf()`/`asCnpj()` completam o valor com zeros à esquerda +(`str_pad` até 11 e até 14) antes de validar, devolvendo o valor **completado**. + +Juntas, as duas coisas fazem com que um valor curto possa sair reclassificado +e com zeros a mais. É o mesmo trecho em `Customer` (`cpf_cnpj`, +`tax_receipt`), `Holder` e `Seller`. `Owner` e `Establishment` usam só o +`asCpf()`. + +### Atributo com nome de método vira mutator + +O `Model::loadMutator()` trata como mutator qualquer método chamável com o +nome do atributo e o chama sem argumentos. Se um atributo tiver o nome de um +método existente (`get`, `set`, `fill`, `jsonSerialize`, um accessor…), o +model quebra no primeiro `get`/`set` desse atributo, com `ArgumentCountError` +ou com `Expected value from mutator '' to be instance of Mutator`. + +### `origin/develop` diverge da `master` + +São 9 commits não mergeados, de 2024-10, com endpoints novos (Account, +Receivable, EstablishmentDispute…). Eles usam o sufixo oposto +(`XxxEndpointV2`) e os **mesmos** accessors `subscriptionV2()` e +`paymentLinksV2()` retornando outras classes, então o merge conflita. Não +branche dela. Se algo de lá for útil, traga por cherry-pick numa task própria. + +### `IpagClientMock` vai para o pacote + +Ele está em `src/Mock/`, então é carregado no autoload dos consumidores. +Mudar a assinatura dele é mudar API pública. + +## Processo + +### Dado de produção não entra no repo + +Task que nasce de um caso de suporte costuma trazer CPF/CNPJ de cliente final, +dados do estabelecimento, IDs de recurso e trechos de log de produção. Nada +disso entra em teste, exemplo, commit, PR, issue ou `CHANGELOG.md`, porque o +repo é público e o histórico não se apaga. Reproduza com documento sintético +válido (gerado pelo dígito verificador) que tenha a **mesma característica** +do caso real, como o mesmo número de zeros à esquerda. + +### Tags irregulares + +`1.4.0` saiu sem `v`, `v1.4.1` é lightweight e `v1.3.1` não está num merge +commit. O padrão a seguir está em +[setup.md § Versionamento e release](setup.md#versionamento-e-release). + +### O README não cobre v2 + +`23bb290` adicionou `paymentLinksV2()` e `subscriptionV2()` sem tocar o +`README.md`. Ele também tem o badge de versão parado em `1.0.0`, a primeira +âncora do índice quebrada e uma seção "Testes" sem comandos. Endpoint ou +método público novo precisa de seção no README no mesmo PR. diff --git a/docs/sdk.md b/docs/sdk.md new file mode 100644 index 0000000..bbaa589 --- /dev/null +++ b/docs/sdk.md @@ -0,0 +1,254 @@ +← [CLAUDE.md](../CLAUDE.md) · [setup](setup.md) · **sdk** · [gotchas](gotchas.md) + +# Como o SDK funciona + +PSR-4: `Ipag\Sdk\` → `src/` e `Ipag\Sdk\Tests\` → `tests/`. + +## Mapa + +``` +IpagClient (src/Core/IpagClient.php) + ├─ IpagEnvironment base URL (LOCAL | SANDBOX | PRODUCTION) + ├─ GuzzleHttpClient Basic auth + header x-api-version + ├─ JsonSerializer + └─ customer(), payment(), paymentLinksV2(), ... -> XxxEndpoint::make() (instância nova a cada chamada) + +XxxEndpoint::create(Model $m) + └─ Endpoint::_POST($m->jsonSerialize(), $query) src/Core/Endpoint.php + └─ Client::request() serializa, loga, chama o Guzzle + └─ Http\Response <- é isto que o consumidor recebe +``` + +## Client e ambiente + +Assinatura: `new IpagClient(string $apiID, string $apiKey, string $environment, +?LoggerInterface $logger = null, string $version = '2')`. + +- **O ambiente é uma whitelist exata.** `IpagEnvironment` só aceita as strings + das constantes `LOCAL` (`'api.ipag.test'`, **sem esquema**), `SANDBOX` + (`'https://sandbox.ipag.com.br'`) e `PRODUCTION` + (`'https://api.ipag.com.br'`). Qualquer outro valor lança + `UnexpectedValueException("The environment must be valid")`, inclusive + `http://api.ipag.test`. +- **Há duas noções de versão, independentes:** + - o header `x-api-version`: é o 5º argumento do construtor (default `'2'`) e + vale para o client inteiro; + - a **rota**: cada classe de endpoint define a sua em `$location`. +- **O logger é PSR-3** (`psr/log` `^1|^2|^3`) e cai em `NullLogger` se for + omitido. Em `debug`, ele loga o **body completo** da request, incluindo + dados de cartão, e o body da resposta. + +## Endpoints + +Cada classe em `src/Endpoint/` estende `Core\Endpoint` e define +`protected string $location` com o **caminho completo** da rota. Os métodos +chamam `_GET/_POST/_PUT/_PATCH/_DELETE($body?, $query, $header, $relativeUrl)`. + +| Família | `$location` | Classes | +|---|---|---| +| Recursos clássicos | `/service/resources/` | Charge, Checkout, Customer, Establishment, FutureTransfer, PaymentLinks, Seller, SellerTransfer, SplitRules, Subscription, SubscriptionPlan, Token, Transaction, Transfer, Voucher, Webhook | +| v2 | `/service/v2/` | PaymentLinksV2, SubscriptionV2 (só create/update), EstablishmentTransaction, EstablishmentPaymentMethods, EstablishmentAntifraud | +| Pagamento | `/service` + relativo (`/payment`, `/consult`, `/capture`, `/cancel`) | Payment | + +Convenções que o código segue: + +- **O ID vai na query, não no path.** `get`, `update` e `delete` passam + `['id' => $id]`, ex.: `PUT /service/v2/payment_links?id=10`. Só + sub-recursos usam segmento (`PaymentLinksV2Endpoint::listTransactions` → + `/{id}/transactions`). +- **Sub-endpoints não aninham o path.** `EstablishmentEndpoint::transaction()` + e `TransferEndpoint::seller()` criam o filho com + `X::make($this->parent, $this->parent)`, então cada filho declara a própria + rota completa. +- **Alguns métodos reescrevem `$this->location` em tempo de execução** + (`SubscriptionEndpoint::unlinkToken` e `_actionsInstallmentPayment`). Isso só + é seguro porque cada accessor do `IpagClient` devolve uma instância nova. +- **Nome de versão 2:** classe `V2Endpoint`, accessor + `V2()` e models em `src/Model/V2/`. Os sub-endpoints de + establishment, que já são v2, não carregam o sufixo. + +## Models + +Os models são **payloads de request**. Cada subclasse de `Model` +(`src/Model/Model.php`) declara o schema e termina com `return $schema->build();`: + +```php +protected function schema(SchemaBuilder $schema): Schema +{ + $schema->string('name')->nullable(); + $schema->int('stock_limit')->nullable(); + $schema->has('address', Address::class)->nullable(); + $schema->hasMany('products', Product::class)->nullable(); + return $schema->build(); +} +``` + +| DSL | O que faz | +|---|---| +| `any`, `string`, `int`, `float`, `bool`, `date($fmt)`, `enum($valores)` | Tipos. `int` rejeita `"1"`, e `float` aceita int mas rejeita string numérica. | +| `has($attr, Classe)`, `hasMany(...)` | Relação com outro model | +| `->nullable()`, `->default($v)`, `->hidden()` | `hidden` tira o atributo do JSON | +| `->array()` / `->list()` | Atributo vira lista do tipo | +| `->limit(n)`, `->truncate(n)`, `->between(min, max)` | Strings | +| `->min()`, `->max()` | Floats | + +- **O construtor recebe um array associativo.** Uma lista (`[0 => ...]`) lança + `\Exception`. +- **Os accessors são escritos à mão e tipados**, ex. + `getX(): ?T { return $this->get('x'); }` e + `setX(?T $x): self { $this->set('x', $x); return $this; }`. Tipo errado num + setter lança `\TypeError`, e há testes que contam com isso. +- **O mutator é um método `protected` com o nome exato do atributo**, que + retorna `new Mutator($getter, $setter)`. O setter recebe + `($value, MutatorContext $ctx)` e falha com `$ctx->raise('msg')` ou + `$ctx->assert($cond, 'msg')`, que lançam `MutatorAttributeException`. Para + validar, o código costuma usar `Kubinyete\Assertation\Assert` (ver + [gotchas](gotchas.md#or-do-assert-prioriza-o-lado-direito)). + `Model::loadMutator` descobre o mutator com `is_callable([$this, $nome])`. +- **Serialização:** `jsonSerialize()` emite **todo** atributo não-`hidden`, + **inclusive `null`**, e serializa as relações recursivamente. + - Os models v2 (`PaymentLinkV2`, `SubscriptionV2`, `Plan`, `PlanTrial`) + sobrescrevem o método para tirar os nulos com + `array_filter(..., fn($v) => !is_null($v))`. + - `CheckoutSettings` e os models v1 aninhados (ex. `Customer` dentro de + `SubscriptionV2`) não sobrescrevem, e mandam `null`. +- **Mutator com data** precisa de guarda para `null` antes de + `\DateTime::createFromFormat`. Sem ela, o PHP 8.1+ emite deprecation, que a + suíte transforma em erro. + +## Respostas + +Nenhum endpoint transforma a resposta em model. `Http\Response` expõe: + +- `getData()`: o array decodificado; +- `getParsedPath('attributes.amount')`: lookup com ponto, a partir da raiz do + body (re-decodifica o body a cada chamada); +- `getBody()`: o body cru; +- `getHeaders()` e `getStatusCode()`. + +Body vazio ou JSON que decodifica para string vira `['data' => ]`. +`Model::parse()` e `tryParse()` existem, mas nenhum endpoint os usa. + +## Exceções + +``` +\Exception + ├─ BaseException + │ ├─ HttpException (abstract) getResponse() getStatusCode() getStatusMessage() getErrors() + │ │ ├─ HttpClientException 4xx (Guzzle ClientException) + │ │ ├─ HttpServerException 5xx (Guzzle ServerException) + │ │ └─ HttpTransferException rede (Guzzle ConnectException) + │ └─ ParseException JSON inválido em JsonSerializer + ├─ SchemaException ── SchemaAttributeParseException, SchemaAttributeSerializeException + └─ MutatorException ── MutatorAttributeException +``` + +O mapeamento Guzzle → SDK fica em `GuzzleHttpClient::request()`. Depois, +`Endpoint::request()` **re-embrulha só a `HttpClientException`**: + +- a mensagem vira `response message: "" (status code: N)`; +- os erros saem de `message`, `error` ou `data` do body e vão para + `getErrors()`; +- `statusCode` e `statusMessage` ficam `null`, e o status passa a estar em + `getCode()` e em `getResponse()->getStatusCode()` + ([gotchas](gotchas.md#getstatuscode-é-null-na-httpclientexception)). + +5xx e erro de rede sobem como vieram. As exceções de schema **não** estendem +`BaseException`, então um `catch (BaseException)` não as pega. + +## Testes + +| Pasta | Base | Padrão | +|---|---|---| +| `tests/Model/` | `PHPUnit\Framework\TestCase` | monta o model por construtor e por setters e confere getters e `jsonSerialize()`. Validação: `expectException(MutatorAttributeException::class)` ou `\TypeError`. | +| `tests/Endpoint/` | `Ipag\Sdk\Tests\IpagClient` | `$this->instanceClient([new Response(200, [], json_encode([...]))])`, depois chama o endpoint e confere `getParsedPath(...)`. Erro: `try/catch`, com `assertInstanceOf(HttpException::class, ...)` e `assertSame(4xx, $e->getCode())`. | +| `tests/Support/` | `TestCase` | credenciais e providers | + +Os fixtures são métodos privados no próprio teste (não há pasta de fixtures). +O `phpunit.xml` é estrito: `failOnRisky`, `failOnWarning`, +`beStrictAboutOutputDuringTests` e deprecation vira exceção. + +**Para verificar a request enviada**, monte o stack com o middleware de +histórico do Guzzle. O `instanceClient()` não expõe isso: + +```php +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Response; +use Ipag\Sdk\Core\IpagEnvironment; +use Ipag\Sdk\Mock\IpagClientMock; + +$history = []; +$stack = HandlerStack::create(new MockHandler([new Response(200, [], '{}')])); +$stack->push(Middleware::history($history)); +$client = new IpagClientMock('test', 'AAA', IpagEnvironment::SANDBOX, '2', ['handler' => $stack]); + +$client->customer()->update($customer, 10); + +$request = $history[0]['request']; +$this->assertSame('PUT', $request->getMethod()); +$this->assertSame('/service/resources/customers', $request->getUri()->getPath()); +$this->assertSame('id=10', $request->getUri()->getQuery()); +$body = json_decode((string) $request->getBody(), true); +``` + +Use `SANDBOX` aqui, não `LOCAL`. Sem esquema, o Guzzle trata +`api.ipag.test/...` inteiro como path, e `getHost()` vem vazio. O +`MockHandler` nunca chega à rede em nenhum dos dois casos. + +**Dados de teste são sintéticos.** Gere CPF e CNPJ válidos pelo dígito +verificador. Nunca use documento, nome ou ID vindos de um caso real (ver +[gotchas](gotchas.md#dado-de-produção-não-entra-no-repo)). + +## Adicionar um endpoint + +O padrão foi tirado do commit que trouxe links de pagamento e assinatura v2 +(`23bb290`): + +1. `src/Endpoint/V2Endpoint.php` com `$location` e métodos que + recebem o model e chamam `$this->_POST($model->jsonSerialize())` (e + equivalentes). +2. Models em `src/Model/V2/`, com `jsonSerialize()` filtrando os + nulos, como os outros v2. +3. Accessor e `use` em `src/Core/IpagClient.php`. +4. `tests/Endpoint/V2EndpointTest.php` **verificando método, path e + query** (snippet acima) e `tests/Model/...Test.php`. +5. `examples/_v2/NN-.php`. +6. Seção no `README.md`. O `23bb290` não fez isso, e é por isso que o README + não tem nada de v2. + +## Adicionar um campo a um model + +O padrão foi tirado de `4f601a6`: + +1. Adicione o atributo ao `schema()`. Confira na documentação da API o tipo, o + formato e se ele é anulável. +2. Escreva o getter e o setter tipados, e o mutator se houver validação ou + normalização. +3. Atualize o `tests/Model/Test.php` (construtor, setter e serialização) + e o exemplo que monta esse model. +4. Antes de nomear o atributo, verifique se ele não colide com um método do + model, porque senão vira mutator. + +## Notas de leitura + +Bugs latentes em caminhos pouco exercitados. Não corrija de passagem: cada um +merece task própria. + +- `SchemaAttribute.php` importa `Ipag\Sdk\Util\StringUtil`, que não existe. O + erro aparece quando o parse de um atributo array falha. +- `MutatorContext::getAttributeRelativeName()` chama `$target->getName()`. O + `Model` base não tem esse método, e nos models que têm (ex. `Customer`) ele + devolve o atributo `name`, não o nome do model. Só é usado quando o contexto + é criado sem o schema do atributo, o que o `Model` não faz hoje. +- `Endpoint::sanitizeErrorMessage()` pode retornar `null`, que vai direto para + `implode`. Isso é um provável `TypeError` no PHP 8 para uma resposta 4xx com + body inesperado. +- Body 4xx que não é JSON lança `ParseException` de dentro do `catch`, no lugar + da `HttpClientException`. +- `IpagClient::IpagClient()` é um método vazio, resto de construtor no estilo + PHP 4. Não é construtor. +- `IpagClientMock` tem uma assinatura diferente da do `IpagClient` + (`version` em 4º, `configGuzzle` em 5º, sem logger) e monta o header Basic + à mão. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..07cf761 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,142 @@ +← [CLAUDE.md](../CLAUDE.md) · **setup** · [sdk](sdk.md) · [gotchas](gotchas.md) + +# Setup e operação + +Biblioteca pura: não tem servidor nem banco. O ciclo é instalar dependências, +rodar o PHPUnit e, quando precisar, rodar um exemplo contra a sandbox. + +## Do zero + +Só precisa de Docker. Os comandos usam as imagens oficiais `composer:2` e +`php:-cli` e rodam com o seu usuário (`-u`), para que `vendor/` não +fique com dono `root` ([gotchas](gotchas.md#vendor-e-phpunitcache-com-dono-root)). + +```bash +git clone git@github.com:ipagdevs/ipag-sdk-php.git && cd ipag-sdk-php + +# dependências: instala exatamente o composer.lock (resolvido para PHP 7.4.33) +docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ + -v "$PWD":/app -w /app composer:2 install --no-interaction + +# suíte nas três versões do CI +for v in 7.4 8.3 8.4; do + docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app -w /app php:$v-cli vendor/bin/phpunit || break +done +``` + +A imagem `composer:2` roda PHP 8.x, mas o `config.platform.php=7.4.33` do +`composer.json` faz o install respeitar o lock resolvido para 7.4. O mesmo +`vendor/` serve para as três versões. + +## Comandos + +Todos a partir da raiz do repo. Troque `7.4` pela versão desejada. + +| Para | Rode | +|---|---| +| Instalar dependências | `docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD":/app -w /app composer:2 install --no-interaction` | +| Suíte inteira | `docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app -w /app php:7.4-cli vendor/bin/phpunit` | +| Um arquivo | `... php:7.4-cli vendor/bin/phpunit tests/Model/CustomerTest.php` | +| Um teste | `... php:7.4-cli vendor/bin/phpunit --filter testShouldCreateCustomerObjectWithConstructorSuccessfully` | +| Saída legível | acrescente `--testdox` | +| Um exemplo | `... php:7.4-cli php examples/customer/00-customer-create.php` (credenciais: ver [Exemplos](#exemplos)) | +| Script avulso | `docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app:ro -v /caminho/do/script:/s:ro php:7.4-cli php /s/x.php` | + +**Cobertura não roda nas imagens oficiais.** Elas não trazem Xdebug nem PCOV. +O CI gera o `clover.xml` com Xdebug. Para ver cobertura, abra o PR e olhe o +relatório do CI. + +**Não troque o `composer install` por `composer update`.** O `update` +re-resolve tudo e, se o pin de plataforma for removido ou contornado, traz +pacotes que exigem PHP 8. Mudança de dependência é decisão de task, não efeito +colateral (ver [gotchas](gotchas.md#composerlock-muda-sem-ninguém-pedir)). + +### Por que `docker run` e não o compose local + +Algumas máquinas têm `Dockerfile`, `docker-compose.yml`, `.docker/php.ini` e +`.devcontainer/`, mas eles estão no `.gitignore` e **não existem num clone**. +Não documente nem dependa deles: um comando que só funciona com esses arquivos +quebra para o próximo dev ou agente. As imagens oficiais cobrem tudo o que a +suíte precisa (as extensões padrão do `php:*-cli` bastam). + +## Exemplos + +`examples//NN-.php` são scripts que fazem chamadas **reais**. +Todos fazem `require_once` de `examples/config.php`, que monta o client com +`'apiID'`, `'apiKey'` e `IpagEnvironment::SANDBOX`. + +Para rodar um exemplo: + +1. Edite `examples/config.php` com credenciais de **sandbox**. +2. Rode-o como na tabela acima. O container tem rede por padrão. +3. **Desfaça a edição antes de commitar.** O arquivo é versionado e o repo é + público. Antes de qualquer `git add`, confira que + `git diff --quiet examples/config.php` não retorna nada. + +Alguns exemplos usam IDs fixos (ex. `examples/subscription_v2/02-*.php`) que +só existem na conta de quem os escreveu. Troque pelos da sua conta. + +## CI + +`.github/workflows/ci-tests.yml` é o único workflow. + +| | | +|---|---| +| Dispara em | push em `master` e `develop`, e PR para `master` | +| Matriz | PHP `7.4`, `8.3` e `8.4` (`ubuntu-latest`, `shivammathur/setup-php`, Xdebug) | +| Instala | `composer install --prefer-dist`, que respeita o lock e o pin de plataforma | +| Roda | `php vendor/bin/phpunit --color --testdox --coverage-clover clover.xml tests` | +| Cobertura | Code Climate, só no job 7.4 e só com o secret `CODE_CLIMATE_ID` | + +Não há lint nem análise estática no CI, nem configuração deles no repo. + +## Versionamento e release + +O `composer.json` **não tem campo `version`**: o Packagist publica cada tag do +GitHub como uma versão. Consumidor com pin conservador (`~1.4.0`, `1.4.*`) +recebe patch sem revisar, então decida o bump pelo **conteúdo** do release: + +| O release contém | Bump | +|---|---| +| quebra de compatibilidade: método público removido ou renomeado, assinatura alterada, mudança de serialização da qual consumidor depende | MAJOR | +| **qualquer feature nova**: endpoint, método, campo de model, enum | **MINOR** | +| só correção, compatibilidade de PHP ou refactor sem efeito observável | PATCH | + +O release são **três artefatos**, e esquecer um deixa o release inconsistente: + +| Artefato | Onde | +|---|---| +| entrada do release | `CHANGELOG.md`, no topo (`## vX.Y.Z - AAAA-MM-DD`, PT-BR), **no mesmo PR da mudança** | +| tag | anotada (`git tag -a`), no **merge commit do PR na `master`**, mensagem `Release vX.Y.Z` | +| GitHub Release | na tag, com o texto da entrada do `CHANGELOG.md` | + +```bash +# só depois do PR mergear na master +git checkout master && git pull origin master +git log -1 --oneline # confirme: é o merge commit do PR +git tag -a vX.Y.Z -m "Release vX.Y.Z" +git push origin vX.Y.Z +``` + +O histórico de tags é irregular, e **não siga** os desvios: +- `1.4.0` saiu sem o `v`; +- `v1.4.1` é lightweight; +- `v1.3.1` aponta para um commit direto, sem PR. + +O padrão é o de `v1.3.0`: tag anotada, com `v`, no merge commit. + +Tag publicada **não se move**. Se um número errado já saiu, corrija no release +seguinte, nunca reescrevendo a tag: quem já resolveu a dependência fixou o +commit no `composer.lock`. + +O `CHANGELOG.md` começou na primeira versão depois de `v1.4.1`. As versões +anteriores só têm a tag e os commits. + +### Chegar ao consumidor é outro passo + +Publicar a tag não atualiza ninguém. O módulo Magento 2 +([`ipagdevs/magento2`](https://github.com/ipagdevs/magento2)) fixa a versão +exata do SDK no `composer.json`, então a correção só chega lá com um PR no +próprio módulo, e as integrações de lojistas só a recebem quando atualizam a +dependência. Se a task nasceu de um caso de cliente, o release só fecha o ciclo +quando quem acompanha o caso é avisado da versão. From cb97404a8fc8c7c2679c5bf8a3eb416b1778b4bf Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 21 Oct 2026 12:02:00 -0300 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20remove=20o=20gotchas.md=20e=20as=20?= =?UTF-8?q?refer=C3=AAncias=20a=20ele?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove docs/gotchas.md - tira os links para ele do CLAUDE.md, docs/setup.md, docs/sdk.md e da skill - remove o índice por sintoma da skill, que espelhava o gotchas.md --- .../skills/ipag-sdk-php-execution/SKILL.md | 57 ++---- CLAUDE.md | 1 - docs/gotchas.md | 186 ------------------ docs/sdk.md | 11 +- docs/setup.md | 6 +- 5 files changed, 19 insertions(+), 242 deletions(-) delete mode 100644 docs/gotchas.md diff --git a/.claude/skills/ipag-sdk-php-execution/SKILL.md b/.claude/skills/ipag-sdk-php-execution/SKILL.md index 3dc46f2..b821d37 100644 --- a/.claude/skills/ipag-sdk-php-execution/SKILL.md +++ b/.claude/skills/ipag-sdk-php-execution/SKILL.md @@ -20,9 +20,8 @@ description: > Equivalente, para **este** projeto, do skill genérico de execução iPag. É autossuficiente e não depende de outro skill estar carregado. -Contexto técnico não se repete aqui: está em `CLAUDE.md`, `docs/setup.md`, -`docs/sdk.md` e `docs/gotchas.md` (caminhos relativos à raiz do repo). Leia o -`docs/gotchas.md` antes de mexer em dependências ou release. +Contexto técnico não se repete aqui: está em `CLAUDE.md`, `docs/setup.md` e +`docs/sdk.md` (caminhos relativos à raiz do repo). --- @@ -31,7 +30,7 @@ Contexto técnico não se repete aqui: está em `CLAUDE.md`, `docs/setup.md`, | | | |---|---| | Host | **GitHub** `ipagdevs/ipag-sdk-php`, **público** | -| Branch principal | `master` (**nunca** `develop`: ver gotchas) | +| Branch principal | `master` (**nunca** `develop`) | | PR | GitHub. O `gh` **não está instalado** neste host e não há MCP do GitHub, então devolva o link. | | Distribuição | Packagist, a partir das **tags**. O `composer.json` não tem `version`. | | Consumidores | integrações de lojistas via Composer e o módulo Magento 2 (`ipagdevs/magento2`), que fixa a versão exata | @@ -99,7 +98,7 @@ docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ ``` Se o `vendor/` estiver atrás do lock, ou com dono `root`, reinstale antes de -confiar em qualquer resultado de teste ([gotchas](../../../docs/gotchas.md#vendor-e-phpunitcache-com-dono-root)). +confiar em qualquer resultado de teste. --- @@ -163,15 +162,14 @@ detalhes do caso real. Se a task pede "revisar se outros lugares têm a mesma lógica", faça o levantamento **no plano**, com a lista de arquivos, e não descubra no meio da -implementação. Ex.: a validação de documento com `Assert ... or() ...` aparece -em `Customer`, `Holder` e `Seller` ([gotchas](../../../docs/gotchas.md#or-do-assert-prioriza-o-lado-direito)). +implementação. --- ## Passo 5 — Implementar -- **Sintaxe de PHP 7.4.** A lista do que não pode está em - [gotchas](../../../docs/gotchas.md#o-piso-é-o-php-74-os-polyfills-são-só-de-função). +- **Sintaxe de PHP 7.4.** A lista do que não pode está na regra 1 do + `CLAUDE.md`. - **Não rode `composer update` nem `composer require`** sem que a task peça uma mudança de dependência. Se o lock mudar só no `plugin-api-version`, reverta. - **A semântica do campo vem de [developers.ipag.com.br](https://developers.ipag.com.br)** @@ -229,8 +227,7 @@ O que os testes precisam cobrir: ([sdk.md § Testes](../../../docs/sdk.md#testes)). Os testes existentes não fazem isso, e uma rota errada passaria no CI. - **Dados:** CPF/CNPJ sintéticos válidos, gerados pelo dígito verificador. - Nunca o documento do caso real - ([gotchas](../../../docs/gotchas.md#dado-de-produção-não-entra-no-repo)). + Nunca o documento do caso real. **Validação contra a API** (quando a task espera comportamento da API, ex. "a API retorna o documento com a máscara X"): @@ -244,35 +241,6 @@ O que os testes precisam cobrir: Registre o resultado como comentário na task Jira **quando houver consenso** de que a implementação está finalizada, não no meio de uma iteração. -### Índice por sintoma - -O `docs/gotchas.md` é organizado por causa. Abaixo está o mesmo conteúdo -indexado pelo **sintoma**: - -- **A suíte inteira falha no PHP 8.4 com `Errors: 461` e deprecation em - `sebastian/*`:** o `vendor/` está mais velho que o lock. Reinstale. -- **`Permission denied` no `composer install`:** o `vendor/` tem dono `root`, - de um container antigo. -- **O teste passa, mas o endpoint chama a rota errada:** os testes não - verificam a request. -- **Passa no 7.4 e falha no 8.x:** é deprecation virando erro (`null` em - função interna, parâmetro implicitamente anulável). -- **Falha no 7.4 e passa no 8.x:** sintaxe de PHP 8 no código. -- **`getStatusCode()` vem `null` na `HttpClientException`:** use `getCode()`. -- **`TypeError` num setter ou `"1"` rejeitado:** a tipagem do schema é - estrita. -- **`null` no JSON enviado:** o model é v1, que não filtra nulos. -- **O documento sai diferente do que entrou (zeros a mais, CPF virando - CNPJ):** é o `str_pad` e a precedência do `or()` em `kubinyete/assertation`. -- **`Expected value from mutator ...` ou `ArgumentCountError` ao setar um - atributo:** o nome do atributo colide com um método do model. -- **`The environment must be valid`:** o ambiente está fora da whitelist exata - (ex. `http://api.ipag.test`). -- **`The "-r" option does not exist` ao apagar o `vendor/`:** na imagem - `composer:2`, `rm` é alias de `composer remove`. Use `php:7.4-cli rm -rf ...`. - -Lista completa em `docs/gotchas.md`. - --- ## Passo 7 — Abrir PR @@ -394,10 +362,9 @@ de lá for pedido, traga por cherry-pick para uma branch nova a partir da ## Manter este skill vivo -Gotcha novo descoberto durante a execução (comando, permissão, padrão de -dados, armadilha de infra) entra **na mesma PR da task**. Vai aqui se for sobre -*como executar tasks*, e em `docs/gotchas.md` se for sobre *como o SDK se -comporta*. Escreva em termos públicos: o repo é aberto. +Aprendizado novo da execução (comando, permissão, padrão de dados) entra **na +mesma PR da task**. Vai aqui se for sobre *como executar tasks*, e no doc +correspondente em `docs/` se for sobre *como o SDK funciona*. Escreva em termos públicos: o repo é aberto. --- @@ -424,4 +391,4 @@ comporta*. Escreva em termos públicos: o repo é aberto. - [ ] Depois do merge, com o ok do usuário, a tag anotada foi criada no merge commit, com GitHub Release e Packagist conferidos? - [ ] Quem depende foi avisado (caso de suporte; módulo Magento, se for o caso)? - [ ] Os links de PR, issue e release foram devolvidos ao usuário? -- [ ] O gotcha novo descoberto na execução foi registrado (aqui ou em `docs/gotchas.md`)? +- [ ] O aprendizado novo da execução foi registrado (aqui ou no doc correspondente em `docs/`)? diff --git a/CLAUDE.md b/CLAUDE.md index 4c50a69..bb7de26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,6 @@ produção não entram aqui. |---|---| | [docs/setup.md](docs/setup.md) | for instalar dependências, rodar testes ou exemplos, entender o CI, versionar ou publicar | | [docs/sdk.md](docs/sdk.md) | for ler ou alterar o SDK: client, endpoints, DSL de models, mutators, exceções, testes, como adicionar endpoint ou campo | -| [docs/gotchas.md](docs/gotchas.md) | antes de mexer em ambiente ou release, e quando algo não fizer sentido | O fluxo de execução de tasks (do pickup ao release) está na skill [`ipag-sdk-php-execution`](.claude/skills/ipag-sdk-php-execution/SKILL.md). diff --git a/docs/gotchas.md b/docs/gotchas.md deleted file mode 100644 index eea3f54..0000000 --- a/docs/gotchas.md +++ /dev/null @@ -1,186 +0,0 @@ -← [CLAUDE.md](../CLAUDE.md) · [setup](setup.md) · [sdk](sdk.md) · **gotchas** - -# Armadilhas - -Só o que não se descobre lendo o código. Os bugs latentes estão em -[sdk.md § Notas de leitura](sdk.md#notas-de-leitura). - -## Ambiente - -### O compose local não existe num clone - -`Dockerfile`, `docker-compose.yml`, `.docker/` e `.devcontainer/` estão no -`.gitignore` (`Dockerfile`, `*docker*`, `.devcontainer`). Onde existem, são -arquivos pessoais: o serviço tem nome de dev, a imagem é PHP 7.4 com Xdebug em -`start_with_request=yes` e o `XDEBUG_CONFIG` usa sintaxe do Xdebug 2. Use os -comandos `docker run` de [setup.md](setup.md#comandos), que funcionam em -qualquer clone. - -### `vendor/` e `.phpunit.cache` com dono `root` - -Um `composer install` rodado como root num container deixa `vendor/` e -`.phpunit.cache/` com dono `root`. Os sintomas são dois: - -- o `composer install` com `-u` falha com `Permission denied`; -- o `vendor/` envelhece sem ninguém perceber. O lock evolui, mas ninguém - consegue reinstalar. - -Com o `vendor/` velho, o PHPUnit antigo emite deprecation no PHP 8.4, e a -suíte inteira cai com `Tests: 461, Assertions: 0, Errors: 461` -(`Implicitly marking parameter ... as nullable` em `sebastian/cli-parser`). Não -é bug do SDK, é o `vendor/`. - -```bash -# o vendor/ bate com o lock? (só lê, nada é alterado) -docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ - -v "$PWD":/app:ro -w /app composer:2 install --dry-run --no-interaction -# "0 installs, 0 updates" = em dia - -# reinstalar: apaga como root (o dono atual) e instala com o seu usuário. -# Use a imagem php: na composer:2, "rm" é alias de "composer remove". -docker run --rm -v "$PWD":/app -w /app php:7.4-cli rm -rf vendor .phpunit.cache -docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ - -v "$PWD":/app -w /app composer:2 install --no-interaction -``` - -O `.phpunit.cache` com dono `root` não quebra nada: o PHPUnit só deixa de -gravar o cache de resultados. - -### `composer.lock` muda sem ninguém pedir - -- Um Composer mais novo reescreve o `plugin-api-version` no lock. Se essa for a - única mudança no lock, reverta (`git checkout composer.lock`). -- `config.platform.php=7.4.33` e o `audit.ignore` do `composer.json` vieram - juntos em `97d439b`, que fez o downgrade dos pacotes para manter o PHP 7.4. - Não remova nenhum dos dois sem uma task que trate disso. Sem o pin, um - `composer update` resolve pacotes que exigem PHP 8, e o job 7.4 do CI quebra. - -### O piso é o PHP 7.4, os polyfills são só de função - -`symfony/polyfill-php80` e `polyfill-php81` trazem **funções** -(`str_contains`, `array_is_list`, que é usada em `SchemaRelationAttribute`), -nunca sintaxe. - -| Não pode | Pode (7.4) | -|---|---| -| union types, `mixed`, retorno `static`, `match`, `?->`, named args, promoção no construtor, `enum`, `readonly`, vírgula final em **lista de parâmetros** | propriedades tipadas, arrow functions, `??=`, vírgula final em **chamada** | - -### Deprecation é erro - -Com `convertDeprecationsToExceptions="true"`, um aviso do PHP 8.x derruba o -teste. As duas classes que já apareceram (corrigidas em `686bd49` e `4db912a`): - -- **`null` passado para função interna** (8.1). Os mutators de data precisam de - `if (is_null($value)) return null;` antes de `\DateTime::createFromFormat`, e - os de string, de `$value ?? ''` antes de `preg_replace`. -- **Parâmetro implicitamente anulável** (8.4). Use `?string $x = null`, nunca - `string $x = null`. - -### 18 arquivos estão com CRLF - -Os arquivos de v2 que entraram em `23bb290` (em `src/`, `tests/` e `examples/`) -estão commitados com CRLF (`git ls-files --eol | grep crlf`). Os demais estão -com LF, e o repo não tem regra de `eol` no `.gitattributes`. Crie arquivos -novos com LF. Normalizar os existentes no meio de outra task gera um diff de -arquivo inteiro e esconde a mudança real: isso merece um PR próprio. - -### `examples/config.php` é versionado - -Para rodar um exemplo é preciso editá-lo com credenciais reais, e ele é -rastreado num repo público. Antes de qualquer `git add`, confira que -`git diff --quiet examples/config.php` não retorna nada. - -## SDK - -### Os testes não veem a request - -Os testes de endpoint enfileiram respostas no `MockHandler` e conferem só o que -voltou. Nenhum deles inspeciona URL, verbo, query ou body. Um `$location` -errado, `_PUT` no lugar de `_POST` ou o ID no path em vez da query passam no -CI. Quando a mudança toca nisso, use o middleware de histórico -([sdk.md § Testes](sdk.md#testes)). - -### `getStatusCode()` é `null` na `HttpClientException` - -O `Endpoint::request()` re-embrulha todo 4xx numa `HttpClientException` nova, -passando `statusCode` e `statusMessage` como `null`. Use `getCode()` ou -`getResponse()->getStatusCode()`. 5xx e erro de rede não passam por isso e -mantêm o status. - -### `headers` no Guzzle derruba o User-Agent - -O `GuzzleHttpClient` junta o default com a config usando um `array_merge` -**raso**. Como o `IpagClient` passa uma chave `headers`, o default -`User-Agent: IPag SDK for PHP` é descartado. O mesmo vale para quem passar -`headers` ao montar o client. - -### `IpagEnvironment::LOCAL` não tem esquema - -A whitelist aceita só o literal `'api.ipag.test'`. Quem passa -`http://api.ipag.test` recebe `UnexpectedValueException`, e sem esquema o -Guzzle trata a URL inteira como path relativo. Nos testes isso não aparece, -porque o `MockHandler` nunca resolve a URL. - -### Models v1 mandam `null` - -O `jsonSerialize()` do `Model` base emite todos os atributos, inclusive os -nulos. Um `update` de `Customer` só com `name` envia os outros 15 campos como -`null`. Os models v2 filtram os nulos, mas os v1 aninhados neles não. - -### `or()` do Assert prioriza o lado direito - -A validação de documento vem de `kubinyete/assertation`. Em -`Assert::value($v)->asCpf(false)->or()->asCnpj(false)->get()`, o `get()` -devolve o resultado do **último** ramo, se ele for válido, e só cai no anterior -se não for. E `asCpf()`/`asCnpj()` completam o valor com zeros à esquerda -(`str_pad` até 11 e até 14) antes de validar, devolvendo o valor **completado**. - -Juntas, as duas coisas fazem com que um valor curto possa sair reclassificado -e com zeros a mais. É o mesmo trecho em `Customer` (`cpf_cnpj`, -`tax_receipt`), `Holder` e `Seller`. `Owner` e `Establishment` usam só o -`asCpf()`. - -### Atributo com nome de método vira mutator - -O `Model::loadMutator()` trata como mutator qualquer método chamável com o -nome do atributo e o chama sem argumentos. Se um atributo tiver o nome de um -método existente (`get`, `set`, `fill`, `jsonSerialize`, um accessor…), o -model quebra no primeiro `get`/`set` desse atributo, com `ArgumentCountError` -ou com `Expected value from mutator '' to be instance of Mutator`. - -### `origin/develop` diverge da `master` - -São 9 commits não mergeados, de 2024-10, com endpoints novos (Account, -Receivable, EstablishmentDispute…). Eles usam o sufixo oposto -(`XxxEndpointV2`) e os **mesmos** accessors `subscriptionV2()` e -`paymentLinksV2()` retornando outras classes, então o merge conflita. Não -branche dela. Se algo de lá for útil, traga por cherry-pick numa task própria. - -### `IpagClientMock` vai para o pacote - -Ele está em `src/Mock/`, então é carregado no autoload dos consumidores. -Mudar a assinatura dele é mudar API pública. - -## Processo - -### Dado de produção não entra no repo - -Task que nasce de um caso de suporte costuma trazer CPF/CNPJ de cliente final, -dados do estabelecimento, IDs de recurso e trechos de log de produção. Nada -disso entra em teste, exemplo, commit, PR, issue ou `CHANGELOG.md`, porque o -repo é público e o histórico não se apaga. Reproduza com documento sintético -válido (gerado pelo dígito verificador) que tenha a **mesma característica** -do caso real, como o mesmo número de zeros à esquerda. - -### Tags irregulares - -`1.4.0` saiu sem `v`, `v1.4.1` é lightweight e `v1.3.1` não está num merge -commit. O padrão a seguir está em -[setup.md § Versionamento e release](setup.md#versionamento-e-release). - -### O README não cobre v2 - -`23bb290` adicionou `paymentLinksV2()` e `subscriptionV2()` sem tocar o -`README.md`. Ele também tem o badge de versão parado em `1.0.0`, a primeira -âncora do índice quebrada e uma seção "Testes" sem comandos. Endpoint ou -método público novo precisa de seção no README no mesmo PR. diff --git a/docs/sdk.md b/docs/sdk.md index bbaa589..75901bc 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -1,4 +1,4 @@ -← [CLAUDE.md](../CLAUDE.md) · [setup](setup.md) · **sdk** · [gotchas](gotchas.md) +← [CLAUDE.md](../CLAUDE.md) · [setup](setup.md) · **sdk** # Como o SDK funciona @@ -102,8 +102,7 @@ protected function schema(SchemaBuilder $schema): Schema retorna `new Mutator($getter, $setter)`. O setter recebe `($value, MutatorContext $ctx)` e falha com `$ctx->raise('msg')` ou `$ctx->assert($cond, 'msg')`, que lançam `MutatorAttributeException`. Para - validar, o código costuma usar `Kubinyete\Assertation\Assert` (ver - [gotchas](gotchas.md#or-do-assert-prioriza-o-lado-direito)). + validar, o código costuma usar `Kubinyete\Assertation\Assert`. `Model::loadMutator` descobre o mutator com `is_callable([$this, $nome])`. - **Serialização:** `jsonSerialize()` emite **todo** atributo não-`hidden`, **inclusive `null`**, e serializa as relações recursivamente. @@ -150,8 +149,7 @@ O mapeamento Guzzle → SDK fica em `GuzzleHttpClient::request()`. Depois, - os erros saem de `message`, `error` ou `data` do body e vão para `getErrors()`; - `statusCode` e `statusMessage` ficam `null`, e o status passa a estar em - `getCode()` e em `getResponse()->getStatusCode()` - ([gotchas](gotchas.md#getstatuscode-é-null-na-httpclientexception)). + `getCode()` e em `getResponse()->getStatusCode()`. 5xx e erro de rede sobem como vieram. As exceções de schema **não** estendem `BaseException`, então um `catch (BaseException)` não as pega. @@ -198,8 +196,7 @@ Use `SANDBOX` aqui, não `LOCAL`. Sem esquema, o Guzzle trata `MockHandler` nunca chega à rede em nenhum dos dois casos. **Dados de teste são sintéticos.** Gere CPF e CNPJ válidos pelo dígito -verificador. Nunca use documento, nome ou ID vindos de um caso real (ver -[gotchas](gotchas.md#dado-de-produção-não-entra-no-repo)). +verificador. Nunca use documento, nome ou ID vindos de um caso real. ## Adicionar um endpoint diff --git a/docs/setup.md b/docs/setup.md index 07cf761..f337594 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,4 +1,4 @@ -← [CLAUDE.md](../CLAUDE.md) · **setup** · [sdk](sdk.md) · [gotchas](gotchas.md) +← [CLAUDE.md](../CLAUDE.md) · **setup** · [sdk](sdk.md) # Setup e operação @@ -9,7 +9,7 @@ rodar o PHPUnit e, quando precisar, rodar um exemplo contra a sandbox. Só precisa de Docker. Os comandos usam as imagens oficiais `composer:2` e `php:-cli` e rodam com o seu usuário (`-u`), para que `vendor/` não -fique com dono `root` ([gotchas](gotchas.md#vendor-e-phpunitcache-com-dono-root)). +fique com dono `root`. ```bash git clone git@github.com:ipagdevs/ipag-sdk-php.git && cd ipag-sdk-php @@ -49,7 +49,7 @@ relatório do CI. **Não troque o `composer install` por `composer update`.** O `update` re-resolve tudo e, se o pin de plataforma for removido ou contornado, traz pacotes que exigem PHP 8. Mudança de dependência é decisão de task, não efeito -colateral (ver [gotchas](gotchas.md#composerlock-muda-sem-ninguém-pedir)). +colateral. ### Por que `docker run` e não o compose local