198 lines
7.0 KiB
Python
198 lines
7.0 KiB
Python
"""Service entry point.
|
|
|
|
The application runs behind the AppFactory reverse proxy under
|
|
/apps/<app-id>. The prefix is stripped before the request reaches the
|
|
container, so only the OpenAPI document has to know about it. That is what
|
|
root_path does.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from .config import get_settings
|
|
from .deps import Container, set_container
|
|
from .engines.chromium import ChromiumEngine
|
|
from .engines.weasy import WeasyPrintEngine
|
|
from .errors import ConversionError
|
|
from .logging_setup import setup_logging
|
|
from .routers import convert as convert_router
|
|
from .routers import health as health_router
|
|
from .routers import jobs as jobs_router
|
|
from .services.jobs import JobManager
|
|
from .services.pipeline import ConversionPipeline
|
|
from .services.storage import Storage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DESCRIPTION = """
|
|
Sluzba prevadi HTML dokument na PDF. Prijme adresu dokumentu nebo HTML primo
|
|
v tele requestu a vrati soubor PDF.
|
|
|
|
Pro dokumenty o stovkach az tisicich stranek pouzijte asynchronni endpoint
|
|
POST /jobs. Synchronni POST /convert je urceny pro mensi dokumenty.
|
|
|
|
## Render enginy
|
|
|
|
- **weasyprint** je vychozi, ma spravne strankovani a nizkou pametovou
|
|
narocnost, nespousti JavaScript
|
|
- **chromium** zvladne i dokumenty dokreslovane JavaScriptem, ale nema
|
|
pouzitelne CSS countery, takze cisla stranek se dopisuji do hotoveho PDF
|
|
|
|
## Strankovani
|
|
|
|
Nastaveni je rozdelene do ctyr bloku tela requestu.
|
|
|
|
**page** rozmer a okraje. `format` je nazev formatu (`A4`, `A5`, `A3`,
|
|
`Letter`, `Legal`) nebo explicitni rozmer `210mm 297mm`. `orientation` se
|
|
uplatni jen u nazvu formatu. `margin` jsou ctyri CSS delky. Pokud si zdrojove
|
|
HTML nastavi vlastni `@page`, ma prednost jeho hodnota.
|
|
|
|
**page_numbers** cislovani stranek, vychozi stav je vypnuto. `format` je
|
|
sablona se zastupnymi symboly `{page}` a `{pages}`, `position` je jeden
|
|
z sesti okrajovych boxu stranky. Rezimy:
|
|
|
|
- `css` cisla resi CSS countery pri renderu. Nejcistsi vysledek, vyzaduje ale
|
|
`engine: weasyprint` a `chunking.enabled: false`, protoze v kazde casti se
|
|
citac stranek restartuje. Jina kombinace vraci 400 `unsupported_combination`.
|
|
- `overlay` cisla se dopisi do hotoveho PDF jako pruhledna vrstva. Funguje
|
|
vzdy, jedina moznost u clenenych dokumentu a u Chromia. Jen v tomto rezimu
|
|
se uplatni `start_at`.
|
|
- `auto` zvoli `css` u necleneneho dokumentu renderovaneho WeasyPrintem,
|
|
jinak `overlay`.
|
|
|
|
Cisla se kresli do okraje stranky, pri nulovem `margin` se nemaji kam vejit.
|
|
|
|
**chunking** deleni velkeho dokumentu na casti renderovane samostatne. Drzi
|
|
spotrebu pameti nizko, casti se pak slouci do jednoho souvisleho PDF. Rez
|
|
vznika jen mezi primymi potomky hlavniho kontejneru, tabulka ani odstavec se
|
|
nikdy nerozdeli. Delici body jsou `section`, `article`, `h1`, elementy
|
|
s atributem `data-chunk` a elementy se stylem obsahujicim `page-break-before`
|
|
nebo `break-before`. Dokument bez takovych bodu se vyrenderuje vcelku.
|
|
|
|
**toc** obsah se skutecnymi cisly stranek. Vyzaduje `engine: weasyprint`.
|
|
Dokument se kvuli nemu renderuje dvakrat, ukazatel postupu proto probehne
|
|
dvakrat. U cleneneho dokumentu nejsou odkazy v obsahu klikatelne, cisla
|
|
stranek jsou spravna a sluzba na to upozorni ve `warnings`.
|
|
|
|
Rucni zalomeni se resi v samotnem HTML pres CSS `break-before: page`, sluzba
|
|
do neho nezasahuje.
|
|
|
|
## Uchovavani souboru
|
|
|
|
Zadny vstup ani vysledek se neuklada trvale. Vsechno zije jen v docasnem
|
|
adresari sluzby a v pameti procesu.
|
|
|
|
- **POST /convert** vysledek vznikne na disku, odesle se klientovi a hned po
|
|
odeslani odpovedi se soubor i pracovni adresar smazou. Nic ke stazeni
|
|
nezustava, opakovane stazeni znamena novou konverzi.
|
|
- **POST /jobs** vysledek zustava na disku, aby sel stahnout pres
|
|
GET /jobs/{job_id}/result. Meziprodukty se po dokonceni smazou, zustava jen
|
|
hotove PDF. Doba dostupnosti je `JOB_RESULT_TTL_SECONDS`, vychozi 1 hodina,
|
|
a odpocitava se od dokonceni jobu. Presny cas je v poli `expires_at`.
|
|
- Po expiraci se soubor smaze i se zaznamem jobu. Dalsi dotaz na job vraci 404
|
|
`job_not_found`.
|
|
- **DELETE /jobs/{job_id}** zrusi bezici job nebo smaze hotovy vysledek hned,
|
|
bez cekani na expiraci.
|
|
- Neuspesny job svuj pracovni adresar maze okamzite, na disku po nem nezustane
|
|
nic.
|
|
- Fronta i evidence jobu jsou v pameti procesu. Restart sluzby znamena ztratu
|
|
rozpracovanych jobu i hotovych vysledku, ktere jeste nikdo nestahl.
|
|
Rozpracovane joby se oznaci jako failed s kodem `service_restarted`.
|
|
- Stahovani neni jednorazove. Dokud vysledek nevyprsi, jde ho stahnout
|
|
opakovane.
|
|
"""
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
settings = get_settings()
|
|
setup_logging(settings.log_level)
|
|
|
|
engines: dict = {}
|
|
|
|
weasy = WeasyPrintEngine()
|
|
if await weasy.available():
|
|
engines["weasyprint"] = weasy
|
|
else:
|
|
logger.error("WeasyPrint engine is unavailable, the service will rely on Chromium only")
|
|
|
|
chromium = ChromiumEngine()
|
|
if settings.chromium_enabled:
|
|
engines["chromium"] = chromium
|
|
else:
|
|
logger.info("Chromium engine is disabled by configuration")
|
|
|
|
if not engines:
|
|
logger.error("No render engine is available, conversion requests will fail")
|
|
|
|
storage = Storage(settings.storage_dir)
|
|
pipeline = ConversionPipeline(engines, settings)
|
|
manager = JobManager(pipeline, storage, settings)
|
|
|
|
set_container(
|
|
Container(
|
|
settings=settings,
|
|
storage=storage,
|
|
pipeline=pipeline,
|
|
jobs=manager,
|
|
engines=engines,
|
|
)
|
|
)
|
|
|
|
await manager.start()
|
|
logger.info(
|
|
"Service started",
|
|
extra={"engines": sorted(engines), "root_path": settings.root_path},
|
|
)
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
await manager.stop()
|
|
for engine in engines.values():
|
|
await engine.shutdown()
|
|
logger.info("Service stopped")
|
|
|
|
|
|
settings = get_settings()
|
|
setup_logging(settings.log_level)
|
|
|
|
app = FastAPI(
|
|
title=settings.app_name,
|
|
version=settings.app_version,
|
|
description=DESCRIPTION,
|
|
root_path=settings.root_path,
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
@app.exception_handler(ConversionError)
|
|
async def conversion_error_handler(request: Request, exc: ConversionError) -> JSONResponse:
|
|
logger.warning(
|
|
"Request failed",
|
|
extra={"error_code": exc.error_code, "path": request.url.path, "status_code": exc.status_code},
|
|
)
|
|
return JSONResponse(status_code=exc.status_code, content=exc.to_dict())
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
logger.exception("Unhandled error", extra={"path": request.url.path})
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={
|
|
"error_code": "internal_error",
|
|
"message": "Doslo k neocekavane chybe sluzby.",
|
|
},
|
|
)
|
|
|
|
|
|
app.include_router(health_router.router)
|
|
app.include_router(convert_router.router)
|
|
app.include_router(jobs_router.router)
|