Your OpenAPI document says type: object. Your users say "why is my map in the Indian Ocean?"
geojson-openapi-lint reads OpenAPI 3.0 and 3.1 documents and reports the sixteen
mistakes that spatial APIs make over and over again: reversed coordinate axes,
untyped geometry schemas, feature collections with no bounding-box filter and no
upper bound, tile endpoints with no cache story, nearest-neighbour endpoints that
will happily scan an entire index. It runs offline, needs no database, and ships
as both a CLI and a reusable GitHub Action with SARIF output.
Point it at one or more specs:
$ geojson-openapi-lint examples/bad-spec.yamlIt walks every operation, parameter, response and schema in the document
(resolving local $refs as it goes), applies sixteen rules, and reports each
violation with a stable rule id, a severity, a JSON-pointer-ish location, a
concrete remediation, and a link to a page explaining the underlying pattern.
examples/bad-spec.yaml
error GEO004 components.schemas.Position
Example position [51.5074, -122.4194] has |second axis| > 90, which is only valid for a longitude -- the axes appear to be swapped.
fix: Rewrite the example as [longitude, latitude] and add a validator that rejects out-of-range positions at the edge of the API.
docs: https://www.geospatial-api.com/advanced-spatial-endpoint-implementation-data-contracts/strict-pydantic-validation-for-geometry/
error GEO006 components.schemas.Geometry
Schema `Geometry` is an unstructured object with no `type`/`coordinates` properties.
fix: Model the GeoJSON member explicitly: a `type` enum, a `coordinates` schema with bounded positions, and `required: [type, coordinates]`.
docs: https://www.geospatial-api.com/advanced-spatial-endpoint-implementation-data-contracts/openapi-schema-generation-for-spatial-types/
warning GEO003 paths./features.get
GET /features returns a FeatureCollection but accepts no bbox/extent filter.
fix: Add a `bbox` query parameter (minLon,minLat,maxLon,maxLat) and push it into an `ST_Intersects` predicate so the spatial index is used.
docs: https://www.geospatial-api.com/advanced-spatial-endpoint-implementation-data-contracts/bounding-box-spatial-index-queries/
... 21 more findings ...
Checked 1 spec(s): 24 finding(s) (4 error, 17 warning, 3 info).
fail-on = error: 4 finding(s) at or above the threshold.
Four output formats — pretty, json, sarif, github — and three exit codes
mean it works equally well as a local pre-commit check, a PR annotator, or a
GitHub code-scanning source.
Generic OpenAPI linters check that your document is valid. None of them know anything about geography, so all of the following sail straight through:
- A
Geometryschema declared as a baretype: object. The document validates. Generated clients getAny. Request validation is a no-op. Nobody finds out until a malformed polygon reaches PostGIS. [latitude, longitude]in an example, a description or a parameter name. RFC 7946 fixes the order at longitude-first. Reversed axes never raise an error; the data simply lands in the wrong hemisphere, and by the time anyone notices, three downstream consumers have compensated for it.GET /featureswith nolimit, no cursor and nobbox. It is fine in staging with two thousand rows. In production a viewport over a dense city returns six orders of magnitude more data than the tile next to it, and a single request pins a database connection for minutes.- A tile endpoint with no
Cache-Control. Tiles are the most cacheable thing a spatial API serves and the most expensive to regenerate; without a declared TTL every CDN in front of it guesses, and most guess "do not cache". - A bulk import that answers
200. Parsing, validating, reprojecting and indexing a large geometry payload routinely outlives an HTTP timeout.
These are contract problems, which means they are cheapest to catch in the contract — before the client SDKs are generated and before the first consumer integrates. That is what this tool is for.
This project is not published on PyPI. Clone it and install from source.
$ git clone https://github.com/geospatial-api/geojson-openapi-lint.git
$ cd geojson-openapi-lint
$ python -m venv .venv && source .venv/bin/activate
$ pip install -e .With uv:
$ git clone https://github.com/geospatial-api/geojson-openapi-lint.git
$ cd geojson-openapi-lint
$ uv venv --python 3.12
$ uv pip install -e '.[dev]'Requires Python 3.10 or newer. The only runtime dependency is PyYAML (plus
tomli on Python 3.10, where tomllib is not yet in the standard library).
$ geojson-openapi-lint examples/clean-spec.yaml
examples/clean-spec.yaml no findings
Checked 1 spec(s): no findings.
$ echo $?
0$ geojson-openapi-lint examples/bad-spec.yaml
... 24 findings ...
$ echo $?
1Restrict the run to a single rule while you fix it:
$ geojson-openapi-lint examples/bad-spec.yaml --select GEO003 --format json
{
"version": "1.0.0",
"specs": [
"examples/bad-spec.yaml"
],
"summary": {
"specs": 1,
"findings": 1,
"error": 0,
"warning": 1,
"info": 0,
"failOn": "error",
"failing": 0
},
"errors": [],
"findings": [
{
"rule": "GEO003",
"severity": "warning",
"spec": "examples/bad-spec.yaml",
"location": "paths./features.get",
"message": "GET /features returns a FeatureCollection but accepts no bbox/extent filter.",
"remediation": "Add a `bbox` query parameter (minLon,minLat,maxLon,maxLat) and push it into an `ST_Intersects` predicate so the spatial index is used.",
"docs": "https://www.geospatial-api.com/advanced-spatial-endpoint-implementation-data-contracts/bounding-box-spatial-index-queries/",
"line": 12
}
]
}usage: geojson-openapi-lint [-h] [--format {pretty,json,sarif,github}] [--output FILE]
[--config FILE] [--no-config] [--select IDS] [--ignore IDS]
[--fail-on LEVEL] [--color {auto,always,never}]
[--list-rules] [--version]
[SPEC ...]
| Flag | Meaning |
|---|---|
SPEC ... |
One or more OpenAPI documents. .json is parsed as JSON, everything else as YAML. |
-f, --format |
pretty (default), json, sarif or github. |
-o, --output FILE |
Write the report to a file instead of stdout. Required for SARIF upload. |
-c, --config FILE |
Use a specific config file instead of discovering one. |
--no-config |
Ignore any discovered config file; run with built-in defaults. |
--select IDS |
Run only these rules, e.g. --select GEO003,GEO004. Re-enables rules a config file disabled. |
--ignore IDS |
Skip these rules. Beats --select. |
--fail-on LEVEL |
error (default), warning, info or never. |
--color |
auto (default, colour only on a TTY), always, never. NO_COLOR is honoured. |
--list-rules |
Print the rule reference (id, severity, name, summary, docs link) and exit. |
--version |
Print the version and exit. |
pretty— grouped by spec, colourised on a TTY, each finding followed by afix:line and adocs:link. This is the format for humans.json— a stable object withversion,specs,summary,errorsandfindings. Every finding carriesrule,severity,spec,location,message,remediation,docsand a best-effortline.sarif— SARIF 2.1.0. The driver advertises all sixteen rules withshortDescription,fullDescription,help(text and markdown) andhelpUri, so GitHub's code scanning UI renders a full explanation and a "further reading" link for every alert.github—::error/::warning/::noticeworkflow commands, so findings appear as inline annotations on the diff in a pull request.
| Code | Meaning |
|---|---|
0 |
No findings, or only findings below the fail-on threshold. |
1 |
At least one finding at or above the threshold. |
2 |
Usage error, missing file, unparseable document, or invalid configuration. |
Exit code 2 is deliberately distinct from 1: a broken build pipeline should
not look like a failing lint.
Rule ids are a stable, public contract. A published id is never re-used for a different check and never renumbered.
| ID | Default | Rule | What it catches |
|---|---|---|---|
| GEO001 | warning | collection-endpoint-missing-pagination |
A collection GET with no limit/offset/page/cursor parameter at all. See cursor pagination for spatial result sets. |
| GEO002 | warning | unbounded-feature-response |
No hard ceiling anywhere: the limit parameter has no maximum and the features array has no maxItems. See cursor pagination for spatial result sets. |
| GEO003 | warning | feature-collection-missing-bbox |
A FeatureCollection endpoint with no bbox/bounds/extent filter, so the GiST index cannot help. Skipped for proximity endpoints. See bounding-box spatial index queries. |
| GEO004 | error | coordinate-order-looks-reversed |
Latitude-first ordering inferred from prose, a latlng-style parameter name, or an example position whose second axis exceeds ±90. See strict Pydantic validation for geometry. |
| GEO005 | info | crs-not-declared |
Geometry is exposed but no CRS is documented, or a non-WGS84 EPSG code appears alongside GeoJSON payloads. See strict Pydantic validation for geometry. |
| GEO006 | error | geojson-schema-is-bare-object |
A geometry-ish schema that resolves to type: object with no properties and no composition keywords. See OpenAPI schema generation for spatial types. |
| GEO007 | warning | position-array-unbounded |
A position/coordinate array of numbers with no minItems/maxItems, so [] validates. See OpenAPI schema generation for spatial types. |
| GEO008 | error | coordinates-typed-as-string |
coordinates typed as a string (or an array of strings) — usually WKT smuggled through a GeoJSON-shaped envelope. See strict Pydantic validation for geometry. |
| GEO009 | warning | knn-endpoint-unbounded |
A nearest/KNN endpoint missing a bounded k/limit or a bounded search radius. See k-nearest-neighbour routing algorithms. |
| GEO010 | warning | tile-endpoint-missing-cache-headers |
A tile route with no Cache-Control/ETag response header and no documented TTL. See tile generation and CDN distribution. |
| GEO011 | warning | expensive-endpoint-no-rate-limit |
An expensive spatial endpoint (tile, geofence, proximity) with no 429, no RateLimit-* headers and no documented policy. See rate limiting geofence and tile endpoints. |
| GEO012 | warning | api-not-versioned |
No version in any path, server URL or media type. See API versioning for GIS endpoints. |
| GEO013 | warning | synchronous-bulk-ingest |
A bulk/batch/import operation with no 202, no Location header and no job pattern. See async bulk uploads with Celery. |
| GEO014 | info | no-serialization-negotiation |
A feature collection served as plain application/json only — no application/geo+json, no GeoParquet. See GeoJSON versus GeoParquet serialization. |
| GEO015 | warning | bbox-parameter-untyped |
A bbox parameter typed as a bare string with no pattern, format or enum. See bounding-box spatial index queries. |
| GEO016 | warning | coordinate-bounds-missing |
Longitude/latitude fields and parameters with no minimum/maximum. Tile z/x/y indices are exempt. See strict Pydantic validation for geometry. |
geojson-openapi-lint --list-rules prints the same table on the terminal,
including each rule's documentation URL:
ID SEVERITY RULE
GEO001 warning collection-endpoint-missing-pagination
Collection endpoint exposes no pagination parameters
https://www.geospatial-api.com/core-geospatial-api-architecture-with-fastapi-postgis/spatial-pagination-cursor-strategies/
GEO002 warning unbounded-feature-response
Collection response has no hard upper bound
https://www.geospatial-api.com/core-geospatial-api-architecture-with-fastapi-postgis/spatial-pagination-cursor-strategies/
...
- error — almost certainly a bug that will produce wrong data.
- warning — a design problem that will hurt in production under load.
- info — an interoperability or documentation improvement.
Every severity is configurable; see Configuration.
There is no schema-registry magic and no network access. The pipeline is four steps:
- Load.
.jsonfiles go through the JSON parser (better error messages); everything else throughyaml.safe_load. The document must have a top-levelopenapikey — Swagger 2.0 is rejected with a message telling you to convert first. - Resolve. Local JSON pointers (
#/components/schemas/Point) are followed on demand, including chains of aliases and OpenAPI 3.1$ref-with-siblings (siblings win). Reference cycles resolve to{}instead of recursing forever; external$refs are never fetched, because the linter is strictly offline. - Build a context. Every operation is materialised with its path-level and
operation-level parameters merged, its 2xx responses and media types
dereferenced, and a set of classifiers layered on top: is this a collection
GET? a FeatureCollection endpoint? a tile route? a proximity search? a bulk
ingest? Schemas are walked once each, at their canonical location, so a
shared
components/schemasentry referenced from twelve places is reported once rather than twelve times. - Run rules. Each rule is a generator over the context yielding findings.
Suppressions, per-path ignores and severity overrides are applied by the
engine, not by individual rules, so they behave identically everywhere. A rule
that crashes on an unusual document degrades into a single
infofinding rather than taking the whole run down.
Locations are dotted paths that mirror the document structure —
paths./features.get.parameters[2], components.schemas.Position.items — and
the SARIF and GitHub formatters additionally do a best-effort scan of the raw
source text to attach a line number.
An OpenAPI document does not tell you which endpoints are spatial, so the rules
infer it from response schemas (a features array, a FeatureCollection
constant), media types (application/geo+json), path shapes ({z}/{x}/{y}),
parameter names and operation ids. The heuristics are tuned to stay quiet on
non-spatial APIs, and anything they get wrong can be suppressed per rule, per
path or per node — see below.
from geojson_openapi_lint import Config, Spec, lint_spec
spec = Spec.from_file("openapi.yaml")
for finding in lint_spec(spec, Config(ignore={"GEO014"})):
print(finding.rule_id, finding.severity.label, finding.location, finding.message)lint_paths(["a.yaml", "b.json"], config) returns a LintResult with
findings, errors, counts() and exit_code(config).
The repository root ships a composite action, so you can consume it directly:
- uses: geospatial-api/geojson-openapi-lint@v1
with:
spec: openapi.yamlInputs:
| Input | Default | Meaning |
|---|---|---|
spec |
(required) | Whitespace-separated files or globs, e.g. specs/*.yaml. |
format |
github |
pretty, json, sarif or github. |
fail-on |
error |
error, warning, info or never. |
config |
(auto) | Path to a config file. |
output |
(stdout) | Write the report to this file. Required for SARIF upload. |
working-directory |
. |
Directory to run from. |
python-version |
3.12 |
Python used to run the linter. |
Outputs: exit-code (0/1/2) and report (the path written, if any).
name: Lint OpenAPI specs
on: [pull_request]
jobs:
geolint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: geospatial-api/geojson-openapi-lint@v1
with:
spec: openapi.yaml
format: github
fail-on: warningEach finding becomes an annotation on the offending line of the spec.
Upload the SARIF report so findings land in the Security tab with full rule
descriptions and links. Note the continue-on-error: you want the upload to
happen even when the lint fails, and then to fail the job explicitly afterwards.
jobs:
geolint:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- id: geolint
uses: geospatial-api/geojson-openapi-lint@v1
continue-on-error: true
with:
spec: specs/*.yaml
format: sarif
output: geojson-openapi-lint.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: geojson-openapi-lint.sarif
category: geojson-openapi-lint
- if: steps.geolint.outputs.exit-code == '1'
run: exit 1A working version of both jobs — running against this repository's own example
specs — lives in .github/workflows/lint-specs.yml.
Nothing about the CLI is GitHub-specific:
# GitLab CI
lint-openapi:
image: python:3.12
script:
- pip install -e .
- geojson-openapi-lint specs/*.yaml --fail-on warning- repo: local
hooks:
- id: geojson-openapi-lint
name: geojson-openapi-lint
entry: geojson-openapi-lint
language: system
files: ^specs/.*\.(ya?ml|json)$Lowest precedence to highest:
- Built-in defaults.
[tool.geojson-openapi-lint]inpyproject.toml..geojson-openapi-lint.toml.--config FILE.- CLI flags:
--select,--ignore,--fail-on. - Inline
x-geolint-ignoreextensions inside the spec.
Discovery starts in the directory of the first spec and walks up the tree. In a
single directory, .geojson-openapi-lint.toml wins over pyproject.toml.
# .geojson-openapi-lint.toml
# Findings at or above this severity make the run exit 1.
# One of: error, warning, info, never.
fail-on = "error"
# Rules that never run. `disable` and `ignore` are synonyms.
disable = ["GEO014"]
# Or invert it: run only these.
# select = ["GEO003", "GEO004", "GEO008"]
# Promote or demote individual rules.
[severity]
GEO003 = "error"
GEO012 = "info"
# Suppress rules for particular OpenAPI paths (fnmatch globs).
[per-path-ignores]
"/internal/*" = ["GEO001", "GEO002", "GEO012"]
"/v1/tiles/*" = ["GEO014"]The same keys work under [tool.geojson-openapi-lint] in pyproject.toml.
A copy of this sample lives at examples/config-sample.toml. Underscored key
names (fail_on, per_path_ignores) are accepted as aliases, and unknown keys
are a hard error rather than a silent typo.
Add x-geolint-ignore to any node in the document. It suppresses the listed
rules for that node and everything beneath it:
paths:
/v1/regions:
get:
x-geolint-ignore: [GEO003] # small fixed lookup table, no bbox needed
operationId: listRegions
responses:
"200":
description: All regions.Scopes, from broadest to narrowest:
| Placement | Effect |
|---|---|
| Document root | Disables the rules everywhere in that document. |
| Path item | Disables them for every operation on that path. |
| Operation | Disables them for that operation and its parameters and responses. |
| Parameter / schema / response | Disables them for that node only. |
Both [GEO003] and "GEO003, GEO004" are accepted, and ids are
case-insensitive.
A useful progression for an existing API: start at --fail-on never to see the
picture, move to --fail-on error (which blocks only the data-corrupting
rules — GEO004, GEO006, GEO008), then tighten to warning once the backlog is
clear.
The suite is offline and hermetic: no network, no database, no fixtures on the filesystem beyond the two example specs. Every rule has at least one positive and one negative case built from an in-memory document.
$ uv venv --python 3.12
$ uv pip install -e '.[dev]'
$ .venv/bin/python -m pytest -q
192 passed in 0.31s
$ uvx ruff check .
All checks passed!What is covered:
- Rules — positive and negative fixtures for all sixteen, plus the
exemptions (proximity endpoints skip the bbox rule, tile
z/x/yskip the coordinate-bounds rule, non-spatial schemas skip the geometry rules). - Spec loading — YAML and JSON producing identical findings, malformed
input, Swagger 2.0 rejection,
$refchains,$refsiblings, reference cycles, dangling and external refs, JSON-pointer escaping. - Configuration — file discovery,
pyproject.tomlversus dedicated file precedence, CLI override layering, invalid values. - Suppression — inline ignores at document, path, operation, parameter and
schema scope; per-path globs;
--select/--ignore. - Output — all four formats, including SARIF 2.1.0 structure (driver rules,
helpUri,ruleIndex, severity mapping, invocation status) and GitHub workflow-command escaping. - End to end — the clean example spec produces zero findings; the bad
example spec triggers all sixteen rules; exit codes
0,1and2.
examples/clean-spec.yaml— a versioned, paged, bbox-filtered, cache-controlled, rate-limited spatial API. Zero findings, and a reasonable template to copy from.examples/bad-spec.yaml— the same API with every mistake in the rule set. Twenty-four findings across all sixteen rules.
The rules are distilled from a longer body of work on building spatial APIs with FastAPI and PostGIS. Each finding links to the relevant page; these are the chapters the rules draw on most:
- Core geospatial API architecture with FastAPI and PostGIS — the foundations the rest builds on.
- Spatial resource modeling patterns — how to shape features, collections and links before you write a single endpoint.
- Spatial pagination and cursor strategies — why offset paging falls apart on spatial tables (GEO001, GEO002).
- GeoJSON versus GeoParquet serialization — when to stop shipping JSON (GEO014).
- API versioning for GIS endpoints — retrofitting a version is far more disruptive than adding one (GEO012).
- Strict Pydantic validation for geometry — the runtime counterpart to GEO004, GEO005, GEO008 and GEO016.
- Bounding-box spatial index queries — making the GiST index actually do the work (GEO003, GEO015).
- K-nearest-neighbour routing algorithms — bounded proximity search (GEO009).
- OpenAPI schema generation for spatial types — generating the schemas this linter wants to find (GEO006, GEO007).
- Async PostGIS transaction patterns — what happens behind an endpoint once the contract is right.
- Async bulk uploads with Celery — the 202-plus-job pattern GEO013 asks for.
- Query plan analysis and index tuning — for when a bbox filter still is not fast enough.
- Redis caching for spatial queries — the caching layer above the database.
- Connection pooling and PgBouncer setup — why one unbounded endpoint hurts everything else.
- Materialized views for spatial aggregations — precomputing the expensive answers.
- Tile generation and CDN distribution — the caching story GEO010 is asking you to write down.
- JWT authentication for spatial scopes — scoping access by geography.
- Row-level security for multi-tenant PostGIS — tenant isolation below the API.
- Rate limiting geofence and tile endpoints — the policy GEO011 wants documented.
- Containerizing PostGIS and FastAPI — reproducible local and CI environments.
- CI/CD pipelines for spatial APIs — where a linter like this fits into the pipeline.
- Edge routing and tile delivery at scale — the far end of the tile story.
Adding a rule means adding one decorated generator in
src/geojson_openapi_lint/rules/, a docs URL in docs.py, and a positive plus a
negative test. Please keep new ids sequential and never renumber an existing
one — downstream configs and inline suppressions depend on them.
MIT. See LICENSE. Copyright (c) 2026 geospatial-api.com.