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.

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:

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.