From 09ecad1e7c39df7b6cca97a88d6fd4e0a5270bc6 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 23 Apr 2026 15:21:50 +0200 Subject: [PATCH] add swagger to puppeteer --- .../app/__pycache__/main.cpython-313.pyc | Bin 2087 -> 4588 bytes Dockers/puppeteer-api/app/main.py | 73 +++++++++++++++++- Dockers/puppeteer-api/app/routes/browser.py | 71 ++++++++++++++--- Dockers/puppeteer-api/app/routes/cache.py | 2 +- Dockers/puppeteer-api/app/routes/health.py | 2 +- 5 files changed, 132 insertions(+), 16 deletions(-) diff --git a/Dockers/puppeteer-api/app/__pycache__/main.cpython-313.pyc b/Dockers/puppeteer-api/app/__pycache__/main.cpython-313.pyc index e23b09835f86599ef2c368f060feb7ba4ddbccce..0a613406b3c58194aee4adc750dfa1043e13842a 100644 GIT binary patch literal 4588 zcma)9O>7&-6`ox#x%?9)l9DLW`dP|~6WS8#RPv7;+livkmMKRvxDk*-1lB8ZC9O^E zva?GoBG5nq0wh5TSVj>zhaPftd#R5(^pMYEXlqz$?0 zLY#Rs@6DT;H}8G#=~+*Y7s2<(;eV(n0to$`4D63`VDM~@Md))RB9U1{83r=~q1iC|I~KW&6FVu*ExIy1<|*x5bZ0!+LuuEdH{-*;j34{ym|yJ41aKhJi+kzVz1Wut z;$S9(LzyrR(>c##e`Wv=P};jVn2F#B(7xQz3@iF$=njXYVh@hxoZ~1L69Z!JEQ?R% zm~kZb<)Y*0Tt(GV<~Nl>xk#sd^H|s3!|KKc3|Wg;HPw^~YgRO0RAjAOlJsIh%FD1qQZy5*iUCso z`J3svrIlOKe0F)s3eC^WFQldP`?qdne<>|2uVlr9!*XpZa?#wh_%*C=8w$3ZR12IW zvaaL2B6V+2k~csxV{x}+SmMHpS;ktvV>pC37&zef?61&#j#gW5h|ns;=h0_dE9Rx3*yx| zf#PV@Z?EBz47U)_7}{{IqbrUvw9X`4OFxn8So}Q&8>+5VoMm%;>LS>~t!R0@plTbI zt0pdeW3)TX|rq}H&!m3l|dm$!(r)OFQV61WI5+h!#`B_kJtR;kFHn!<5mAm-G8a(zqIea{3+Au?YsZ$ zy@jfK_}>S<{%44B4t>`fX*!_)j}e4j{`Bfx1bq|n&P_PJImtj<@w`6;(J%$!a19K= zY4Vn0dF8UHOA5w1t_<8N$~)T-1e>M+f`X`EIDh5yR&WX6zAzTdP1zL6hGGa^@}@4_ zQw`M=s1HvHdAS76s|ZFOD~e`p>ZUO%z*}HBZwg|1IklyjvY=`Z1Y0tBl1V|<3c{AG zK@12otcO@jdcAXHa}!=b%~PRE0zAPzwV(*MD}mdBt_fKART&(@NLHMwf}S@jt`t^) zsPv>-AdtLc=$h4EB2EP$0s3^TR;ar761?R!RTD6vA1r413JRQ8r7mK*i8`r_i^P+F zrlhZ>lnw@rZJ7XuR8}SMIxwdRn>-h~VGImqFr)Y{{2q|GrHq<=P1`Gj?lD}&t6Kt0%{_UeI zR5{fN-Kmnkt$>FMG(!Q%3T0C*swU*Pq~)9^aaa*(*NRSph%{IY6Qam~*aSh&lsAlo z$8rD&S^+ys3}O!%ldScE9q}1rOs}Ds1!Y|>7fmY+kRvUoA$6wb-brVrrMY+02?w4f zYbS|tpM7_Us)Z|)$Pxqrj}dktUp7pA%g+7y3QQ*8XB00pgqsw1~ z_JfyqGmS{R9+|8~CaY6dzh?I%^Hr|@hrXdkVDO;tWFtELWafHxdf_jgY9tFexshjZKZIwXNig^Vp-C#n=b^EJ6i3@d#vOeP zke^ixRv%VMMH%W3w2C}|-UQryTP48( z<=!R1_G@rxQqlpdScD{1QXz54>j36L#U*S))Ebp|D{_RFDo!{pPkT2~a)9A1X9=sI zoPaAPHPasuzDj87vAtA=dNZvdQ*Tx$-}>5B^rpU)(F`L?lQ=`Afm(17>0%# zq|Oc2xWO;dRnm{sJD>TMo1}V{60chPI{YU9=!=OfS_7?7(yj#vv>6nStUjA(!wM#T zZN=qM`xTRI%1ExY|8Ky=cdcl*9JmiD1dJr{E6fwVAAm4fkWCptS;54WF)62^;DY(Q zuC1#Z9k)1|J<2SYO_z%q+B5!zpaZasj8{?kyWmLmwnfPJiLiOauuL4ac>ser$ZWI^*9B%ZDHBP+#c%~Y=co^~X?5^XVqhriy^Bo5D zj_f)<_5tiNTpfjKD71I};gtth9=X4)RLS@k4diGsEJj$+@m)^?dFm)wL%{|&(%^dQ-0jzWk&|JY zeTebevL{}Y9ez&s5MgO(3rq^~REv3MpNSr_ NEHg!zF-p-0`7c2mPG$fA delta 1041 zcma)5O=}ZT6uo1bxmjhv%Ow%c867Eb8R}uUJ z4YSC`jk*xm{sh5&F`x{DEL;hSmHYsGFUibA5$s!>cki8Z&b#mO=G(@bNOs1uK_F{u z@Iig=0r)}_Yx$PJEHTX99*BPQXP^c(PbDA*F(`&GG|#=2u*f1Sa>$81^7ERn5)q>q z&49$LLt*KIF)3EfTJFv+vDI5nW<>>N5fKln8d!CEm$*yFY_=6h{C`xgm4?@(wQ7E$ z{NsGdFL|nhW$EneAO)?yYyMiG7QE{w{o5uE6F`by!XspR417*s^O5YVnl0j5*gl+1 zIb%VVzX?WvMVK4NGIl7zfc;ZaTr=L@>Ie diff --git a/Dockers/puppeteer-api/app/main.py b/Dockers/puppeteer-api/app/main.py index 948b897..7c6e42d 100644 --- a/Dockers/puppeteer-api/app/main.py +++ b/Dockers/puppeteer-api/app/main.py @@ -1,18 +1,83 @@ from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi +from fastapi.security import APIKeyHeader from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from app.database import init_db, cleanup_old_cache_entries from app.config import CLEANUP_CRON, CACHE_EXPIRY_HOURS from app.routes import health, browser, cache from app.services.browser import force_cleanup_old_pages +from pathlib import Path + + +def _read_version() -> str: + version_file = Path(__file__).resolve().parents[1] / "version" + try: + v = version_file.read_text(encoding="utf-8").strip() + return v or "latest" + except Exception: + return "latest" + + +API_KEY_HEADER_NAME = "X-API-Key" +api_key_header = APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=False) # Create FastAPI app -app = FastAPI() +app = FastAPI( + title="Playwright API Server", + version=_read_version(), + description=( + "FastAPI server that uses Playwright to visit pages, capture screenshots, " + "extract SEO/meta information, and manage a cache.\n\n" + f"Authentication: provide `{API_KEY_HEADER_NAME}` on requests." + ), + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + swagger_ui_parameters={"persistAuthorization": True}, +) + + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + + components = openapi_schema.setdefault("components", {}) + security_schemes = components.setdefault("securitySchemes", {}) + security_schemes["ApiKeyAuth"] = { + "type": "apiKey", + "in": "header", + "name": API_KEY_HEADER_NAME, + "description": f"Send your API key in the `{API_KEY_HEADER_NAME}` header.", + } + + # Apply API key auth globally (individual endpoints may still allow anonymous access; + # this just documents the expected auth mechanism). + openapi_schema["security"] = [{"ApiKeyAuth": []}] + + openapi_schema["tags"] = [ + {"name": "Health", "description": "Liveness / basic health checks."}, + {"name": "Browser", "description": "Playwright-powered browsing utilities."}, + {"name": "Cache", "description": "Cache management and statistics."}, + ] + + app.openapi_schema = openapi_schema + return app.openapi_schema + + +app.openapi = custom_openapi # Include routers -app.include_router(health.router) -app.include_router(browser.router) -app.include_router(cache.router) +app.include_router(health.router, tags=["Health"]) +app.include_router(browser.router, tags=["Browser"]) +app.include_router(cache.router, tags=["Cache"]) # Initialize scheduler for periodic cache cleanup scheduler = BackgroundScheduler() diff --git a/Dockers/puppeteer-api/app/routes/browser.py b/Dockers/puppeteer-api/app/routes/browser.py index 7778cad..d293314 100644 --- a/Dockers/puppeteer-api/app/routes/browser.py +++ b/Dockers/puppeteer-api/app/routes/browser.py @@ -14,7 +14,7 @@ from app.services.browser import ( get_resulting_url_service ) -router = APIRouter() +router = APIRouter(tags=["Browser"]) class ClickInteraction(BaseModel): action: Literal["click"] @@ -43,14 +43,21 @@ class Viewport(BaseModel): class InteractionsRequest(BaseModel): - url: str - returnType: Optional[Literal["screenshot", "html"]] = "screenshot" + url: str = Field(..., description="Target URL (will be URL-decoded server-side).") + returnType: Optional[Literal["screenshot", "html"]] = Field( + "screenshot", + description="Return a PNG screenshot or HTML content.", + ) viewport: Optional[Viewport] = None scrollX: Optional[int] = None scrollY: Optional[int] = None interactions: List[Interaction] -@router.get("/") +@router.get( + "/", + summary="Visit URL", + description="Navigate to `url` and return HTML content (cached unless `skipCache=true`).", +) async def visit_url(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)): # Validate API key if not x_api_key or x_api_key != API_KEY: @@ -78,7 +85,16 @@ async def visit_url(url: str, skipCache: bool = False, x_api_key: Optional[str] print(f"Error visiting URL {decoded_url}: {e}") raise HTTPException(status_code=500, detail=str(e)) -@router.get("/screenshot") +@router.get( + "/screenshot", + summary="Capture screenshot", + description="Capture a PNG screenshot for `url`.", + responses={ + 200: {"content": {"image/png": {}}}, + 401: {"description": "Invalid API key"}, + 500: {"description": "Screenshot capture failed"}, + }, +) async def screenshot_url(url: str, fullPage: bool = True, x_api_key: Optional[str] = Header(None)): """Capture a screenshot of a website and return it as PNG""" if not x_api_key or x_api_key != API_KEY: @@ -102,7 +118,26 @@ async def screenshot_url(url: str, fullPage: bool = True, x_api_key: Optional[st print(f"Error taking screenshot for URL {decoded_url}: {e}") raise HTTPException(status_code=500, detail=str(e)) -@router.post("/interactions") +@router.post( + "/interactions", + summary="Run interactions", + description=( + "Run a sequence of interactions (click/type) on a page.\n\n" + "- `returnType=screenshot` returns `image/png`\n" + "- `returnType=html` returns `text/html`" + ), + responses={ + 200: { + "content": { + "image/png": {}, + "text/html": {}, + "application/json": {}, + } + }, + 401: {"description": "Invalid API key"}, + 500: {"description": "Interaction failed"}, + }, +) async def run_interactions(payload: InteractionsRequest, x_api_key: Optional[str] = Header(None)): if not x_api_key or x_api_key != API_KEY: raise HTTPException(status_code=401, detail="Invalid API key") @@ -137,7 +172,11 @@ async def run_interactions(payload: InteractionsRequest, x_api_key: Optional[str print(f"Error running interactions on URL {decoded_url}: {e}") raise HTTPException(status_code=500, detail=str(e)) -@router.get("/seo") +@router.get( + "/seo", + summary="Extract SEO information", + description="Extract SEO information from `url` (cached unless `skipCache=true`).", +) async def extract_seo(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)): """Extract SEO information from a website""" # Validate API key @@ -165,7 +204,11 @@ async def extract_seo(url: str, skipCache: bool = False, x_api_key: Optional[str except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@router.get("/meta") +@router.get( + "/meta", + summary="Extract meta tags", + description="Extract meta tags / Open Graph / Twitter card data from `url` (cached unless `skipCache=true`).", +) async def extract_meta_tags(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)): """Extract meta tags from a website""" # Validate API key @@ -194,7 +237,11 @@ async def extract_meta_tags(url: str, skipCache: bool = False, x_api_key: Option raise HTTPException(status_code=500, detail=str(e)) -@router.get("/outgoing-calls") +@router.get( + "/outgoing-calls", + summary="Capture outgoing calls", + description="Capture outgoing API calls from `url` (cached unless `skipCache=true`).", +) async def capture_outgoing_calls(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)): """Capture outgoing API calls from a website""" # Validate API key @@ -224,7 +271,11 @@ async def capture_outgoing_calls(url: str, skipCache: bool = False, x_api_key: O raise HTTPException(status_code=500, detail=str(e)) -@router.get("/resulting-url") +@router.get( + "/resulting-url", + summary="Get resulting URL", + description="Get the final URL after navigation/redirects for `url` (cached unless `skipCache=true`).", +) async def get_resulting_url(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)): """Get the resulting URL after navigation (handles redirects)""" # Validate API key diff --git a/Dockers/puppeteer-api/app/routes/cache.py b/Dockers/puppeteer-api/app/routes/cache.py index e61fc77..b5bdb70 100644 --- a/Dockers/puppeteer-api/app/routes/cache.py +++ b/Dockers/puppeteer-api/app/routes/cache.py @@ -3,7 +3,7 @@ from typing import Optional from app.config import API_KEY from app.services.cache import clear_cache, get_cache_stats -router = APIRouter() +router = APIRouter(tags=["Cache"]) @router.get("/cache/clear") async def clear_cache_route(x_api_key: Optional[str] = Header(None)): diff --git a/Dockers/puppeteer-api/app/routes/health.py b/Dockers/puppeteer-api/app/routes/health.py index 6194522..61ca585 100644 --- a/Dockers/puppeteer-api/app/routes/health.py +++ b/Dockers/puppeteer-api/app/routes/health.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -router = APIRouter() +router = APIRouter(tags=["Health"]) @router.head("/") async def health_check():