66
77use std:: collections:: { BTreeMap , HashMap , HashSet } ;
88
9+ use heck:: ToSnakeCase ;
910use openapiv3:: OpenAPI ;
1011use proc_macro2:: TokenStream ;
1112use quote:: quote;
@@ -68,6 +69,8 @@ pub struct GenerationSettings {
6869 extra_derives : Vec < String > ,
6970 extra_cli_bounds : Vec < String > ,
7071
72+ operation_id_strategy : OperationIdStrategy ,
73+
7174 map_type : Option < String > ,
7275 unknown_crates : UnknownPolicy ,
7376 crates : BTreeMap < String , CrateSpec > ,
@@ -114,6 +117,26 @@ impl Default for TagStyle {
114117 }
115118}
116119
120+ /// Style for handing operations that do not have an operation ID.
121+ #[ derive( Copy , Clone ) ]
122+ pub enum OperationIdStrategy {
123+ /// The default behaviour. Reject when any operation on the resulting
124+ /// client does not have an operation ID.
125+ RejectMissing ,
126+ /// Omit any operation on the resulting client that does not have an
127+ /// operation ID.
128+ OmitMissing ,
129+ /// Generate plausible names for operations on the resulting client that
130+ /// do not have an operation ID.
131+ GenerateMissing ,
132+ }
133+
134+ impl Default for OperationIdStrategy {
135+ fn default ( ) -> Self {
136+ Self :: RejectMissing
137+ }
138+ }
139+
117140impl GenerationSettings {
118141 /// Create new generator settings with default values.
119142 pub fn new ( ) -> Self {
@@ -255,6 +278,16 @@ impl GenerationSettings {
255278 self . timeout = Some ( timeout) ;
256279 self
257280 }
281+
282+ /// Set the strategy to be used when encountering operations that do not
283+ /// have an operation ID.
284+ pub fn with_operation_id_strategy (
285+ & mut self ,
286+ operation_id_strategy : OperationIdStrategy ,
287+ ) -> & mut Self {
288+ self . operation_id_strategy = operation_id_strategy;
289+ self
290+ }
258291}
259292
260293impl Default for Generator {
@@ -320,7 +353,7 @@ impl Generator {
320353
321354 /// Emit a [TokenStream] containing the generated client code.
322355 pub fn generate_tokens ( & mut self , spec : & OpenAPI ) -> Result < TokenStream > {
323- validate_openapi ( spec) ?;
356+ validate_openapi ( spec, self . settings . operation_id_strategy ) ?;
324357
325358 // Convert our components dictionary to schemars
326359 let schemas = spec. components . iter ( ) . flat_map ( |components| {
@@ -342,8 +375,16 @@ impl Generator {
342375 ( path. as_str ( ) , method, operation, & item. parameters )
343376 } )
344377 } )
345- . map ( |( path, method, operation, path_parameters) | {
346- self . process_operation ( operation, & spec. components , path, method, path_parameters)
378+ . filter_map ( |( path, method, operation, path_parameters) | {
379+ self . process_operation (
380+ operation,
381+ & spec. components ,
382+ path,
383+ method,
384+ path_parameters,
385+ self . settings . operation_id_strategy ,
386+ )
387+ . transpose ( )
347388 } )
348389 . collect :: < Result < Vec < _ > > > ( ) ?;
349390
@@ -690,7 +731,7 @@ fn validate_openapi_spec_version(spec_version: &str) -> Result<()> {
690731}
691732
692733/// Do some very basic checks of the OpenAPI documents.
693- pub fn validate_openapi ( spec : & OpenAPI ) -> Result < ( ) > {
734+ pub fn validate_openapi ( spec : & OpenAPI , operation_id_strategy : OperationIdStrategy ) -> Result < ( ) > {
694735 validate_openapi_spec_version ( spec. openapi . as_str ( ) ) ?;
695736
696737 let mut opids = HashSet :: new ( ) ;
@@ -700,21 +741,31 @@ pub fn validate_openapi(spec: &OpenAPI) -> Result<()> {
700741 format ! ( "path {} uses reference, unsupported" , p. 0 , ) ,
701742 ) ) ,
702743 openapiv3:: ReferenceOr :: Item ( item) => {
703- // Make sure every operation has an operation ID, and that each
704- // operation ID is only used once in the document.
705- item. iter ( ) . try_for_each ( |( _, o) | {
706- if let Some ( oid) = o. operation_id . as_ref ( ) {
707- if !opids. insert ( oid. to_string ( ) ) {
744+ // Make sure every operation has an operation ID, or that the operation id
745+ // strategy allows ignoring / generating one, and that each operation ID is
746+ // only used once in the document.
747+ item. iter ( ) . try_for_each ( |( method, o) | {
748+ match resolve_operation_id_with_strategy (
749+ p. 0 ,
750+ method,
751+ o. operation_id . as_deref ( ) ,
752+ operation_id_strategy,
753+ ) {
754+ Ok ( Some ( oid) ) => {
755+ if !opids. insert ( oid. to_string ( ) ) {
756+ return Err ( Error :: UnexpectedFormat ( format ! (
757+ "duplicate operation ID: {}" ,
758+ oid,
759+ ) ) ) ;
760+ }
761+ }
762+ Ok ( None ) => { }
763+ Err ( Rejected ) => {
708764 return Err ( Error :: UnexpectedFormat ( format ! (
709- "duplicate operation ID: {} " ,
710- oid ,
765+ "path {} is missing operation ID" ,
766+ p . 0 ,
711767 ) ) ) ;
712768 }
713- } else {
714- return Err ( Error :: UnexpectedFormat ( format ! (
715- "path {} is missing operation ID" ,
716- p. 0 ,
717- ) ) ) ;
718769 }
719770 Ok ( ( ) )
720771 } )
@@ -725,11 +776,52 @@ pub fn validate_openapi(spec: &OpenAPI) -> Result<()> {
725776 Ok ( ( ) )
726777}
727778
779+ /// Rejected operation ID
780+ // Clippy doesn't like () being used as the error type,
781+ // and double option is confusing.
782+ #[ derive( PartialEq , Eq , Clone , Copy ) ]
783+ pub struct Rejected ;
784+
785+ /// Resolve the operation ID for the given operation, using the given
786+ /// operation ID strategy.
787+ ///
788+ /// Ok(None) means none was found but that is OK
789+ /// Ok(Some(String)) means the operation ID was found
790+ /// Err(()) means the operation ID was not found and the
791+ /// chosen strategy rejects the operation
792+ pub fn resolve_operation_id_with_strategy (
793+ path : & str ,
794+ method : & str ,
795+ id : Option < & str > ,
796+ strategy : OperationIdStrategy ,
797+ ) -> std:: result:: Result < Option < String > , Rejected > {
798+ if let Some ( oid) = id {
799+ return Ok ( Some ( oid. to_string ( ) ) ) ;
800+ }
801+ match strategy {
802+ OperationIdStrategy :: RejectMissing => Err ( Rejected ) ,
803+ OperationIdStrategy :: OmitMissing => Ok ( None ) ,
804+ OperationIdStrategy :: GenerateMissing => {
805+ let path = path. to_snake_case ( ) ;
806+ let method = method. to_snake_case ( ) ;
807+ let oid = if path. is_empty ( ) {
808+ method
809+ } else {
810+ format ! ( "{}_{}" , method, path)
811+ } ;
812+ Ok ( Some ( oid) )
813+ }
814+ }
815+ }
816+
728817#[ cfg( test) ]
729818mod tests {
730819 use serde_json:: json;
731820
732- use crate :: { validate_openapi_spec_version, Error } ;
821+ use crate :: {
822+ resolve_operation_id_with_strategy, validate_openapi_spec_version, Error ,
823+ OperationIdStrategy ,
824+ } ;
733825
734826 #[ test]
735827 fn test_bad_value ( ) {
@@ -776,4 +868,137 @@ mod tests {
776868 "unexpected or unhandled format in the OpenAPI document invalid version: 3.1.0"
777869 ) ;
778870 }
871+
872+ #[ test]
873+ fn test_resolve_operation_id_with_existing_id ( ) {
874+ // When operation ID exists, all strategies should return it
875+ let strategies = [
876+ OperationIdStrategy :: RejectMissing ,
877+ OperationIdStrategy :: OmitMissing ,
878+ OperationIdStrategy :: GenerateMissing ,
879+ ] ;
880+
881+ for strategy in strategies {
882+ let result = resolve_operation_id_with_strategy (
883+ "/api/users" ,
884+ "GET" ,
885+ Some ( "getUserList" ) ,
886+ strategy,
887+ ) ;
888+ assert_eq ! ( result, Ok ( Some ( "getUserList" . to_string( ) ) ) ) ;
889+ }
890+ }
891+
892+ #[ test]
893+ fn test_resolve_operation_id_reject_missing_strategy ( ) {
894+ // RejectMissing should return Err when no operation ID
895+ let result = resolve_operation_id_with_strategy (
896+ "/api/users" ,
897+ "GET" ,
898+ None ,
899+ OperationIdStrategy :: RejectMissing ,
900+ ) ;
901+ assert_eq ! ( result, Err ( ( ) ) ) ;
902+ }
903+
904+ #[ test]
905+ fn test_resolve_operation_id_omit_missing_strategy ( ) {
906+ // OmitMissing should return Ok(None) when no operation ID
907+ let result = resolve_operation_id_with_strategy (
908+ "/api/users" ,
909+ "GET" ,
910+ None ,
911+ OperationIdStrategy :: OmitMissing ,
912+ ) ;
913+ assert_eq ! ( result, Ok ( None ) ) ;
914+ }
915+
916+ #[ test]
917+ fn test_resolve_operation_id_generate_missing_strategy ( ) {
918+ // GenerateMissing should generate operation IDs from method and path
919+ let result = resolve_operation_id_with_strategy (
920+ "/api/users" ,
921+ "GET" ,
922+ None ,
923+ OperationIdStrategy :: GenerateMissing ,
924+ ) ;
925+ assert_eq ! ( result, Ok ( Some ( "get_api_users" . to_string( ) ) ) ) ;
926+ }
927+
928+ #[ test]
929+ fn test_resolve_operation_id_generate_with_path_params ( ) {
930+ // Test with path parameters
931+ let result = resolve_operation_id_with_strategy (
932+ "/api/users/{id}" ,
933+ "GET" ,
934+ None ,
935+ OperationIdStrategy :: GenerateMissing ,
936+ ) ;
937+ assert_eq ! ( result, Ok ( Some ( "get_api_users_id" . to_string( ) ) ) ) ;
938+ }
939+
940+ #[ test]
941+ fn test_resolve_operation_id_generate_with_camel_case ( ) {
942+ // Test that camelCase paths are converted to snake_case
943+ let result = resolve_operation_id_with_strategy (
944+ "/api/userProfiles" ,
945+ "POST" ,
946+ None ,
947+ OperationIdStrategy :: GenerateMissing ,
948+ ) ;
949+ assert_eq ! ( result, Ok ( Some ( "post_api_user_profiles" . to_string( ) ) ) ) ;
950+ }
951+
952+ #[ test]
953+ fn test_resolve_operation_id_generate_with_empty_path ( ) {
954+ // Test with empty path (root)
955+ let result = resolve_operation_id_with_strategy (
956+ "" ,
957+ "GET" ,
958+ None ,
959+ OperationIdStrategy :: GenerateMissing ,
960+ ) ;
961+ assert_eq ! ( result, Ok ( Some ( "get" . to_string( ) ) ) ) ;
962+ }
963+
964+ #[ test]
965+ fn test_resolve_operation_id_generate_with_uppercase_method ( ) {
966+ // Test that uppercase methods are converted to lowercase
967+ let result = resolve_operation_id_with_strategy (
968+ "/api/users" ,
969+ "DELETE" ,
970+ None ,
971+ OperationIdStrategy :: GenerateMissing ,
972+ ) ;
973+ assert_eq ! ( result, Ok ( Some ( "delete_api_users" . to_string( ) ) ) ) ;
974+ }
975+
976+ #[ test]
977+ fn test_resolve_operation_id_generate_with_complex_path ( ) {
978+ // Test with complex path containing multiple segments
979+ let result = resolve_operation_id_with_strategy (
980+ "/api/v1/organizations/{orgId}/users/{userId}/profile" ,
981+ "PATCH" ,
982+ None ,
983+ OperationIdStrategy :: GenerateMissing ,
984+ ) ;
985+ assert_eq ! (
986+ result,
987+ Ok ( Some (
988+ "patch_api_v1_organizations_org_id_users_user_id_profile" . to_string( )
989+ ) )
990+ ) ;
991+ }
992+
993+ #[ test]
994+ fn test_resolve_operation_id_generate_with_hyphens ( ) {
995+ // Test that hyphens in paths are handled correctly
996+ let result = resolve_operation_id_with_strategy (
997+ "/api/user-profiles" ,
998+ "GET" ,
999+ None ,
1000+ OperationIdStrategy :: GenerateMissing ,
1001+ ) ;
1002+ assert_eq ! ( result, Ok ( Some ( "get_api_user_profiles" . to_string( ) ) ) ) ;
1003+ }
7791004}
0 commit comments