feat: add placeholder api supporting svg and png

This commit is contained in:
Bradan J. Wolbeck 2026-09-12 17:11:09 -06:00
parent 840cd6c02b
commit 7390cdd6a6
18 changed files with 1603 additions and 0 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.git
.venv
__pycache__
*.py[cod]
build
dist
.agents
.codex

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

42
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,42 @@
default_install_hook_types: [pre-commit, commit-msg]
repos:
- repo: local
hooks:
- id: ruff-check
name: Ruff lint
entry: uv run --locked ruff check --fix
language: system
types_or: [python, pyi]
stages: [pre-commit]
require_serial: true
- id: ruff-format
name: Ruff format
entry: uv run --locked ruff format
language: system
types_or: [python, pyi]
stages: [pre-commit]
require_serial: true
- id: pyright
name: Pyright
entry: uv run --locked pyright
language: system
pass_filenames: false
always_run: true
stages: [pre-commit]
- id: pytest
name: Pytest
entry: uv run --locked pytest -q
language: system
pass_filenames: false
always_run: true
stages: [pre-commit]
- id: commitizen
name: Check commit message
entry: uv run --locked cz check --commit-msg-file
language: system
stages: [commit-msg]

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.12

24
Containerfile Normal file
View file

@ -0,0 +1,24 @@
FROM python:3.12-alpine
COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /uvx /bin/
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
# Cairo renders PNGs; DejaVu supplies the placeholder's sans-serif font.
RUN apk add --no-cache cairo font-dejavu \
&& addgroup -S app \
&& adduser -S -G app app
COPY pyproject.toml uv.lock README.md ./
COPY src ./src
RUN apk add --no-cache --virtual .build-deps build-base libffi-dev \
&& uv sync --locked --no-dev --no-editable \
&& uv cache clean \
&& apk del .build-deps
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "app:create_app()"]

100
README.md Normal file
View file

