feat: certificates + series API v1 (Task 4)
Port certificate lifecycle (parse/encrypt/upload/deactivate) and FiscalSeries CRUD from the auto, adapted to (product_id, tenant_ref, branch_ref) tenancy. Closes the "guard retroativo" PATCH /v1/series next_number regression check that Task 3 deferred to this task. Routes: POST/GET/DELETE /v1/certificados, POST/GET/PATCH /v1/series.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""`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)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
Reference in New Issue
Block a user