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:
co-authored by
Claude Opus 4.8
parent
260644c961
commit
c2d2d30b70
+73
-9
@@ -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"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
"401": {
|
||||
"description": "API key ausente ou inv\u00e1lida"
|
||||
},
|
||||
"description": "Validation Error"
|
||||
"409": {
|
||||
"description": "`detail.code`=`certificate_upload_conflict` -- upload concorrente venceu a corrida"
|
||||
},
|
||||
"413": {
|
||||
"description": "Certificado excede o tamanho m\u00e1ximo permitido (262144 bytes)"
|
||||
},
|
||||
"422": {
|
||||
"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": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user