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>
141 lines
5.8 KiB
Python
141 lines
5.8 KiB
Python
"""`POST/GET/DELETE /v1/certificados` -- Task 4. `tenant_ref`/`branch_ref`
|
|
travel as QUERY params on all three verbs (consistent shape across POST/GET/
|
|
DELETE, since POST's body is multipart -- file + form fields -- and cannot
|
|
also carry a JSON body); `cnpj`/`password` are `Form(...)` fields alongside
|
|
the file, same multipart shape as the auto's own certificate upload."""
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from fiscal_svc.certificates import crypto as certificate_lib
|
|
from fiscal_svc.certificates import service
|
|
from fiscal_svc.certificates.schemas import FiscalCertificateRead
|
|
from fiscal_svc.core.db import get_session
|
|
from fiscal_svc.shared.errors import conflict
|
|
from fiscal_svc.tenancy.deps import require_product
|
|
from fiscal_svc.tenancy.models import Product
|
|
|
|
router = APIRouter(prefix="/v1/certificados", tags=["certificados"])
|
|
|
|
# Teto de tamanho do upload do .pfx -- mesmo valor/racional do auto
|
|
# (`fiscal.router._MAX_PFX_UPLOAD_BYTES`): um A1 típico fica na casa de
|
|
# poucos KB; 256 KiB é generoso o bastante sem deixar o endpoint aceitar um
|
|
# upload arbitrariamente grande.
|
|
_MAX_PFX_UPLOAD_BYTES = 256 * 1024
|
|
|
|
|
|
async def _read_upload_capped(file: UploadFile, max_bytes: int) -> bytes:
|
|
chunk_size = 64 * 1024
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
while True:
|
|
chunk = await file.read(chunk_size)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > max_bytes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
detail=f"Certificado excede o tamanho máximo permitido ({max_bytes} bytes)",
|
|
)
|
|
chunks.append(chunk)
|
|
return b"".join(chunks)
|
|
|
|
|
|
@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),
|
|
cnpj: str = Form(..., min_length=14, max_length=14),
|
|
file: UploadFile = File(...),
|
|
password: str = Form(...),
|
|
product: Product = Depends(require_product),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> FiscalCertificateRead:
|
|
pfx_bytes = await _read_upload_capped(file, _MAX_PFX_UPLOAD_BYTES)
|
|
try:
|
|
certificate = await service.upload_certificate(
|
|
session, product.id, tenant_ref, branch_ref, cnpj, pfx_bytes, password
|
|
)
|
|
except (certificate_lib.InvalidPfxError, certificate_lib.WrongPasswordError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
|
) from exc
|
|
except service.CertificateCnpjMismatchError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
|
) from exc
|
|
except certificate_lib.CertificateExpiredError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
|
) from exc
|
|
except service.CertificateNotYetValidError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
|
) from exc
|
|
except service.CertificateUploadConflictError as exc:
|
|
raise conflict("certificate_upload_conflict", str(exc)) from exc
|
|
return FiscalCertificateRead.model_validate(certificate)
|
|
|
|
|
|
@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),
|
|
product: Product = Depends(require_product),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> FiscalCertificateRead:
|
|
"""Metadados do certificado VIVO -- NUNCA o binário (o .pfx/senha
|
|
cifrados nunca saem do banco por esta rota; só `certificates.crypto.
|
|
load_private_key_and_cert`, uso interno da emissão, Task 5, os
|
|
descriptografa, e só em memória)."""
|
|
certificate = await service.get_certificate(session, product.id, tenant_ref, branch_ref)
|
|
if certificate is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Certificado não encontrado"
|
|
)
|
|
return FiscalCertificateRead.model_validate(certificate)
|
|
|
|
|
|
@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),
|
|
product: Product = Depends(require_product),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> None:
|
|
try:
|
|
await service.deactivate_certificate(session, product.id, tenant_ref, branch_ref)
|
|
except service.CertificateNotFoundError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Certificado não encontrado"
|
|
) from exc
|