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 8fd4b6d..e1186e5 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 2053ff5..729c979 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 @@ -258,3 +262,173 @@ def server_timing_header(timings: dict[str, timedelta]) -> str: f"{name.replace(' ', '-')};dur={duration // timedelta(milliseconds=1)}" for name, duration in timings.items() ) + + +class RenderError(Exception): + """Base exception for resume rendering failures""" + + pass + + +class TemplateRenderError(RenderError): + """Raised when template rendering fails""" + + pass + + +class InvalidResumeDataError(RenderError): + """Raised when resume data is invalid""" + + pass + + +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: + """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 + web.Response: Error response when: + - Resume cannot be fetched from destination (NOT_FOUND) + - Response is not valid JSON (BAD_REQUEST) + - JSON data doesn't match resume schema + """ + async with session.get(self.destination) as response: + if response.status != HTTPStatus.OK: + raise RenderError(f"Failed to fetch resume from {self.destination}") + content = await response.text() + try: + resume_data = json.loads(content) + return ResumeSchema(**resume_data) + except json.JSONDecodeError: + raise InvalidResumeDataError("Invalid JSON format in resume data") + + def render(self, resume_data: ResumeSchema) -> str: + """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: + - Resume data doesn't match expected schema + - Template rendering fails + """ + try: + template = Template.from_model_config(resume_data) + rendered = template.render() + if not rendered: + raise TemplateRenderError("Template rendering failed") + return rendered + except ResumeConfigError: + raise InvalidResumeDataError("Resume configuration error") + + 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) + 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") + else: + return web.Response( + text="No cache available", status=HTTPStatus.SERVICE_UNAVAILABLE + ) + except (RenderError, InvalidResumeDataError) as exc: + log.error("Resume rendering error", error=str(exc)) + if self._last_valid_render: + self.cache = self._last_valid_render + log.warning("Using last valid render as fallback") + else: + return web.Response( + text="Unable to render resume", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + log.debug("Serving rendered template.") + return web.Response(text=self.cache) diff --git a/tests/web/test_server.py b/tests/web/test_server.py index d032115..67057a2 100644 --- a/tests/web/test_server.py +++ b/tests/web/test_server.py @@ -7,11 +7,12 @@ import pytest from aiohttp.client import ClientResponse -from aiohttp.web import Application +from aiohttp.web import Application, Response, json_response from ancv.web.server import ( SHOWCASE_RESUME, SHOWCASE_USERNAME, + WebHandler, is_terminal_client, server_timing_header, ) @@ -299,3 +300,79 @@ def test_server_timing_header( def test_exact_showcase_output(showcase_output: str) -> None: assert SHOWCASE_RESUME == showcase_output + + +@pytest.mark.filterwarnings("ignore:Request.message is deprecated") +@pytest.mark.filterwarnings("ignore:Exception ignored in") +class TestWebHandler: + async def test_web_handler_basic_functionality( + self, + aiohttp_client: Any, + aiohttp_server: Any, + ) -> None: + hitcount = 0 + + # Set up a mock resume server + async def mock_resume_handler(request): + nonlocal hitcount + hitcount += 1 + return json_response( + {"basics": {"name": "Test User", "label": "Developer"}} + ) + + # Create and start mock server + mock_app = Application() + mock_app.router.add_get("/resume.json", mock_resume_handler) + mock_server = await aiohttp_server(mock_app) + + # Create WebHandler pointing to our mock server + destination = f"http://localhost:{mock_server.port}/resume.json" + refresh_interval = timedelta(seconds=1) + handler = WebHandler(destination, refresh_interval=refresh_interval) + + # Create test client + client = await aiohttp_client(handler.app) + + # Test initial fetch + resp = await client.get("/") + assert resp.status == HTTPStatus.OK + assert hitcount == 1 # First hit + content = await resp.text() + assert "Test User" in content + assert "Developer" in content + + # Test caching + first_response = content + resp = await client.get("/") + assert await resp.text() == first_response + assert hitcount == 1 # Still one hit, response was cached + + # Test refresh after interval + await asyncio.sleep(refresh_interval.total_seconds() + 0.1) + resp = await client.get("/") + assert resp.status == HTTPStatus.OK + assert await resp.text() == first_response + assert hitcount == 2 # Second hit after cache expired + + async def test_web_handler_error_handling( + self, + aiohttp_client: Any, + aiohttp_server: Any, + ) -> None: + # Set up server that returns errors + async def error_handler(request): + return Response(status=500) + + mock_app = Application() + mock_app.router.add_get("/error.json", error_handler) + mock_server = await aiohttp_server(mock_app) + + # Create WebHandler pointing to error endpoint + destination = f"http://localhost:{mock_server.port}/error.json" + handler = WebHandler(destination) + + client = await aiohttp_client(handler.app) + + # Test error response + resp = await client.get("/") + assert resp.status == HTTPStatus.BAD_REQUEST