feat: add placeholder api supporting svg and png
This commit is contained in:
parent
840cd6c02b
commit
7390cdd6a6
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
42
.pre-commit-config.yaml
Normal 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
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.12
|
||||||
24
Containerfile
Normal file
24
Containerfile
Normal 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
100
README.md
Normal 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
39
pyproject.toml
Normal 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
8
pyrightconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"include": ["src", "tests"],
|
||||||
|
"extraPaths": ["src"],
|
||||||
|
"venvPath": ".",
|
||||||
|
"venv": ".venv",
|
||||||
|
"pythonVersion": "3.12",
|
||||||
|
"typeCheckingMode": "standard"
|
||||||
|
}
|
||||||
37
ruff.toml
Normal file
37
ruff.toml
Normal 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
22
src/app/__init__.py
Normal 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
19
src/app/config.py
Normal 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
6
src/app/extensions.py
Normal 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)
|
||||||
3
src/app/routes/__init__.py
Normal file
3
src/app/routes/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from app.routes.api import api
|
||||||
|
|
||||||
|
__all__ = ["api"]
|
||||||
81
src/app/routes/api.py
Normal file
81
src/app/routes/api.py
Normal 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
14
src/app/templates/svg.j2
Normal 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
23
tests/conftest.py
Normal 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
106
tests/test_api.py
Normal 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
|
||||||
Loading…
Reference in a new issue