feat(openapi): declare the full response contract (401/404/409/413) on every v1 route

Honors portability safeguard (c) -- the committed docs/openapi-v1.json was
enforced to stay in sync with app.openapi() (test_openapi_committed.py),
but the app declared almost no error responses: POST /v1/emissoes showed
only 200/422 (no 201), and no route declared 401/404/409/413 or the
structured 409 codes the spec names as contract. A Go reimplementation
reading only the committed OpenAPI as source of truth wouldn't learn them.

Adds responses={...} to every v1 router (emissao, certificados, series,
documentos GET/xml) covering the status codes each route actually returns
-- 201 as the default on POST /v1/emissoes (with 200 documented for the
Idempotency-Key replay case), 401 on every authenticated route, 404 where
the anti-oracle boundary applies, 409 naming the structured codes each
route raises, 413 on the certificate upload's size cap. Regenerated
docs/openapi-v1.json from app.openapi() (uv run python -c '...json.dump...'
per test_openapi_committed.py's own docstring) so the committed==generated
assertion stays green with FIX 1-4's new 409 codes included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jonatanritter
2026-08-08 15:36:58 -03:00
co-authored by Claude Opus 4.8
parent 260644c961
commit c2d2d30b70
4 changed files with 177 additions and 19 deletions
+73 -9
View File
@@ -1166,6 +1166,12 @@
"204": {
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"404": {
"description": "Nenhum certificado vivo para este (tenant_ref, branch_ref) -- anti-oracle"
},
"422": {
"content": {
"application/json": {
@@ -1234,6 +1240,12 @@
},
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"404": {
"description": "Nenhum certificado vivo para este (tenant_ref, branch_ref) -- anti-oracle"
},
"422": {
"content": {
"application/json": {
@@ -1311,15 +1323,17 @@
},
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"409": {
"description": "`detail.code`=`certificate_upload_conflict` -- upload concorrente venceu a corrida"
},
"413": {
"description": "Certificado excede o tamanho m\u00e1ximo permitido (262144 bytes)"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
"description": "PFX inv\u00e1lido/senha incorreta, CNPJ do certificado diverge do declarado, certificado vencido ou ainda n\u00e3o vigente"
}
},
"summary": "Upload Certificate Endpoint",
@@ -1438,6 +1452,9 @@
},
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"422": {
"content": {
"application/json": {
@@ -1497,6 +1514,12 @@
},
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"404": {
"description": "Documento fiscal n\u00e3o encontrado (ou de outro product/tenant -- anti-oracle)"
},
"422": {
"content": {
"application/json": {
@@ -1550,10 +1573,17 @@
"content": {
"application/json": {
"schema": {}
}
},
"application/xml": {}
},
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"404": {
"description": "Documento fiscal n\u00e3o encontrado (ou de outro product/tenant -- anti-oracle)"
},
"422": {
"content": {
"application/json": {
@@ -1616,6 +1646,16 @@
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FiscalDocumentRead"
}
}
},
"description": "Idempotency-Key repetida -- documento j\u00e1 emitido devolvido (replay, created=False)"
},
"201": {
"content": {
"application/json": {
"schema": {
@@ -1625,6 +1665,12 @@
},
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"409": {
"description": "Conflito estruturado -- `detail.code` identifica a causa: `fiscal_config_missing` (certificado/s\u00e9rie ausentes ou certificado vencido), `emitente_certificate_cnpj_mismatch` (CNPJ do emitente diverge do certificado da filial), `pagamento_total_diverge` (\u03a3 vPag \u2260 vNF), `fiscal_document_conflict` (colis\u00e3o de chave de acesso)."
},
"422": {
"content": {
"application/json": {
@@ -1720,6 +1766,9 @@
},
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"422": {
"content": {
"application/json": {
@@ -1777,6 +1826,12 @@
},
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"409": {
"description": "`detail.code`=`duplicate_fiscal_series` -- j\u00e1 existe uma s\u00e9rie fiscal (live ou soft-deletada) para este (tenant_ref, branch_ref, document_model, serie)"
},
"422": {
"content": {
"application/json": {
@@ -1846,6 +1901,15 @@
},
"description": "Successful Response"
},
"401": {
"description": "API key ausente ou inv\u00e1lida"
},
"404": {
"description": "S\u00e9rie fiscal n\u00e3o encontrada (ou de outro product -- anti-oracle)"
},
"409": {
"description": "`detail.code`=`fiscal_series_number_regression`"
},
"422": {
"content": {
"application/json": {
+32 -3
View File
@@ -41,7 +41,22 @@ async def _read_upload_capped(file: UploadFile, max_bytes: int) -> bytes:
return b"".join(chunks)
@router.post("", response_model=FiscalCertificateRead, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=FiscalCertificateRead,
status_code=status.HTTP_201_CREATED,
responses={
401: {"description": "API key ausente ou inválida"},
409: {"description": "`detail.code`=`certificate_upload_conflict` -- upload concorrente venceu a corrida"},
413: {"description": f"Certificado excede o tamanho máximo permitido ({_MAX_PFX_UPLOAD_BYTES} bytes)"},
422: {
"description": (
"PFX inválido/senha incorreta, CNPJ do certificado diverge do declarado, "
"certificado vencido ou ainda não vigente"
)
},
},
)
async def upload_certificate_endpoint(
tenant_ref: str = Query(..., max_length=64),
branch_ref: str = Query(..., max_length=64),
@@ -77,7 +92,14 @@ async def upload_certificate_endpoint(
return FiscalCertificateRead.model_validate(certificate)
@router.get("", response_model=FiscalCertificateRead)
@router.get(
"",
response_model=FiscalCertificateRead,
responses={
401: {"description": "API key ausente ou inválida"},
404: {"description": "Nenhum certificado vivo para este (tenant_ref, branch_ref) -- anti-oracle"},
},
)
async def read_certificate_endpoint(
tenant_ref: str = Query(..., max_length=64),
branch_ref: str = Query(..., max_length=64),
@@ -96,7 +118,14 @@ async def read_certificate_endpoint(
return FiscalCertificateRead.model_validate(certificate)
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"",
status_code=status.HTTP_204_NO_CONTENT,
responses={
401: {"description": "API key ausente ou inválida"},
404: {"description": "Nenhum certificado vivo para este (tenant_ref, branch_ref) -- anti-oracle"},
},
)
async def deactivate_certificate_endpoint(
tenant_ref: str = Query(..., max_length=64),
branch_ref: str = Query(..., max_length=64),
+44 -4
View File
@@ -12,8 +12,30 @@ from fiscal_svc.tenancy.models import Product
router = APIRouter(prefix="/v1", tags=["emissao"])
_ERRO_409 = {
"description": (
"Conflito estruturado -- `detail.code` identifica a causa: "
"`fiscal_config_missing` (certificado/série ausentes ou certificado vencido), "
"`emitente_certificate_cnpj_mismatch` (CNPJ do emitente diverge do certificado da filial), "
"`pagamento_total_diverge` (Σ vPag ≠ vNF), "
"`fiscal_document_conflict` (colisão de chave de acesso)."
),
}
@router.post("/emissoes", response_model=FiscalDocumentRead)
@router.post(
"/emissoes",
response_model=FiscalDocumentRead,
status_code=status.HTTP_201_CREATED,
responses={
200: {
"description": "Idempotency-Key repetida -- documento já emitido devolvido (replay, created=False)",
"model": FiscalDocumentRead,
},
401: {"description": "API key ausente ou inválida"},
409: _ERRO_409,
},
)
async def emitir_documento_endpoint(
payload: EmissaoRequest,
response: Response,
@@ -42,7 +64,14 @@ async def emitir_documento_endpoint(
return FiscalDocumentRead.model_validate(document)
@router.get("/documentos/{document_id}", response_model=FiscalDocumentRead)
@router.get(
"/documentos/{document_id}",
response_model=FiscalDocumentRead,
responses={
401: {"description": "API key ausente ou inválida"},
404: {"description": "Documento fiscal não encontrado (ou de outro product/tenant -- anti-oracle)"},
},
)
async def get_fiscal_document_endpoint(
document_id: uuid.UUID,
product: Product = Depends(require_product),
@@ -56,7 +85,14 @@ async def get_fiscal_document_endpoint(
return FiscalDocumentRead.model_validate(document)
@router.get("/documentos/{document_id}/xml")
@router.get(
"/documentos/{document_id}/xml",
responses={
200: {"content": {"application/xml": {}}},
401: {"description": "API key ausente ou inválida"},
404: {"description": "Documento fiscal não encontrado (ou de outro product/tenant -- anti-oracle)"},
},
)
async def get_fiscal_document_xml_endpoint(
document_id: uuid.UUID,
product: Product = Depends(require_product),
@@ -70,7 +106,11 @@ async def get_fiscal_document_xml_endpoint(
return Response(content=document.xml_assinado, media_type="application/xml")
@router.get("/documentos", response_model=list[FiscalDocumentRead])
@router.get(
"/documentos",
response_model=list[FiscalDocumentRead],
responses={401: {"description": "API key ausente ou inválida"}},
)
async def list_fiscal_documents_endpoint(
tenant_ref: str | None = Query(default=None, max_length=64),
branch_ref: str | None = Query(default=None, max_length=64),
+28 -3
View File
@@ -13,7 +13,20 @@ from fiscal_svc.tenancy.models import Product
router = APIRouter(prefix="/v1/series", tags=["series"])
@router.post("", response_model=FiscalSeriesRead, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=FiscalSeriesRead,
status_code=status.HTTP_201_CREATED,
responses={
401: {"description": "API key ausente ou inválida"},
409: {
"description": (
"`detail.code`=`duplicate_fiscal_series` -- já existe uma série fiscal "
"(live ou soft-deletada) para este (tenant_ref, branch_ref, document_model, serie)"
)
},
},
)
async def create_fiscal_series_endpoint(
payload: FiscalSeriesCreate,
product: Product = Depends(require_product),
@@ -26,7 +39,11 @@ async def create_fiscal_series_endpoint(
return FiscalSeriesRead.model_validate(series)
@router.get("", response_model=list[FiscalSeriesRead])
@router.get(
"",
response_model=list[FiscalSeriesRead],
responses={401: {"description": "API key ausente ou inválida"}},
)
async def list_fiscal_series_endpoint(
tenant_ref: str = Query(..., max_length=64),
branch_ref: str = Query(..., max_length=64),
@@ -37,7 +54,15 @@ async def list_fiscal_series_endpoint(
return [FiscalSeriesRead.model_validate(series) for series in series_list]
@router.patch("/{series_id}", response_model=FiscalSeriesRead)
@router.patch(
"/{series_id}",
response_model=FiscalSeriesRead,
responses={
401: {"description": "API key ausente ou inválida"},
404: {"description": "Série fiscal não encontrada (ou de outro product -- anti-oracle)"},
409: {"description": "`detail.code`=`fiscal_series_number_regression`"},
},
)
async def update_fiscal_series_endpoint(
series_id: uuid.UUID,
payload: FiscalSeriesPatch,