From 4e7b8b093f9ec75bfc7127bd8922fa224acf4c6c Mon Sep 17 00:00:00 2001 From: chrischeng-c4 Date: Sat, 24 Oct 2026 19:43:06 +0800 Subject: [PATCH] feat(openapi-codegen): parse x-retry-class vendor extension into RetryClassIR Add RetryClassIR enum with SafeRead, IdempotencyKey, NonRetryable, and Unclassified variants. Parse operation's x-retry-class vendor extension into OperationIR.retry_class without inferring from HTTP method. Re-export RetryClassIR in api::ir::operations and add isolated unit tests in operations.rs. --- crates/openapi-codegen/src/api/ir.rs | 2 +- .../src/domain/ir/operations.rs | 116 ++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/crates/openapi-codegen/src/api/ir.rs b/crates/openapi-codegen/src/api/ir.rs index bac2788..9a0b570 100644 --- a/crates/openapi-codegen/src/api/ir.rs +++ b/crates/openapi-codegen/src/api/ir.rs @@ -21,7 +21,7 @@ pub mod openapi { } pub mod operations { - pub use crate::domain::ir::operations::{build, BodyIR, OperationIR, ParamIR}; + pub use crate::domain::ir::operations::{build, BodyIR, OperationIR, ParamIR, RetryClassIR}; } pub mod typemap { diff --git a/crates/openapi-codegen/src/domain/ir/operations.rs b/crates/openapi-codegen/src/domain/ir/operations.rs index 27e4bc0..73c350a 100644 --- a/crates/openapi-codegen/src/domain/ir/operations.rs +++ b/crates/openapi-codegen/src/domain/ir/operations.rs @@ -23,6 +23,16 @@ pub struct BodyIR { pub required: bool, } +/// Service-supplied retry classification carried via the `x-retry-class` vendor extension. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RetryClassIR { + SafeRead, + IdempotencyKey, + NonRetryable, + #[default] + Unclassified, +} + /// One HTTP operation, fully structural (no language-specific names or types). #[derive(Debug, Clone)] pub struct OperationIR { @@ -53,6 +63,8 @@ pub struct OperationIR { /// operation on this same path item (i.e. the same path template). /// `None` for every other method. pub post_twin_path: Option, + /// Service-supplied retry classification from `x-retry-class`. + pub retry_class: RetryClassIR, } impl OperationIR { @@ -154,6 +166,13 @@ fn build_one( .unwrap_or_else(|| path.to_string()) }); + let retry_class = match op.extensions.get("x-retry-class").and_then(|v| v.as_str()) { + Some("safe-read") => RetryClassIR::SafeRead, + Some("idempotency-key") => RetryClassIR::IdempotencyKey, + Some("non-retryable") => RetryClassIR::NonRetryable, + _ => RetryClassIR::Unclassified, + }; + OperationIR { operation_id: op.operation_id.clone(), method: method.to_string(), @@ -166,6 +185,7 @@ fn build_one( body, response, post_twin_path, + retry_class, } } @@ -261,4 +281,100 @@ mod tests { assert_eq!(purge.operation_id.as_deref(), Some("purgePets")); assert_eq!(purge.post_twin_path, None); } + + #[test] + fn retry_class_maps_known_extension_values() { + let s = spec( + r##"{"paths":{ + "/read":{"get":{"operationId":"readOp","x-retry-class":"safe-read","responses":{"200":{"description":"ok"}}}}, + "/write":{"post":{"operationId":"writeOp","x-retry-class":"idempotency-key","responses":{"200":{"description":"ok"}}}}, + "/mutate":{"delete":{"operationId":"mutateOp","x-retry-class":"non-retryable","responses":{"200":{"description":"ok"}}}} + }}"##, + ); + let ops = build(&s); + let read = ops + .iter() + .find(|o| o.operation_id.as_deref() == Some("readOp")) + .unwrap(); + assert_eq!(read.retry_class, RetryClassIR::SafeRead); + + let write = ops + .iter() + .find(|o| o.operation_id.as_deref() == Some("writeOp")) + .unwrap(); + assert_eq!(write.retry_class, RetryClassIR::IdempotencyKey); + + let mutate = ops + .iter() + .find(|o| o.operation_id.as_deref() == Some("mutateOp")) + .unwrap(); + assert_eq!(mutate.retry_class, RetryClassIR::NonRetryable); + } + + #[test] + fn retry_class_defaults_to_unclassified_when_missing() { + let s = spec( + r##"{"paths":{"/items":{ + "get":{"operationId":"getItems","responses":{"200":{"description":"ok"}}}, + "post":{"operationId":"createItem","responses":{"201":{"description":"ok"}}}, + "delete":{"operationId":"deleteItem","responses":{"204":{"description":"ok"}}}, + "query":{"operationId":"queryItems","responses":{"200":{"description":"ok"}}} + }}}"##, + ); + let ops = build(&s); + let get_op = ops.iter().find(|o| o.method == "get").unwrap(); + assert_eq!(get_op.retry_class, RetryClassIR::Unclassified); + + let post_op = ops.iter().find(|o| o.method == "post").unwrap(); + assert_eq!(post_op.retry_class, RetryClassIR::Unclassified); + + let delete_op = ops.iter().find(|o| o.method == "delete").unwrap(); + assert_eq!(delete_op.retry_class, RetryClassIR::Unclassified); + + let query_op = ops.iter().find(|o| o.method == "query").unwrap(); + assert_eq!(query_op.retry_class, RetryClassIR::Unclassified); + } + + #[test] + fn retry_class_defaults_to_unclassified_for_unknown_or_non_string_values() { + let s = spec( + r##"{"paths":{"/items":{ + "get":{"operationId":"unknownVal","x-retry-class":"yes","responses":{"200":{"description":"ok"}}}, + "post":{"operationId":"boolVal","x-retry-class":true,"responses":{"200":{"description":"ok"}}}, + "put":{"operationId":"numVal","x-retry-class":42,"responses":{"200":{"description":"ok"}}} + }}}"##, + ); + let ops = build(&s); + let unknown_op = ops + .iter() + .find(|o| o.operation_id.as_deref() == Some("unknownVal")) + .unwrap(); + assert_eq!(unknown_op.retry_class, RetryClassIR::Unclassified); + + let bool_op = ops + .iter() + .find(|o| o.operation_id.as_deref() == Some("boolVal")) + .unwrap(); + assert_eq!(bool_op.retry_class, RetryClassIR::Unclassified); + + let num_op = ops + .iter() + .find(|o| o.operation_id.as_deref() == Some("numVal")) + .unwrap(); + assert_eq!(num_op.retry_class, RetryClassIR::Unclassified); + } + + #[test] + fn retry_class_reads_extension_on_additional_operations() { + let s = spec( + r##"{"paths":{"/pets":{ + "additionalOperations":{ + "PURGE":{"operationId":"purgePets","x-retry-class":"non-retryable","responses":{"204":{"description":"no content"}}} + } + }}}"##, + ); + let ops = build(&s); + let purge = ops.iter().find(|o| o.method == "purge").unwrap(); + assert_eq!(purge.retry_class, RetryClassIR::NonRetryable); + } }