From edb5d60948c7515d0e4d0d9a42a20648c979dd35 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 24 Sep 2026 12:33:11 -0400 Subject: [PATCH] Add mapbox matrix compute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth of the Navigation-category APIs with no prior CLI coverage. Same shape as directions/isochrone/map-matching: 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 (fourth row, not a fourth mechanism). Named `compute` rather than the API-naming doc's proposed `get`, matching the precedent set by the three commands before it (route/contours/match) — this returns a matrix computed fresh from the request, not a stored resource. Verified a real discrepancy against production while testing: the API answers a comma-separated --sources/--destinations list with a 422 ("may be \"all\" or semicolon-separated list of 0-based integer indices"), unlike this CLI's own convention elsewhere of comma-separating index lists. Documented in the spec and in --help, not just discovered and left as a surprise. Also fixes a --help truncation on --fallback-speed ("Legacy." as a leading sentence, same class of bug as directions.yaml's earlier ones) by moving the qualifier to the end of the description. Smoke-tested against production: a full 3x3 matrix and a subsetted 1x2 matrix via --sources/--destinations, both verified to return the documented shape. 487 tests, fmt and clippy clean. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 13 ++ README.md | 9 +- custom-openapi/matrix/openapi/matrix.yaml | 165 ++++++++++++++++++++++ docs/commands.md | 106 +++++++++++++- src/remedy.rs | 1 + src/spec.rs | 57 +++++++- tests/fixtures/api_command_surface.txt | 1 + 7 files changed, 346 insertions(+), 6 deletions(-) create mode 100644 custom-openapi/matrix/openapi/matrix.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2122057..cbd34b9 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 matrix`, travel time and/or distance between every pair in a set + of up to 25 coordinates in one call, 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 `compute`) would disambiguate; see `spec::FLATTENED_SERVICES`. + Hand-authored into `custom-openapi/` for the same reason the other + Navigation commands were: no upstream spec exists yet. Reuses the + `profile`-vs-`--profile` collision fix (`ARG_NAME_OVERRIDES` gets a + fourth row) and the free-form (not `enum`) routing profile, for the same + OEM-account reason. `--sources`/`--destinations` take + semicolon-separated indices, not comma; verified against production + after the API answered a comma-separated list with a 422. + - `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 diff --git a/README.md b/README.md index f59456a..71f5001 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,11 @@ mapbox styles * mapbox tilesets * ``` -`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. +`mapbox directions`, `mapbox isochrone`, `mapbox map-matching`, and `mapbox +matrix` 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 belongs to is decided per operation. So `sprites` and `tilesets` are each diff --git a/custom-openapi/matrix/openapi/matrix.yaml b/custom-openapi/matrix/openapi/matrix.yaml new file mode 100644 index 0000000..f7b9edf --- /dev/null +++ b/custom-openapi/matrix/openapi/matrix.yaml @@ -0,0 +1,165 @@ +openapi: "3.0.0" +# `parse_spec` turns `info.description` below into this service's clap +# `long_about`, so it also reaches `mapbox matrix --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/matrix. 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. +info: + title: "Mapbox Matrix API" + description: >- + Travel time and distance between every pair in a set of up to 25 + coordinates, in one call — for driving (with or without live traffic), + walking, or cycling. + version: "0.0.0" +servers: + - url: https://api.mapbox.com + description: Matrix API +paths: + /directions-matrix/v1/{profile}/{coordinates}: + get: + operationId: compute + summary: A travel time/distance matrix across a set of coordinates. + description: >- + Returns a `durations` and/or `distances` matrix in row-major order — + `durations[i][j]` is the time from the ith source to the jth + destination — across every source/destination pair. Defaults to + every coordinate as both a source and a destination (a full N×N + matrix); `--sources`/`--destinations` narrow either side to a + subset. Answers "which of these is reachable soonest", not a route + through all of them. See `mapbox directions` for a route through + fixed stops in order. + 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, caps at 10 coordinates instead of 25), + `mapbox/driving`, `mapbox/walking`, and `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-25 coordinates, semicolon-separated, each + `{longitude},{latitude}` — 10 max for `mapbox/driving-traffic`. + schema: + type: string + minLength: 1 + example: "-122.42,37.78;-122.45,37.91;-122.41,37.80" + - name: "access_token" + in: query + required: true + description: "Mapbox API Access Token" + schema: + type: string + minLength: 1 + - name: "annotations" + in: query + required: false + description: >- + Which matrix or matrices to return, comma-separated. Options + are `duration` (the default) and `distance` — both together + returns both matrices. + schema: + type: string + example: "duration,distance" + - name: "approaches" + in: query + required: false + description: >- + Which side of the road to approach each coordinate from, + semicolon-separated — `unrestricted` or `curb` per coordinate. + schema: + type: string + - name: "bearings" + in: query + required: false + description: >- + `{angle},{degrees}` per coordinate, semicolon-separated, + filtering the road segments considered by direction of travel. + schema: + type: string + - name: "sources" + in: query + required: false + description: >- + Which coordinates act as sources (matrix rows) — `all` + (the default), or zero-based indices, semicolon-separated. + Verified against production: a comma-separated list is a 422, + "may be \"all\" or semicolon-separated list of 0-based integer + indices" — unlike every other index/value list on this CLI's + Navigation commands, which are comma- or semicolon-separated + per parameter but never comma where this API wants semicolons. + schema: + type: string + example: "0;2" + - name: "destinations" + in: query + required: false + description: >- + Which coordinates act as destinations (matrix columns) — `all` + (the default), or zero-based indices, semicolon-separated. See + `sources` above — comma-separated is a 422 here specifically. + schema: + type: string + example: "1;3" + - name: "fallback_speed" + in: query + required: false + # Content before "Legacy", not after: `first_sentence` in + # `src/main.rs` cuts a `--help` line at the first `.`, and + # "Legacy." on its own left `--help` showing just that word. + # `--schema` and `docs/commands.md` still show it in full. + description: >- + Replaces a `null` (unreachable) cell with a straight-line + estimate at this speed, km/h, rather than leaving it `null`. + Legacy. + schema: + type: integer + minimum: 1 + - name: "depart_at" + in: query + required: false + description: >- + Departure time, ISO 8601, for future traffic conditions and + time-dependent road restrictions. + schema: + type: string + responses: + "200": + description: >- + A JSON object with a `code`, a `durations` and/or `distances` + matrix (row-major, seconds and meters respectively — `null` for + an unreachable pair), and the snapped `sources`/`destinations` + waypoints. + "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. diff --git a/docs/commands.md b/docs/commands.md index c0ef195..d8f240e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -85,6 +85,8 @@ nests, and is typed `mapbox styles draft get`. **[Map Matching](#map-matching)** — [map-matching](#mapbox-map-matching) +**[Matrix](#matrix)** — [matrix](#mapbox-matrix) + **[Search](#search)** — [search.forward](#mapbox-search-forward) · [search.reverse](#mapbox-search-reverse) · [search.category](#mapbox-search-category) · @@ -1449,12 +1451,114 @@ enough off the road network to leave its tracepoint `null`. -Like `directions route`, neither output mode has a bespoke rendering for +Like `mapbox directions`, 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. +--- +## Matrix + +Travel time and distance between every pair in a set of up to 25 +coordinates, in one call, for driving (with or without live traffic), +walking, or cycling. Curated by hand down to the parameters documented at +docs.mapbox.com/api/navigation/matrix — see `custom-openapi/README.md` for +why this command group doesn't come from the vendored specs the way most +others do. + +**vs. `mapbox directions`**: this answers "how far/long between every pair", +not a route through all of them in order — `mapbox directions` is a route +through fixed stops; this is an N×N table, useful for ranking or filtering +many candidates by reachability before committing to a route through any of +them. + +### `mapbox matrix` + +A `durations` and/or `distances` matrix in row-major order — +`durations[i][j]` is the time from the ith source to the jth destination — +across every source/destination pair, or a subset of either side. 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-25 `{longitude},{latitude}` pairs, semicolon-separated, +10 max for `mapbox/driving-traffic`. + +| Parameter | Effect | +| --- | --- | +| `--annotations ` | Which matrix or matrices to return, comma-separated. `duration` alone is the default; both together returns both. | +| `--approaches ` | Which side of the road to approach each coordinate from. | +| `--bearings ` | Filter road segments by direction of travel, one entry per coordinate. | +| `--sources ` | Which coordinates are matrix rows — `all` (the default) or zero-based indices, **semicolon**-separated. Verified against production: comma-separated is a 422 here, unlike most other index lists on these commands. | +| `--destinations ` | Which coordinates are matrix columns — same rules as `--sources`. | +| `--fallback-speed ` | Replaces a `null` (unreachable) cell with a straight-line estimate at this speed, rather than leaving it `null`. Legacy. | +| `--depart-at ` | For future traffic conditions and time-dependent road restrictions. | + +#### Examples + +```sh +mapbox matrix mapbox/driving "-122.42,37.78;-122.45,37.91;-122.41,37.80" +mapbox matrix mapbox/driving "-122.42,37.78;-122.45,37.91;-122.41,37.80" \ + --sources 0 --destinations "1;2" +``` + +#### Outputs + +Captured live: a full 3×3 matrix between three San Francisco points, both +`durations` (seconds) and `distances` (meters). + + + + +
Terminal — -o textAgent — -o json
+ +```json +{ + "code": "Ok", + "durations": [ + [0, 2381.5, 790.1], + [2593.8, 0, 2272.5], + [994.8, 2269.7, 0] + ], + "distances": [ + [0, 25766, 3348.3], + [26960.4, 0, 25382], + [3781.3, 25174.1, 0] + ], + "sources": [ + { "name": "Van Ness Avenue", "location": [-122.420122, 37.779978] }, + { "name": "Playa Verde", "location": [-122.461997, 37.89621] }, + { "name": "Columbus Avenue", "location": [-122.409926, 37.800067] } + ], + "destinations": [ + { "name": "Van Ness Avenue", "location": [-122.420122, 37.779978] }, + { "name": "Playa Verde", "location": [-122.461997, 37.89621] }, + { "name": "Columbus Avenue", "location": [-122.409926, 37.800067] } + ] +} +``` + + + +```json +{"code":"Ok","durations":[[0,2381.5,790.1],[2593.8,0,2272.5],[994.8,2269.7,0]],"distances":[[0,25766,3348.3],[26960.4,0,25382],[3781.3,25174.1,0]],"sources":[{"name":"Van Ness Avenue","location":[-122.420122,37.779978]},{"name":"Playa Verde","location":[-122.461997,37.89621]},{"name":"Columbus Avenue","location":[-122.409926,37.800067]}],"destinations":[{"name":"Van Ness Avenue","location":[-122.420122,37.779978]},{"name":"Playa Verde","location":[-122.461997,37.89621]},{"name":"Columbus Avenue","location":[-122.409926,37.800067]}]} +``` + +
+ +Neither output mode has a bespoke rendering for this response, same as +`mapbox directions` and `mapbox map-matching` — both print the same JSON, +`-o text` pretty-printed and `-o json` on one line. Dropped each waypoint's +own snap `distance` for length; the real response carries it too. + --- ## Search diff --git a/src/remedy.rs b/src/remedy.rs index ad7c5a1..4b979b7 100644 --- a/src/remedy.rs +++ b/src/remedy.rs @@ -92,6 +92,7 @@ const SERVICE_DOCS: &[(&str, &str)] = &[ "map-matching", "https://docs.mapbox.com/api/navigation/map-matching/", ), + ("matrix", "https://docs.mapbox.com/api/navigation/matrix/"), ("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 1641d79..d09c1c2 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -110,6 +110,7 @@ const ARG_NAME_OVERRIDES: &[(&str, &str, &str)] = &[ ("directions", "profile", "routing-profile"), ("isochrone", "profile", "routing-profile"), ("map-matching", "profile", "routing-profile"), + ("matrix", "profile", "routing-profile"), ]; /// The `arg_name` a parameter should present as, when its spec name collides @@ -148,7 +149,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", "map-matching"]; +pub const FLATTENED_SERVICES: &[&str] = &["directions", "isochrone", "map-matching", "matrix"]; /// (service, path parameter name) pairs whose value is trusted to reach the /// URL unescaped, because every legitimate value already contains a @@ -172,6 +173,7 @@ pub const UNESCAPED_PATH_PARAMS: &[(&str, &str)] = &[ ("directions", "profile"), ("isochrone", "profile"), ("map-matching", "profile"), + ("matrix", "profile"), ]; /// The media types an operation's request body may be sent as. @@ -724,6 +726,10 @@ pub const CUSTOM_SPEC_ENTRIES: &[SpecEntry] = &[ name: "map-matching", yaml: include_str!("../custom-openapi/map-matching/openapi/map-matching.yaml"), }, + SpecEntry { + name: "matrix", + yaml: include_str!("../custom-openapi/matrix/openapi/matrix.yaml"), + }, ]; /// The list the CLI actually generates commands from: [`MAPBOX_SPEC_ENTRIES`], @@ -2228,6 +2234,51 @@ paths: assert_eq!(matched.command(), "map-matching"); } + /// Same regression, for the fourth spec that ran into it. + #[test] + fn the_matrix_profile_parameter_does_not_collide_with_the_global_flag() { + let spec = parse_spec( + "matrix", + include_str!("../custom-openapi/matrix/openapi/matrix.yaml"), + ) + .expect("matrix.yaml parses"); + + let compute = spec + .operations + .iter() + .find(|op| op.command_path == ["compute"]) + .expect("the compute operation exists"); + + let profile = compute + .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(&("matrix", "profile")), + "profile's literal `/` must still reach the URL unescaped, \ + now that it can't rely on being an enum to prove that" + ); + + // `matrix` 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 + // `["compute"]` for internal lookups. + assert_eq!(compute.command(), "matrix"); + } + #[test] fn arg_name_override_only_fires_for_the_row_it_names() { assert_eq!( @@ -2242,6 +2293,10 @@ paths: arg_name_override("map-matching", "profile"), Some("routing-profile") ); + assert_eq!( + arg_name_override("matrix", "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 53542df..f16f224 100644 --- a/tests/fixtures/api_command_surface.txt +++ b/tests/fixtures/api_command_surface.txt @@ -10,6 +10,7 @@ mapbox geocoder forward | aliases: (none) mapbox geocoder reverse | aliases: (none) mapbox isochrone | aliases: (none) mapbox map-matching | aliases: (none) +mapbox matrix | aliases: (none) mapbox search category | aliases: (none) mapbox search forward | aliases: (none) mapbox search list-category | aliases: (none)