From b8f4e3026befe9a61cc8e858d8258e188624f3d2 Mon Sep 17 00:00:00 2001 From: woggioni-opencode-agent Date: Fri, 4 Sep 2026 19:57:31 +0800 Subject: [PATCH] Initial commit: ifconfig.me clone on the kaya framework --- .dockerignore | 20 ++++ .env.example | 7 ++ .gitea/workflows/build.yaml | 38 ++++++ .gitignore | 9 ++ Dockerfile | 52 +++++++++ README.md | 110 ++++++++++++++++++ docker-compose.yml | 11 ++ opencode.json | 9 ++ pyproject.toml | 31 +++++ requirements.txt | 47 ++++++++ src/pyfconfig/__init__.py | 0 src/pyfconfig/app.py | 10 ++ src/pyfconfig/config.py | 39 +++++++ src/pyfconfig/page.py | 113 ++++++++++++++++++ src/pyfconfig/py.typed | 0 src/pyfconfig/routes/__init__.py | 0 src/pyfconfig/routes/connection.py | 105 +++++++++++++++++ src/pyfconfig/routes/health.py | 15 +++ src/pyfconfig/routes/root.py | 27 +++++ tests/__init__.py | 6 + tests/test_routes.py | 179 +++++++++++++++++++++++++++++ 21 files changed, 828 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 opencode.json create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/pyfconfig/__init__.py create mode 100644 src/pyfconfig/app.py create mode 100644 src/pyfconfig/config.py create mode 100644 src/pyfconfig/page.py create mode 100644 src/pyfconfig/py.typed create mode 100644 src/pyfconfig/routes/__init__.py create mode 100644 src/pyfconfig/routes/connection.py create mode 100644 src/pyfconfig/routes/health.py create mode 100644 src/pyfconfig/routes/root.py create mode 100644 tests/__init__.py create mode 100644 tests/test_routes.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c0ba0da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# Keep the build context lean: exclude the local venv, caches, tests, +# and anything not needed to build the pyfconfig wheel. +.git/ +.gitignore +.env +.env.example +.venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.mypy_cache/ +.pytest_cache/ +dist/ +build/ +tests/ +Dockerfile +.dockerignore +opencode.json +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a94d022 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# App server +APP_HOST=0.0.0.0 +APP_PORT=8000 + +# Name used in the HTML page title/heading and the curl examples +# (e.g. "ifconfig.me" when deployed under that domain). +SITE_NAME=pyfconfig diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..46bcd43 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,38 @@ +name: CI +on: + push: + tags: + - 'release/*' + +jobs: + build_and_push_docker_image: + name: "Build and push docker image" + runs-on: hostinger + steps: + - name: Checkout sources + uses: actions/checkout@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Gitea container registry + uses: docker/login-action@v3 + with: + registry: gitea.woggioni.net + username: woggioni-opencode-agent + password: ${{ secrets.PUBLISHER_TOKEN }} + - name: Extract metadata for image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: gitea.woggioni.net/woggioni-opencode-agent/pyfconfig + flavor: latest=false + tags: | + type=match,pattern=release/(.*),group=1 + type=raw,value=latest + - name: Build and push docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: true + pull: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aee049f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.venv/ +.env +*.egg-info/ +dist/ +build/ +.mypy_cache/ +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e95f0a3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 +# Multi-stage production build for pyfconfig on the kaya framework. +# Base: alpine:3.24 (python3 = 3.14). Deps come from the kaya Gitea registry +# (primary) with PyPI as fallback. granian ships musllinux wheels, so no +# compiler is strictly required; build-base + python3-dev are kept in the +# builder only as a safety net and are discarded in the runtime image. +# +# apk and pip both use BuildKit cache mounts (type=cache): the package +# caches persist in the builder's cache across builds instead of being +# re-downloaded, and never land in the image layers. Requires BuildKit +# (default for `docker build` / `docker buildx` on modern daemons). + +# --- Builder --------------------------------------------------------------- +FROM alpine:3.24 AS builder + +RUN --mount=type=cache,target=/var/cache/apk \ + apk add python3 py3-pip build-base python3-dev + +WORKDIR /build + +COPY pyproject.toml README.md requirements.txt ./ +COPY src/ ./src/ + +RUN --mount=type=cache,target=/root/.cache/pip \ + python3 -m venv /opt/venv \ + && /opt/venv/bin/pip install --upgrade pip \ + && /opt/venv/bin/pip install -r requirements.txt . + +# --- Runtime --------------------------------------------------------------- +FROM alpine:3.24 + +RUN --mount=type=cache,target=/var/cache/apk \ + apk add python3 ca-certificates tzdata \ + && addgroup -S app && adduser -S -G app app + +COPY --from=builder /opt/venv /opt/venv + +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + GRANIAN_HOST=0.0.0.0 \ + GRANIAN_PORT=8080 \ + GRANIAN_INTERFACE=rsgi + +USER app + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1 + +CMD ["granian", "pyfconfig.app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c30bc6 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# pyfconfig + +A clone of [ifconfig.me](https://ifconfig.me/) built on the +[kaya](https://gitea.woggioni.net/woggioni/kaya) framework +(`kaya-core` + `kaya-rsgi`), served by [Granian](https://github.com/emmett-framework/granian) +over the RSGI protocol. + +## Stack + +- **kaya-core** — routing and HTTP request/response handling +- **kaya-rsgi** — Granian (RSGI) adapter +- **granian** — application server +- **httpx + pwo** — test client over kaya's ASGI transport + +No database, sessions, or authentication — the app is stateless. + +## Endpoints + +| Method | Path | Response | +|---|---|---| +| GET | `/` | HTML page for browsers (`Accept: text/html`), plain-text IP otherwise | +| GET | `/ip` | Client IP address | +| GET | `/ua` | `User-Agent` header | +| GET | `/lang` | `Accept-Language` header | +| GET | `/encoding` | `Accept-Encoding` header | +| GET | `/mime` | `Accept` header | +| GET | `/charset` | `Accept-Charset` header | +| GET | `/forwarded` | `X-Forwarded-For` header | +| GET | `/all` | All connection fields as `key: value` lines (empty fields included; `remote_host` shown as `unavailable`) | +| GET | `/all.json` | Same fields as a JSON object, empty fields omitted | +| GET | `/api/health` | Liveness probe (`{"status":"ok"}`) | + +The `/all` field order mirrors the reference site: `ip_addr`, +`remote_host`, `user_agent`, `port`, `language`, `referer`, `connection`, +`keep_alive`, `method`, `encoding`, `mime`, `charset`, `via`, `forwarded`. + +Example: + +```console +$ curl http://localhost:8080/ +203.0.113.7 +$ curl http://localhost:8080/all.json +{"ip_addr":"203.0.113.7","user_agent":"curl/8.21.0","port":"51342","method":"GET","mime":"*/*"} +``` + +## Running + +### With Docker Compose (recommended) + +```bash +docker compose up -d --build +# App published at http://localhost:8080 +``` + +### On the host + +```bash +cp .env.example .env # optional; all settings have defaults +python -m venv .venv && . .venv/bin/activate +pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple \ + --extra-index-url https://pypi.org/simple \ + -e . +granian --interface rsgi --host 0.0.0.0 --port 8000 pyfconfig.app:app +``` + +### Configuration + +Environment variables (see `.env.example`): + +| Variable | Default | Description | +|---|---|---| +| `APP_HOST` | `0.0.0.0` | Bind host (informational; granian is configured via `GRANIAN_*`) | +| `APP_PORT` | `8000` | Bind port (informational) | +| `SITE_NAME` | `pyfconfig` | Public name used in the HTML page title and the curl examples (set to your domain, e.g. `ifconfig.example.com`) | + +## Tests + +```bash +. .venv/bin/activate +python -m unittest discover -s tests -t . +``` + +The tests drive the app in-process through kaya's ASGI interface using +`httpx.ASGITransport`; no external services are needed. + +## Project layout + +``` +src/pyfconfig/ +├── app.py # KayaApp assembly; imports route modules +├── config.py # env-driven Settings dataclass +├── page.py # HTML landing page builder (kaya has no templating) +└── routes/ + ├── health.py # GET /api/health + ├── root.py # GET / with content negotiation + └── connection.py # /ip /ua /lang /encoding /mime /charset /forwarded /all /all.json +tests/ +└── test_routes.py # unittest + httpx.ASGITransport +``` + +Route modules import the module-level `app` from `pyfconfig.app` and +register handlers with `@app.GET(...)` decorators at import time — +`pyfconfig.app` imports them last to complete the wiring. + +## Deployment + +The `Dockerfile` is a multi-stage alpine build producing a minimal, +non-root runtime image; pushing a `release/*` tag triggers the Gitea +Actions workflow in `.gitea/workflows/build.yaml`, which builds and +publishes the image to `gitea.woggioni.net/woggioni-opencode-agent/pyfconfig`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c944a9b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + pyfconfig: + build: . + image: pyfconfig:latest + restart: unless-stopped + environment: + # Public name shown in the HTML page and the curl examples; set to the + # deployment domain (e.g. "ifconfig.example.com"). + SITE_NAME: ${SITE_NAME:-pyfconfig} + ports: + - "127.0.0.1:8080:8080" diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..d1d82b1 --- /dev/null +++ b/opencode.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://opencode.ai/config.json", + "references": { + "kaya": { + "path": "../kaya", + "description": "Use for Kaya project code and resources" + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..56dc518 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyfconfig" +version = "0.1.0" +description = "A clone of https://ifconfig.me/ built on the kaya framework" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "kaya-core", + "kaya-rsgi", + "granian>=2.0", + "httpx", + "pwo", +] + +[project.optional-dependencies] +dev = [ + "mypy", +] + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = false + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +plugins = [] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4299bc8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,47 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --no-index --output-file=requirements.txt pyproject.toml +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +anyio==4.15.0 + # via httpx +certifi==2026.7.22 + # via + # httpcore + # httpx +click==8.5.0 + # via granian +granian==2.8.2 + # via + # kaya-rsgi + # pyfconfig (pyproject.toml) +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via pyfconfig (pyproject.toml) +idna==3.19 + # via + # anyio + # httpx +kaya-core==0.0.1 + # via + # kaya-rsgi + # pyfconfig (pyproject.toml) +kaya-rsgi==0.0.1 + # via pyfconfig (pyproject.toml) +pwo==0.1.2 + # via + # kaya-core + # kaya-rsgi + # pyfconfig (pyproject.toml) +typing-extensions==4.16.0 + # via + # anyio + # kaya-core + # pwo diff --git a/src/pyfconfig/__init__.py b/src/pyfconfig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pyfconfig/app.py b/src/pyfconfig/app.py new file mode 100644 index 0000000..9b16bab --- /dev/null +++ b/src/pyfconfig/app.py @@ -0,0 +1,10 @@ +"""Application entry point.""" +from __future__ import annotations + +from kaya.core import KayaApp + +app = KayaApp() + +# Register routes by importing modules. Order does not matter; each module +# pulls ``app`` from here and decorates its handlers at import time. +from .routes import connection, health, root # noqa: E402,F401 diff --git a/src/pyfconfig/config.py b/src/pyfconfig/config.py new file mode 100644 index 0000000..81b97ce --- /dev/null +++ b/src/pyfconfig/config.py @@ -0,0 +1,39 @@ +"""Environment-driven configuration for the pyfconfig application. + +Mirrors kaya's own pattern: read ``os.environ`` directly into a plain +dataclass. No pydantic-settings, no settings module. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + + +def _env(name: str, default: Optional[str] = None) -> str: + value = os.environ.get(name) + if value is None or value == "": + if default is None: + raise RuntimeError(f"Missing required environment variable: {name}") + return default + return value + + +@dataclass(frozen=True) +class Settings: + app_host: str + app_port: int + site_name: str + + @staticmethod + def from_env() -> "Settings": + return Settings( + app_host=_env("APP_HOST", "0.0.0.0"), + app_port=int(_env("APP_PORT", "8000")), + # Public name of the deployment, used in the HTML page title + # and in the command-line examples (e.g. "ifconfig.me"). + site_name=_env("SITE_NAME", "pyfconfig"), + ) + + +settings: Settings = Settings.from_env() diff --git a/src/pyfconfig/page.py b/src/pyfconfig/page.py new file mode 100644 index 0000000..7d0d95e --- /dev/null +++ b/src/pyfconfig/page.py @@ -0,0 +1,113 @@ +"""HTML landing page for browser visitors. + +Kaya has no templating engine, so the page is assembled as a plain +string; every dynamic value is HTML-escaped. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from html import escape +from typing import Dict + +_PAGE = """ + + + + +What Is My IP Address? - {site} + + + +

