diff --git a/codegen/templates/cli/api_resource.rs.jinja b/codegen/templates/cli/api_resource.rs.jinja index db6b09d7b..b25ff373c 100644 --- a/codegen/templates/cli/api_resource.rs.jinja +++ b/codegen/templates/cli/api_resource.rs.jinja @@ -70,7 +70,7 @@ use super::{ {{ title }}: { {%- for field in schema.fields | selectattr("positional", "false") %} - \"{{ field.name }}\": {% if field.example is defined %}\"{{ field.example }}\"{% else %}{{ get_type_example(field, api) }}{% endif %}{{ "," if not loop.last else "" }} + \"{{ field.name }}\": {% if field.example is defined %}\"{{ field.example }}\"{% else %}{{ get_type_example(field, api) }}{% endif %}{{ "," if not loop.last else "" }}{% if field.description is defined %} // {{ field.description | split("\n\n") | first | replace("\n", " ") | trim }}{% endif %} {%- endfor %} } {%- endmacro -%} diff --git a/z-clients/cli/src/cmds/api/admin_auth_policy.rs b/z-clients/cli/src/cmds/api/admin_auth_policy.rs index 45f0e055f..4cfba71c6 100644 --- a/z-clients/cli/src/cmds/api/admin_auth_policy.rs +++ b/z-clients/cli/src/cmds/api/admin_auth_policy.rs @@ -92,8 +92,8 @@ pub enum AdminAuthPolicyCommands { ))] #[command(after_help = "Example body: { - \"limit\": 123, - \"iterator\": \"some_access_policy\" + \"limit\": 123, // Limit the number of returned items + \"iterator\": \"some_access_policy\" // The iterator returned from a prior invocation }\n\nExample response: { \"data\": [{\"id\": \"some_access_policy\", \"description\": \"...\", \"rules\": [{\"effect\": \"allow\", \"resource\": \"...\", \"actions\": [\"...\"]}], \"created\": 1234567890123, \"updated\": 1234567890123}], diff --git a/z-clients/cli/src/cmds/api/admin_auth_role.rs b/z-clients/cli/src/cmds/api/admin_auth_role.rs index ddbc70c6c..ec3faab31 100644 --- a/z-clients/cli/src/cmds/api/admin_auth_role.rs +++ b/z-clients/cli/src/cmds/api/admin_auth_role.rs @@ -95,8 +95,8 @@ pub enum AdminAuthRoleCommands { ))] #[command(after_help = "Example body: { - \"limit\": 123, - \"iterator\": \"some_role_id\" + \"limit\": 123, // Limit the number of returned items + \"iterator\": \"some_role_id\" // The iterator returned from a prior invocation }\n\nExample response: { \"data\": [{\"id\": \"some_role_id\", \"description\": \"...\", \"rules\": [{\"effect\": \"allow\", \"resource\": \"...\", \"actions\": [\"...\"]}], \"policies\": [\"...\"], \"context\": {\"key\": \"...\"}, \"created\": 1234567890123, \"updated\": 1234567890123}], diff --git a/z-clients/cli/src/cmds/api/admin_auth_token.rs b/z-clients/cli/src/cmds/api/admin_auth_token.rs index 918c5d68b..3db09e2af 100644 --- a/z-clients/cli/src/cmds/api/admin_auth_token.rs +++ b/z-clients/cli/src/cmds/api/admin_auth_token.rs @@ -28,8 +28,8 @@ pub enum AdminAuthTokenCommands { { \"name\": \"some token name\", \"role\": \"some token role\", - \"expiry_ms\": 60000, - \"enabled\": true + \"expiry_ms\": 60000, // Milliseconds from now until the token expires. + \"enabled\": true // Whether the token is enabled. Defaults to `true`. }\n\nExample response: { \"id\": \"key_06etngr201xwv7qj08mt4cs03w\", @@ -52,7 +52,7 @@ pub enum AdminAuthTokenCommands { #[command(after_help = "Example body: { \"id\": \"key_06etngr201xwv7qj08mt4cs03w\", - \"expiry_ms\": 60000 + \"expiry_ms\": 60000 // Milliseconds from now until the token expires. `None` means expire immediately. }\n\nExample response: { }\n")] @@ -111,8 +111,8 @@ pub enum AdminAuthTokenCommands { ))] #[command(after_help = "Example body: { - \"limit\": 123, - \"iterator\": \"key_06etngr201xwv7qj08mt4cs03w\" + \"limit\": 123, // Limit the number of returned items + \"iterator\": \"key_06etngr201xwv7qj08mt4cs03w\" // The iterator returned from a prior invocation }\n\nExample response: { \"data\": [{\"id\": \"key_06etngr201xwv7qj08mt4cs03w\", \"name\": \"...\", \"created\": 1234567890123, \"updated\": 1234567890123, \"expiry\": 1234567890123, \"role\": \"...\", \"enabled\": true, \"expired\": true}], diff --git a/z-clients/cli/src/cmds/api/cache.rs b/z-clients/cli/src/cmds/api/cache.rs index caf2ccbe3..1fc6652f7 100644 --- a/z-clients/cli/src/cmds/api/cache.rs +++ b/z-clients/cli/src/cmds/api/cache.rs @@ -29,7 +29,7 @@ pub enum CacheCommands { #[command(after_help = "Example body: { \"namespace\": \"some_namespace\", - \"ttl_ms\": 60000 + \"ttl_ms\": 60000 // Time to live in milliseconds }\n\nExample response: { }\n")] @@ -53,7 +53,7 @@ pub enum CacheCommands { \"consistency\": \"strong\" }\n\nExample response: { - \"expiry\": 1234567890123, + \"expiry\": 1234567890123, // Time of expiry \"value\": \"...\" }\n")] Get { diff --git a/z-clients/cli/src/cmds/api/cluster_admin.rs b/z-clients/cli/src/cmds/api/cluster_admin.rs index e199c43d1..61a84a861 100644 --- a/z-clients/cli/src/cmds/api/cluster_admin.rs +++ b/z-clients/cli/src/cmds/api/cluster_admin.rs @@ -26,13 +26,13 @@ pub enum ClusterAdminCommands { ))] #[command(after_help = "Example response: { - \"cluster_id\": \"...\", - \"cluster_name\": \"...\", - \"this_node_id\": \"a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8\", - \"this_node_state\": \"leader\", - \"this_node_last_committed_timestamp\": 1234567890123, - \"this_node_last_snapshot_id\": \"...\", - \"nodes\": [{\"node_id\": \"a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8\", \"address\": \"...\", \"state\": \"leader\", \"last_committed_log_index\": 123, \"last_committed_term\": 123}] + \"cluster_id\": \"...\", // The unique ID of this cluster. + \"cluster_name\": \"...\", // The name of this cluster (as defined in the config) + \"this_node_id\": \"a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8\", // The unique ID of the node servicing this request + \"this_node_state\": \"leader\", // The cluster state of the node servicing this request + \"this_node_last_committed_timestamp\": 1234567890123, // The timestamp of the last transaction committed on this node + \"this_node_last_snapshot_id\": \"...\", // The last snapshot taken on this node + \"nodes\": [{\"node_id\": \"a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8\", \"address\": \"...\", \"state\": \"leader\", \"last_committed_log_index\": 123, \"last_committed_term\": 123}] // A list of all nodes known to be in the cluster }\n")] Status {}, /// Initialize this node as the leader of a new cluster @@ -91,9 +91,9 @@ pub enum ClusterAdminCommands { { }\n\nExample response: { - \"snapshot_time\": 1234567890123, - \"snapshot_log_index\": 123, - \"snapshot_id\": \"...\" + \"snapshot_time\": 1234567890123, // The wall-clock time at which the snapshot was initiated + \"snapshot_log_index\": 123, // The log index at which the snapshot was initiated + \"snapshot_id\": \"...\" // If this is `null`, the snapshot is still building in the background }\n")] ForceSnapshot { cluster_force_snapshot_in: diff --git a/z-clients/cli/src/cmds/api/idempotency.rs b/z-clients/cli/src/cmds/api/idempotency.rs index 3dc2d3fb2..61459276b 100644 --- a/z-clients/cli/src/cmds/api/idempotency.rs +++ b/z-clients/cli/src/cmds/api/idempotency.rs @@ -29,7 +29,7 @@ pub enum IdempotencyCommands { #[command(after_help = "Example body: { \"namespace\": \"some_namespace\", - \"lock_period_ms\": 60000 + \"lock_period_ms\": 60000 // How long to hold the lock on start before releasing it. }\n\nExample response: { }\n")] @@ -49,9 +49,9 @@ pub enum IdempotencyCommands { #[command(after_help = "Example body: { \"namespace\": \"some_namespace\", - \"response\": \"...\", - \"context\": {\"key\": \"...\"}, - \"ttl_ms\": 60000 + \"response\": \"...\", // The response to cache + \"context\": {\"key\": \"...\"}, // Optional metadata to store alongside the response + \"ttl_ms\": 60000 // How long to keep the idempotency response for. }\n\nExample response: { }\n")] diff --git a/z-clients/cli/src/cmds/api/kv.rs b/z-clients/cli/src/cmds/api/kv.rs index 70cc54e3b..f64d0c0f2 100644 --- a/z-clients/cli/src/cmds/api/kv.rs +++ b/z-clients/cli/src/cmds/api/kv.rs @@ -29,9 +29,9 @@ pub enum KvCommands { #[command(after_help = "Example body: { \"namespace\": \"some_namespace\", - \"ttl_ms\": 60000, + \"ttl_ms\": 60000, // Time to live in milliseconds \"behavior\": \"upsert\", - \"version\": 123 + \"version\": 123 // If set, the write only succeeds when the stored version matches this value. Use the `version` field from a prior `get` response. }\n\nExample response: { \"version\": 123 @@ -56,9 +56,9 @@ pub enum KvCommands { \"consistency\": \"strong\" }\n\nExample response: { - \"expiry\": 1234567890123, + \"expiry\": 1234567890123, // Time of expiry \"value\": \"...\", - \"version\": 123 + \"version\": 123 // Opaque version token for optimistic concurrency control. Pass as `version` in a subsequent `set` to perform a conditional write. }\n")] Get { key: String, @@ -76,10 +76,10 @@ pub enum KvCommands { #[command(after_help = "Example body: { \"namespace\": \"some_namespace\", - \"version\": 123 + \"version\": 123 // If set, the delete only succeeds when the stored version matches this value. Use the `version` field from a prior `get` response. }\n\nExample response: { - \"success\": true + \"success\": true // Whether the operation succeeded or was a noop due to pre-conditions. }\n")] Delete { key: String, diff --git a/z-clients/cli/src/cmds/api/msgs_queue.rs b/z-clients/cli/src/cmds/api/msgs_queue.rs index 887365d1b..02b540442 100644 --- a/z-clients/cli/src/cmds/api/msgs_queue.rs +++ b/z-clients/cli/src/cmds/api/msgs_queue.rs @@ -33,7 +33,7 @@ pub enum MsgsQueueCommands { \"namespace\": \"some_namespace\", \"batch_size\": 123, \"lease_duration_ms\": 60000, - \"batch_wait_ms\": 60000 + \"batch_wait_ms\": 60000 // Maximum time (in milliseconds) to wait for messages before returning. }\n\nExample response: { \"msgs\": [{\"msg_id\": \"...\", \"value\": \"...\", \"headers\": {\"key\": \"...\"}, \"timestamp\": 1234567890123, \"scheduled_at\": 1234567890123}] diff --git a/z-clients/cli/src/cmds/api/msgs_stream.rs b/z-clients/cli/src/cmds/api/msgs_stream.rs index 0b11f355a..965a5617d 100644 --- a/z-clients/cli/src/cmds/api/msgs_stream.rs +++ b/z-clients/cli/src/cmds/api/msgs_stream.rs @@ -33,7 +33,7 @@ pub enum MsgsStreamCommands { \"batch_size\": 123, \"lease_duration_ms\": 60000, \"default_starting_position\": \"earliest\", - \"batch_wait_ms\": 60000 + \"batch_wait_ms\": 60000 // Maximum time (in milliseconds) to wait for messages before returning. }\n\nExample response: { \"msgs\": [{\"offset\": 123, \"topic\": \"some_topic_name~0\", \"value\": \"...\", \"headers\": {\"key\": \"...\"}, \"timestamp\": 1234567890123, \"scheduled_at\": 1234567890123}] diff --git a/z-clients/cli/src/cmds/api/msgs_svix_poller.rs b/z-clients/cli/src/cmds/api/msgs_svix_poller.rs index a590b06c5..ef1ab97b1 100644 --- a/z-clients/cli/src/cmds/api/msgs_svix_poller.rs +++ b/z-clients/cli/src/cmds/api/msgs_svix_poller.rs @@ -27,9 +27,9 @@ pub enum MsgsSvixPollerCommands { #[command(after_help = "Example body: { \"namespace\": \"some_namespace\", - \"topic\": \"some_topic_name\", - \"poller_id\": \"...\", - \"token\": \"...\" + \"topic\": \"some_topic_name\", // The topic that polled webhooks will be written too. If the topic does not exist, a new topic will be created automatically. + \"poller_id\": \"...\", // A user chosen unique identifier for the Poller. + \"token\": \"...\" // The AutoConfig token generated by Svix. }\n\nExample response: { \"topic\": \"some_topic_name\", @@ -73,8 +73,8 @@ pub enum MsgsSvixPollerCommands { #[command(after_help = "Example body: { \"namespace\": \"some_namespace\", - \"limit\": 123, - \"iterator\": \"...\" + \"limit\": 123, // Limit the number of returned items + \"iterator\": \"...\" // The iterator returned from a prior invocation }\n\nExample response: { \"data\": [{\"topic\": \"some_topic_name\", \"poller_id\": \"...\", \"token\": \"...\"}], diff --git a/z-clients/cli/src/cmds/api/rate_limit.rs b/z-clients/cli/src/cmds/api/rate_limit.rs index 36a511b70..8e20ba4d5 100644 --- a/z-clients/cli/src/cmds/api/rate_limit.rs +++ b/z-clients/cli/src/cmds/api/rate_limit.rs @@ -30,13 +30,13 @@ pub enum RateLimitCommands { { \"namespace\": \"some_namespace\", \"key\": \"some_key\", - \"tokens\": 123, - \"config\": {\"capacity\": 123, \"refill_amount\": 123, \"refill_interval_ms\": 60000} + \"tokens\": 123, // Number of tokens to consume (default: 1) + \"config\": {\"capacity\": 123, \"refill_amount\": 123, \"refill_interval_ms\": 60000} // Rate limiter configuration }\n\nExample response: { - \"allowed\": true, - \"remaining\": 123, - \"retry_after_ms\": 60000 + \"allowed\": true, // Whether the request is allowed + \"remaining\": 123, // Number of tokens remaining + \"retry_after_ms\": 60000 // Milliseconds until enough tokens are available (only present when allowed is false) }\n")] Limit { rate_limit_check_in: crate::json::JsonOf, @@ -54,11 +54,11 @@ pub enum RateLimitCommands { { \"namespace\": \"some_namespace\", \"key\": \"some_key\", - \"config\": {\"capacity\": 123, \"refill_amount\": 123, \"refill_interval_ms\": 60000} + \"config\": {\"capacity\": 123, \"refill_amount\": 123, \"refill_interval_ms\": 60000} // Rate limiter configuration }\n\nExample response: { - \"remaining\": 123, - \"retry_after_ms\": 60000 + \"remaining\": 123, // Number of tokens remaining + \"retry_after_ms\": 60000 // Milliseconds until at least one token is available (only present when remaining is 0) }\n")] GetRemaining { rate_limit_get_remaining_in: crate::json::JsonOf, @@ -76,7 +76,7 @@ pub enum RateLimitCommands { { \"namespace\": \"some_namespace\", \"key\": \"some_key\", - \"config\": {\"capacity\": 123, \"refill_amount\": 123, \"refill_interval_ms\": 60000} + \"config\": {\"capacity\": 123, \"refill_amount\": 123, \"refill_interval_ms\": 60000} // Rate limiter configuration }\n\nExample response: { }\n")] diff --git a/z-clients/cli/src/json/mod.rs b/z-clients/cli/src/json/mod.rs index c6435b0a2..d62437adb 100644 --- a/z-clients/cli/src/json/mod.rs +++ b/z-clients/cli/src/json/mod.rs @@ -14,17 +14,81 @@ impl FromStr for JsonOf { type Err = Error; fn from_str(s: &str) -> Result { - if s == "-" { + let input = if s == "-" { let mut stdin = std::io::stdin().lock(); let mut input = String::new(); stdin .read_to_string(&mut input) .context("Error reading stdin for '-' argument")?; - Ok(JsonOf(serde_json::from_str(&input)?)) + input } else { - Ok(JsonOf(serde_json::from_str(s)?)) + s.to_owned() + }; + + Ok(JsonOf(serde_json::from_str(&strip_json_comments(&input))?)) + } +} + +/// Removes `//` line comments and `/* */` block comments so a JSONC body can be +/// parsed as strict JSON. +/// +/// Comment markers inside string literals are left untouched, and newlines +/// inside comments are preserved so parse-error line numbers still line up with +/// the original input. +fn strip_json_comments(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut in_string = false; + let mut escaped = false; + + while let Some(c) = chars.next() { + if in_string { + out.push(c); + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_string = false; + } + continue; + } + + match c { + '"' => { + in_string = true; + out.push(c); + } + // Line comment: drop everything up to (but not including) the line + // ending, which may be LF, CRLF or a lone CR. + '/' if chars.peek() == Some(&'/') => { + chars.next(); + while chars + .peek() + .is_some_and(|&next| !matches!(next, '\n' | '\r')) + { + chars.next(); + } + } + // Block comment: replace with a single space (a comment acts as a + // token separator, like whitespace), keeping newlines. + '/' if chars.peek() == Some(&'*') => { + chars.next(); + out.push(' '); + while let Some(inner) = chars.next() { + if inner == '\n' { + out.push('\n'); + } else if inner == '*' && chars.peek() == Some(&'/') { + chars.next(); + break; + } + } + } + _ => out.push(c), } } + + out } impl JsonOf { @@ -46,3 +110,87 @@ where Err(e) => Err(e.into()), } } + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + use super::*; + + #[derive(Debug, Deserialize, PartialEq)] + struct Pair { + a: u32, + b: u32, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct Note { + url: String, + note: String, + } + + fn parse(s: &str) -> T { + JsonOf::::from_str(s).unwrap().into_inner() + } + + #[test] + fn plain_json_is_unchanged() { + let input = r#"{"a": 1, "b": 2}"#; + assert_eq!(strip_json_comments(input), input); + assert_eq!(parse::(input), Pair { a: 1, b: 2 }); + } + + #[test] + fn strips_line_comments() { + let input = "{\n \"a\": 1 // trailing\n ,\"b\": 2 // another\n}"; + assert_eq!(parse::(input), Pair { a: 1, b: 2 }); + } + + #[test] + fn strips_line_comments_with_carriage_returns() { + let crlf = "{\r\n \"a\": 1, // trailing\r\n \"b\": 2\r\n}"; + let cr = "{\r \"a\": 1, // trailing\r \"b\": 2\r}"; + assert_eq!(parse::(crlf), Pair { a: 1, b: 2 }); + assert_eq!(parse::(cr), Pair { a: 1, b: 2 }); + } + + #[test] + fn strips_block_comments() { + let input = "{ /* leading */ \"a\": 1, \"b\": /* inline */ 2 }"; + assert_eq!(parse::(input), Pair { a: 1, b: 2 }); + } + + #[test] + fn block_comments_separate_tokens() { + // A comment is a token separator, so this must not turn into `[60]`. + assert_eq!(strip_json_comments("[6/*c*/0]"), "[6 0]"); + JsonOf::>::from_str("[6/*c*/0]").unwrap_err(); + } + + #[test] + fn keeps_comment_markers_inside_strings() { + let input = r#"{"url": "https://example.com", "note": "a /* b */ c"}"#; + assert_eq!(strip_json_comments(input), input); + assert_eq!( + parse::(input), + Note { + url: "https://example.com".to_owned(), + note: "a /* b */ c".to_owned(), + }, + ); + } + + #[test] + fn handles_escaped_quote_before_comment() { + let input = r#"{"a": "b\"// not a comment"}"#; + assert_eq!(strip_json_comments(input), input); + } + + #[test] + fn preserves_line_numbers_in_block_comments() { + let input = "{\n/* one\ntwo */\n\"a\": nope\n}"; + // Error should point at line 4, where the invalid token actually is. + let err = JsonOf::::from_str(input).unwrap_err(); + assert!(err.to_string().contains("line 4"), "got: {err}"); + } +}