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.
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.
Related
- Implementing Cache Invalidation for Real-Time Updates
- Integrating Indoor Maps with React Native SDKs
- How to Define Indoor CRS for Multi-Building Campuses
This page is part of the JSON Schema Design for Indoor Maps topic within the Production-Ready Indoor Map Deployment section.