From 59368eca1d0312982c881ad863f8e4d250cf39e2 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Thu, 9 Oct 2025 13:17:05 -0700 Subject: [PATCH 1/2] impl "multipart/form-data" support --- Cargo.lock | 62 ++++++++++ Cargo.toml | 6 +- README.md | 11 +- cargo-progenitor/src/main.rs | 2 +- example-build/Cargo.toml | 2 +- example-macro/Cargo.toml | 2 +- progenitor-client/src/progenitor_client.rs | 38 +++++- progenitor-impl/src/cli.rs | 3 +- progenitor-impl/src/httpmock.rs | 6 +- progenitor-impl/src/lib.rs | 62 ++++++++-- progenitor-impl/src/method.rs | 132 +++++++++++++++++++-- progenitor-macro/Cargo.toml | 1 + progenitor-macro/src/lib.rs | 13 ++ progenitor/tests/build_sevdesk.rs | 51 ++++++++ sample_openapi/sevdesk-san-sub.yaml | 80 +++++++++++++ 15 files changed, 435 insertions(+), 36 deletions(-) create mode 100644 progenitor/tests/build_sevdesk.rs create mode 100644 sample_openapi/sevdesk-san-sub.yaml diff --git a/Cargo.lock b/Cargo.lock index 43270402..78d89ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,6 +201,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.2" @@ -443,6 +452,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -634,6 +644,21 @@ dependencies = [ "wasm-bindgen-test", ] +[[package]] +name = "expander" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c470c71d91ecbd179935b24170459e926382eaaa86b590b78814e180d8a8e2" +dependencies = [ + "blake2", + "file-guard", + "fs-err", + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "expectorate" version = "1.2.0" @@ -653,6 +678,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "file-guard" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -698,6 +733,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "futures" version = "0.3.31" @@ -1288,6 +1332,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minicov" version = "0.3.5" @@ -1646,6 +1700,7 @@ dependencies = [ name = "progenitor-macro" version = "0.11.2" dependencies = [ + "expander", "openapiv3", "proc-macro2", "progenitor-impl", @@ -1776,6 +1831,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -2675,6 +2731,12 @@ dependencies = [ "typify-impl", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.19" diff --git a/Cargo.toml b/Cargo.toml index cf18698c..72bbcc58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,11 @@ quote = "1.0.40" rand = "0.9.2" regex = "1.11.2" regress = "0.10.4" -reqwest = { version = "0.12.4", default-features = false, features = ["json", "stream"] } +reqwest = { version = "0.12.4", default-features = false, features = [ + "json", + "stream", + "multipart", +] } rustfmt-wrapper = "0.2.1" schemars = { version = "0.8.22", features = ["chrono", "uuid1"] } semver = "1.0.27" diff --git a/README.md b/README.md index 05f5caac..45c32048 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ You'll need to add the following to `Cargo.toml`: [dependencies] futures = "0.3" progenitor = { git = "https://github.com/oxidecomputer/progenitor" } -reqwest = { version = "0.12", features = ["json", "stream"] } +reqwest = { version = "0.12", features = ["json", "stream", "multipart"] } serde = { version = "1.0", features = ["derive"] } ``` @@ -133,7 +133,7 @@ You'll need to add the following to `Cargo.toml`: [dependencies] futures = "0.3" progenitor-client = { git = "https://github.com/oxidecomputer/progenitor" } -reqwest = { version = "0.12", features = ["json", "stream"] } +reqwest = { version = "0.12", features = ["json", "stream", "multipart"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -175,6 +175,7 @@ cargo progenitor -i sample_openapi/keeper.json -o keeper -n keeper -v 0.1.0 ``` ... or within the repo: + ``` cargo run --bin cargo-progenitor -- progenitor -i sample_openapi/keeper.json -o keeper -n keeper -v 0.1.0 ``` @@ -200,7 +201,7 @@ bytes = "1.9" chrono = { version = "0.4", default-features=false, features = ["serde"] } futures-core = "0.3" progenitor-client = "0.9.1" -reqwest = { version = "0.12", default-features=false, features = ["json", "stream"] } +reqwest = { version = "0.12", default-features=false, features = ["json", "stream", "multipart"] } serde = { version = "1.0", features = ["derive"] } serde_urlencoded = "0.7" ``` @@ -378,7 +379,7 @@ Currently, the generated code doesn't deal with request headers. To add default ```rust let baseurl = std::env::var("API_URL").expect("$API_URL not set"); - + let access_token = std::env::var("API_ACCESS_TOKEN").expect("$API_ACCESS_TOKEN not set"); let authorization_header = format!("Bearer {}", access_token); @@ -396,4 +397,4 @@ Currently, the generated code doesn't deal with request headers. To add default .unwrap(); let client = Client::new_with_client(baseurl, client_with_custom_defaults); -``` \ No newline at end of file +``` diff --git a/cargo-progenitor/src/main.rs b/cargo-progenitor/src/main.rs index 191675a7..fe2f8f08 100644 --- a/cargo-progenitor/src/main.rs +++ b/cargo-progenitor/src/main.rs @@ -243,7 +243,7 @@ pub fn dependencies(builder: Generator, include_client: bool) -> Vec { let mut deps = vec![ format!("bytes = \"{}\"", DEPENDENCIES.bytes), format!("futures-core = \"{}\"", DEPENDENCIES.futures), - format!("reqwest = {{ version = \"{}\", default-features=false, features = [\"json\", \"stream\"] }}", DEPENDENCIES.reqwest), + format!("reqwest = {{ version = \"{}\", default-features=false, features = [\"json\", \"stream\", \"multipart\"] }}", DEPENDENCIES.reqwest), format!("serde = {{ version = \"{}\", features = [\"derive\"] }}", DEPENDENCIES.serde), format!("serde_urlencoded = \"{}\"", DEPENDENCIES.serde_urlencoded), ]; diff --git a/example-build/Cargo.toml b/example-build/Cargo.toml index 94b91447..eae8b7a4 100644 --- a/example-build/Cargo.toml +++ b/example-build/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [dependencies] chrono = { version = "0.4", features = ["serde"] } progenitor-client = { path = "../progenitor-client" } -reqwest = { version = "0.12.4", features = ["json", "stream"] } +reqwest = { version = "0.12.4", features = ["json", "stream", "multipart"] } base64 = "0.22" rand = "0.9" serde = { version = "1.0", features = ["derive"] } diff --git a/example-macro/Cargo.toml b/example-macro/Cargo.toml index d47916d5..ace8d6e3 100644 --- a/example-macro/Cargo.toml +++ b/example-macro/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [dependencies] chrono = { version = "0.4", features = ["serde"] } progenitor = { path = "../progenitor" } -reqwest = { version = "0.12.4", features = ["json", "stream"] } +reqwest = { version = "0.12.4", features = ["json", "stream", "multipart"] } schemars = { version = "0.8.22", features = ["uuid1"] } serde = { version = "1.0", features = ["derive"] } uuid = { version = "1.18", features = ["serde", "v4"] } diff --git a/progenitor-client/src/progenitor_client.rs b/progenitor-client/src/progenitor_client.rs index 6f8dcca0..12546783 100644 --- a/progenitor-client/src/progenitor_client.rs +++ b/progenitor-client/src/progenitor_client.rs @@ -8,7 +8,7 @@ use std::ops::{Deref, DerefMut}; use bytes::Bytes; use futures_core::Stream; -use reqwest::RequestBuilder; +use reqwest::{multipart::Part, RequestBuilder}; use serde::{de::DeserializeOwned, ser::SerializeStruct, Serialize}; #[cfg(not(target_arch = "wasm32"))] @@ -527,8 +527,16 @@ pub fn encode_path(pc: &str) -> String { } #[doc(hidden)] -pub trait RequestBuilderExt { +pub trait RequestBuilderExt +where + Self: Sized, +{ fn form_urlencoded(self, body: &T) -> Result>; + + fn form_from_raw, T: AsRef<[u8]>, I: Sized + IntoIterator>( + self, + iter: I, + ) -> Result>; } impl RequestBuilderExt for RequestBuilder { @@ -539,10 +547,32 @@ impl RequestBuilderExt for RequestBuilder { reqwest::header::HeaderValue::from_static("application/x-www-form-urlencoded"), ) .body( - serde_urlencoded::to_string(body) - .map_err(|_| Error::InvalidRequest("failed to serialize body".to_string()))?, + serde_urlencoded::to_string(body).map_err(|e| { + Error::InvalidRequest(format!("failed to serialize body: {e:?}")) + })?, )) } + + fn form_from_raw, T: AsRef<[u8]>, I: Sized + IntoIterator>( + self, + iter: I, + ) -> Result> { + use reqwest::multipart::Form; + + let mut form = Form::new(); + for (name, value) in iter { + form = form.part( + name.as_ref().to_owned(), + Part::stream(Vec::from(value.as_ref())), + ); + } + Ok(self + .header( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("multipart/form-data"), + ) + .multipart(form)) + } } #[doc(hidden)] diff --git a/progenitor-impl/src/cli.rs b/progenitor-impl/src/cli.rs index ed32ff95..c9dacfa3 100644 --- a/progenitor-impl/src/cli.rs +++ b/progenitor-impl/src/cli.rs @@ -440,7 +440,8 @@ impl Generator { // are currently... OperationParameterType::RawBody => None, - OperationParameterType::Type(body_type_id) => Some(body_type_id), + OperationParameterType::Type(body_type_id) + | OperationParameterType::Form(body_type_id) => Some(body_type_id), }); if let Some(body_type_id) = maybe_body_type_id { diff --git a/progenitor-impl/src/httpmock.rs b/progenitor-impl/src/httpmock.rs index 1e7f9745..61b985e0 100644 --- a/progenitor-impl/src/httpmock.rs +++ b/progenitor-impl/src/httpmock.rs @@ -156,7 +156,8 @@ impl Generator { description: _, }| { let arg_type_name = match typ { - OperationParameterType::Type(arg_type_id) => self + OperationParameterType::Type(arg_type_id) + | OperationParameterType::Form(arg_type_id) => self .type_space .get_type(arg_type_id) .unwrap() @@ -243,11 +244,10 @@ impl Generator { }, ), OperationParameterKind::Body(body_content_type) => match typ { - OperationParameterType::Type(_) => ( + OperationParameterType::Type(_) | OperationParameterType::Form(_) => ( true, quote! { Self(self.0.json_body_obj(value)) - }, ), OperationParameterType::RawBody => match body_content_type { diff --git a/progenitor-impl/src/lib.rs b/progenitor-impl/src/lib.rs index 68454ece..327b7c05 100644 --- a/progenitor-impl/src/lib.rs +++ b/progenitor-impl/src/lib.rs @@ -6,11 +6,13 @@ use std::collections::{BTreeMap, HashMap, HashSet}; +use indexmap::IndexSet; use openapiv3::OpenAPI; use proc_macro2::TokenStream; use quote::quote; use serde::Deserialize; use thiserror::Error; +use typify::{TypeDetails, TypeId}; use typify::{TypeSpace, TypeSpaceSettings}; use crate::to_schema::ToSchema; @@ -50,6 +52,7 @@ pub type Result = std::result::Result; /// OpenAPI generator. pub struct Generator { type_space: TypeSpace, + forms: IndexSet, settings: GenerationSettings, uses_futures: bool, uses_websockets: bool, @@ -261,6 +264,7 @@ impl Default for Generator { fn default() -> Self { Self { type_space: TypeSpace::new(TypeSpaceSettings::default().with_type_mod("types")), + forms: Default::default(), settings: Default::default(), uses_futures: Default::default(), uses_websockets: Default::default(), @@ -313,6 +317,7 @@ impl Generator { Self { type_space: TypeSpace::new(&type_settings), settings: settings.clone(), + forms: Default::default(), uses_futures: false, uses_websockets: false, } @@ -374,6 +379,43 @@ impl Generator { let types = self.type_space.to_stream(); + let extra_impl = TokenStream::from_iter( + self.forms + .iter() + .map(|type_id| { + let typ = self.get_type_space().get_type(type_id).unwrap(); + let td = typ.details(); + let TypeDetails::Struct(tstru) = td else { unreachable!() }; + let properties = indexmap::IndexMap::<&'_ str, _>::from_iter( + tstru + .properties() + .filter_map(|(prop_name, prop_id)| { + self.get_type_space() + .get_type(&prop_id).ok() + .map(|prop_typ| (prop_name, prop_typ)) + }) + ); + let properties = syn::punctuated::Punctuated::<_, syn::Token![,]>::from_iter( + properties + .into_iter() + .map(|(prop_name, _prop_ty)| { + let ident = quote::format_ident!("{}", prop_name); + quote!{ (#prop_name, &self. #ident) } + })); + + let form_name = quote::format_ident!("{}",typ.name()); + + quote! { + impl #form_name { + pub fn as_form<'f>(&'f self) -> impl std::iter::Iterator { + [#properties] + .into_iter() + .filter_map(|(name, val)| val.as_ref().map(|val| (name, val.as_bytes()))) + } + } + } + })); + let (inner_type, inner_fn_value) = match self.settings.inner_type.as_ref() { Some(inner_type) => (inner_type.clone(), quote! { &self.inner }), None => (quote! { () }, quote! { &() }), @@ -397,20 +439,20 @@ impl Generator { let client_timeout = self.settings.timeout.unwrap_or(15); let client_docstring = { - let mut s = format!("Client for {}", spec.info.title); + let mut doc = format!("Client for {}", spec.info.title); - if let Some(ss) = &spec.info.description { - s.push_str("\n\n"); - s.push_str(ss); + if let Some(desc) = &spec.info.description { + doc.push_str("\n\n"); + doc.push_str(desc); } - if let Some(ss) = &spec.info.terms_of_service { - s.push_str("\n\n"); - s.push_str(ss); + if let Some(tos) = &spec.info.terms_of_service { + doc.push_str("\n\n"); + doc.push_str(tos); } - s.push_str(&format!("\n\nVersion: {}", &spec.info.version)); + doc.push_str(&format!("\n\nVersion: {}", &spec.info.version)); - s + doc }; let version_str = &spec.info.version; @@ -440,6 +482,8 @@ impl Generator { #[allow(clippy::all)] pub mod types { #types + + #extra_impl } #[derive(Clone, Debug)] diff --git a/progenitor-impl/src/method.rs b/progenitor-impl/src/method.rs index 8bf1d0fe..342c13db 100644 --- a/progenitor-impl/src/method.rs +++ b/progenitor-impl/src/method.rs @@ -6,6 +6,7 @@ use std::{ str::FromStr, }; +use indexmap::IndexSet; use openapiv3::{Components, Parameter, ReferenceOr, Response, StatusCode}; use proc_macro2::TokenStream; use quote::{format_ident, quote, ToTokens}; @@ -102,9 +103,10 @@ pub struct OperationParameter { pub kind: OperationParameterKind, } -#[derive(Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq)] pub enum OperationParameterType { Type(TypeId), + Form(TypeId), RawBody, } @@ -137,6 +139,7 @@ pub enum BodyContentType { OctetStream, Json, FormUrlencoded, + FormData, Text(String), } @@ -149,6 +152,7 @@ impl FromStr for BodyContentType { "application/octet-stream" => Ok(Self::OctetStream), "application/json" => Ok(Self::Json), "application/x-www-form-urlencoded" => Ok(Self::FormUrlencoded), + "form-data" | "multipart/form-data" => Ok(Self::FormData), "text/plain" | "text/x-markdown" => Ok(Self::Text(String::from(&s[..offset]))), _ => Err(Error::UnexpectedFormat(format!( "unexpected content type: {}", @@ -164,6 +168,7 @@ impl std::fmt::Display for BodyContentType { Self::OctetStream => "application/octet-stream", Self::Json => "application/json", Self::FormUrlencoded => "application/x-www-form-urlencoded", + Self::FormData => "multipart/form-data", Self::Text(typ) => typ, }) } @@ -562,16 +567,24 @@ impl Generator { .map(|param| { let name = format_ident!("{}", param.name); let typ = match (¶m.typ, param.kind.is_optional()) { - (OperationParameterType::Type(type_id), false) => self + ( + OperationParameterType::Type(type_id) + | OperationParameterType::Form(type_id), + false, + ) => self .type_space .get_type(type_id) .unwrap() .parameter_ident_with_lifetime("a"), - (OperationParameterType::Type(type_id), true) => { + ( + OperationParameterType::Type(type_id) + | OperationParameterType::Form(type_id), + true, + ) => { let t = self .type_space .get_type(type_id) - .unwrap() + .expect("TypeIDs are _never_ deleted. qed") .parameter_ident_with_lifetime("a"); quote! { Option<#t> } } @@ -918,6 +931,16 @@ impl Generator { // returns an error in the case of a serialization failure. .form_urlencoded(&body)? }), + ( + OperationParameterKind::Body(BodyContentType::FormData), + OperationParameterType::Form(_), + ) => { + Some(quote! { + // This uses `progenitor_client::RequestBuilderExt` which + // sets up a simple form data based on bytes + .form_from_raw(body.as_form())? + }) + } (OperationParameterKind::Body(_), _) => { unreachable!("invalid body kind/type combination") } @@ -1399,7 +1422,7 @@ impl Generator { /// param_1, /// param_2, /// } = self; - /// + /// /// let param_1 = param_1.map_err(Error::InvalidRequest)?; /// let param_2 = param_1.map_err(Error::InvalidRequest)?; /// @@ -1436,7 +1459,7 @@ impl Generator { .params .iter() .map(|param| match ¶m.typ { - OperationParameterType::Type(type_id) => { + OperationParameterType::Type(type_id) | OperationParameterType::Form(type_id) => { let ty = self.type_space.get_type(type_id)?; // For body parameters only, if there's a builder we'll @@ -1469,7 +1492,7 @@ impl Generator { .params .iter() .map(|param| match ¶m.typ { - OperationParameterType::Type(type_id) => { + OperationParameterType::Type(type_id) | OperationParameterType::Form(type_id) => { let ty = self.type_space.get_type(type_id)?; // Fill in the appropriate initial value for the @@ -1499,7 +1522,7 @@ impl Generator { .params .iter() .map(|param| match ¶m.typ { - OperationParameterType::Type(type_id) => { + OperationParameterType::Type(type_id) | OperationParameterType::Form(type_id) => { let ty = self.type_space.get_type(type_id)?; if ty.builder().is_some() { let type_name = ty.ident(); @@ -1511,6 +1534,7 @@ impl Generator { Ok(quote! {}) } } + OperationParameterType::RawBody => Ok(quote! {}), }) .collect::>>()?; @@ -1523,7 +1547,8 @@ impl Generator { .map(|param| { let param_name = format_ident!("{}", param.name); match ¶m.typ { - OperationParameterType::Type(type_id) => { + OperationParameterType::Type(type_id) + | OperationParameterType::Form(type_id) => { let ty = self.type_space.get_type(type_id)?; match (ty.builder(), param.kind.is_optional()) { // TODO right now optional body parameters are not @@ -2091,7 +2116,8 @@ impl Generator { )), } if enumeration.is_empty() => Ok(()), _ => Err(Error::UnexpectedFormat(format!( - "invalid schema for application/octet-stream: {:?}", + "invalid schema for {}: {:?}", + BodyContentType::OctetStream, schema ))), }?; @@ -2129,8 +2155,94 @@ impl Generator { content_type, schema ))), }?; + OperationParameterType::RawBody } + BodyContentType::FormData => { + // For form data, we expect a key-value set of types, specific schema: + + // ```yaml + // type: "object" + // properties: + // file: + // description: "The file to upload" + // type: "string" + // format: "binary" + // ``` + // "schema": { + // "type": "string", + // "format": "binary" + // } + + let _mapped = match schema.item(components)? { + openapiv3::Schema { + schema_data: + openapiv3::SchemaData { + nullable: false, + discriminator: None, + default: None, + // Other fields that describe or document the + // schema are fine. + .. + }, + schema_kind: + openapiv3::SchemaKind::Type(openapiv3::Type::Object( + openapiv3::ObjectType { + properties, + additional_properties: _, + .. + }, + )), + } => { + let mapped = Result::>::from_iter(properties.into_iter().map( + |(name, property)| { + // properties must be plain key value types for now + let ReferenceOr::Item(property) = property else { + return Err(Error::UnexpectedFormat(format!( + "invalid schema for {}: didn't expect a reference", + BodyContentType::FormData, + ))); + }; + match &property.schema_kind { + openapiv3::SchemaKind::Type(openapiv3::Type::String( + openapiv3::StringType { + format: + openapiv3::VariantOrUnknownOrEmpty::Item( + openapiv3::StringFormat::Binary, + ), + pattern: None, + enumeration, + min_length: None, + max_length: None, + }, + )) if enumeration.is_empty() => Ok(name.to_owned()), + schema => Err(Error::UnexpectedFormat(format!( + "invalid schema for {}: {:?}", + BodyContentType::FormData, + schema + ))), + } + }, + ))?; + Ok(mapped) + } + _ => Err(Error::UnexpectedFormat(format!( + "invalid schema for {}: {:?}", + BodyContentType::FormData, + schema + ))), + }?; + + let form_name = sanitize( + &format!("{}-form", operation.operation_id.as_ref().unwrap(),), + Case::Pascal, + ); + let type_id = self + .type_space + .add_type_with_name(&schema.to_schema(), Some(form_name))?; + self.forms.insert(type_id.clone()); + OperationParameterType::Form(type_id) + } BodyContentType::Json | BodyContentType::FormUrlencoded => { // TODO it would be legal to have the encoding field set for // application/x-www-form-urlencoded content, but I'm not sure diff --git a/progenitor-macro/Cargo.toml b/progenitor-macro/Cargo.toml index 19052cda..4789ddec 100644 --- a/progenitor-macro/Cargo.toml +++ b/progenitor-macro/Cargo.toml @@ -21,3 +21,4 @@ serde_json = "1.0" serde_yaml = "0.9" serde_tokenstream = "0.2.0" syn = { version = "2.0", features = ["full", "extra-traits"] } +expander = "2" diff --git a/progenitor-macro/src/lib.rs b/progenitor-macro/src/lib.rs index 6f5a2ad4..f07a012e 100644 --- a/progenitor-macro/src/lib.rs +++ b/progenitor-macro/src/lib.rs @@ -378,5 +378,18 @@ fn do_generate_api(item: TokenStream) -> Result { const _: &str = include_str!(#path_str); }; + println!("cargo::rerun-if-changed={}", path_str); + + let output = expander::Expander::new(format!( + "{}", + std::path::PathBuf::from(spec.value()) + .file_name() + .unwrap() + .to_string_lossy() + )) + .fmt(expander::Edition::_2021) + .verbose(true) + .write_to_out_dir(output) + .expect("Writing file works. qed"); Ok(output.into()) } diff --git a/progenitor/tests/build_sevdesk.rs b/progenitor/tests/build_sevdesk.rs new file mode 100644 index 00000000..cda5c017 --- /dev/null +++ b/progenitor/tests/build_sevdesk.rs @@ -0,0 +1,51 @@ +// Copyright 2022 Oxide Computer Company + +mod positional { + use self::types::VoucherUploadFileForm; + + progenitor::generate_api!("../sample_openapi/sevdesk-san-sub.yaml"); + + fn _ignore() { + let _ = Client::new("").voucher_upload_file(&VoucherUploadFileForm { + file: Some("foo".to_owned()), + }); + } +} + +mod builder_untagged { + use self::types::VoucherUploadFileForm; + + progenitor::generate_api!( + spec = "../sample_openapi/sevdesk-san-sub.yaml", + interface = Builder, + tags = Merged, + ); + + fn _ignore() { + let _ = Client::new("") + .voucher_upload_file() + .body(&VoucherUploadFileForm { + file: Some("foo".to_owned()), + }) + .send(); + } +} + +mod builder_tagged { + use self::types::VoucherUploadFileForm; + + progenitor::generate_api!( + spec = "../sample_openapi/sevdesk-san-sub.yaml", + interface = Builder, + tags = Separate, + ); + + fn _ignore() { + let _ = Client::new("") + .voucher_upload_file() + .body(&VoucherUploadFileForm { + file: Some("foo".to_owned()), + }) + .send(); + } +} diff --git a/sample_openapi/sevdesk-san-sub.yaml b/sample_openapi/sevdesk-san-sub.yaml new file mode 100644 index 00000000..f057c373 --- /dev/null +++ b/sample_openapi/sevdesk-san-sub.yaml @@ -0,0 +1,80 @@ +# subset sample of +openapi: 3.0.0 +info: + title: sevDesk API + description: "Contact: To contact our support click here

\r\n# General information\r\nWelcome to our API!
\r\nsevDesk offers you the possibility of retrieving data using an interface, namely the sevDesk API, and making changes without having to use the web UI. The sevDesk interface is a REST-Full API. All sevDesk data and functions that are used in the web UI can also be controlled through the API.\r\n\n# Cross-Origin Resource Sharing\r\nThis API features Cross-Origin Resource Sharing (CORS).
\r\nIt enables cross-domain communication from the browser.
\r\nAll responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site.\r\n\r\n# Embedding resources\r\nWhen retrieving resources by using this API, you might encounter nested resources in the resources you requested.
\r\nFor example, an invoice always contains a contact, of which you can see the ID and the object name.
\r\nThis API gives you the possibility to embed these resources completely into the resources you originally requested.
\r\nTaking our invoice example, this would mean, that you would not only see the ID and object name of a contact, but rather the complete contact resource.\r\n\r\nTo embed resources, all you need to do is to add the query parameter 'embed' to your GET request.
\r\nAs values, you can provide the name of the nested resource.
\r\nMultiple nested resources are also possible by providing multiple names, separated by a comma.\r\n \n# Authentication and Authorization\n The sevDesk API uses a token authentication to authorize calls. For this purpose every sevDesk administrator has one API token, which is a hexadecimal string containing 32 characters. The following clip shows where you can find the API token if this is your first time with our API.


The token will be needed in every request that you want to send and needs to be attached to the request url as a Query Parameter
or provided as a value of an Authorization Header.
For security reasons, we suggest putting the API Token in the Authorization Header and not in the query string.
However, in the request examples in this documentation, we will keep it in the query string, as it is easier for you to copy them and try them yourself.
The following url is an example that shows where your token needs to be placed as a query parameter.
In this case, we used some random API token.
  • https://my.sevdesk.de/api/v1/Contact?token=b7794de0085f5cd00560f160f290af38
The next example shows the token in the Authorization Header.
  • \"Authorization\" :\"b7794de0085f5cd00560f160f290af38\"
The api tokens have an infinite lifetime and, in other words, exist as long as the sevDesk user exists.
For this reason, the user should NEVER be deleted.
If really necessary, it is advisable to save the api token as we will NOT be able to retrieve it afterwards!
It is also possible to generate a new API token, for example, if you want to prevent the usage of your sevDesk account by other people who got your current API token.
To achieve this, you just need to click on the \"generate new\" symbol to the right of your token and confirm it with your password. \n# API News\n To never miss API news and updates again, subscribe to our free API newsletter and get all relevant information to keep your sevDesk software running smoothly. To subscribe, simply click here and confirm the email address to which we may send all updates relevant to you. \n# API Requests\n In our case, REST API requests need to be build by combining the following components.
Component Description
HTTP-Methods
  • GET (retrieve a resource)
  • POST (create a resource)
  • PUT (update a resource)
  • DELETE (delete a resource)
URL of the API https://my.sevdesk.de/api/v1
URI of the resource The resource to query.
For example contacts in sevDesk:

/Contact

Which will result in the following complete URL:

https://my.sevdesk.de/api/v1/Contact
Query parameters Are attached by using the connectives ? and & in the URL.
Request headers Typical request headers are for example:

  • Content-type
  • Authorization
  • Accept-Encoding
  • User-Agent
  • X-Version: Used for resource versioning see information below
  • ...
Response headers Typical response headers are for example:

  • Deprecation: If a resource is deprecated we return true or a timestamp since when
  • ...
Request body Mostly required in POST and PUT requests.
Often the request body contains json, in our case, it also accepts url-encoded data.

Note: please pass a meaningful entry at the header \"User-Agent\". If the \"User-Agent\" is set meaningfully, we can offer better support in case of queries from customers.
An example how such a \"User-Agent\" can look like: \"Integration-name by abc\".

This is a sample request for retrieving existing contacts in sevDesk using curl:

Get Request

As you can see, the request contains all the components mentioned above.
It's HTTP method is GET, it has a correct endpoint (https://my.sevdesk.de/api/v1/Contact), query parameters like token and additional header information!

Query Parameters

As you might have seen in the sample request above, there are several other parameters besides \"token\", located in the url.
Those are mostly optional but prove to be very useful for a lot of requests as they can limit, extend, sort or filter the data you will get as a response.

These are the three most used query parameter for the sevDesk API.
Parameter Description
limit Limits the number of entries that are returned.
Most useful in GET requests which will most likely deliver big sets of data like country or currency lists.
In this case, you can bypass the default limitation on returned entries by providing a high number.
offset Specifies a certain offset for the data that will be returned.
As an example, you can specify \"offset=2\" if you want all entries except for the first two.
embed Will extend some of the returned data.
A brief example can be found below.
countAll \"countAll=true\" returns the number of items
This is an example for the usage of the embed parameter.
The following first request will return all company contact entries in sevDesk up to a limit of 100 without any additional information and no offset.



Now have a look at the category attribute located in the response.
Naturally, it just contains the id and the object name of the object but no further information.
We will now use the parameter embed with the value \"category\".



As you can see, the category object is now extended and shows all the attributes and their corresponding values.

There are lot of other query parameters that can be used to filter the returned data for objects that match a certain pattern, however, those will not be mentioned here and instead can be found at the detail documentation of the most used API endpoints like contact, invoice or voucher.

Request Headers

The HTTP request (response) headers allow the client as well as the server to pass additional information with the request.
They transfer the parameters and arguments which are important for transmitting data over HTTP.
Three headers which are useful / necessary when using the sevDesk API are \"Authorization, \"Accept\" and \"Content-type\".
Underneath is a brief description of why and how they should be used.

Authorization

Can be used if you want to provide your API token in the header instead of having it in the url.
  • Authorization:yourApiToken
Accept

Specifies the format of the response.
Required for operations with a response body.
  • Accept:application/format
In our case, format could be replaced with json or xml

Content-type

Specifies which format is used in the request.
Is required for operations with a request body.
  • Content-type:application/format
In our case,formatcould be replaced with json or x-www-form-urlencoded

API Responses

HTTP status codes
When calling the sevDesk API it is very likely that you will get a HTTP status code in the response.
Some API calls will also return JSON response bodies which will contain information about the resource.
Each status code which is returned will either be a success code or an error code.

Success codes
Status code Description
200 OK The request was successful
201 Created Most likely to be found in the response of a POST request.
This code indicates that the desired resource was successfully created.

Error codes
Status code Description
400 Bad request The request you sent is most likely syntactically incorrect.
You should check if the parameters in the request body or the url are correct.
401 Unauthorized The authentication failed.
Most likely caused by a missing or wrong API token.
403 Forbidden You do not have the permission the access the resource which is requested.
404 Not found The resource you specified does not exist.
500 Internal server error An internal server error has occurred.
Normally this means that something went wrong on our side.
However, sometimes this error will appear if we missed to catch an error which is normally a 400 status code!


Resource Versioning

We use resource versioning to handle breaking changes for our endpoints, these are rarely used and will be communicated before we remove older versions.
To call a different version we use a specific header X-Version that should be filled with the desired version.
  • If you do not specify any version we assume default
  • If you specify a version that does not exist or was removed, you will get an error with information which versions are available
X-Version Description
default Should always reference the oldest version.
If a specific resource is updated with a new version,
then the default version stays the same until the old version is deleted
1.0 ... 1.9 Our incrementally version for each resource independent
Important: A resource can be available via default but not 1.0
\n# Your First Request\n After reading the introduction to our API, you should now be able to make your first call.
For testing our API, we would always recommend to create a trial account for sevDesk to prevent unwanted changes to your main account.
A trial account will be in the highest tariff (materials management), so every sevDesk function can be tested!

To start testing we would recommend one of the following tools: This example will illustrate your first request, which is creating a new Contact in sevDesk.
  1. Download Postman for your desired system and start the application
  2. Enter https://my.sevdesk.de/api/v1/Contact as the url
  3. Use the connective ? to append token= to the end of the url, or create an authorization header. Insert your API token as the value
  4. For this test, select POST as the HTTP method
  5. Go to Headers and enter the key-value pair Content-type + application/x-www-form-urlencoded
    As an alternative, you can just go to Body and select x-www-form-urlencoded
  6. Now go to Body (if you are not there yet) and enter the key-value pairs as shown in the following picture



  7. Click on Send. Your response should now look like this:

As you can see, a successful response in this case returns a JSON-formatted response body containing the contact you just created.
For keeping it simple, this was only a minimal example of creating a contact.
There are however numerous combinations of parameters that you can provide which add information to your contact." + version: 2.0.0 + x-logo: + url: https://my.sevdesk.de/img/logos/1_100.png + backgroundColor: '#263241' +servers: + - url: https://my.sevdesk.de/api/v1 + description: Our main application instance which most of our customers work with +security: + - api_key: [] +paths: + /Voucher/Factory/uploadTempFile: + post: + tags: + - Voucher + summary: Upload voucher file + description: >- + To attach a document to a voucher, you will need to upload it first for + later use.
To do this, you can use this request.
When you + successfully uploaded the file, you will get a sevDesk internal filename + in the response.
The filename will be a hash generated from your + uploaded file. You will need it in the next request!
After you got + the just mentioned filename, you can enter it as a value for the + filename parameter of the saveVoucher request.
If you provided all + necessary parameters and kept all of them in the right order, the file + will be attached to your voucher. + operationId: voucherUploadFile + requestBody: + description: File to upload + content: + form-data: + schema: + properties: + file: + description: The file to upload + type: string + format: binary + type: object + responses: + '201': + description: A pdf file + content: + application/json: + schema: + type: object + properties: + objects: + type: object + properties: + pages: + type: number + example: 1 + mimeType: + type: string + example: image/jpg + originMimeType: + type: string + example: application/pdf + filename: + type: string + example: f019bec36c65f5a0e7d2c63cc33f0681.pdf + contentHash: + type: string + example: >- + 1998dea8c6e9e489139caf896690641c0ea065ce5770b51cf2a4d10797f99685 + content: + type: array + items: + example: null + '400': + description: Bad request + '401': + description: Authentication required + '500': + description: Server Error + deprecated: false From 20ee59ca2cdd25cc8d5cf2380e3bee6d61121fa7 Mon Sep 17 00:00:00 2001 From: Jacob Birkett Date: Thu, 9 Oct 2025 14:14:23 -0700 Subject: [PATCH 2/2] cargo: lints: forbid unused_crate_dependencies --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 72bbcc58..ac42a2f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,3 +70,6 @@ uuid = { version = "1.18.1", features = ["serde", "v4"] } #serde_tokenstream = { path = "../serde_tokenstream" } #typify = { path = "../typify/typify" } #rustfmt-wrapper = { path = "../rustfmt-wrapper" } + +[workspace.lints.rust] +unused_crate_dependencies = "forbid"