What Is My IP Address? - {site}

+ +

Your Connection

+ + + + + + + + + + +
IP Address{ip_addr}
User Agent{user_agent}
Language{language}
Referer{referer}
Method{method}
Encoding{encoding}
MIME Type{mime}
Charset{charset}
X-Forwarded-For{forwarded}
+ +

Command Line Interface

+
$ curl {site}
+⇒ {ip_addr}
+
+$ curl {site}/ip
+⇒ {ip_addr}
+
+$ curl {site}/ua
+⇒ {user_agent}
+
+$ curl {site}/lang
+⇒ {language}
+
+$ curl {site}/encoding
+⇒ {encoding}
+
+$ curl {site}/mime
+⇒ {mime}
+
+$ curl {site}/charset
+⇒ {charset}
+
+$ curl {site}/forwarded
+⇒ {forwarded}
+
+$ curl {site}/all
+⇒ ip_addr: {ip_addr}
+  remote_host: {remote_host}
+  user_agent: {user_agent}
+  port: {port}
+  language: {language}
+  referer: {referer}
+  connection: {connection}
+  keep_alive: {keep_alive}
+  method: {method}
+  encoding: {encoding}
+  mime: {mime}
+  charset: {charset}
+  via: {via}
+  forwarded: {forwarded}
+
+$ curl {site}/all.json
+⇒ {all_json}
+ + + + +""" + + +def render_page(data: Dict[str, str], site: str) -> str: + """Render the landing page from the connection data mapping.""" + escaped = {key: escape(value) for key, value in data.items()} + escaped["remote_host"] = escaped["remote_host"] or "unavailable" + all_json = json.dumps( + {key: value for key, value in data.items() if value}, + separators=(",", ":"), + ) + return _PAGE.format( + site=escape(site), + year=datetime.now(timezone.utc).year, + all_json=escape(all_json), + **escaped, + ) diff --git a/src/pyfconfig/py.typed b/src/pyfconfig/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/pyfconfig/routes/__init__.py b/src/pyfconfig/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pyfconfig/routes/connection.py b/src/pyfconfig/routes/connection.py new file mode 100644 index 0000000..e0b8378 --- /dev/null +++ b/src/pyfconfig/routes/connection.py @@ -0,0 +1,105 @@ +"""Connection introspection endpoints — the core of the ifconfig.me clone. + +A single :func:`connection_data` helper collects every field from the +request context in the same order the reference site +(https://ifconfig.me/all) uses; the individual endpoints below are thin +views over that mapping. +""" +from __future__ import annotations + +import json +from typing import Dict, Optional, Tuple + +from kaya.core import HttpContext + +from ..app import app + +_PLAIN = {"Content-Type": "text/plain; charset=utf-8"} +_JSON = {"Content-Type": "application/json"} + + +def _header(ctx: HttpContext, name: str) -> str: + values = ctx.headers.get(name) + if not values: + return "" + return ", ".join(values) + + +def connection_data(ctx: HttpContext) -> Dict[str, str]: + """Collect the connection fields, ordered like the reference site.""" + client: Optional[Tuple[str, int]] = ctx.client + ip_addr = client[0] if client else "" + port = str(client[1]) if client else "" + return { + "ip_addr": ip_addr, + # Reverse DNS resolution is not performed; ``/all`` renders an + # empty value as "unavailable" and ``/all.json`` omits it. + "remote_host": "", + "user_agent": _header(ctx, "user-agent"), + "port": port, + "language": _header(ctx, "accept-language"), + "referer": _header(ctx, "referer"), + "connection": _header(ctx, "connection"), + "keep_alive": _header(ctx, "keep-alive"), + "method": ctx.method.value, + "encoding": _header(ctx, "accept-encoding"), + "mime": _header(ctx, "accept"), + "charset": _header(ctx, "accept-charset"), + "via": _header(ctx, "via"), + "forwarded": _header(ctx, "x-forwarded-for"), + } + + +async def _send_field(ctx: HttpContext, field: str) -> None: + await ctx.send_str(200, connection_data(ctx)[field] + "\n", _PLAIN) + + +@app.GET("/ip") +async def ip(ctx: HttpContext) -> None: + await _send_field(ctx, "ip_addr") + + +@app.GET("/ua") +async def user_agent(ctx: HttpContext) -> None: + await _send_field(ctx, "user_agent") + + +@app.GET("/lang") +async def language(ctx: HttpContext) -> None: + await _send_field(ctx, "language") + + +@app.GET("/encoding") +async def encoding(ctx: HttpContext) -> None: + await _send_field(ctx, "encoding") + + +@app.GET("/mime") +async def mime(ctx: HttpContext) -> None: + await _send_field(ctx, "mime") + + +@app.GET("/charset") +async def charset(ctx: HttpContext) -> None: + await _send_field(ctx, "charset") + + +@app.GET("/forwarded") +async def forwarded(ctx: HttpContext) -> None: + await _send_field(ctx, "forwarded") + + +@app.GET("/all") +async def all_text(ctx: HttpContext) -> None: + data = connection_data(ctx) + lines = [ + f"{key}: {value if value else ('unavailable' if key == 'remote_host' else '')}" + for key, value in data.items() + ] + await ctx.send_str(200, "\n".join(lines) + "\n", _PLAIN) + + +@app.GET("/all.json") +async def all_json(ctx: HttpContext) -> None: + data = {key: value for key, value in connection_data(ctx).items() if value} + await ctx.send_str(200, json.dumps(data, separators=(",", ":")) + "\n", _JSON) diff --git a/src/pyfconfig/routes/health.py b/src/pyfconfig/routes/health.py new file mode 100644 index 0000000..e6c99ba --- /dev/null +++ b/src/pyfconfig/routes/health.py @@ -0,0 +1,15 @@ +"""Liveness probe.""" +from __future__ import annotations + +from kaya.core import HttpContext + +from ..app import app + + +@app.GET("/api/health") +async def health(ctx: HttpContext) -> None: + await ctx.send_bytes( + 200, + b'{"status":"ok"}', + {"content-type": ("application/json",)}, + ) diff --git a/src/pyfconfig/routes/root.py b/src/pyfconfig/routes/root.py new file mode 100644 index 0000000..9bb552f --- /dev/null +++ b/src/pyfconfig/routes/root.py @@ -0,0 +1,27 @@ +"""Landing page with content negotiation. + +Browsers (``Accept: text/html``) get the HTML page; command-line clients +such as curl (``Accept: */*``) get the plain-text IP address, mirroring +the behaviour of https://ifconfig.me/. +""" +from __future__ import annotations + +from kaya.core import HttpContext + +from ..app import app +from ..config import settings +from ..page import render_page +from .connection import connection_data + +_HTML = {"Content-Type": "text/html; charset=utf-8"} +_PLAIN = {"Content-Type": "text/plain; charset=utf-8"} + + +@app.GET("/") +async def index(ctx: HttpContext) -> None: + data = connection_data(ctx) + accept = ", ".join(ctx.headers.get("accept", ())) + if "text/html" in accept: + await ctx.send_str(200, render_page(data, settings.site_name), _HTML) + else: + await ctx.send_str(200, data["ip_addr"] + "\n", _PLAIN) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..14097da --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +"""Test package init. + +pyfconfig's settings all have safe defaults, so no environment overrides +are required before importing :mod:`pyfconfig.app`; this file exists so +``python -m unittest discover -s tests -t .`` treats tests as a package. +""" diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..28e7bd7 --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,179 @@ +"""Route tests using kaya's ASGI transport via httpx.""" +from __future__ import annotations + +import json +import unittest + +from httpx import ASGITransport, AsyncClient +from pwo import async_test + +from pyfconfig.app import app + +# httpx's ASGITransport populates the scope with this client tuple. +CLIENT_IP = "127.0.0.1" +CLIENT_PORT = "123" + +ALL_HEADERS = { + "User-Agent": "test-agent/1.0", + "Accept-Language": "en-US,en;q=0.9", + "Referer": "https://example.com/page", + "Connection": "keep-alive", + "Keep-Alive": "timeout=5", + "Accept-Encoding": "gzip, deflate", + "Accept": "text/html,application/xhtml+xml", + "Accept-Charset": "utf-8", + "Via": "1.1 proxy", + "X-Forwarded-For": "203.0.113.7, 10.0.0.1", +} + + +class RoutesTest(unittest.TestCase): + def setUp(self) -> None: + self.transport = ASGITransport(app=app) + + def client(self) -> AsyncClient: + return AsyncClient(transport=self.transport, base_url="http://127.0.0.1") + + @async_test + async def test_health(self) -> None: + async with self.client() as client: + r = await client.get("/api/health") + self.assertEqual(200, r.status_code) + self.assertIn("ok", r.text) + + @async_test + async def test_ip(self) -> None: + async with self.client() as client: + r = await client.get("/ip") + self.assertEqual(200, r.status_code) + self.assertEqual(CLIENT_IP, r.text.strip()) + + @async_test + async def test_ua(self) -> None: + async with self.client() as client: + r = await client.get("/ua", headers={"User-Agent": "test-agent/1.0"}) + self.assertEqual("test-agent/1.0", r.text.strip()) + + @async_test + async def test_lang(self) -> None: + async with self.client() as client: + r = await client.get("/lang", headers={"Accept-Language": "en-US"}) + self.assertEqual("en-US", r.text.strip()) + + @async_test + async def test_encoding(self) -> None: + async with self.client() as client: + r = await client.get("/encoding", headers={"Accept-Encoding": "gzip"}) + self.assertEqual("gzip", r.text.strip()) + + @async_test + async def test_mime(self) -> None: + async with self.client() as client: + r = await client.get("/mime", headers={"Accept": "application/json"}) + self.assertEqual("application/json", r.text.strip()) + + @async_test + async def test_charset(self) -> None: + async with self.client() as client: + r = await client.get("/charset", headers={"Accept-Charset": "utf-8"}) + self.assertEqual("utf-8", r.text.strip()) + + @async_test + async def test_forwarded(self) -> None: + async with self.client() as client: + r = await client.get("/forwarded", headers={"X-Forwarded-For": "203.0.113.7"}) + self.assertEqual("203.0.113.7", r.text.strip()) + + @async_test + async def test_forwarded_absent_is_empty(self) -> None: + async with self.client() as client: + r = await client.get("/forwarded") + self.assertEqual("", r.text.strip()) + + @async_test + async def test_all_text(self) -> None: + async with self.client() as client: + r = await client.get("/all", headers=ALL_HEADERS) + self.assertEqual(200, r.status_code) + lines = r.text.strip().splitlines() + keys = [line.split(":", 1)[0] for line in lines] + self.assertEqual( + [ + "ip_addr", "remote_host", "user_agent", "port", "language", + "referer", "connection", "keep_alive", "method", "encoding", + "mime", "charset", "via", "forwarded", + ], + keys, + ) + values = dict(line.split(": ", 1) for line in lines) + self.assertEqual(CLIENT_IP, values["ip_addr"]) + self.assertEqual("unavailable", values["remote_host"]) + self.assertEqual("test-agent/1.0", values["user_agent"]) + self.assertEqual(CLIENT_PORT, values["port"]) + self.assertEqual("en-US,en;q=0.9", values["language"]) + self.assertEqual("https://example.com/page", values["referer"]) + self.assertEqual("keep-alive", values["connection"]) + self.assertEqual("timeout=5", values["keep_alive"]) + self.assertEqual("GET", values["method"]) + self.assertEqual("gzip, deflate", values["encoding"]) + self.assertEqual("text/html,application/xhtml+xml", values["mime"]) + self.assertEqual("utf-8", values["charset"]) + self.assertEqual("1.1 proxy", values["via"]) + self.assertEqual("203.0.113.7, 10.0.0.1", values["forwarded"]) + + @async_test + async def test_all_json_omits_empty_fields(self) -> None: + async with self.client() as client: + r = await client.get("/all.json", headers=ALL_HEADERS) + self.assertEqual(200, r.status_code) + data = json.loads(r.text) + self.assertEqual(CLIENT_IP, data["ip_addr"]) + self.assertEqual("test-agent/1.0", data["user_agent"]) + self.assertEqual(CLIENT_PORT, data["port"]) + self.assertEqual("GET", data["method"]) + # Empty fields are omitted entirely. + data_minimal = json.loads( + (await client.get("/all.json")).text + ) + self.assertNotIn("remote_host", data_minimal) + self.assertNotIn("language", data_minimal) + self.assertNotIn("forwarded", data_minimal) + self.assertEqual(CLIENT_IP, data_minimal["ip_addr"]) + + @async_test + async def test_root_plain_text_for_cli(self) -> None: + async with self.client() as client: + # httpx sends Accept: */* by default, like curl. + r = await client.get("/") + self.assertEqual(200, r.status_code) + self.assertIn("text/plain", r.headers["content-type"]) + self.assertEqual(CLIENT_IP, r.text.strip()) + + @async_test + async def test_root_html_for_browsers(self) -> None: + async with self.client() as client: + r = await client.get("/", headers={"Accept": "text/html"}) + self.assertEqual(200, r.status_code) + self.assertIn("text/html", r.headers["content-type"]) + self.assertIn("What Is My IP Address?", r.text) + self.assertIn(CLIENT_IP, r.text) + self.assertIn("curl pyfconfig/all.json", r.text) + + @async_test + async def test_root_html_escapes_user_input(self) -> None: + async with self.client() as client: + r = await client.get( + "/", + headers={ + "Accept": "text/html", + "User-Agent": "", + }, + ) + self.assertNotIn("", r.text) + self.assertIn("<script>", r.text) + + @async_test + async def test_unknown_path_is_404(self) -> None: + async with self.client() as client: + r = await client.get("/nope") + self.assertEqual(404, r.status_code)