Skip to content

Commit

Permalink
feat(web): Updating docstrings and few small few fixes/improvements f…
Browse files Browse the repository at this point in the history
…ound in the PR review
  • Loading branch information
kiraum committed Nov 16, 2024
1 parent ce17cd2 commit f87d2a0
Show file tree
Hide file tree
Showing 2 changed files with 51 additions and 49 deletions.
82 changes: 39 additions & 43 deletions ancv/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,24 @@ def server_timing_header(timings: dict[str, timedelta]) -> str:
)


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."""

Expand All @@ -284,10 +302,8 @@ def __init__(

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:
Expand All @@ -302,73 +318,59 @@ async def app_context(self, app: web.Application) -> AsyncGenerator[None, None]:
"""
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:
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
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
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:
return web.Response(
text=f"Failed to fetch resume from {self.destination}",
status=HTTPStatus.NOT_FOUND,
)
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:
LOGGER.error("Invalid JSON format in resume data")
return web.Response(
text="Invalid JSON format in resume data",
status=HTTPStatus.BAD_REQUEST,
)
raise InvalidResumeDataError("Invalid JSON format in resume data")

def render(self, resume_data: ResumeSchema) -> str | web.Response:
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 rendering fails
Raises:
ResumeConfigError: When resume data doesn't match expected schema
ValueError: When template rendering fails
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 isinstance(rendered, str):
return rendered
return web.Response(text="Template rendering failed")
except ResumeConfigError as exc:
return web.Response(text=str(exc))
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.
Expand Down Expand Up @@ -404,11 +406,7 @@ async def root(self, request: web.Request) -> web.Response:
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)
if isinstance(rendered, web.Response):
return rendered
self._last_valid_render = rendered
self.cache = rendered
self.last_fetch = current_time
Expand All @@ -417,19 +415,17 @@ async def root(self, request: web.Request) -> web.Response:
if self._last_valid_render:
self.cache = self._last_valid_render
log.warning("Using last valid render as fallback")
elif not self.cache:
else:
return web.Response(
text="No cache available", status=HTTPStatus.SERVICE_UNAVAILABLE
)
except ResumeConfigError as exc:
log.error("Resume configuration error", error=str(exc))
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")
elif not self.cache:
return web.Response(
text="Invalid resume format", status=HTTPStatus.BAD_REQUEST
)
else:
return web.Response(text=str(exc), status=HTTPStatus.BAD_REQUEST)

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

log.debug("Serving rendered template.")
return web.Response(text=self.cache)
18 changes: 12 additions & 6 deletions tests/web/test_server.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import asyncio
import json
from contextlib import AbstractContextManager
from contextlib import nullcontext as does_not_raise
from datetime import timedelta
Expand All @@ -8,7 +7,7 @@

import pytest
from aiohttp.client import ClientResponse
from aiohttp.web import Application, Response
from aiohttp.web import Application, Response, json_response

from ancv.web.server import (
SHOWCASE_RESUME,
Expand Down Expand Up @@ -311,10 +310,14 @@ async def test_web_handler_basic_functionality(
aiohttp_client: Any,
aiohttp_server: Any,
) -> None:
hitcount = 0

# Set up a mock resume server
async def mock_resume_handler(request):
return Response(
text=json.dumps({"basics": {"name": "Test User", "label": "Developer"}})
nonlocal hitcount
hitcount += 1
return json_response(
{"basics": {"name": "Test User", "label": "Developer"}}
)

# Create and start mock server
Expand All @@ -333,6 +336,7 @@ async def mock_resume_handler(request):
# 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
Expand All @@ -341,12 +345,14 @@ async def mock_resume_handler(request):
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 # Should be same content
assert await resp.text() == first_response
assert hitcount == 2 # Second hit after cache expired

async def test_web_handler_error_handling(
self,
Expand All @@ -369,4 +375,4 @@ async def error_handler(request):

# Test error response
resp = await client.get("/")
assert resp.status == HTTPStatus.NOT_FOUND
assert resp.status == HTTPStatus.BAD_REQUEST

0 comments on commit f87d2a0

Please sign in to comment.