Entre na beta da Hanzo e ganhe 50% de desconto em qualquer plano pago

O roteador é o registro

A Hanzo Cloud não tem arquivo OpenAPI versionado. A tabela de rotas viva é a fonte, projetada de três jeitos, e um teste de bijeção faz o build falhar se alguma projeção desviar. 1,362 operações em 963 paths, geradas por processo na hora da requisição.

Nesta página

Não existe openapi.yaml no repositório da Hanzo Cloud. Não existe um segundo registro listando endpoints, nem arquivo de anotações, nem um passo de gerador na CI que grave uma spec em disco. Se você quer saber o que a API é, pergunta a um processo em execução, e ele responde lendo o próprio roteador.

Este texto é sobre por que esse acabou sendo o único arranjo que se sustenta, e sobre as partes que não funcionam — que acabam sendo a metade mais útil.

Uma tabela, três projeções

serve.go compõe uma única tabela de rotas. Três coisas a leem em seguida, todas depois de MountAll, para que cada uma veja uma tabela completa:

  • /zap reproduz os handlers de /v1 sobre o transporte ZAP.
  • O console os desenha.
  • GET /v1/openapi.json os descreve.

Nenhuma das três guarda uma cópia de nada. Essa é toda a restrição de desenho, e o resto decorre dela. Uma projeção que guardasse a própria lista de endpoints seria uma segunda fonte da verdade, e uma segunda fonte da verdade desvia — não às vezes, sempre, no ritmo em que alguém esquece.

A projeção que descreve é a que as pessoas perguntam, então: openapi.Live(app) chama app.Fiber().GetRoutes(true), e toda outra função em openapi/ é uma função pura do []Route que volta. O documento não é construído a partir do roteador. O documento é o roteador, em outra forma.

Por que ler o roteador vivo é a única fonte total

A alternativa óbvia é varrer o código-fonte. Não funciona, e o motivo vale ser dito com precisão, porque é a razão de muita ferramenta de API subnotificar, em silêncio.

POST /v1/kms/auth/login é registrado assim:

Group("/v1/kms/auth").Post("/login")

Nenhum grep acha esse caminho. A string /v1/kms/auth/login não existe no código. Ela só existe no roteador montado, em tempo de execução, depois que o framework concatena o prefixo do grupo e a folha.

Aí piora, de um jeito que na verdade está certo. O conjunto de rotas é uma função da configuração de deploy — cfg.Enabled, mais portões internos como o if kc != nil do KMS. Um deploy que não monta admin não serve rotas de admin. Então a spec honesta desse deploy não as anuncia.

Por isso o documento é gerado por processo, na hora da requisição, em vez de ser construído uma vez na CI. Uma spec construída na CI descreve um deploy máximo hipotético, que pode não ser aquele com o qual você está falando. Uma spec lida do roteador vivo descreve o processo que te respondeu.

O teste que torna isso verdade

Uma arquitetura em que a spec não pode desviar é uma afirmação, e afirmações precisam de um portão. O nosso é uma bijeção, em cmd/cloud/openapi_test.go:

