Skip to content

Commit c14b31a

Browse files
authored
feat(sdk): generate richer typed API surfaces from OpenAPI metadata (#167)
1 parent 9acb2f0 commit c14b31a

15 files changed

Lines changed: 1553 additions & 63 deletions

codegen/src/operation.rs

Lines changed: 95 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ struct ErrorGeneration {
2020
#[derive(Clone)]
2121
enum BodyKind {
2222
Schema(Ident),
23+
Unknown,
2324
Empty,
2425
}
2526

@@ -271,23 +272,7 @@ fn generate_operation_method(
271272
error_definition,
272273
} = generate_response_handling(&operation_name, operation, spec)?;
273274

274-
// Add doc comment if available - combine summary and description
275-
let doc_comment = match (&operation.summary, &operation.description) {
276-
(Some(summary), Some(description)) if summary != description => {
277-
// Both available and different - combine them
278-
let combined = format!("{}\n\n{}", summary.trim(), description.trim());
279-
Some(crate::schema::generate_doc_comment(&combined))
280-
}
281-
(Some(summary), _) => {
282-
// Only summary, or both are the same
283-
Some(crate::schema::generate_doc_comment(summary))
284-
}
285-
(None, Some(description)) => {
286-
// Only description
287-
Some(crate::schema::generate_doc_comment(description))
288-
}
289-
(None, None) => None,
290-
};
275+
let doc_comment = build_operation_doc_comment(operation);
291276

292277
// Build query parameter additions
293278
let query_additions = if has_query_params {
@@ -523,6 +508,65 @@ fn get_response_type_for_single(
523508
}
524509
}
525510

511+
fn build_operation_doc_comment(operation: &openapiv3::Operation) -> Option<TokenStream> {
512+
let mut lines = Vec::new();
513+
514+
match (&operation.summary, &operation.description) {
515+
(Some(summary), Some(description)) if summary != description => {
516+
lines.extend(summary.trim().lines().map(|line| line.trim().to_string()));
517+
lines.push(String::new());
518+
lines.extend(
519+
description
520+
.trim()
521+
.lines()
522+
.map(|line| line.trim().to_string()),
523+
);
524+
}
525+
(Some(summary), _) => {
526+
lines.extend(summary.trim().lines().map(|line| line.trim().to_string()));
527+
}
528+
(None, Some(description)) => {
529+
lines.extend(
530+
description
531+
.trim()
532+
.lines()
533+
.map(|line| line.trim().to_string()),
534+
);
535+
}
536+
(None, None) => {}
537+
}
538+
539+
let mut response_lines = Vec::new();
540+
for (status_code, response_ref) in &operation.responses.responses {
541+
let openapiv3::StatusCode::Code(code) = status_code else {
542+
continue;
543+
};
544+
let response = match response_ref {
545+
openapiv3::ReferenceOr::Item(response) => response,
546+
openapiv3::ReferenceOr::Reference { .. } => continue,
547+
};
548+
let description = response.description.trim();
549+
if description.is_empty() {
550+
continue;
551+
}
552+
response_lines.push(format!("- {}: {}", code, description));
553+
}
554+
555+
if !response_lines.is_empty() {
556+
if !lines.is_empty() {
557+
lines.push(String::new());
558+
}
559+
lines.push("Responses:".to_string());
560+
lines.extend(response_lines);
561+
}
562+
563+
if lines.is_empty() {
564+
None
565+
} else {
566+
Some(crate::schema::generate_doc_comment(&lines.join("\n")))
567+
}
568+
}
569+
526570
/// Converts a numeric status code to an equivalent `reqwest::StatusCode` token when available.
527571
fn status_code_to_constant(status: u16) -> TokenStream {
528572
match status {
@@ -651,6 +695,7 @@ fn generate_error_handling(
651695
let status_const = status_code_to_constant(*status_code);
652696
let body_kind = match extract_error_schema_ident(response_ref, spec) {
653697
Some(ident) => BodyKind::Schema(ident),
698+
None if response_has_content(response_ref, spec) => BodyKind::Unknown,
654699
None => BodyKind::Empty,
655700
};
656701
entries.push(ErrorEntry {
@@ -686,6 +731,9 @@ fn generate_error_handling(
686731
BodyKind::Schema(ident) => {
687732
variant_defs.push(quote! { #variant_ident(#ident), });
688733
}
734+
BodyKind::Unknown => {
735+
variant_defs.push(quote! { #variant_ident(crate::error::UnknownApiBody), });
736+
}
689737
BodyKind::Empty => {
690738
variant_defs.push(quote! { #variant_ident, });
691739
}
@@ -702,6 +750,15 @@ fn generate_error_handling(
702750
}
703751
});
704752
}
753+
BodyKind::Unknown => {
754+
match_arms.push(quote! {
755+
#status_const => {
756+
let body_bytes = response.bytes().await?;
757+
let body = crate::error::UnknownApiBody::from_bytes(body_bytes.as_ref());
758+
Err(crate::error::SdkError::api(#enum_ident::#variant_ident(body)))
759+
}
760+
});
761+
}
705762
BodyKind::Empty => {
706763
match_arms.push(quote! {
707764
#status_const => {
@@ -768,6 +825,27 @@ fn extract_schema_from_response(response: &openapiv3::Response) -> Option<Ident>
768825
}
769826
}
770827

828+
fn response_has_content(
829+
response_ref: &openapiv3::ReferenceOr<openapiv3::Response>,
830+
spec: &openapiv3::OpenAPI,
831+
) -> bool {
832+
match response_ref {
833+
openapiv3::ReferenceOr::Item(response) => !response.content.is_empty(),
834+
openapiv3::ReferenceOr::Reference { reference } => {
835+
let Some(response_name) = reference.strip_prefix("#/components/responses/") else {
836+
return false;
837+
};
838+
let Some(components) = &spec.components else {
839+
return false;
840+
};
841+
let Some(response_ref) = components.responses.get(response_name) else {
842+
return false;
843+
};
844+
response_has_content(response_ref, spec)
845+
}
846+
}
847+
}
848+
771849
/// Handles operations lacking explicit success responses by only validating the status.
772850
fn generate_no_success_response_handling(
773851
error_generation: &ErrorGeneration,

codegen/src/schema.rs

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,31 @@ pub fn generate_schema_doc_comment(
5757
lines.extend(constraints.into_iter().map(|line| format!("- {}", line)));
5858
}
5959

60+
if let Some(example) = format_schema_example(schema) {
61+
if !lines.is_empty() {
62+
lines.push(String::new());
63+
}
64+
lines.push(format!("Example: `{}`", example));
65+
}
66+
6067
generate_doc_comment_from_lines(lines)
6168
}
6269

70+
fn format_schema_example(schema: &openapiv3::Schema) -> Option<String> {
71+
let example = schema.schema_data.example.as_ref()?;
72+
format_json_example(example)
73+
}
74+
75+
pub(crate) fn format_json_example(example: &serde_json::Value) -> Option<String> {
76+
match example {
77+
serde_json::Value::Null => Some("null".to_string()),
78+
serde_json::Value::Bool(value) => Some(value.to_string()),
79+
serde_json::Value::Number(value) => Some(value.to_string()),
80+
serde_json::Value::String(value) => Some(value.clone()),
81+
serde_json::Value::Array(_) | serde_json::Value::Object(_) => None,
82+
}
83+
}
84+
6385
fn generate_doc_comment_from_lines(lines: Vec<String>) -> TokenStream {
6486
if lines.is_empty() {
6587
return quote! {};
@@ -740,6 +762,23 @@ impl<'spec, 'schemas> NestedStructGenerator<'spec, 'schemas> {
740762

741763
Ok(())
742764
}
765+
766+
fn generate_for_string_enum(
767+
&mut self,
768+
parent_name: &str,
769+
field_name: &str,
770+
schema: &openapiv3::Schema,
771+
enumeration: &[Option<String>],
772+
fallback_suffix: &str,
773+
) -> Result<(), String> {
774+
let type_name = nested_inline_type_name(parent_name, field_name, fallback_suffix);
775+
let type_ident = Ident::new(&type_name, Span::call_site());
776+
let description = schema.schema_data.description.as_deref();
777+
let enum_tokens =
778+
generate_inline_string_enum(&type_ident, enumeration, description, schema)?;
779+
self.nested_schemas.push(enum_tokens);
780+
Ok(())
781+
}
743782
}
744783

745784
fn collect_mixin_all_of_references(
@@ -805,6 +844,17 @@ pub fn collect_nested_schemas(
805844
}
806845
generator.generate_for_object(parent_name, field_name, schema, obj)?;
807846
}
847+
openapiv3::SchemaKind::Type(openapiv3::Type::String(string_type)) => {
848+
if !string_type.enumeration.is_empty() {
849+
generator.generate_for_string_enum(
850+
parent_name,
851+
field_name,
852+
schema,
853+
&string_type.enumeration,
854+
"",
855+
)?;
856+
}
857+
}
808858
openapiv3::SchemaKind::AllOf { all_of } => {
809859
if let Some((
810860
combined_properties,
@@ -831,6 +881,17 @@ pub fn collect_nested_schemas(
831881
openapiv3::SchemaKind::Type(openapiv3::Type::Array(arr)) => {
832882
if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
833883
match &item_schema.schema_kind {
884+
openapiv3::SchemaKind::Type(openapiv3::Type::String(string_type)) => {
885+
if !string_type.enumeration.is_empty() {
886+
generator.generate_for_string_enum(
887+
parent_name,
888+
field_name,
889+
item_schema,
890+
&string_type.enumeration,
891+
"Item",
892+
)?;
893+
}
894+
}
834895
openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj)) => {
835896
if should_emit_free_form_object_alias(
836897
&obj.properties,
@@ -1222,7 +1283,15 @@ pub fn infer_rust_type(
12221283
) -> TokenStream {
12231284
let base_type = match schema_kind {
12241285
openapiv3::SchemaKind::Type(openapiv3::Type::String(string_type)) => {
1225-
if let Some(kind) = string_encoded_numeric_kind(&string_type.format) {
1286+
if !string_type.enumeration.is_empty() {
1287+
if let Some((parent_name, field_name)) = parent_field {
1288+
let type_name = nested_inline_type_name(parent_name, field_name, "");
1289+
let type_ident = Ident::new(&type_name, Span::call_site());
1290+
quote! { #type_ident }
1291+
} else {
1292+
quote! { String }
1293+
}
1294+
} else if let Some(kind) = string_encoded_numeric_kind(&string_type.format) {
12261295
numeric_kind_rust_type(kind)
12271296
} else {
12281297
match &string_type.format {
@@ -1278,6 +1347,21 @@ pub fn infer_rust_type(
12781347
quote! { #type_ident }
12791348
}
12801349
openapiv3::ReferenceOr::Item(schema) => match &schema.schema_kind {
1350+
openapiv3::SchemaKind::Type(openapiv3::Type::String(string_type)) => {
1351+
if !string_type.enumeration.is_empty() {
1352+
if let Some((parent_name, field_name)) = parent_field {
1353+
let type_name =
1354+
nested_inline_type_name(parent_name, field_name, "Item");
1355+
let type_ident = Ident::new(&type_name, Span::call_site());
1356+
quote! { #type_ident }
1357+
} else {
1358+
quote! { String }
1359+
}
1360+
} else {
1361+
let dummy_ref = openapiv3::ReferenceOr::Item(schema.clone());
1362+
infer_rust_type(&schema.schema_kind, true, false, None, &dummy_ref)
1363+
}
1364+
}
12811365
openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj)) => {
12821366
if should_emit_free_form_object_alias(
12831367
&obj.properties,
@@ -1482,6 +1566,65 @@ pub fn sanitize_enum_variant(variant: &str) -> String {
14821566
}
14831567
}
14841568

1569+
fn nested_inline_type_name(parent_name: &str, field_name: &str, suffix: &str) -> String {
1570+
format!(
1571+
"{}{}{}",
1572+
parent_name.to_upper_camel_case(),
1573+
field_name.to_upper_camel_case(),
1574+
suffix
1575+
)
1576+
}
1577+
1578+
fn generate_inline_string_enum(
1579+
type_ident: &Ident,
1580+
enumeration: &[Option<String>],
1581+
description: Option<&str>,
1582+
schema: &openapiv3::Schema,
1583+
) -> Result<TokenStream, String> {
1584+
let mut variant_names: HashSet<String> = HashSet::new();
1585+
let mut variants_tokens = Vec::new();
1586+
1587+
for variant in enumeration.iter().filter_map(|value| value.as_deref()) {
1588+
let variant_name = sanitize_enum_variant(variant);
1589+
if !variant_names.insert(variant_name.clone()) {
1590+
return Err(format!(
1591+
"Duplicate enum variant name generated for inline enum type: {variant_name}"
1592+
));
1593+
}
1594+
1595+
let variant_ident = Ident::new(&variant_name, Span::call_site());
1596+
if variant != variant_name {
1597+
variants_tokens.push(quote! {
1598+
#[serde(rename = #variant)]
1599+
#variant_ident
1600+
});
1601+
} else {
1602+
variants_tokens.push(quote! { #variant_ident });
1603+
}
1604+
}
1605+
1606+
if variants_tokens.is_empty() {
1607+
return Ok(quote! { pub type #type_ident = String; });
1608+
}
1609+
1610+
let other_variant_ident = if variant_names.contains("Other") {
1611+
Ident::new("OtherValue", Span::call_site())
1612+
} else {
1613+
Ident::new("Other", Span::call_site())
1614+
};
1615+
let description = generate_schema_doc_comment(description, schema);
1616+
1617+
Ok(quote! {
1618+
#description
1619+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1620+
pub enum #type_ident {
1621+
#(#variants_tokens,)*
1622+
#[serde(untagged)]
1623+
#other_variant_ident(String),
1624+
}
1625+
})
1626+
}
1627+
14851628
/// Generates a `std::error::Error` implementation for schemas marked as error types.
14861629
fn generate_error_impl(
14871630
struct_name: &Ident,

openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11279,4 +11279,4 @@
1127911279
]
1128011280
}
1128111281
]
11282-
}
11282+
}

0 commit comments

Comments
 (0)