@ -0,0 +1,100 @@
# Placeholder API
Generate SVG and PNG placeholder images with configurable dimensions and font size.
## Prerequisites
Local development uses uv and Python 3.12 or newer. PNG rendering also needs
Cairo, which you install with your system package manager before running `uv sync`.
Run the appropriate command for your distribution as root:
| Distribution | Install Cairo |
| --- | --- |
| [Alpine](https://pkgs.alpinelinux.org/packages?name=cairo) | `apk add cairo` |
| [Arch](https://archlinux.org/packages/extra/x86_64/cairo/) | `pacman -S cairo` |
| [Debian](https://packages.debian.org/en/trixie/libcairo2) | `apt install libcairo2` |
| [Void](https://github.com/void-linux/void-packages/blob/master/srcpkgs/cairo/template) | `xbps-install -S cairo` |
For a container setup, you only need Podman on the host. The image includes
Python, Cairo, and fonts.
## Running the app
Run the development server:
```sh
uv sync
uv run flask --app app:create_app run --debug
```
Alternatively, `uv run app` starts the development server without debug mode.
- `GET /placeholder.svg` returns an SVG placeholder.
- `GET /placeholder.png` returns a PNG placeholder.
- `GET /healthz` returns `{"status": "ok"}`.
Image endpoints accept `width`, `height`, and `font_size` query parameters.
For example, `/placeholder.png?width=320&height=180&font_size=20` returns a
320 × 180 PNG with 20-pixel text.
Run the Gunicorn server locally:
```sh
uv run gunicorn --bind 0.0.0.0:8000 --workers 2 --access-logfile - --error-logfile - 'app:create_app()'
```
Build and run the container:
```sh
podman build -t placeholder-api -f Containerfile .
podman run --rm -p 8000:8000 placeholder-api
```
The container runs as an unprivileged user and serves HTTP on port 8000.
Check it with `curl http://localhost:8000/healthz`.
Additional Gunicorn options can be passed through `GUNICORN_CMD_ARGS`.
The application factory is in `src/app/__init__.py`; API routes live in
`src/app/routes/api.py`.
## Development checks
Install the Git hooks once after cloning and running `uv sync`:
```sh
uv run pre-commit install
```
Run all checks on tracked files:
```sh
uv run pre-commit run --all-files
```
The hooks run Ruff, Pyright, and pytest before commits, and validate commit
messages with Commitizen. To run just the tests:
```sh
uv run pytest -q
```
## Caching and rate limits
Successful image responses allow public caching for one day. Rendered images are
also cached in memory on the server with a one-day expiration.
Rate limits apply per client IP, per Gunicorn worker:
| Format | Requests per minute | Uncached renders per minute |
| --- | ---: | ---: |
| SVG | 900 | 300 |
| PNG | 900 | 30 |
Exceeding a limit returns HTTP 429. Server-cached images remain available after
the render limit is reached, subject to the request limit. Responses served
directly from a browser or CDN cache do not reach the app or consume its quotas.
Each Gunicorn worker maintains its own cache and rate-limit counters. Defaults
are configured in `src/app/config.py`.

39
pyproject.toml Normal file
View file

@ -0,0 +1,39 @@
[project]
name = "app"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Bradan J. Wolbeck", email = "bwolbeck@compaqdisc.com" }
]
requires-python = ">=3.12"
dependencies = [
"cairosvg>=2.9.1",
"flask>=3.1.3",
"flask-caching>=2.5.1",
"flask-limiter>=4.1.1",
"gunicorn>=26.2.0",
]
[project.scripts]
app = "app:main"
[build-system]
requires = ["uv_build>=0.12.9,<0.13.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"commitizen>=4.18.0",
"pre-commit>=4.6.2",
"pyright[nodejs]>=1.1.414",
"pytest>=9.1.1",
"ruff>=0.16.7",
]
[tool.commitizen]
name = "cz_conventional_commits"
tag_format = "v$version"
version_scheme = "pep440"
version_provider = "uv"
major_version_zero = true

8
pyrightconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"include": ["src", "tests"],
"extraPaths": ["src"],
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.12",
"typeCheckingMode": "standard"
}

37
ruff.toml Normal file
View file

@ -0,0 +1,37 @@
target-version = "py312"
line-length = 120
src = [
"src"
]
exclude = [
"**/__pycache__",
"**/node_modules",
"**/.*",
".venv",
]
[lint]
select = [
"A", # Builtin shadowing
"B", # Common bug patterns
"S", # Security checks
"C4", # Comprehension cleanup
"DTZ", # Timezone mistakes
"PIE", # Redundant or unnecessary code
"PLE", # Pylint error checks
"I", # Import sorting
"E4", "E7", "E9", # Core style and syntax errors
"F", # Undefined names, unused imports, etc.
"UP", # Modern Python syntax
"RUF100", # Unused noqa comments
]
ignore = []
[lint.per-file-ignores]
"tests/**/*.py" = ["S101"] # Allow pytest assertions
[format]
quote-style = "double"
indent-style = "space"
line-ending = "native"
skip-magic-trailing-comma = false

22
src/app/__init__.py Normal file
View file

@ -0,0 +1,22 @@
from flask import Flask
from app.extensions import cache, limiter
from app.routes import api
def create_app() -> Flask:
app = Flask(__name__)
app.config.from_object("app.config")
cache.init_app(app)
limiter.init_app(app)
app.register_blueprint(api, url_prefix="/")
@app.get("/healthz")
def healthz():
return {"status": "ok"}
return app
def main() -> None:
create_app().run()

19
src/app/config.py Normal file
View file

@ -0,0 +1,19 @@
DEFAULT_IMAGE_WIDTH = 800
DEFAULT_IMAGE_HEIGHT = 600
DEFAULT_FONT_SIZE = 24
MAX_IMAGE_WIDTH = 4096
MAX_IMAGE_HEIGHT = 4096
MAX_FONT_SIZE = 288
# Cache timeout is in seconds (one day).
CACHE_DEFAULT_TIMEOUT = 86400
# In-memory backends keep state separately in each Gunicorn worker.
CACHE_TYPE = "SimpleCache"
RATELIMIT_STORAGE_URI = "memory://"
SVG_REQUEST_LIMIT = "900/minute"
SVG_RENDER_LIMIT = "300/minute"
PNG_REQUEST_LIMIT = "900/minute"
PNG_RENDER_LIMIT = "30/minute"

6
src/app/extensions.py Normal file
View file

@ -0,0 +1,6 @@
from flask_caching import Cache
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
cache = Cache()
limiter = Limiter(key_func=get_remote_address)

View file

@ -0,0 +1,3 @@
from app.routes.api import api
__all__ = ["api"]

81
src/app/routes/api.py Normal file
View file

@ -0,0 +1,81 @@
from io import BytesIO
from typing import cast
import cairosvg
from flask import Blueprint, current_app, render_template, request, send_file
from app import config
from app.extensions import cache, limiter
api = Blueprint("api", __name__)
def _generate_svg(
width: int = config.DEFAULT_IMAGE_WIDTH,
height: int = config.DEFAULT_IMAGE_HEIGHT,
font_size: int = config.DEFAULT_FONT_SIZE,
) -> str:
return render_template("svg.j2", w=width, h=height, font_size=font_size)
def _parse_args() -> tuple[int, int, int]:
width = request.args.get("width", config.DEFAULT_IMAGE_WIDTH, type=int)
height = request.args.get("height", config.DEFAULT_IMAGE_HEIGHT, type=int)
font_size = request.args.get("font_size", config.DEFAULT_FONT_SIZE, type=int)
if not 1 <= width <= config.MAX_IMAGE_WIDTH:
raise ValueError("width outside of allowable range")
elif not 1 <= height <= config.MAX_IMAGE_HEIGHT:
raise ValueError("height outside of allowable range")
elif not 1 <= font_size <= config.MAX_FONT_SIZE:
raise ValueError("font_size outside of allowable range")
return width, height, font_size
@cache.memoize()
def _render_svg(width: int, height: int, font_size: int) -> str:
with limiter.limit(config.SVG_RENDER_LIMIT):
return _generate_svg(width, height, font_size)
@cache.memoize()
def _render_png(width: int, height: int, font_size: int) -> bytes:
with limiter.limit(config.PNG_RENDER_LIMIT):
# CairoSVG returns bytes when write_to is omitted.
return cast(
bytes,
cairosvg.svg2png(bytestring=_generate_svg(width, height, font_size).encode("utf-8")),
)
@api.get("/<name>.svg")
@limiter.limit(config.SVG_REQUEST_LIMIT)
def placeholder_svg(name: str):
try:
width, height, font_size = _parse_args()
except ValueError as e:
return {"status": "error", "message": str(e)}, 400
return send_file(
BytesIO(_render_svg(width, height, font_size).encode("utf-8")),
mimetype="image/svg+xml",
download_name=f"{name}.svg",
max_age=current_app.config["CACHE_DEFAULT_TIMEOUT"],
)
@api.get("/<name>.png")
@limiter.limit(config.PNG_REQUEST_LIMIT)
def placeholder_png(name: str):
try:
width, height, font_size = _parse_args()
except ValueError as e:
return {"status": "error", "message": str(e)}, 400
return send_file(
BytesIO(_render_png(width, height, font_size)),
mimetype="image/png",
download_name=f"{name}.png",
max_age=current_app.config["CACHE_DEFAULT_TIMEOUT"],
)

14
src/app/templates/svg.j2 Normal file
View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="{{ w }}" height="{{ h }}" viewBox="0 0 {{ w }} {{ h }}">
<rect width="{{ w }}" height="{{ h }}" fill="#e5e5e7"/>
<g stroke="#c7c7cc" stroke-width="1">
<line x1="0" y1="0" x2="{{ w }}" y2="{{ h }}"/>
<line x1="{{ w }}" y1="0" x2="0" y2="{{ h }}"/>
</g>
<text x="50%" y="50%"
text-anchor="middle"
dominant-baseline="middle"
font-family="sans-serif"
font-size="{{ font_size }}"
font-weight="600"
fill="#55555c">{{ w }} × {{ h }}</text>
</svg>

After

Width:  |  Height:  |  Size: 552 B

23
tests/conftest.py Normal file
View file

@ -0,0 +1,23 @@
import pytest
from app import create_app
from app.extensions import cache, limiter
@pytest.fixture
def app():
application = create_app()
application.config["TESTING"] = True
# Keep cached images and rate-limit counters isolated between tests.
with application.app_context():
cache.clear()
limiter.reset()
yield application
with application.app_context():
cache.clear()
limiter.reset()
@pytest.fixture
def client(app):
return app.test_client()

106
tests/test_api.py Normal file
View file

@ -0,0 +1,106 @@
import struct
from xml.etree import ElementTree
import pytest
from app import config
@pytest.fixture(params=["svg", "png"])
def image_url(request):
return f"/placeholder.{request.param}"
def test_health(client):
response = client.get("/healthz")
assert response.status_code == 200
assert response.json == {"status": "ok"}
assert "Cache-Control" not in response.headers
@pytest.mark.parametrize("custom_dimensions", [False, True])
def test_image_output(client, image_url, custom_dimensions):
parameters = {"width": 321, "height": 123, "font_size": 17} if custom_dimensions else {}
width = parameters.get("width", config.DEFAULT_IMAGE_WIDTH)
height = parameters.get("height", config.DEFAULT_IMAGE_HEIGHT)
font_size = parameters.get("font_size", config.DEFAULT_FONT_SIZE)
response = client.get(image_url, query_string=parameters)
assert response.status_code == 200
if image_url.endswith(".svg"):
assert response.mimetype == "image/svg+xml"
root = ElementTree.fromstring(response.data) # noqa: S314 - trusted SVG generated by the app
assert root.tag == "{http://www.w3.org/2000/svg}svg"
assert root.attrib["width"] == str(width)
assert root.attrib["height"] == str(height)
text = root.find("{http://www.w3.org/2000/svg}text")
assert text is not None
assert text.attrib["font-size"] == str(font_size)
assert text.text == f"{width} × {height}"
else:
assert response.mimetype == "image/png"
assert response.data[:8] == b"\x89PNG\r\n\x1a\n"
assert response.data[12:16] == b"IHDR"
# IHDR starts with width and height as big-endian unsigned 32-bit integers.
assert struct.unpack(">II", response.data[16:24]) == (width, height)
@pytest.mark.parametrize(
("parameter", "maximum"),
[
("width", config.MAX_IMAGE_WIDTH),
("height", config.MAX_IMAGE_HEIGHT),
("font_size", config.MAX_FONT_SIZE),
],
)
@pytest.mark.parametrize("boundary", ["below", "minimum", "maximum", "above"])
def test_parameter_bounds(client, image_url, parameter, maximum, boundary):
value = {"below": 0, "minimum": 1, "maximum": maximum, "above": maximum + 1}[boundary]
# Vary one bound at a time, keeping the other dimensions small for cheap renders.
parameters = {"width": 10, "height": 10, "font_size": 1, parameter: value}
response = client.get(image_url, query_string=parameters)
if boundary in {"minimum", "maximum"}:
assert response.status_code == 200
else:
assert response.status_code == 400
assert response.json == {
"status": "error",
"message": f"{parameter} outside of allowable range",
}
assert "Cache-Control" not in response.headers
@pytest.mark.parametrize("timeout", [config.CACHE_DEFAULT_TIMEOUT, 120])
def test_image_cache_control(app, client, image_url, timeout):
# An override catches headers that hard-code the default timeout.
app.config["CACHE_DEFAULT_TIMEOUT"] = timeout
response = client.get(image_url)
assert response.status_code == 200
assert response.cache_control.public
assert response.cache_control.max_age == timeout
assert not response.cache_control.no_cache
assert not response.cache_control.no_store
def test_cached_image_survives_render_limit(client, image_url, monkeypatch):
limit_name = "SVG_RENDER_LIMIT" if image_url.endswith(".svg") else "PNG_RENDER_LIMIT"
# Exhaust the render quota with one request; the request quota remains available.
monkeypatch.setattr(config, limit_name, "1/minute")
first = client.get(image_url, query_string={"width": 10, "height": 10})
assert first.status_code == 200
# A new size needs a render, but the original size should still come from cache.
uncached = client.get(image_url, query_string={"width": 11, "height": 10})
assert uncached.status_code == 429
assert "Cache-Control" not in uncached.headers
cached = client.get(image_url, query_string={"width": 10, "height": 10})
assert cached.status_code == 200
assert cached.data == first.data

1060
uv.lock Normal file

File diff suppressed because it is too large Load diff