func TestSpecDoesNotDriftFromLiveRouter(t *testing.T) {

Ele monta cada subsistema em apps.Wire(), gera o documento e afirma o mapeamento nos dois sentidos: cada rota viva aparece como operação, e cada operação é sustentada por uma rota viva. Não um subconjunto em nenhuma das direções.

Rodado hoje:

bijection holds over 1362 operations / 963 paths / 138 products

É o único teste do repositório cuja falha significa que o documento publicado mente. Todo o resto que quebra deixa o produto errado; este deixa o contrato errado, o que é pior, porque quem integra já construiu contra ele.

A contagem se mover não é problema — ela se move sempre que um subsistema é acrescentado. O número que importa é a diferença entre os dois lados, e esse número é zero ou o build falha.

O eixo de produto é mecânico

O primeiro segmento de caminho depois de /v1/ é o produto. openapi.Product o lê e etiqueta cada operação com ele, o que deixa uma CLI construir hanzo <product> <resource> <verb> sem que ninguém julgue onde um comando deve viver.

De propósito, não é o nome do subsistema. clients/billing também serve /v1/finance/*, e a URL é o que quem chama vê, então a URL ganha. Nomear o eixo pelo pacote interno teria feito da CLI um artefato curado — uma coisa com opiniões dentro, que alguém tem de manter — em vez de uma projeção.

O que o roteador não pode te dizer

Aqui está o limite, e não pretendemos contorná-lo.

Método, caminho, parâmetros de caminho e produto se derivam do roteador. Esquemas de requisição e de resposta, parâmetros de query e de header, códigos de status e autenticação não. O roteador guarda um func(*zip.Ctx) error. O tipo da requisição é uma variável local dentro do corpo do handler:

var req secretPutRequest
json.Unmarshal(ctx.Body(), &req)

Go não consegue refletir de um valor de função para o corpo dela. Não há um jeito esperto aqui, e tentar um no gerador seria chutar — que é como uma spec ganha entradas confiantes e erradas.

O único caminho até os esquemas é uma op tipada:

zip.Get[In, Out](...)

In e Out estão no registro, então o esquema fica disponível sem ler a mente de ninguém. E, como estão no registro, a mesma entrada também produz uma ferramenta MCP — zip/openapi.go e zip/mcp.go são dois leitores de um só registro. Essa é a quarta projeção, e ela chegou sem um quarto mecanismo.

A migração é incremental por construção: GetRoutes() é um superconjunto de app.ops, então converter um handler em uma op tipada acrescenta esquema sem mudar nada a montante.

As rotas que não podem ser tipadas, e por quê

clients/integrations está convertido até onde o fio permite: 20 de 41 rotas são ops tipadas. As outras 21 continuam handlers crus, e cada uma está listada num mapa rawRoutes em ops_projection_test.go com o motivo de não poder mudar:

  • 13 redirecionamentos de navegador. O callback de OAuth e cada trecho do vínculo respondem 302. Uma op tipada devolve um valor, não um header Location e um status nos 300.
  • 6 webhooks. HMAC do Slack e do GitHub, Ed25519 do Discord, JWT do Bot Framework do Teams, o header secret-token do Telegram. Cada um autentica uma assinatura sobre os bytes crus da requisição. Uma op tipada recebe um In já decodificado e nunca vê os bytes sobre os quais a assinatura foi calculada, então tipá-los quebraria a autenticação.
  • 2 criadores de 202 Accepted. cloud.Created nos dá uma emenda de 201. Não há emenda de 202. Duas rotas precisam de uma, então duas rotas esperam.

Achamos que essa lista é o artefato mais valioso do sistema inteiro. É um inventário enumerado, preso por testes, de exatamente onde a abstração para, com um motivo por entrada que um revisor pode contestar. O modo de falha que ela evita é aquele em que uma rota volta em silêncio a um handler cru e some de uma vez do documento, do conjunto de ferramentas MCP e da CLI — de forma invisível, porque nada afirmava que ela estava lá.

Uma arquitetura não são as partes que generalizam. São as partes que generalizam mais um relato honesto, e cobrado, das partes que não generalizam.

O que isso compra

Quem integra contra a Hanzo Cloud lê um documento produzido pelo processo que está respondendo aos pedidos dele, no deploy com o qual ele de fato fala, há alguns milissegundos. Um agente que chama por MCP chega ao mesmo handler, com a mesma checagem de tenant, que quem chama por REST — uma chamada anônima a /mcp recebe o mesmo 403 que o REST dá, porque é o mesmo caminho de código e um teste o prende.

Nada disso exigiu que alguém lembrasse de regenerar qualquer coisa.

A forma geral: se dois artefatos têm de concordar, não gere um a partir do outro e espere. Faça de um uma vista do outro, e escreva o teste que falha quando eles não são.

Comentários

Entre com seu Hanzo ID para comentar.

Carregando comentários…

Leia mais