From 531cbbc06eea68f833ae656346618387245759b8 Mon Sep 17 00:00:00 2001 From: kiraum Date: Sat, 26 Oct 2024 22:50:13 +0200 Subject: [PATCH] feat(web): Add web server command to serve JSON resume from URL with periodic refresh --- README.md | 19 +++++ ancv/__main__.py | 27 +++++++ ancv/web/server.py | 172 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c2a5e0d..93f3e3b 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,25 @@ $ ancv render resume.json $ docker run -v $(pwd)/resume.json:/app/resume.json ghcr.io/alexpovel/ancv render ``` +Alternatively, you can directly serve your resume from any HTTP URL using he built-in web server: + +```bash +# pip route: +$ ancv serve web https://raw.githubusercontent.com/alexpovel/ancv/refs/heads/main/ancv/data/showcase.resume.json +# container route: +$ docker run ghcr.io/alexpovel/ancv serve web https://raw.githubusercontent.com/alexpovel/ancv/refs/heads/main/ancv/data/showcase.resume.json +``` + +Test it: +```bash +curl http://localhost:8080 +``` + +The web server includes useful features like: +- Automatic refresh of resume content (configurable interval) +- Fallback to cached version if source is temporarily unavailable +- Configurable host/port binding (default: http://localhost:8080) + ## Self-hosting Self-hosting is a first-class citizen here. diff --git a/ancv/__main__.py b/ancv/__main__.py index b734055..7fb944f 100644 --- a/ancv/__main__.py +++ b/ancv/__main__.py @@ -69,6 +69,33 @@ def file( FileHandler(file).run(context) +@server_app.command(no_args_is_help=True) +def web( + destination: str = typer.Argument( + ..., help="HTTP/HTTPS URL of the JSON resume file to serve." + ), + refresh: int = typer.Option( + 3600, help="Refresh interval in seconds for fetching updates from the URL." + ), + port: int = typer.Option(8080, help="Port to bind to."), + host: str = typer.Option("0.0.0.0", help="Hostname to bind to."), + path: Optional[str] = typer.Option( + None, help="File system path for an HTTP server UNIX domain socket." + ), +) -> None: + """Starts a web server that serves a JSON resume from a URL with periodic refresh. + + The server will fetch and render the resume from the provided URL, caching it for the specified + refresh interval. This is useful for serving resumes hosted on external services. + """ + + from ancv.web.server import WebHandler, ServerContext + from datetime import timedelta + + context = ServerContext(host=host, port=port, path=path) + WebHandler(destination, refresh_interval=timedelta(seconds=refresh)).run(context) + + @app.command() def render( path: Path = typer.Argument( diff --git a/ancv/web/server.py b/ancv/web/server.py index 2bfc978..b72a316 100644 --- a/ancv/web/server.py +++ b/ancv/web/server.py @@ -1,16 +1,20 @@ +import json +import time from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import timedelta from http import HTTPStatus from pathlib import Path +from pydantic import ValidationError from typing import AsyncGenerator, Optional -from aiohttp import ClientSession, web +from aiohttp import ClientSession, ClientError, web from cachetools import TTLCache from gidgethub.aiohttp import GitHubAPI from structlog import get_logger from ancv import PROJECT_ROOT +from ancv.data.models.resume import ResumeSchema from ancv.data.validation import is_valid_github_username from ancv.exceptions import ResumeConfigError, ResumeLookupError from ancv.timing import Stopwatch @@ -261,3 +265,169 @@ def server_timing_header(timings: dict[str, timedelta]) -> str: f"{name.replace(' ', '-')};dur={duration // timedelta(milliseconds=1)}" for name, duration in timings.items() ) + + +class WebHandler(Runnable): + """A handler serving a rendered template loaded from a URL with periodic refresh.""" + + def __init__( + self, destination: str, refresh_interval: timedelta = timedelta(seconds=300) + ) -> None: + """Initializes the handler. + + Args: + destination: The URL to load the JSON Resume from. + refresh_interval: How often to refresh the resume. + """ + self.destination = destination + self.refresh_interval = refresh_interval + self.cache: str = "" + self.last_fetch: float = 0 + self._last_valid_render: str = "" + + LOGGER.debug("Instantiating web application.") + self.app = web.Application() + + LOGGER.debug("Adding routes.") + self.app.add_routes([web.get("/", self.root)]) + + self.app.cleanup_ctx.append(self.app_context) + + def run(self, context: ServerContext) -> None: + LOGGER.info("Loaded, starting server...") + web.run_app(self.app, host=context.host, port=context.port, path=context.path) + + async def app_context(self, app: web.Application) -> AsyncGenerator[None, None]: + """Sets up the application context with required clients. + + Args: + app: The app instance to attach our state to. + """ + log = LOGGER.bind(app=app) + log.debug("App context initialization starting.") + + log.debug("Starting client session.") + session = ClientSession() + app["client_session"] = session + log.debug("Started client session.") + + log.debug("App context initialization done, yielding.") + yield + + log.debug("App context teardown starting.") + await session.close() + log.debug("App context teardown done.") + + async def fetch(self, session: ClientSession) -> ResumeSchema | web.Response: + """Fetches and validates resume JSON from the destination URL. + + Args: + session: The aiohttp client session to use for requests. + + Returns: + ResumeSchema: The validated resume data + + Raises: + ResumeLookupError: When resume cannot be fetched from destination + json.JSONDecodeError: When response is not valid JSON + aiohttp.ClientError: When network request fails + ValidationError: When JSON data doesn't match resume schema + """ + async with session.get(self.destination) as response: + if response.status != HTTPStatus.OK: + return web.Response( + text=f"Failed to fetch resume from {self.destination}", + status=HTTPStatus.NOT_FOUND, + ) + content = await response.text() + try: + resume_data = json.loads(content) + return ResumeSchema(**resume_data) + except json.JSONDecodeError: + LOGGER.error("Invalid JSON format in resume data") + return web.Response( + text="Invalid JSON format in resume data", + status=HTTPStatus.BAD_REQUEST, + ) + + def render(self, resume_data: ResumeSchema) -> str | web.Response: + """Renders resume data into a formatted template string. + + Args: + resume_data: The resume data dictionary to render + + Returns: + str: The successfully rendered resume template + web.Response: Error response when rendering fails + + Raises: + ResumeConfigError: When resume data doesn't match expected schema + ValueError: When template rendering fails + """ + try: + template = Template.from_model_config(resume_data) + return template.render() + except ResumeConfigError as exc: + return web.Response(text=str(exc)) + + async def root(self, request: web.Request) -> web.Response: + """The root endpoint, returning the rendered template with periodic refresh. + + Implements a caching mechanism that refreshes the resume data at configured intervals. + Uses monotonic time to ensure reliable cache invalidation. Falls back to cached version + if refresh fails. + + Args: + request: The incoming web request containing the client session + + Returns: + web.Response: Contains either: + - Fresh or cached rendered template as text + - Error message with SERVICE_UNAVAILABLE status when no cache exists + + Note: + Cache refresh occurs when: + - No cache exists + - No previous fetch timestamp exists + - Refresh interval has elapsed since last fetch + """ + log = LOGGER.bind(request=request) + session: ClientSession = request.app["client_session"] + + current_time = time.monotonic() + should_refresh = ( + not self.cache + or (current_time - self.last_fetch) > self.refresh_interval.total_seconds() + ) + + if should_refresh: + log.debug("Fetching fresh resume data.") + try: + resume_data = await self.fetch(session) + if isinstance(resume_data, web.Response): + return resume_data + rendered = self.render(resume_data) + self._last_valid_render = rendered + self.cache = rendered + self.last_fetch = current_time + except (ClientError, ValidationError) as exc: + log.error("Network or validation error", error=str(exc)) + if self._last_valid_render: + self.cache = self._last_valid_render + log.warning("Using last valid render as fallback") + elif not self.cache: + return web.Response( + text="No cache available", status=HTTPStatus.SERVICE_UNAVAILABLE + ) + except ResumeConfigError as exc: + log.error("Resume configuration error", error=str(exc)) + if self._last_valid_render: + self.cache = self._last_valid_render + log.warning("Using last valid render as fallback") + elif not self.cache: + return web.Response( + text="Invalid resume format", status=HTTPStatus.BAD_REQUEST + ) + + log.debug("Serving rendered template.") + return web.Response(text=self.cache)