Designing JSON Schemas for Indoor Map APIs

This page shows how to write the concrete Draft 2020-12 schema an indoor map API enforces on every payload it accepts or serves, and it sits under the broader JSON Schema Design for Indoor Maps topic where the overall contract-versus-topology split is established.

What an Indoor Map API Schema Actually Is

A JSON Schema for an indoor map API is a machine-checkable description of the single payload shape that crosses your API boundary: a GeoJSON FeatureCollection (IETF RFC 7946) carrying a foreign metadata member plus the spatial primitives and routing edges that make up one floor level. It is not documentation and not a type hint — it is an executable predicate. Given any candidate document, a validator returns a deterministic pass or a precise list of breaches, every time, on every runtime that speaks JSON Schema.

The schema’s job is narrow and structural. It asserts that required members exist, that values hold their declared types and enums, that identifier strings match their patterns, and that no undeclared field sneaks in. It deliberately does not assert that the directed routing graph is referentially sound or connected — that is a separate topological check the API runs after structural validation passes. Keeping the two concerns apart is what lets a malformed corridor fail with 'routing_weight' is a required property rather than a vague 500 three stages downstream. Because RFC 7946 permits foreign members, the metadata block stays compatible with off-the-shelf GeoJSON tooling while still being a hard requirement here.

Where validation sits between a producer and every consumer A sequence across four participants. The producer submits an envelope together with its declared schema version. The validator fetches the matching schema from the registry and receives a compiled validator. It checks structure, enumerated values and numeric ranges. On failure it returns a ValidationError carrying a JSON pointer to the exact offending field, so the producer can fix it without guessing. Only a validated envelope is passed on to consumers. One path to publication, and it goes through the validator Producer Validator Registry Consumer envelope + schema_version fetch schema for that version compiled validator validate: structure, enums, ranges ValidationError with a JSON pointer validated envelope only Nothing reaches a consumer unvalidated: the validator is the only path to publication.

The JSON pointer is what makes the gate usable. “Validation failed” sends someone hunting through 40,000 features; /features/2871/properties/level does not.

The contract below pins the building-local coordinate frame in metadata, requires a topology_hash so downstream stages can verify byte-level integrity, and constrains each Feature so that a LineString is read as a routing edge and a Polygon as a navigable space.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "IndoorMapFeatureCollection",
  "type": "object",
  "additionalProperties": false,
  "required": ["type", "metadata", "features"],
  "properties": {
    "type": { "const": "FeatureCollection" },
    "metadata": {
      "type": "object",
      "additionalProperties": false,
      "required": ["map_version", "schema_revision", "building_id",
                   "floor_level", "topology_hash", "coordinate_system"],
      "properties": {
        "map_version":     { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
        "schema_revision": { "type": "string", "pattern": "^v\\d+\\.\\d+\\.\\d+$" },
        "building_id":     { "type": "string", "minLength": 1 },
        "floor_level":     { "type": "integer" },
        "topology_hash":   { "type": "string", "pattern": "^[a-f0-9]{64}$" },
        "coordinate_system": {
          "type": "string",
          "enum": ["EPSG:0-local-cartesian", "EPSG:3857-anchored"]
        }
      }
    },
    "features": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["type", "id", "geometry", "properties"],
        "properties": {
          "type": { "const": "Feature" },
          "id":   { "type": "string", "minLength": 1 },
          "geometry": {
            "type": "object",
            "required": ["type", "coordinates"],
            "properties": {
              "type": { "enum": ["LineString", "Polygon"] },
              "coordinates": { "type": "array" }
            }
          },
          "properties": { "type": "object" }
        },
        "allOf": [
          {
            "if": { "properties": { "geometry": { "properties": { "type": { "const": "LineString" } } } } },
            "then": {
              "properties": {
                "properties": {
                  "required": ["edge_type", "source", "target", "routing_weight", "traversable"],
                  "properties": {
                    "edge_type":      { "enum": ["corridor", "door", "elevator", "stair", "ramp"] },
                    "source":         { "type": "string", "minLength": 1 },
                    "target":         { "type": "string", "minLength": 1 },
                    "routing_weight": { "type": "number", "minimum": 0 },
                    "traversable":    { "type": "boolean" }
                  }
                }
              }
            }
          },
          {
            "if": { "properties": { "geometry": { "properties": { "type": { "const": "Polygon" } } } } },
            "then": {
              "properties": {
                "properties": {
                  "required": ["category", "floor_level"],
                  "properties": {
                    "category":    { "type": "string", "minLength": 1 },
                    "floor_level": { "type": "integer" }
                  }
                }
              }
            }
          }
        ]
      }
    }
  }
}

The conditional allOf blocks are what make this an indoor schema rather than a generic GeoJSON validator: a LineString must declare its edge_type and a non-negative routing_weight, and a Polygon must declare a category drawn from the project’s POI taxonomy. additionalProperties: false at every level stops schema drift dead — an unrecognised field is a hard failure, never silently ignored payload bloat.

Minimal Working Example

The validator below is the whole technique in one self-contained function: compile the schema, guard against a broken contract, then collect every structural breach with a precise path. It reports the first failing location rather than dying on a single exception, which is exactly what an API handler needs to return a useful 422.

import logging
from typing import Any
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
log = logging.getLogger("indoor-schema")


def validate_indoor_payload(payload: dict[str, Any], schema: dict[str, Any]) -> bool:
    """Validate one indoor-map FeatureCollection against the API schema.

    Returns True when the payload conforms, False on the first structural breach.
    """
    try:
        Draft202012Validator.check_schema(schema)      # guard a broken contract
        validator = Draft202012Validator(schema)
    except SchemaError as exc:
        log.error("Schema itself is invalid: %s", exc.message)
        return False

    errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.absolute_path))
    if errors:
        for err in errors:
            location = "/".join(str(p) for p in err.absolute_path) or "<root>"
            log.error("Rejected at %s: %s", location, err.message)
        return False

    log.info("Payload conforms to schema revision %s", payload["metadata"]["schema_revision"])
    return True

