From 232803a28ba38c79c14bb6552ac52c145aeee6de Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 23 Sep 2026 18:38:13 -0400 Subject: [PATCH] Add mapbox map-matching match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of the Navigation-category APIs with no prior CLI coverage. Same shape as directions/isochrone (mapbox/mapbox-cli#43, #44): hand-authored into custom-openapi/ since openapi-specs has no spec for this API either, reusing ARG_NAME_OVERRIDES for the same profile-vs-global-flag collision (third row, not a third mechanism). Excludes POST, for the same documented reason directions route does: this spec format can't express "GET or POST, caller's choice" for one operationId, and the API's own POST exists specifically for a trace too long for a URL (~8100 bytes) — a real gap, not a design choice. Smoke-tested against production: a three-point San Francisco trace returned a real match with legs/steps/geometry, including a null tracepoint for a point too far from the road network to match — the documented shape for that case, not a bug. 486 tests, fmt and clippy clean. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 13 + README.md | 6 +- .../map-matching/openapi/map-matching.yaml | 274 ++++++++++++++++++ docs/commands.md | 121 ++++++++ src/remedy.rs | 4 + src/spec.rs | 63 +++- tests/fixtures/api_command_surface.txt | 1 + 7 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 custom-openapi/map-matching/openapi/map-matching.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index eca8a84..2122057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,19 @@ that may never merge. They are not releases and are not listed here. ## Unreleased +- `mapbox map-matching`, snapping a noisy GPS trace to the road network and + returning the route it most likely followed, for driving (with or + without live traffic), walking, or cycling. No subcommand: like `mapbox + directions` below, this API has one operation, so there's nothing a + second word (the old `match`) would disambiguate; see + `spec::FLATTENED_SERVICES`. Hand-authored into `custom-openapi/` for the + same reason `mapbox directions` and `mapbox isochrone` were: no upstream + spec exists yet. Reuses the `profile`-vs-`--profile` collision fix + (`ARG_NAME_OVERRIDES` gets a third row) and the free-form (not `enum`) + routing profile, for the same OEM-account reason. Excludes POST, for the + same reason `directions` does: the API's own POST is for a trace too long + for a URL, a real gap rather than a design choice. + - `mapbox isochrone`, how far you can get from a point in a given time or distance, for driving (with or without live traffic), walking, or cycling, returned as GeoJSON polygons or linestrings. No subcommand: diff --git a/README.md b/README.md index 473843e..f59456a 100644 --- a/README.md +++ b/README.md @@ -177,9 +177,9 @@ mapbox styles * mapbox tilesets * ``` -`mapbox directions` and `mapbox isochrone` are the exceptions: each API has -a single operation, so there's a bare command with no subcommand at all, -the same shape `mapbox usage` already has, see +`mapbox directions`, `mapbox isochrone`, and `mapbox map-matching` are the +exceptions: each API has a single operation, so there's a bare command +with no subcommand at all, the same shape `mapbox usage` already has, see [docs/commands.md](./docs/commands.md) for their own parameters. A command group is not the same thing as a spec file: which one an operation diff --git a/custom-openapi/map-matching/openapi/map-matching.yaml b/custom-openapi/map-matching/openapi/map-matching.yaml new file mode 100644 index 0000000..0a11fc8 --- /dev/null +++ b/custom-openapi/map-matching/openapi/map-matching.yaml @@ -0,0 +1,274 @@ +openapi: "3.0.0" +# `parse_spec` turns `info.description` below into this service's clap +# `long_about`, so it also reaches `mapbox map-matching --help`, `--schema` +# and `generate-skills` output. Keep it to API prose only — the provenance +# below is for whoever edits this file, not for a CLI user: +# +# Hand-authored down to the parameters documented at +# docs.mapbox.com/api/navigation/map-matching. See `custom-openapi/README.md` +# for how a file like this is wired in, and `directions.yaml`'s header for +# why `profile` needs `ARG_NAME_OVERRIDES` in `src/spec.rs` — the same +# reason applies here. Excludes POST, for the same reason `directions.yaml` +# does: this spec format has no way to say "GET or POST, caller's choice" +# for one operationId. The API's own POST is for a request too long for a +# URL (~8100 bytes) — a real gap for a very long trace, not a design choice. +info: + title: "Mapbox Map Matching API" + description: >- + Snaps a noisy GPS trace to the road network and returns the route it + most likely followed, for driving (with or without live traffic), + walking, or cycling. + version: "0.0.0" +servers: + - url: https://api.mapbox.com + description: Map Matching API +paths: + /matching/v5/{profile}/{coordinates}.json: + get: + operationId: match + summary: Match a GPS trace to the road network. + description: >- + Returns one or more matched routes (more than one where the trace is + ambiguous enough to split), each with a `confidence` the API assigns + itself, plus one tracepoint per input coordinate — `null` for a + coordinate too far from any candidate to match at all. + parameters: + - name: "profile" + in: path + required: true + # Not an `enum`: the four documented values are what's public, but + # not what's exhaustive — some customers (OEM agreements, mainly) + # have additional profiles never published to docs.mapbox.com. + # An `enum` here becomes a clap `PossibleValuesParser` that + # rejects anything else client-side, which would break this CLI + # for exactly the accounts that most need a routing profile + # named beyond `driving`/`walking`/`cycling`. Same fix as + # `directions.yaml`'s `profile`. + description: >- + The routing profile — `mapbox/driving-traffic` (accounts for + live traffic), `mapbox/driving`, `mapbox/walking`, or + `mapbox/cycling` are documented, but not necessarily + exhaustive: some accounts have additional profiles of their + own. Sent exactly as typed; the API is the authority on + whether a value is valid, not this description. + schema: + type: string + minLength: 1 + example: "mapbox/driving" + - name: "coordinates" + in: path + required: true + description: >- + 2-100 trace points, semicolon-separated, each + `{longitude},{latitude}` — or an OpenLR-encoded string of up to + 50 points, in which case use `--openlr-spec`/`--openlr-format` + to say which flavor. + schema: + type: string + minLength: 1 + example: "-122.42,37.78;-122.421,37.781;-122.422,37.782" + - name: "access_token" + in: query + required: true + description: "Mapbox API Access Token" + schema: + type: string + minLength: 1 + # Prose rather than an `enum`: this is a comma-separated list, and + # the command builder turns a spec `enum` into a clap + # `PossibleValuesParser`, which accepts one value and would refuse + # `distance,duration`. Same reasoning as `directions.yaml`'s + # `annotations`. + - name: "annotations" + in: query + required: false + description: >- + Segment-level metadata to add to each leg, comma-separated. + Requires `--overview full`. Options are `distance`, `duration`, + `speed`, `congestion`, `congestion_numeric`, `maxspeed`. + schema: + type: string + example: "duration,distance" + - name: "approaches" + in: query + required: false + description: >- + Which side of the road to approach each waypoint from, + semicolon-separated — `unrestricted` or `curb` per coordinate. + Requires `--steps`. + schema: + type: string + - name: "geometries" + in: query + required: false + description: "The route geometry's format. Defaults to `polyline`." + schema: + type: string + enum: ["geojson", "polyline", "polyline6"] + - name: "overview" + in: query + required: false + description: >- + How much geometry detail the response carries. Defaults to + `simplified`. + schema: + type: string + enum: ["full", "simplified", "false"] + - name: "radiuses" + in: query + required: false + description: >- + Maximum distance in meters, 0-50, a coordinate may snap to the + road network, semicolon-separated, one per coordinate. Defaults + to 5. + schema: + type: string + - name: "steps" + in: query + required: false + description: >- + Return turn-by-turn instructions. Several other parameters + (`approaches`, `banner_instructions`, `language`, + `roundabout_exits`, `voice_instructions`) only take effect when + this is set. + schema: + type: boolean + - name: "banner_instructions" + in: query + required: false + description: "Return banner objects for display. Requires `--steps`." + schema: + type: boolean + - name: "language" + in: query + required: false + description: >- + The language turn-by-turn instructions are written in. Defaults + to `en`. Requires `--steps`. + schema: + type: string + example: "en" + - name: "roundabout_exits" + in: query + required: false + description: >- + Emit a separate instruction for entering and exiting a + roundabout, rather than one instruction for the whole + maneuver. Requires `--steps`. + schema: + type: boolean + - name: "voice_instructions" + in: query + required: false + description: >- + Return SSML-marked-up voice guidance text. Requires `--steps`. + schema: + type: boolean + - name: "voice_units" + in: query + required: false + description: >- + Units for voice instructions. Requires `--steps` and + `--voice-instructions`. + schema: + type: string + enum: ["imperial", "british_imperial", "metric"] + - name: "tidy" + in: query + required: false + description: >- + Remove clusters and resample the trace before matching — useful + for a trace recorded at an inconsistent sample rate. + schema: + type: boolean + - name: "timestamps" + in: query + required: false + description: >- + A Unix timestamp per coordinate, semicolon-separated and + ascending — when the trace was actually recorded, rather than + assumed from even spacing. + schema: + type: string + - name: "waypoint_names" + in: query + required: false + description: >- + A name per waypoint, semicolon-separated, used in that + waypoint's arrival instruction instead of the road name. Up to + 500 characters total. Requires `--steps`. + schema: + type: string + - name: "waypoints" + in: query + required: false + description: >- + Zero-based indices into `coordinates` marking which ones get + their own arrival instruction — must include `0` and the last + index. Requires `--steps`. + schema: + type: string + example: "0,2" + # Prose rather than an `enum`, for the reason given on `annotations` + # above. + - name: "ignore" + in: query + required: false + description: >- + Restrictions to ignore while matching, comma-separated. + Options are `access`, `oneways`, `restrictions`. + `mapbox/driving` only. + schema: + type: string + - name: "linear_references" + in: query + required: false + description: >- + Return an OpenLR reference (base64) per matched leg, alongside + the ordinary geometry. + schema: + type: boolean + - name: "openlr_spec" + in: query + required: false + description: >- + Which OpenLR specification `coordinates` is encoded with, when + it is an OpenLR string rather than a coordinate list. Defaults + to `tomtom`. + schema: + type: string + enum: ["tomtom", "here"] + - name: "openlr_format" + in: query + required: false + description: >- + The OpenLR binary format `coordinates` is encoded in, when it + is an OpenLR string. Only `tomtom` exists today. + schema: + type: string + enum: ["tomtom"] + - name: "depart_at" + in: query + required: false + description: >- + Departure time, ISO 8601 — for `mapbox/driving-traffic`, which + live traffic conditions to route against. + schema: + type: string + responses: + "200": + description: >- + A JSON object with a `code`, a `matchings` array (each with + `confidence`, `distance`, `duration`, `geometry`, and `legs`), + and a `tracepoints` array — one entry per input coordinate, + `null` for one too far from any candidate to match. + "401": + description: Unauthorized + "403": + description: Forbidden + "404": + description: Not Found — an invalid profile. + "422": + description: >- + Unprocessable Entity — invalid input, or more coordinates than + the profile allows (100 for a coordinate list, 50 for OpenLR). diff --git a/docs/commands.md b/docs/commands.md index dfc9b21..c0ef195 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -83,6 +83,8 @@ nests, and is typed `mapbox styles draft get`. **[Isochrone](#isochrone)** — [isochrone](#mapbox-isochrone) +**[Map Matching](#map-matching)** — [map-matching](#mapbox-map-matching) + **[Search](#search)** — [search.forward](#mapbox-search-forward) · [search.reverse](#mapbox-search-reverse) · [search.category](#mapbox-search-category) · @@ -1334,6 +1336,125 @@ line, same shape as `mapbox directions`'s Outputs section above: Trimmed to one of the two features (the response has one per `--contours-minutes` value) and the polygon's coordinates, for length. +--- +## Map Matching + +Snaps a noisy GPS trace to the road network and returns the route it most +likely followed, for driving (with or without live traffic), walking, or +cycling. Curated by hand down to the parameters documented at +docs.mapbox.com/api/navigation/map-matching — see `custom-openapi/README.md` +for why this command group doesn't come from the vendored specs the way +most others do. Excludes POST, which this CLI's spec format has no way to +express alongside GET for the same operation — the API's own POST is for a +trace too long for a URL (~8100 bytes), a real gap rather than a design +choice. + +### `mapbox map-matching` + +One or more matched routes — more than one where the trace is ambiguous +enough to split — each carrying a `confidence` the API assigns itself, plus +one tracepoint per input coordinate (`null` for one too far from any +candidate to match at all). No subcommand: this API has one operation, so +there's nothing a second word would disambiguate, the same reason `mapbox +directions` has none either. + +#### Parameters + +`` and `` (both positional) are required. +`` is sent exactly as typed, not checked against a fixed +list: `mapbox/driving-traffic`, `mapbox/driving`, `mapbox/walking` and +`mapbox/cycling` are documented, but some accounts (OEM agreements, mainly) +have additional profiles of their own that were never published, the API +is the authority on whether a value is valid, not this page. `` +is 2-100 `{longitude},{latitude}` trace points, semicolon-separated, or an +OpenLR-encoded string of up to 50 points (pair with `--openlr-spec`/ +`--openlr-format`). + +| Parameter | Effect | +| --- | --- | +| `--annotations ` | Segment-level metadata per leg, comma-separated (`distance`, `duration`, `speed`, `congestion`, `congestion_numeric`, `maxspeed`). Requires `--overview full`. | +| `--approaches ` | Which side of the road to approach each waypoint from. Requires `--steps`. | +| `--geometries ` | Route geometry format. Defaults to `polyline`. | +| `--overview ` | Geometry detail level. Defaults to `simplified`. | +| `--radiuses ` | Max snap distance, 0-50, one per coordinate. Defaults to 5. | +| `--steps` | Return turn-by-turn instructions. Several flags below only take effect with this set. | +| `--banner-instructions` | Return banner objects for display. Requires `--steps`. | +| `--language ` | Instruction language. Defaults to `en`. Requires `--steps`. | +| `--roundabout-exits` | Separate entry/exit instructions for a roundabout. Requires `--steps`. | +| `--voice-instructions` | Return SSML-marked voice guidance. Requires `--steps`. | +| `--voice-units ` | Requires `--steps` and `--voice-instructions`. | +| `--tidy` | Remove clusters and resample the trace before matching — for a trace recorded at an inconsistent sample rate. | +| `--timestamps ` | When the trace was recorded, per coordinate, ascending — rather than assumed from even spacing. | +| `--waypoint-names ` | A name per waypoint for its arrival instruction. Requires `--steps`. | +| `--waypoints ` | Which coordinates get their own arrival instruction — must include `0` and the last index. Requires `--steps`. | +| `--ignore ` | Restrictions to ignore, comma-separated (`access`, `oneways`, `restrictions`). `mapbox/driving` only. | +| `--linear-references` | Return an OpenLR reference (base64) per matched leg, alongside the ordinary geometry. | +| `--openlr-spec ` | Which OpenLR spec `coordinates` is encoded with, if it's an OpenLR string. Defaults to `tomtom`. | +| `--openlr-format tomtom` | The OpenLR binary format `coordinates` is encoded in, if it's an OpenLR string. | +| `--depart-at ` | For `mapbox/driving-traffic`, which live traffic conditions to route against. | + +#### Examples + +```sh +mapbox map-matching mapbox/driving "-122.42,37.78;-122.421,37.781;-122.422,37.782" +mapbox map-matching mapbox/driving "-122.42,37.78;-122.421,37.781;-122.422,37.782" \ + --steps --geometries geojson +``` + +#### Outputs + +Captured live: three trace points in San Francisco, one deliberately far +enough off the road network to leave its tracepoint `null`. + + + + +
Terminal — -o textAgent — -o json
+ +```json +{ + "code": "Ok", + "matchings": [ + { + "confidence": 0, + "distance": 353.157, + "duration": 92.702, + "geometry": "q|qeFndejVf@lJyDd@Y_E", + "legs": [ + { + "distance": 353.157, + "duration": 92.702, + "steps": [], + "summary": "McAllister Street, Franklin Street", + "weight": 126.614 + } + ], + "weight": 126.614, + "weight_name": "auto" + } + ], + "tracepoints": [ + { "name": "McAllister Street", "location": [-122.420084, 37.780093], "waypoint_index": 0 }, + { "name": "Golden Gate Avenue", "location": [-122.421141, 37.780946], "waypoint_index": 1 }, + null + ] +} +``` + + + +```json +{"code":"Ok","matchings":[{"confidence":0,"distance":353.157,"duration":92.702,"geometry":"q|qeFndejVf@lJyDd@Y_E","legs":[{"distance":353.157,"duration":92.702,"steps":[],"summary":"McAllister Street, Franklin Street","weight":126.614}],"weight":126.614,"weight_name":"auto"}],"tracepoints":[{"name":"McAllister Street","location":[-122.420084,37.780093],"waypoint_index":0},{"name":"Golden Gate Avenue","location":[-122.421141,37.780946],"waypoint_index":1},null]} +``` + +
+ +Like `directions route`, neither output mode has a bespoke rendering for +this response — it isn't GeoJSON at the top level — so both print the same +JSON, `-o text` pretty-printed and `-o json` on one line. Trimmed to one +matching and dropped `admins`/`via_waypoints`/`alternatives_count`/`uuid` +for length; the real response carries them too. + --- ## Search diff --git a/src/remedy.rs b/src/remedy.rs index 63f129d..ad7c5a1 100644 --- a/src/remedy.rs +++ b/src/remedy.rs @@ -88,6 +88,10 @@ const SERVICE_DOCS: &[(&str, &str)] = &[ "isochrone", "https://docs.mapbox.com/api/navigation/isochrone/", ), + ( + "map-matching", + "https://docs.mapbox.com/api/navigation/map-matching/", + ), ("search", "https://docs.mapbox.com/api/search/search-box/"), // Static Images and Static Tiles merged into one `static` command group // (#116); neither upstream page covers both, so this points at Static diff --git a/src/spec.rs b/src/spec.rs index 248292e..1641d79 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -109,6 +109,7 @@ const BODY_CONTENT_TYPE_OVERRIDES: &[(&str, &str, &str)] = &[("styles", "starFil const ARG_NAME_OVERRIDES: &[(&str, &str, &str)] = &[ ("directions", "profile", "routing-profile"), ("isochrone", "profile", "routing-profile"), + ("map-matching", "profile", "routing-profile"), ]; /// The `arg_name` a parameter should present as, when its spec name collides @@ -147,7 +148,7 @@ fn arg_name_override(service_name: &str, param_name: &str) -> Option<&'static st /// `generate-skills`, this file's own `command()` above — reads a /// [`FLATTENED_SERVICES`] service correctly for free, because they all go /// through `command()` rather than reconstructing the string themselves. -pub const FLATTENED_SERVICES: &[&str] = &["directions", "isochrone"]; +pub const FLATTENED_SERVICES: &[&str] = &["directions", "isochrone", "map-matching"]; /// (service, path parameter name) pairs whose value is trusted to reach the /// URL unescaped, because every legitimate value already contains a @@ -167,8 +168,11 @@ pub const FLATTENED_SERVICES: &[&str] = &["directions", "isochrone"]; /// (`mapbox/driving`, `mapbox/cycling`, an OEM's own profile name, …), which /// the routing profile's own path segment depends on reaching the API /// unescaped regardless of which spelling was typed. -pub const UNESCAPED_PATH_PARAMS: &[(&str, &str)] = - &[("directions", "profile"), ("isochrone", "profile")]; +pub const UNESCAPED_PATH_PARAMS: &[(&str, &str)] = &[ + ("directions", "profile"), + ("isochrone", "profile"), + ("map-matching", "profile"), +]; /// The media types an operation's request body may be sent as. /// @@ -716,6 +720,10 @@ pub const CUSTOM_SPEC_ENTRIES: &[SpecEntry] = &[ name: "isochrone", yaml: include_str!("../custom-openapi/isochrone/openapi/isochrone.yaml"), }, + SpecEntry { + name: "map-matching", + yaml: include_str!("../custom-openapi/map-matching/openapi/map-matching.yaml"), + }, ]; /// The list the CLI actually generates commands from: [`MAPBOX_SPEC_ENTRIES`], @@ -2175,6 +2183,51 @@ paths: assert_eq!(contours.command(), "isochrone"); } + /// Same regression, for the third spec that ran into it. + #[test] + fn the_map_matching_profile_parameter_does_not_collide_with_the_global_flag() { + let spec = parse_spec( + "map-matching", + include_str!("../custom-openapi/map-matching/openapi/map-matching.yaml"), + ) + .expect("map-matching.yaml parses"); + + let matched = spec + .operations + .iter() + .find(|op| op.command_path == ["match"]) + .expect("the match operation exists"); + + let profile = matched + .path_params + .iter() + .find(|p| p.name == "profile") + .expect("a path parameter named profile"); + + assert_ne!( + profile.arg_name, "profile", + "must not collide with the global --profile id" + ); + // Deliberately not an `enum`: see `UNESCAPED_PATH_PARAMS`'s own doc + // comment for why a closed set was wrong here (OEM accounts have + // undocumented profiles of their own). + assert!( + profile.enum_values.is_empty(), + "profile must accept any value, not just the four documented ones" + ); + assert!( + UNESCAPED_PATH_PARAMS.contains(&("map-matching", "profile")), + "profile's literal `/` must still reach the URL unescaped, \ + now that it can't rely on being an enum to prove that" + ); + + // `map-matching` has exactly one operation and is in + // `FLATTENED_SERVICES` — `command()` must say so, dropping + // `command_path` from the string entirely, even though + // `command_path` itself stays `["match"]` for internal lookups. + assert_eq!(matched.command(), "map-matching"); + } + #[test] fn arg_name_override_only_fires_for_the_row_it_names() { assert_eq!( @@ -2185,6 +2238,10 @@ paths: arg_name_override("isochrone", "profile"), Some("routing-profile") ); + assert_eq!( + arg_name_override("map-matching", "profile"), + Some("routing-profile") + ); assert_eq!(arg_name_override("directions", "coordinates"), None); assert_eq!(arg_name_override("styles", "profile"), None); } diff --git a/tests/fixtures/api_command_surface.txt b/tests/fixtures/api_command_surface.txt index 4b89ea8..53542df 100644 --- a/tests/fixtures/api_command_surface.txt +++ b/tests/fixtures/api_command_surface.txt @@ -9,6 +9,7 @@ mapbox geocoder batch | aliases: (none) mapbox geocoder forward | aliases: (none) mapbox geocoder reverse | aliases: (none) mapbox isochrone | aliases: (none) +mapbox map-matching | aliases: (none) mapbox search category | aliases: (none) mapbox search forward | aliases: (none) mapbox search list-category | aliases: (none)