Files
pdf-toolkit/api.py
CM Consultor f16c53bd4c feat: add API Key authentication, Dockerfile, and docker-compose
- core/auth.py: API Key auth via header X-API-Key (env PDFTOOLKIT_API_KEY)
  - Modo dev: sem chave configurada → acesso livre
  - Modo produção: exige chave, retorna 401/403
- api.py: endpoints protegidos com Depends(_auth_required)
  - /health e / permanecem públicos
- Dockerfile: multi-stage (base → api → web)
- docker-compose.yml: api + web com healthcheck, volumes e rede
- .env.example: template de variáveis de ambiente
- README.md: seções de autenticação e Docker
2026-08-09 02:38:19 +00:00

330 lines
10 KiB
Python

"""
pdf-toolkit API — FastAPI com documentação OpenAPI automática e autenticação.
Endpoints REST para todas as operações do toolkit.
Documentação interativa disponível em /docs (Swagger) e /redoc.
Autenticação via header X-API-Key.
Variável de ambiente: PDFTOOLKIT_API_KEY
Se não definida, a API roda sem autenticação (modo dev).
Uso:
uvicorn api:app --host 0.0.0.0 --port 8555 --reload
"""
import io
import zipfile
from typing import List
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Depends
from fastapi.responses import Response, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from config import API_TITLE, API_VERSION, API_DESCRIPTION, MAX_UPLOAD_SIZE_BYTES, QUALITY_PRESETS
from core.engine import PDFEngine
from core.auth import verify_api_key, is_auth_enabled
engine = PDFEngine()
# ── App ─────────────────────────────────────────────────────────────
app = FastAPI(
title=API_TITLE,
version=API_VERSION,
description=API_DESCRIPTION,
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Dependência de autenticação ─────────────────────────────────────
# Endpoints públicos (health, root) usam auth opcional
# Endpoints de operação usam auth obrigatório (se configurado)
def _auth_required(api_key: str = Depends(verify_api_key)):
"""Dependência que aplica autenticação nos endpoints operacionais."""
return api_key
# ── Helpers ─────────────────────────────────────────────────────────
def _read_file(file: UploadFile) -> bytes:
data = file.file.read()
if len(data) > MAX_UPLOAD_SIZE_BYTES:
raise HTTPException(413, f"Arquivo excede o limite de {MAX_UPLOAD_SIZE_BYTES / 1024 / 1024:.0f} MB.")
return data
def _pdf_response(data: bytes, filename: str) -> Response:
return Response(
content=data,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _zip_response(files: List[tuple], zip_name: str = "resultados.zip") -> StreamingResponse:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for nome, dados in files:
zf.writestr(nome, dados)
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
)
# ── Endpoints Públicos (sem auth) ───────────────────────────────────
@app.get("/")
def root():
auth_info = {"authentication": "enabled"} if is_auth_enabled() else {"authentication": "disabled (dev mode)"}
return {
"app": API_TITLE,
"version": API_VERSION,
"docs": "/docs",
"redoc": "/redoc",
**auth_info,
}
@app.get("/health")
def health():
"""Health check — sem autenticação."""
return {"status": "ok", "version": API_VERSION, "auth": is_auth_enabled()}
# ── Endpoints Protegidos ────────────────────────────────────────────
@app.post(
"/inspect",
summary="Inspeciona metadados de um PDF",
dependencies=[Depends(_auth_required)],
)
def inspect(file: UploadFile = File(...)):
"""
Retorna metadados detalhados do PDF: número de páginas,
tamanho, detecção de PDF/A, validade.
"""
data = _read_file(file)
info = engine.inspect(data, file.filename or "documento.pdf")
if not info["valid"]:
raise HTTPException(400, info["error"])
return info
@app.post(
"/compress",
summary="Comprime um PDF",
dependencies=[Depends(_auth_required)],
)
def compress(
file: UploadFile = File(...),
preset: str = Form("ebook"),
):
"""
Comprime um PDF re-amostrando imagens.
- **preset**: screen | ebook | printer | prepress
"""
if preset not in QUALITY_PRESETS:
raise HTTPException(400, f"Preset inválido. Opções: {', '.join(QUALITY_PRESETS.keys())}")
data = _read_file(file)
result = engine.compress(data, preset=preset)
out_name = (file.filename or "documento.pdf").replace(".pdf", "_comprimido.pdf")
return _pdf_response(result, out_name)
@app.post(
"/pdfa-to-regular",
summary="Converte PDF/A para PDF comum",
dependencies=[Depends(_auth_required)],
)
def pdfa_to_regular(file: UploadFile = File(...)):
"""Remove restrições PDF/A gerando um PDF comum padrão."""
data = _read_file(file)
info = engine.inspect(data)
if info.get("is_pdfa"):
result = engine.pdfa_to_regular(data)
else:
result = data
out_name = (file.filename or "documento.pdf").replace(".pdf", "_comum.pdf")
return _pdf_response(result, out_name)
@app.post(
"/compress-pdfa",
summary="Comprime mantendo PDF/A",
dependencies=[Depends(_auth_required)],
)
def compress_pdfa(
file: UploadFile = File(...),
quality: int = Form(60, ge=10, le=100),
):
"""Comprime as imagens internas preservando a estrutura PDF/A."""
data = _read_file(file)
result = engine.compress_pdfa(data, quality=quality)
out_name = (file.filename or "documento.pdf").replace(".pdf", "_otimizado.pdf")
return _pdf_response(result, out_name)
@app.post(
"/merge",
summary="Junta múltiplos PDFs em um",
dependencies=[Depends(_auth_required)],
)
async def merge(files: List[UploadFile] = File(...)):
"""Mescla 2 ou mais PDFs em ordem, retornando um único PDF."""
if len(files) < 2:
raise HTTPException(400, "Informe ao menos 2 PDFs.")
inputs = []
for f in files:
data = _read_file(f)
inputs.append((data, f.filename or "documento.pdf"))
result = engine.merge(inputs)
return _pdf_response(result, "merged.pdf")
@app.post(
"/split",
summary="Extrai intervalos de páginas de um PDF",
dependencies=[Depends(_auth_required)],
)
def split(
file: UploadFile = File(...),
ranges: str = Form(..., description='Ex: "1-3,5,7-9"'),
):
"""
Divide um PDF em múltiplos PDFs baseado em intervalos de páginas.
- **ranges**: string com intervalos. Ex: "1-3,5,7-9" (1-indexado)
"""
data = _read_file(file)
reader = __import__("pypdf", fromlist=["PdfReader"]).PdfReader(io.BytesIO(data))
total = len(reader.pages)
parts = ranges.replace(" ", "").split(",")
page_ranges = []
for part in parts:
if "-" in part:
a, b = part.split("-")
start = max(0, int(a) - 1)
end = min(int(b) - 1, total - 1)
page_ranges.append((start, end))
else:
idx = int(part) - 1
if 0 <= idx < total:
page_ranges.append((idx, idx))
if not page_ranges:
raise HTTPException(400, "Nenhum intervalo válido informado.")
base = (file.filename or "pagina").replace(".pdf", "")
results = engine.split(data, page_ranges, base)
return _zip_response(results, "split.zip")
@app.post(
"/extract-pages",
summary="Extrai páginas específicas",
dependencies=[Depends(_auth_required)],
)
def extract_pages(
file: UploadFile = File(...),
pages: str = Form(..., description='Ex: "1,3,5" (1-indexado)'),
):
"""Extrai páginas específicas de um PDF em ordem."""
data = _read_file(file)
reader = __import__("pypdf", fromlist=["PdfReader"]).PdfReader(io.BytesIO(data))
total = len(reader.pages)
indices = []
for p in pages.replace(" ", "").split(","):
p = p.strip()
if not p:
continue
idx = int(p) - 1
if 0 <= idx < total:
indices.append(idx)
if not indices:
raise HTTPException(400, "Nenhuma página válida informada.")
result = engine.extract(data, indices)
out_name = (file.filename or "documento.pdf").replace(".pdf", "_extraido.pdf")
return _pdf_response(result, out_name)
@app.post(
"/rotate",
summary="Rotaciona páginas de um PDF",
dependencies=[Depends(_auth_required)],
)
def rotate(
file: UploadFile = File(...),
pages: str = Form(..., description='Páginas: "1,3,5" ou "todas"'),
angle: int = Form(90, ge=90, le=270),
):
"""Rotaciona páginas em 90, 180 ou 270 graus."""
data = _read_file(file)
reader = __import__("pypdf", fromlist=["PdfReader"]).PdfReader(io.BytesIO(data))
total = len(reader.pages)
if pages.lower() in ("", "todas", "all"):
page_indices = list(range(total))
else:
page_indices = [int(p.strip()) - 1 for p in pages.replace(" ", "").split(",")]
page_indices = [p for p in page_indices if 0 <= p < total]
rotations = [(p, angle) for p in page_indices]
result = engine.rotate(data, rotations)
out_name = (file.filename or "documento.pdf").replace(".pdf", "_rotacionado.pdf")
return _pdf_response(result, out_name)
@app.post(
"/extract-text",
summary="Extrai texto de um PDF",
dependencies=[Depends(_auth_required)],
)
def extract_text_endpoint(file: UploadFile = File(...)):
"""Extrai todo o texto legível de um PDF e retorna como .txt."""
data = _read_file(file)
text = engine.text(data)
return Response(
content=text,
media_type="text/plain; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{file.filename or "documento"}.txt"'
},
)
@app.post(
"/extract-images",
summary="Extrai imagens de um PDF como ZIP",
dependencies=[Depends(_auth_required)],
)
def extract_images_endpoint(file: UploadFile = File(...)):
"""Extrai todas as imagens embutidas no PDF e retorna um ZIP."""
data = _read_file(file)
images = engine.images(data)
if not images:
raise HTTPException(404, "Nenhuma imagem encontrada neste PDF.")
return _zip_response(images, "imagens.zip")
# ── Entrypoint ──────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run("api:app", host="0.0.0.0", port=8555, reload=True)