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
  • Loading branch information
kiraum committed Oct 27, 2024
1 parent 99d6a18 commit 8579672
Show file tree
Hide file tree
Showing 2 changed files with 185 additions and 0 deletions.
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
158 changes: 158 additions & 0 deletions ancv/web/server.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import json
import tempfile
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import timedelta
Expand Down Expand Up @@ -261,3 +264,158 @@ 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) -> dict:
"""Fetches resume JSON from the destination URL.
Args:
session: The aiohttp client session to use for requests.
Returns:
dict: The parsed resume JSON data
Raises:
ResumeLookupError: When resume cannot be fetched from destination
json.JSONDecodeError: When response is not valid JSON
aiohttp.ClientError: When network request fails
"""
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.BAD_REQUEST,
)
content = await response.text()
return json.loads(content)

def render(self, resume_data: dict) -> 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)
rendered = self.render(resume_data)
self._last_valid_render = rendered
self.cache = rendered
self.last_fetch = current_time
except (aiohttp.ClientError, json.JSONDecodeError) as exc:
log.error("Network or parsing 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)

0 comments on commit 8579672

Please sign in to comment.