Skip to content

Commit

Permalink
feat(web): Add web server command to serve JSON resume from URL with …
Browse files Browse the repository at this point in the history
…periodic refresh (#241)
  • Loading branch information
kiraum authored Nov 18, 2024
1 parent 65ef798 commit 51e1640
Show file tree
Hide file tree
Showing 4 changed files with 299 additions and 2 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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
176 changes: 175 additions & 1 deletion ancv/web/server.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
79 changes: 78 additions & 1 deletion tests/web/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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

0 comments on commit 51e1640

Please sign in to comment.