Add API docs page and update README/AGENTS

- Add /docs route serving web/templates/api.html: full Bootstrap 5
  documentation page covering all three API endpoints with parameter
  tables, example requests, and example responses
- Add 'API docs' link to the navbar on the main map page
- Update README.md: add web frontend section with feature list, dev
  server instructions, and API endpoint summary table
- Update AGENTS.md: add web/ layout, API endpoint table, Flask run
  instructions, and route_master example relation IDs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-02-27 19:03:37 +00:00
parent e0ade9e5ab
commit 3223d4b063
5 changed files with 361 additions and 7 deletions

View file

@ -4,9 +4,8 @@ Guidelines for AI coding agents working in this repository.
## Project overview ## Project overview
A collection of Python CLI tools for working with OpenStreetMap data. Each A collection of Python tools for working with OpenStreetMap public transport
tool is a standalone Python script using Click for the CLI interface and data. Includes a CLI tool and a Flask web frontend with a JSON API.
Requests for HTTP calls.
## Repository layout ## Repository layout
@ -15,10 +14,20 @@ pyproject.toml - Package metadata and build configuration
src/ src/
osm_geojson/ osm_geojson/
__init__.py - Top-level package marker __init__.py - Top-level package marker
py.typed - Marker for mypy typed package
pt/ pt/
__init__.py - Public transport subpackage __init__.py - Public transport subpackage
core.py - Data fetching and processing functions core.py - Data fetching and processing functions
cli.py - Click CLI commands cli.py - Click CLI commands
web/
app.py - Flask application (routes + API endpoints)
templates/
index.html - Main map UI
api.html - API documentation page (served at /docs)
static/
app.js - Frontend JavaScript
style.css - CSS for the map UI
favicon.svg - SVG favicon
tests/ tests/
fixtures/ - Saved OSM API responses for offline testing fixtures/ - Saved OSM API responses for offline testing
test_osm_pt_geojson.py - Test suite test_osm_pt_geojson.py - Test suite
@ -35,10 +44,13 @@ point registered in `pyproject.toml` under `[project.scripts]`.
- Python 3.11+ - Python 3.11+
- CLI via [Click](https://click.palletsprojects.com/) - CLI via [Click](https://click.palletsprojects.com/)
- HTTP via [Requests](https://requests.readthedocs.io/) - HTTP via [Requests](https://requests.readthedocs.io/)
- Web frontend via [Flask](https://flask.palletsprojects.com/) 3.x
- Parse XML with lxml if needed; prefer the OSM JSON API where possible - Parse XML with lxml if needed; prefer the OSM JSON API where possible
- Errors go to stderr; data output goes to stdout - Errors go to stderr; data output goes to stdout (CLI)
- GeoJSON output uses `ensure_ascii=False` - GeoJSON output uses `ensure_ascii=False`
- All modules, functions, and test functions must have docstrings - All modules, functions, and test functions must have docstrings
- Library code (`core.py`) raises `OsmError` instead of calling `sys.exit()`;
the CLI and Flask views catch `OsmError` and handle it appropriately
## OSM API ## OSM API
@ -51,6 +63,30 @@ https://www.openstreetmap.org/api/0.6/
No authentication is required for read-only access. Include a descriptive No authentication is required for read-only access. Include a descriptive
`User-Agent` header in all requests. `User-Agent` header in all requests.
## Web frontend API endpoints
| Endpoint | Description |
|---|---|
| `GET /api/route/<id>` | Full route GeoJSON, stop list, and sibling routes |
| `GET /api/segment/<id>?from=NAME&to=NAME[&stops=0]` | Segment between two named stops |
| `GET /api/route_master/<id>` | All member routes of a route_master |
| `GET /docs` | API documentation page |
All API responses are JSON. Errors return `{"error": "<code>", "message": "<text>"}`.
## Running the web frontend
```
cd web
flask --app app.py run
```
The web dependencies (Flask) are not in `pyproject.toml`; install separately:
```
pip install flask
```
## Type checking ## Type checking
All code uses type hints. Run mypy in strict mode to check: All code uses type hints. Run mypy in strict mode to check:
@ -74,7 +110,9 @@ API. Fixture data is stored in `tests/fixtures/` as saved API responses.
| ID | Description | | ID | Description |
|----------|------------------------------------------| |----------|------------------------------------------|
| 15083963 | M11 Istanbul Metro (subway) | | 15083963 | M11 Istanbul Metro (subway, direction 1) |
| 15083964 | M11 Istanbul Metro (subway, direction 2) |
| 15083966 | M11 Istanbul Metro (route_master) |
| 18892969 | Bus A1: Bristol Airport → Bus Station | | 18892969 | Bus A1: Bristol Airport → Bus Station |
## Dependencies ## Dependencies

View file

@ -1,6 +1,6 @@
# openstreetmap-tools # openstreetmap-tools
A collection of command-line tools for working with OpenStreetMap data. A collection of tools for working with OpenStreetMap public transport data.
## Tools ## Tools
@ -9,7 +9,7 @@ A collection of command-line tools for working with OpenStreetMap data.
Fetch an OSM public transport route relation and list its stops, or export the Fetch an OSM public transport route relation and list its stops, or export the
route as GeoJSON. route as GeoJSON.
#### Usage #### CLI usage
``` ```
osm-pt-geojson list-stations <relation_id> osm-pt-geojson list-stations <relation_id>
@ -67,6 +67,46 @@ cd openstreetmap-tools
pip install -e . pip install -e .
``` ```
---
### Web frontend
An interactive map interface for browsing and downloading public transport
routes as GeoJSON.
#### Features
- Enter a relation ID or OSM URL to load a route onto the map.
- Click stops in the sidebar list or on the map to set start and end points.
- Preview the selected segment highlighted on the map.
- Toggle stop points in/out of the GeoJSON before downloading.
- Download a segment or the full route as a `.geojson` file.
- Entering a `route_master` ID shows all directions on the map with links to
each individual route.
- Bookmarkable URLs: `/<relation_id>` loads that route directly.
#### Running the dev server
```
cd web
flask --app app.py run
```
Open `http://127.0.0.1:5000`.
#### API
The web frontend exposes a JSON API. Full documentation is available at `/docs`
when the server is running.
| Endpoint | Description |
|---|---|
| `GET /api/route/<id>` | Full route GeoJSON, stop list, and sibling routes |
| `GET /api/segment/<id>?from=NAME&to=NAME` | Segment between two named stops |
| `GET /api/route_master/<id>` | All member routes of a route_master |
---
## Licence ## Licence
MIT License. Copyright (c) 2026 Edward Betts. MIT License. Copyright (c) 2026 Edward Betts.

View file

@ -80,6 +80,12 @@ def check_route_tags(
return None return None
@app.route("/docs")
def docs() -> ResponseReturnValue:
"""Render the API documentation page."""
return render_template("api.html")
@app.route("/") @app.route("/")
def index() -> ResponseReturnValue: def index() -> ResponseReturnValue:
"""Render the landing page with no relation loaded.""" """Render the landing page with no relation loaded."""

269
web/templates/api.html Normal file
View file

@ -0,0 +1,269 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API Documentation OSM Public Transport → GeoJSON</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<style>
pre { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; font-size: 0.85rem; }
.endpoint-url { font-family: monospace; font-size: 1rem; }
.badge-get { background: #0d6efd; }
h2 { margin-top: 2.5rem; }
h3 { margin-top: 2rem; font-size: 1.1rem; }
</style>
</head>
<body>
<nav class="navbar navbar-dark bg-dark px-3" style="height:56px">
<a class="navbar-brand" href="/">OSM Public Transport → GeoJSON</a>
<a class="nav-link text-white" href="/docs">API docs</a>
</nav>
<div class="container py-5" style="max-width:860px">
<h1 class="mb-1">API Documentation</h1>
<p class="text-muted mb-4">JSON API for fetching OSM public transport routes as GeoJSON.</p>
<p>All endpoints are read-only and require no authentication. Data is fetched
live from the <a href="https://wiki.openstreetmap.org/wiki/API_v0.6">OSM API v0.6</a>.
Relation IDs can be found on
<a href="https://www.openstreetmap.org">openstreetmap.org</a> — search for a
route and click through to its relation page.</p>
<p>All error responses return JSON with at least <code>error</code> (machine-readable
code) and <code>message</code> (human-readable description) fields.</p>
<!-- ── GET /api/route/<relation_id> ──────────────────────────────────── -->
<h2>
<span class="badge badge-get me-2" style="background:#0d6efd;font-size:0.75rem;vertical-align:middle">GET</span>
<span class="endpoint-url">/api/route/<em>&lt;relation_id&gt;</em></span>
</h2>
<p>Returns the full route as a GeoJSON <code>FeatureCollection</code>, the
ordered list of stops, and links to sibling routes in the same
<code>route_master</code> (if any).</p>
<h3>Path parameters</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>relation_id</code></td><td>integer</td><td>OSM relation ID of a public transport route.</td></tr>
</tbody>
</table>
<h3>Success response <span class="badge bg-success">200 OK</span></h3>
<pre>{
"name": "M11: Arnavutköy Hastane → Gayrettepe",
"ref": "M11",
"stops": [
{ "name": "Arnavutköy Hastane", "lat": 41.1234, "lon": 28.7654 },
{ "name": "İstanbul Havalimanı", "lat": 41.2701, "lon": 28.7519 },
...
],
"geojson": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "LineString", "coordinates": [[28.765, 41.123], ...] },
"properties": { "name": "M11: Arnavutköy Hastane → Gayrettepe",
"ref": "M11", "from": "Arnavutköy Hastane",
"to": "Gayrettepe", "route": "subway" }
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [28.765, 41.123] },
"properties": { "name": "Arnavutköy Hastane" }
},
...
]
},
"other_directions": [
{ "id": 15083964, "name": "M11: Gayrettepe → Arnavutköy Hastane", "ref": "M11" }
]
}</pre>
<p>The <code>geojson</code> field is a <code>FeatureCollection</code> containing
one <code>LineString</code> for the route geometry followed by one
<code>Point</code> per stop. The <code>LineString</code> carries the route tags
as properties (<code>name</code>, <code>ref</code>, <code>from</code>,
<code>to</code>, <code>route</code>). Each <code>Point</code> has a
<code>name</code> property.</p>
<p><code>other_directions</code> lists sibling routes from the same
<code>route_master</code> relation. It is an empty array if the route has no
parent <code>route_master</code>.</p>
<h3>Error responses</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Status</th><th><code>error</code></th><th>When</th></tr></thead>
<tbody>
<tr><td>404</td><td><code>osm_error</code></td><td>Relation not found on OpenStreetMap.</td></tr>
<tr><td>422</td><td><code>is_route_master</code></td><td>Relation is a <code>route_master</code>. Use <code>/api/route_master/&lt;id&gt;</code> instead.</td></tr>
<tr><td>422</td><td><code>not_public_transport</code></td><td>Relation exists but is not a supported public transport route.</td></tr>
<tr><td>502</td><td><code>osm_error</code></td><td>OSM API returned an unexpected error.</td></tr>
</tbody>
</table>
<h3>Example</h3>
<pre>GET /api/route/15083963</pre>
<!-- ── GET /api/segment/<relation_id> ────────────────────────────────── -->
<h2>
<span class="badge me-2" style="background:#0d6efd;font-size:0.75rem;vertical-align:middle">GET</span>
<span class="endpoint-url">/api/segment/<em>&lt;relation_id&gt;</em></span>
</h2>
<p>Returns a GeoJSON <code>FeatureCollection</code> for the portion of the
route between two named stops. Stops are matched case-insensitively.</p>
<h3>Path parameters</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>relation_id</code></td><td>integer</td><td>OSM relation ID of a public transport route.</td></tr>
</tbody>
</table>
<h3>Query parameters</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Parameter</th><th>Required</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>from</code></td><td>Yes</td><td>Name of the start stop (case-insensitive).</td></tr>
<tr><td><code>to</code></td><td>Yes</td><td>Name of the end stop (case-insensitive).</td></tr>
<tr><td><code>stops</code></td><td>No</td><td>Include stop <code>Point</code> features. <code>1</code> (default) to include, <code>0</code> to omit.</td></tr>
</tbody>
</table>
<h3>Success response <span class="badge bg-success">200 OK</span></h3>
<pre>{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "LineString", "coordinates": [[28.800, 41.259], ...] },
"properties": { "name": "M11: Arnavutköy Hastane → Gayrettepe",
"ref": "M11", "from": "Arnavutköy Hastane",
"to": "Gayrettepe", "route": "subway" }
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [28.800, 41.259] },
"properties": { "name": "İstanbul Havalimanı" }
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [28.820, 41.247] },
"properties": { "name": "Hasdal" }
}
]
}</pre>
<p>The <code>LineString</code> is trimmed to the shortest path between the
two stops along the route geometry. The <code>from</code> stop is treated as
the start regardless of the order the names are supplied — the segment always
follows the route direction.</p>
<h3>Error responses</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Status</th><th><code>error</code></th><th>When</th></tr></thead>
<tbody>
<tr><td>400</td><td><code>missing_params</code></td><td><code>from</code> or <code>to</code> parameter is absent.</td></tr>
<tr><td>404</td><td><code>osm_error</code></td><td>Relation not found on OpenStreetMap.</td></tr>
<tr><td>404</td><td><code>station_not_found</code></td><td>One or both stop names were not found. The response includes an <code>available</code> array listing valid stop names.</td></tr>
<tr><td>422</td><td><code>not_public_transport</code></td><td>Relation is not a supported public transport route.</td></tr>
<tr><td>502</td><td><code>osm_error</code></td><td>OSM API returned an unexpected error.</td></tr>
</tbody>
</table>
<h3>Example</h3>
<pre>GET /api/segment/15083963?from=İstanbul+Havalimanı&to=Hasdal</pre>
<pre>GET /api/segment/15083963?from=İstanbul+Havalimanı&to=Hasdal&stops=0</pre>
<!-- ── GET /api/route_master/<relation_id> ────────────────────────────── -->
<h2>
<span class="badge me-2" style="background:#0d6efd;font-size:0.75rem;vertical-align:middle">GET</span>
<span class="endpoint-url">/api/route_master/<em>&lt;relation_id&gt;</em></span>
</h2>
<p>Returns all member routes of an OSM <code>route_master</code> relation,
each with its GeoJSON geometry (stops omitted). Use this to display all
directions of a line on a map.</p>
<h3>Path parameters</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>relation_id</code></td><td>integer</td><td>OSM relation ID of a <code>route_master</code> relation.</td></tr>
</tbody>
</table>
<h3>Success response <span class="badge bg-success">200 OK</span></h3>
<pre>{
"name": "M11",
"ref": "M11",
"routes": [
{
"id": 15083963,
"name": "M11: Arnavutköy Hastane → Gayrettepe",
"ref": "M11",
"from": "Arnavutköy Hastane",
"to": "Gayrettepe",
"geojson": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "LineString", "coordinates": [[28.765, 41.123], ...] },
"properties": { "name": "M11: Arnavutköy Hastane → Gayrettepe", ... }
}
]
}
},
{
"id": 15083964,
"name": "M11: Gayrettepe → Arnavutköy Hastane",
"ref": "M11",
"from": "Gayrettepe",
"to": "Arnavutköy Hastane",
"geojson": { ... }
}
]
}</pre>
<p>Each entry in <code>routes</code> contains the individual route's
<code>id</code>, <code>name</code>, <code>ref</code>, <code>from</code>,
<code>to</code> tags, and a <code>geojson</code> <code>FeatureCollection</code>
with only the <code>LineString</code> geometry (no stop points). If a member
route cannot be fetched, it is included with <code>"geojson": null</code>.</p>
<h3>Error responses</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Status</th><th><code>error</code></th><th>When</th></tr></thead>
<tbody>
<tr><td>404</td><td><code>osm_error</code></td><td>Relation not found on OpenStreetMap.</td></tr>
<tr><td>422</td><td><code>osm_error</code></td><td>Relation exists but is not a <code>route_master</code>.</td></tr>
<tr><td>502</td><td><code>osm_error</code></td><td>OSM API returned an unexpected error.</td></tr>
</tbody>
</table>
<h3>Example</h3>
<pre>GET /api/route_master/15083966</pre>
<!-- ── Supported route types ─────────────────────────────────────────── -->
<h2>Supported route types</h2>
<p>The following OSM <code>route</code> tag values are accepted:</p>
<p>
<code>bus</code> &nbsp;
<code>ferry</code> &nbsp;
<code>funicular</code> &nbsp;
<code>light_rail</code> &nbsp;
<code>monorail</code> &nbsp;
<code>subway</code> &nbsp;
<code>train</code> &nbsp;
<code>tram</code> &nbsp;
<code>trolleybus</code>
</p>
</div>
</body>
</html>

View file

@ -13,6 +13,7 @@
<nav class="navbar navbar-dark bg-dark px-3" style="height:56px"> <nav class="navbar navbar-dark bg-dark px-3" style="height:56px">
<a class="navbar-brand" href="/">OSM Public Transport → GeoJSON</a> <a class="navbar-brand" href="/">OSM Public Transport → GeoJSON</a>
<a class="nav-link text-white" href="/docs">API docs</a>
</nav> </nav>
<div class="container-fluid h-100 p-0"> <div class="container-fluid h-100 p-0">