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..e5f236d 100644 --- a/ancv/web/server.py +++ b/ancv/web/server.py @@ -1,8 +1,11 @@ +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 @@ -11,6 +14,7 @@ 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,162 @@ 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() + resume_data = json.loads(content) + return ResumeSchema(**resume_data) + + 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)