Skip to content

Repository files navigation

geojson-openapi-lint

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.


What it does

Point it at one or more specs:

$ geojson-openapi-lint examples/bad-spec.yaml

It 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.

Why

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 Geometry schema declared as a bare type: object. The document validates. Generated clients get Any. 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 /features with no limit, no cursor and no bbox. 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.

Install

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).

Quickstart

$ 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 $?
1

Restrict 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
    }
  ]
}

Full usage

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.

Output formats

  • pretty — grouped by spec, colourised on a TTY, each finding followed by a fix: line and a docs: link. This is the format for humans.
  • json — a stable object with version, specs, summary, errors and findings. Every finding carries rule, severity, spec, location, message, remediation, docs and a best-effort line.
  • sarif — SARIF 2.1.0. The driver advertises all sixteen rules with shortDescription, fullDescription, help (text and markdown) and helpUri, so GitHub's code scanning UI renders a full explanation and a "further reading" link for every alert.
  • github::error/::warning/::notice workflow commands, so findings appear as inline annotations on the diff in a pull request.

Exit codes

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 reference

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/
...

Severities

  • 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.

How it works

There is no schema-registry magic and no network access. The pipeline is four steps:

  1. Load. .json files go through the JSON parser (better error messages); everything else through yaml.safe_load. The document must have a top-level openapi key — Swagger 2.0 is rejected with a message telling you to convert first.
  2. 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.
  3. 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/schemas entry referenced from twelve places is reported once rather than twelve times.
  4. 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 info finding 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.

Detection is heuristic, on purpose

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.

Using it as a library

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).

CI usage

GitHub Action

The repository root ships a composite action, so you can consume it directly:

- uses: geospatial-api/geojson-openapi-lint@v1
  with:
    spec: openapi.yaml

Inputs:

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).

Inline annotations on pull requests

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: warning

Each finding becomes an annotation on the offending line of the spec.

GitHub code scanning (SARIF)

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 1

A working version of both jobs — running against this repository's own example specs — lives in .github/workflows/lint-specs.yml.

Other CI systems

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

Pre-commit

- repo: local
  hooks:
    - id: geojson-openapi-lint
      name: geojson-openapi-lint
      entry: geojson-openapi-lint
      language: system
      files: ^specs/.*\.(ya?ml|json)$

Configuration

Where config comes from

Lowest precedence to highest:

  1. Built-in defaults.
  2. [tool.geojson-openapi-lint] in pyproject.toml.
  3. .geojson-openapi-lint.toml.
  4. --config FILE.
  5. CLI flags: --select, --ignore, --fail-on.
  6. Inline x-geolint-ignore extensions 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.

File format

# .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.

Inline suppression

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.

Choosing a threshold

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.

Testing

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/y skip 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, $ref chains, $ref siblings, reference cycles, dangling and external refs, JSON-pointer escaping.
  • Configuration — file discovery, pyproject.toml versus 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, 1 and 2.

The example specs

  • 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.

Further reading

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:

Contributing

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.

License

MIT. See LICENSE. Copyright (c) 2026 geospatial-api.com.

About

Lint OpenAPI specs for spatial anti-patterns: missing bbox filters, unbounded feature responses, reversed lat/lng coordinate order, undeclared CRS. CLI plus GitHub Action with SARIF output.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages