Skip to content

Commit 09aa6f3

Browse files
committed
add missing and generated operation id strategies
1 parent abc6620 commit 09aa6f3

5 files changed

Lines changed: 283 additions & 30 deletions

File tree

progenitor-impl/src/cli.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ struct CliOperation {
2424
impl Generator {
2525
/// Generate a `clap`-based CLI.
2626
pub fn cli(&mut self, spec: &OpenAPI, crate_name: &str) -> Result<TokenStream> {
27-
validate_openapi(spec)?;
27+
validate_openapi(spec, self.settings.operation_id_strategy)?;
2828

2929
// Convert our components dictionary to schemars
3030
let schemas = spec.components.iter().flat_map(|components| {
@@ -46,8 +46,16 @@ impl Generator {
4646
(path.as_str(), method, operation, &item.parameters)
4747
})
4848
})
49-
.map(|(path, method, operation, path_parameters)| {
50-
self.process_operation(operation, &spec.components, path, method, path_parameters)
49+
.filter_map(|(path, method, operation, path_parameters)| {
50+
self.process_operation(
51+
operation,
52+
&spec.components,
53+
path,
54+
method,
55+
path_parameters,
56+
self.settings.operation_id_strategy,
57+
)
58+
.transpose()
5159
})
5260
.collect::<Result<Vec<_>>>()?;
5361

progenitor-impl/src/httpmock.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ impl Generator {
3030
/// the SDK. This can include `::` and instances of `-` in the crate name
3131
/// should be converted to `_`.
3232
pub fn httpmock(&mut self, spec: &OpenAPI, crate_path: &str) -> Result<TokenStream> {
33-
validate_openapi(spec)?;
33+
validate_openapi(spec, self.settings.operation_id_strategy)?;
3434

3535
// Convert our components dictionary to schemars
3636
let schemas = spec.components.iter().flat_map(|components| {
@@ -52,8 +52,16 @@ impl Generator {
5252
(path.as_str(), method, operation, &item.parameters)
5353
})
5454
})
55-
.map(|(path, method, operation, path_parameters)| {
56-
self.process_operation(operation, &spec.components, path, method, path_parameters)
55+
.filter_map(|(path, method, operation, path_parameters)| {
56+
self.process_operation(
57+
operation,
58+
&spec.components,
59+
path,
60+
method,
61+
path_parameters,
62+
self.settings.operation_id_strategy,
63+
)
64+
.transpose()
5765
})
5866
.collect::<Result<Vec<_>>>()?;
5967

progenitor-impl/src/lib.rs

Lines changed: 242 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use std::collections::{BTreeMap, HashMap, HashSet};
88

9+
use heck::ToSnakeCase;
910
use openapiv3::OpenAPI;
1011
use proc_macro2::TokenStream;
1112
use 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+
117140
impl 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

260293
impl 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)]
729818
mod 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

Comments
 (0)