diff --git a/docs/reference/cli-command-catalogue.md b/docs/reference/cli-command-catalogue.md index 3338bcb..7f910e2 100644 --- a/docs/reference/cli-command-catalogue.md +++ b/docs/reference/cli-command-catalogue.md @@ -5,7 +5,7 @@ Auto-generated from `specs/*.json.gz` via `scripts/generate_cli_command_catalogu Lists every operation the CLI dispatches. Deprecated operations and the `internal` resource are excluded. Each row corresponds to a concrete `--api-scope` / `--api-version` combination. **Services:** 24 -**Operations:** 1961 +**Operations:** 1962 ## achievement @@ -102,7 +102,7 @@ Lists every operation the CLI dispatches. Deprecated operations and the `interna - Spec name: `basic` - Resources: 5 -- Operations: 57 +- Operations: 58 | Resource | Method | Scope | Version | HTTP | Path | Summary | |----------|--------|-------|---------|------|------|---------| @@ -160,6 +160,7 @@ Lists every operation the CLI dispatches. Deprecated operations and the `interna | `profiles` | `update-custom-attributes` | public | v1 | PUT | `/basic/v1/public/namespaces/{namespace}/users/{userId}/profiles/customAttributes` | Updates custom attributes for a user | | `profiles` | `update-my` | public | v1 | PUT | `/basic/v1/public/namespaces/{namespace}/users/me/profiles` | Updates my profile | | `profiles` | `update-my-private-custom-attributes` | public | v1 | PUT | `/basic/v1/public/namespaces/{namespace}/users/me/profiles/privateCustomAttributes` | Updates my private custom attributes | +| `profiles` | `update-my-zip-code` | public | v1 | PATCH | `/basic/v1/public/namespaces/{namespace}/users/me/profiles/zipCode` | Updates my zip code | | `profiles` | `update-private-custom-attributes` | admin | v1 | PUT | `/basic/v1/admin/namespaces/{namespace}/users/{userId}/profiles/privateCustomAttributes` | Updates private custom attributes for a user | | `profiles` | `update-status` | admin | v1 | PATCH | `/basic/v1/admin/namespaces/{namespace}/users/{userId}/profiles/status` | Updates user profile status | | `profiles` | `update-status` | public | v1 | PATCH | `/basic/v1/public/namespaces/{namespace}/users/{userId}/profiles/status` | Updates user profile status | @@ -1120,7 +1121,7 @@ Lists every operation the CLI dispatches. Deprecated operations and the `interna | Resource | Method | Scope | Version | HTTP | Path | Summary | |----------|--------|-------|---------|------|------|---------| -| `blocks` | `symc` | public | v1 | PATCH | `/lobby/sync/namespaces/{namespace}/me/block` | Syncs the current user's blocked list with linked native first-party platform accounts | +| `blocks` | `sync` | public | v1 | PATCH | `/lobby/sync/namespaces/{namespace}/me/block` | Syncs the current user's blocked list with linked native first-party platform accounts | | `config` | `export` | admin | v1 | GET | `/lobby/v1/admin/config/namespaces/{namespace}/export` | Exports the lobby configuration to a JSON file | | `config` | `get` | admin | v1 | GET | `/lobby/v1/admin/config/namespaces/{namespace}` | Retrieves the configuration for a specified namespace | | `config` | `get-log` | admin | v1 | GET | `/lobby/v1/admin/config/log` | Retrieves the current log configuration | @@ -1963,8 +1964,8 @@ Lists every operation the CLI dispatches. Deprecated operations and the `interna | `stat-cycles` | `create` | admin | v1 | POST | `/social/v1/admin/namespaces/{namespace}/statCycles` | Creates a stat cycle | | `stat-cycles` | `delete` | admin | v1 | DELETE | `/social/v1/admin/namespaces/{namespace}/statCycles/{cycleId}` | Deletes stat cycle | | `stat-cycles` | `export` | admin | v1 | GET | `/social/v1/admin/namespaces/{namespace}/statCycles/export` | Exports all stat cycle configurations | -| `stat-cycles` | `export` | public | v1 | GET | `/social/v1/public/namespaces/{namespace}/statCycles/{cycleId}` | Retrieves a stat cycle | | `stat-cycles` | `get` | admin | v1 | GET | `/social/v1/admin/namespaces/{namespace}/statCycles/{cycleId}` | Retrieves a stat cycle | +| `stat-cycles` | `get` | public | v1 | GET | `/social/v1/public/namespaces/{namespace}/statCycles/{cycleId}` | Retrieves a stat cycle | | `stat-cycles` | `import` | admin | v1 | POST | `/social/v1/admin/namespaces/{namespace}/statCycles/import` | Imports stat cycle configurations | | `stat-cycles` | `list` | admin | v1 | GET | `/social/v1/admin/namespaces/{namespace}/statCycles` | Lists stat cycles | | `stat-cycles` | `list` | public | v1 | GET | `/social/v1/public/namespaces/{namespace}/statCycles` | Lists stat cycles | diff --git a/specs/basic.json.gz b/specs/basic.json.gz index 1f53a1d..03a3b9f 100644 Binary files a/specs/basic.json.gz and b/specs/basic.json.gz differ diff --git a/specs/lobby.json.gz b/specs/lobby.json.gz index 6dcb5d2..575c8b7 100644 Binary files a/specs/lobby.json.gz and b/specs/lobby.json.gz differ diff --git a/specs/social.json.gz b/specs/social.json.gz index f8c9a0d..da0b4c4 100644 Binary files a/specs/social.json.gz and b/specs/social.json.gz differ diff --git a/src/catalogue/parser.rs b/src/catalogue/parser.rs index ad6de1f..ffe5be1 100644 --- a/src/catalogue/parser.rs +++ b/src/catalogue/parser.rs @@ -52,8 +52,20 @@ pub fn parse_spec(service_name: &str, spec: &SwaggerSpec) -> ServiceSchema { continue; }; - let parts: Vec<&str> = x_operation_id.splitn(5, '/').collect(); - if parts.len() < 5 { + // Expect exactly 5 segments: service/scope/resource/version/method. + // Both underflow (too few) and overflow (a stray slash that pushes + // the method into a 6th segment) are spec bugs. Without this + // check, `splitn` would silently fold the overflow into `parts[4]` + // and the version-parse below would fail-and-continue, dropping + // the operation from the CLI surface with no signal. + let parts: Vec<&str> = x_operation_id.split('/').collect(); + if parts.len() != 5 { + debug_assert!( + false, + "x-operationId must have exactly 5 segments \ + (service/scope/resource/version/method), got {} in '{x_operation_id}'", + parts.len(), + ); continue; } let scope = parts[1].to_string(); @@ -62,14 +74,22 @@ pub fn parse_spec(service_name: &str, spec: &SwaggerSpec) -> ServiceSchema { continue; } let method_name = parts[4].to_string(); - // Parse the version integer from "v1", "v2", etc. Skip the - // operation entirely on a non-numeric segment rather than - // coercing to 0 — a `0` here would create a ghost contract - // selectable as `--api-version v0` and could collide with a - // sibling on the duplicate-key panic. + // Parse the version integer from "v1", "v2", etc. A non-numeric + // segment here means the spec is malformed — assert in debug so it + // surfaces during development, skip in release so one bad op does + // not poison the whole service. Coercing to 0 would create a + // ghost contract selectable as `--api-version v0`. let api_version: ApiVersion = match parts[3].parse() { Ok(v) => v, - Err(_) => continue, + Err(_) => { + debug_assert!( + false, + "x-operationId version segment must be numeric (e.g. 'v1' style is allowed \ + only if ApiVersion parses it), got '{}' in '{x_operation_id}'", + parts[3], + ); + continue; + } }; let http_method_upper = http_method_name.to_uppercase(); @@ -749,9 +769,21 @@ mod tests { assert!(names.contains(&"prefs")); } - /// Malformed `x-operationId` values should be skipped instead of panicking. + /// An `x-operationId` with fewer than 5 segments is malformed and should + /// trip the debug assertion so spec drift surfaces during development. + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "x-operationId must have exactly 5 segments")] + fn test_parser_underflow_x_operation_id_asserts_in_debug() { + let spec = spec_from(&[("/api/v1/bans", "get", "too/few/parts", false, "")]); + let _ = parse_spec("test", &spec); + } + + /// In release builds, underflow x-operationId values are skipped so one + /// bad entry does not poison the whole service. #[test] - fn test_skips_malformed_x_operation_id() { + #[cfg(not(debug_assertions))] + fn test_parser_underflow_x_operation_id_skips_in_release() { let spec = spec_from(&[ ("/api/v1/bans", "get", "too/few/parts", false, ""), ("/api/v1/users", "get", "svc/admin/users/v1/list", false, ""), @@ -761,6 +793,69 @@ mod tests { assert_eq!(service.resources[0].name, "users"); } + /// An `x-operationId` with more than 5 segments — a real spec bug seen in + /// `basic/public/profiles/update/v1/my-zip-code` where a stray slash split + /// the method name — must trip the debug assertion. Previously this was + /// silently dropped because `splitn(5, '/')` folded the overflow into the + /// last segment and the version-parse fail path skipped the op. + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "x-operationId must have exactly 5 segments")] + fn test_parser_overflow_x_operation_id_asserts_in_debug() { + let spec = spec_from(&[( + "/basic/v1/public/namespaces/{namespace}/users/me/profiles/zipCode", + "put", + "basic/public/profiles/update/v1/my-zip-code", + false, + "", + )]); + let _ = parse_spec("basic", &spec); + } + + /// In release builds, overflow x-operationId values are skipped (the + /// operation is omitted from the CLI surface) rather than panicking. + #[test] + #[cfg(not(debug_assertions))] + fn test_parser_overflow_x_operation_id_skips_in_release() { + let spec = spec_from(&[ + ( + "/basic/v1/public/namespaces/{namespace}/users/me/profiles/zipCode", + "put", + "basic/public/profiles/update/v1/my-zip-code", + false, + "", + ), + ("/api/v1/users", "get", "svc/admin/users/v1/list", false, ""), + ]); + let service = parse_spec("basic", &spec); + assert_eq!(service.resources.len(), 1); + assert_eq!(service.resources[0].name, "users"); + } + + /// A non-numeric version segment in an otherwise well-shaped (5-segment) + /// `x-operationId` should trip the debug assertion as a spec bug. + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "x-operationId version segment must be numeric")] + fn test_parser_non_numeric_version_asserts_in_debug() { + let spec = spec_from(&[("/api/v1/bans", "get", "svc/admin/bans/vX/list", false, "")]); + let _ = parse_spec("test", &spec); + } + + /// In release builds, a non-numeric version segment skips the operation + /// instead of panicking — the rest of the service still loads. + #[test] + #[cfg(not(debug_assertions))] + fn test_parser_non_numeric_version_skips_in_release() { + let spec = spec_from(&[ + ("/api/v1/bans", "get", "svc/admin/bans/vX/list", false, ""), + ("/api/v1/users", "get", "svc/admin/users/v1/list", false, ""), + ]); + let service = parse_spec("test", &spec); + assert_eq!(service.resources.len(), 1); + assert_eq!(service.resources[0].name, "users"); + } + /// All non-deprecated versions of a contract should be preserved and the /// highest version should become the default. #[test] diff --git a/tests/fixtures/baselines/basic_input_contract.json b/tests/fixtures/baselines/basic_input_contract.json index 9f7f07b..1a2c707 100644 --- a/tests/fixtures/baselines/basic_input_contract.json +++ b/tests/fixtures/baselines/basic_input_contract.json @@ -986,6 +986,21 @@ "summary": "Updates my private custom attributes", "x_operation_id": "basic/public/profiles/v1/update-my-private-custom-attributes" }, + { + "http_method": "PATCH", + "method": "update-my-zip-code", + "parameters": [ + { + "location": "path", + "name": "namespace", + "required": true + } + ], + "path": "/basic/v1/public/namespaces/{namespace}/users/me/profiles/zipCode", + "permissions": [], + "summary": "Updates my zip code", + "x_operation_id": "basic/public/profiles/v1/update-my-zip-code" + }, { "http_method": "PUT", "method": "update-private-custom-attributes", diff --git a/tests/fixtures/baselines/lobby_input_contract.json b/tests/fixtures/baselines/lobby_input_contract.json index 5422178..ae55424 100644 --- a/tests/fixtures/baselines/lobby_input_contract.json +++ b/tests/fixtures/baselines/lobby_input_contract.json @@ -2,7 +2,7 @@ "blocks": [ { "http_method": "PATCH", - "method": "symc", + "method": "sync", "parameters": [ { "location": "path", @@ -13,7 +13,7 @@ "path": "/lobby/sync/namespaces/{namespace}/me/block", "permissions": [], "summary": "Syncs the current user's blocked list with linked native first-party platform accounts", - "x_operation_id": "lobby/public/blocks/v1/symc" + "x_operation_id": "lobby/public/blocks/v1/sync" } ], "config": [ diff --git a/tests/fixtures/baselines/social_input_contract.json b/tests/fixtures/baselines/social_input_contract.json index 9188764..bd46046 100644 --- a/tests/fixtures/baselines/social_input_contract.json +++ b/tests/fixtures/baselines/social_input_contract.json @@ -204,7 +204,7 @@ }, { "http_method": "GET", - "method": "export", + "method": "get", "parameters": [ { "location": "path", @@ -217,10 +217,10 @@ "required": true } ], - "path": "/social/v1/public/namespaces/{namespace}/statCycles/{cycleId}", + "path": "/social/v1/admin/namespaces/{namespace}/statCycles/{cycleId}", "permissions": [], "summary": "Retrieves a stat cycle", - "x_operation_id": "social/public/stat-cycles/v1/export" + "x_operation_id": "social/admin/stat-cycles/v1/get" }, { "http_method": "GET", @@ -237,10 +237,10 @@ "required": true } ], - "path": "/social/v1/admin/namespaces/{namespace}/statCycles/{cycleId}", + "path": "/social/v1/public/namespaces/{namespace}/statCycles/{cycleId}", "permissions": [], "summary": "Retrieves a stat cycle", - "x_operation_id": "social/admin/stat-cycles/v1/get" + "x_operation_id": "social/public/stat-cycles/v1/get" }, { "http_method": "POST",