Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): Add web server command to serve JSON resume from URL with periodic refresh #241

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions ancv/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
163 changes: 163 additions & 0 deletions ancv/web/server.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
kiraum marked this conversation as resolved.
Show resolved Hide resolved