Call it with the parsed payload and the schema dict; a True result means the document is structurally deployable and ready for the separate topology check. Because iter_errors yields every breach rather than raising on the first, an API can hand the caller a complete defect list in a single round trip.

Schema Field Reference

The members the contract enforces, with the rule that backs each one:

Which schema changes are backward compatible and which are not A table of six schema changes. Adding an optional field is compatible; old clients ignore it and it needs only a minor version bump. Adding an enum value is not compatible, because an old client meets a case it cannot handle, and needs a major bump. Making an existing field required breaks old producers. Widening a type from integer to number can cause old parsers to truncate. Relaxing a pattern is compatible and has no client impact. Renaming a field breaks everything and needs a major bump plus an alias period during which both names are accepted. Additive is not the same as compatible Change Compatible? Client impact Version bump Add an optional field ● yes ignored by old clients ● minor Add an enum value △ no △ old client hits an unknown case major Make a field required △ no △ old producers now invalid major Widen a type (int -> num) △ no △ old parsers may truncate major Relax a pattern ● yes none ● minor Rename a field △ no △ everything breaks major + alias period Only two of six changes are safe. Adding an enum value is the one most often assumed safe.

Adding an enum value is the trap. It feels additive, but a client that switches on space_class and has no default branch fails on the first feature carrying the new value — in production, on someone's phone.

Field Type Required on Rule / default Notes
type string root const: "FeatureCollection" Anything else is rejected before features are walked.
metadata.map_version string always ^\d+\.\d+\.\d+$ Semantic version of the map content, not the schema.
metadata.schema_revision string always ^v\d+\.\d+\.\d+$ Pins which contract revision authored the payload.
metadata.floor_level integer always no default Discrete navigable index, not a raw CAD elevation.
metadata.topology_hash string always 64-char lowercase hex SHA-256 over the canonical features; drives cache busting.
metadata.coordinate_system string (enum) always local-cartesian or anchored One frame per payload; mixing frames is a hard failure.
features[].id string every feature minLength: 1 Stable identifier referenced by routing edges.
properties.edge_type string (enum) LineString corridor / door / elevator / stair / ramp Selects the traversal semantics for the edge.
properties.routing_weight number LineString minimum: 0 Cost used by the path solver; null or negative is invalid.
properties.traversable boolean LineString no default A false edge stays in the graph but is skipped by routing.
properties.category string Polygon minLength: 1 Must resolve against the project POI taxonomy.

Common Errors and Fixes

ValidationError: 'routing_weight' is a required property — A LineString edge was exported without a cost, usually because the authoring tool left it blank when an edge was drawn but never weighted. The conditional then block makes the field mandatory the moment geometry is a LineString. Fix it at the source by computing a default from segment length before serialization:

def fill_default_weight(edge: dict[str, Any]) -> dict[str, Any]:
    if edge["properties"].get("routing_weight") is None:
        coords = edge["geometry"]["coordinates"]
        edge["properties"]["routing_weight"] = round(_segment_length(coords), 3)
    return edge

ValidationError: Additional properties are not allowed ('legacy_id' was unexpected) — A new export tool appended an undeclared field. Because additionalProperties: false is set at every level, the stray key fails the whole payload instead of bloating it silently. Either strip the field upstream or, if it is genuinely needed, add it to the schema and bump schema_revision — never relax additionalProperties.

ValidationError: 'EPSG:4326' is not one of ['EPSG:0-local-cartesian', 'EPSG:3857-anchored'] — One floor was authored in raw WGS84 while the rest of the building uses a building-local Cartesian frame, so coordinates land thousands of metres away on the first zoom. The single root coordinate_system enum forbids the mismatch; reproject the offending floor into the declared frame before re-validating. The rules for choosing and pinning that frame live in Indoor Coordinate Reference Systems, and the floor index itself comes from Level Mapping & Z-Axis Logic.

Payload lifecycle: structural validation, then topology, then CI gate A left-to-right data flow. An incoming GeoJSON FeatureCollection enters Stage 1, structural JSON Schema validation with Draft202012Validator iter_errors. On failure the request branches down to a 422 response carrying a per-path error list such as features/3/properties is missing routing_weight. On a clean pass it continues to Stage 2, the topology check, which verifies that routing source and target ids resolve and recomputes and compares the topology_hash. A pass there forwards the validated payload to the CI gate, which blocks any merge that failed either stage. Request lifecycle — structural gate, then topology gate, then CI Incoming FeatureCollection POST /maps 1 STRUCTURAL GATE JSON Schema iter_errors walk 2 TOPOLOGY GATE Graph + hash verify topology_hash 3 MERGE GATE CI gate block or merge 422 Unprocessable — per-path error list features/3/properties: 'routing_weight' is required metadata/coordinate_system: not in enum pass pass fail

How This Fits the Pipeline

A passing structural verdict here is a precondition, not a finish line. The validated FeatureCollection flows next into the directed-graph topology check, then into CI gating for map updates, which blocks any merge whose payload fails either gate. The topology_hash this schema requires is the same value that downstream cache invalidation for real-time updates compares against to decide whether a client must refetch a floor. And the typed feature properties the schema guarantees are exactly what a client deserializes when integrating indoor maps with native SDKs — a deterministic schema is what lets an SDK use generated model classes instead of defensive dictionary access.

This page is part of the JSON Schema Design for Indoor Maps topic within the Production-Ready Indoor Map Deployment section.