add support for application/x-www-form-urlencoded bodies (#109)

* add support for application/x-www-form-urlencoded bodies

* self review

* alphabetizing

* remove commented code

* update changelog
This commit is contained in:
Adam Leventhal 2022-07-07 18:35:36 -07:00 committed by GitHub
parent 4dbad20942
commit 03e2cfad3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 10364 additions and 2937 deletions

View File

@ -16,6 +16,7 @@
https://github.com/oxidecomputer/progenitor/compare/v0.1.1\...HEAD[Full list of commits]
* Add support for a builder-style generation in addition to the positional style (#86)
* Add support for body parameters with application/x-www-form-urlencoded media type (#109)
== 0.1.1 (released 2022-05-13)

1
Cargo.lock generated
View File

@ -975,6 +975,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"serde_urlencoded",
]
[[package]]

View File

@ -13,3 +13,4 @@ percent-encoding = "2.1"
reqwest = { version = "0.11", default-features = false, features = ["json", "stream"] }
serde = "1.0"
serde_json = "1.0"
serde_urlencoded = "0.7.1"

View File

@ -11,7 +11,8 @@ use std::{
use bytes::Bytes;
use futures_core::Stream;
use serde::de::DeserializeOwned;
use reqwest::RequestBuilder;
use serde::{de::DeserializeOwned, Serialize};
/// Represents an untyped byte stream for both success and error responses.
pub type ByteStream =
@ -140,7 +141,7 @@ pub enum Error<E = ()> {
/// The request did not conform to API requirements.
InvalidRequest(String),
/// A server error either with the data, or with the connection.
/// A server error either due to the data, or with the connection.
CommunicationError(reqwest::Error),
/// A documented, expected error response.
@ -247,3 +248,29 @@ const PATH_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
pub fn encode_path(pc: &str) -> String {
percent_encoding::utf8_percent_encode(pc, PATH_SET).to_string()
}
#[doc(hidden)]
pub trait RequestBuilderExt<E> {
fn form_urlencoded<T: Serialize + ?Sized>(
self,
body: &T,
) -> Result<RequestBuilder, Error<E>>;
}
impl<E> RequestBuilderExt<E> for RequestBuilder {
fn form_urlencoded<T: Serialize + ?Sized>(
self,
body: &T,
) -> Result<Self, Error<E>> {
Ok(self
.header(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static(
"application/x-www-form-urlencoded",
),
)
.body(serde_json::to_vec(&body).map_err(|_| {
Error::InvalidRequest("failed to serialize body".to_string())
})?))
}
}

View File

@ -24,7 +24,7 @@ pub enum Error {
UnexpectedFormat(String),
#[error("invalid operation path")]
InvalidPath(String),
#[error("invalid operation path")]
#[error("internal error")]
InternalError(String),
}
@ -206,7 +206,7 @@ impl Generator {
// public interface of Client.
pub use progenitor_client::{ByteStream, Error, ResponseValue};
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub mod types {
use serde::{Deserialize, Serialize};
@ -254,7 +254,6 @@ impl Generator {
pub fn client(&self) -> &reqwest::Client {
&self.client
}
}
#operation_code
@ -301,9 +300,13 @@ impl Generator {
pub mod builder {
use super::types;
#[allow(unused_imports)]
use super::{ByteStream, Error, ResponseValue};
#[allow(unused_imports)]
use super::encode_path;
use super::{
encode_path,
ByteStream,
Error,
RequestBuilderExt,
ResponseValue,
};
#(#builder_struct)*
}
@ -329,9 +332,13 @@ impl Generator {
pub mod builder {
use super::types;
#[allow(unused_imports)]
use super::{ByteStream, Error, ResponseValue};
#[allow(unused_imports)]
use super::encode_path;
use super::{
encode_path,
ByteStream,
Error,
RequestBuilderExt,
ResponseValue,
};
#(#builder_struct)*
}
@ -391,8 +398,9 @@ impl Generator {
"bytes = \"1.1.0\"",
"futures-core = \"0.3.21\"",
"percent-encoding = \"2.1\"",
"serde = { version = \"1.0\", features = [\"derive\"] }",
"reqwest = { version = \"0.11\", features = [\"json\", \"stream\"] }",
"serde = { version = \"1.0\", features = [\"derive\"] }",
"serde_urlencoded = 0.7",
];
if self.type_space.uses_uuid() {
deps.push(

View File

@ -27,7 +27,6 @@ pub(crate) struct OperationMethod {
summary: Option<String>,
description: Option<String>,
params: Vec<OperationParameter>,
raw_body_param: bool,
responses: Vec<OperationResponse>,
dropshot_paginated: Option<DropshotPagination>,
}
@ -91,12 +90,6 @@ struct DropshotPagination {
item: TypeId,
}
#[derive(Debug, PartialEq, Eq)]
enum OperationParameterKind {
Path,
Query(bool),
Body,
}
struct OperationParameter {
/// Sanitized parameter name.
name: String,
@ -112,6 +105,37 @@ enum OperationParameterType {
Type(TypeId),
RawBody,
}
#[derive(Debug, PartialEq, Eq)]
enum OperationParameterKind {
Path,
Query(bool),
Body(BodyContentType),
}
#[derive(Debug, PartialEq, Eq)]
enum BodyContentType {
OctetStream,
Json,
FormUrlencoded,
}
impl FromStr for BodyContentType {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"application/octet-stream" => Ok(Self::OctetStream),
"application/json" => Ok(Self::Json),
"application/x-www-form-urlencoded" => Ok(Self::FormUrlencoded),
_ => Err(Error::UnexpectedFormat(format!(
"unexpected content type: {}",
s
))),
}
}
}
#[derive(Debug)]
struct OperationResponse {
status_code: OperationResponseStatus,
@ -313,44 +337,11 @@ impl Generator {
}
})
.collect::<Result<Vec<_>>>()?;
let mut raw_body_param = false;
if let Some(b) = &operation.request_body {
let b = b.item(components)?;
let typ = if b.is_binary(components)? {
raw_body_param = true;
OperationParameterType::RawBody
} else {
let mt = b.content_json()?;
if !mt.encoding.is_empty() {
todo!("media type encoding not empty: {:#?}", mt);
}
if let Some(s) = &mt.schema {
let schema = s.to_schema();
let name = sanitize(
&format!(
"{}-body",
operation.operation_id.as_ref().unwrap(),
),
Case::Pascal,
);
let typ = self
.type_space
.add_type_with_name(&schema, Some(name))?;
OperationParameterType::Type(typ)
} else {
todo!("media type encoding, no schema: {:#?}", mt);
}
};
params.push(OperationParameter {
name: "body".to_string(),
api_name: "body".to_string(),
description: b.description.clone(),
typ,
kind: OperationParameterKind::Body,
});
if let Some(body_param) = self.get_body_param(operation, components)? {
params.push(body_param);
}
let tmp = crate::template::parse(path)?;
let names = tmp.names();
@ -472,7 +463,6 @@ impl Generator {
.clone()
.filter(|s| !s.is_empty()),
params,
raw_body_param,
responses,
dropshot_paginated,
})
@ -506,7 +496,12 @@ impl Generator {
})
.collect::<Vec<_>>();
let bounds = if method.raw_body_param {
let raw_body_param = method
.params
.iter()
.any(|param| param.typ == OperationParameterType::RawBody);
let bounds = if raw_body_param {
quote! { <'a, B: Into<reqwest::Body> > }
} else {
quote! { <'a> }
@ -724,19 +719,44 @@ impl Generator {
.collect();
let url_path = method.path.compile(url_renames, client.clone());
// Generate code to handle the body...
let body_func =
method.params.iter().filter_map(|param| match &param.kind {
OperationParameterKind::Body => match &param.typ {
OperationParameterType::Type(_) => {
Some(quote! { .json(&body) })
}
OperationParameterType::RawBody => {
Some(quote! { .body(body) })
}
},
// Generate code to handle the body param.
let body_func = method.params.iter().filter_map(|param| {
match (&param.kind, &param.typ) {
(
OperationParameterKind::Body(BodyContentType::OctetStream),
OperationParameterType::RawBody,
) => Some(quote! {
// Set the content type (this is handled by helper
// functions for other MIME types).
.header(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/octet-stream"),
)
.body(body)
}),
(
OperationParameterKind::Body(BodyContentType::Json),
OperationParameterType::Type(_),
) => Some(quote! {
// Serialization errors are deferred.
.json(&body)
}),
(
OperationParameterKind::Body(
BodyContentType::FormUrlencoded,
),
OperationParameterType::Type(_),
) => Some(quote! {
// This uses progenitor_client::RequestBuilderExt which
// returns an error in the case of a serialization failure.
.form_urlencoded(&body)?
}),
(OperationParameterKind::Body(_), _) => {
unreachable!("invalid body kind/type combination")
}
_ => None,
});
}
});
// ... and there can be at most one body.
assert!(body_func.clone().count() <= 1);
@ -1277,7 +1297,7 @@ impl Generator {
.filter_map(|param| match param.kind {
OperationParameterKind::Path
| OperationParameterKind::Query(true)
| OperationParameterKind::Body => {
| OperationParameterKind::Body(_) => {
Some(format_ident!("{}", param.name))
}
OperationParameterKind::Query(false) => None,
@ -1655,6 +1675,100 @@ impl Generator {
impl_body
}
fn get_body_param(
&mut self,
operation: &openapiv3::Operation,
components: &Option<Components>,
) -> Result<Option<OperationParameter>> {
let body = match &operation.request_body {
Some(body) => body.item(components)?,
None => return Ok(None),
};
let (content_str, media_type) =
match (body.content.first(), body.content.len()) {
(None, _) => return Ok(None),
(Some(first), 1) => first,
(_, n) => todo!("more media types than expected: {}", n),
};
let schema = media_type.schema.as_ref().ok_or_else(|| {
Error::UnexpectedFormat(
"No schema specified for request body".to_string(),
)
})?;
let content_type = BodyContentType::from_str(content_str)?;
let typ = match content_type {
BodyContentType::OctetStream => {
// For an octet stream, we expect a simple, specific schema:
// "schema": {
// "type": "string",
// "format": "binary"
// }
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::String(
openapiv3::StringType {
format:
openapiv3::VariantOrUnknownOrEmpty::Item(
openapiv3::StringFormat::Binary,
),
pattern: None,
enumeration,
min_length: None,
max_length: None,
},
)),
} if enumeration.is_empty() => Ok(()),
_ => Err(Error::UnexpectedFormat(format!(
"invalid schema for application/octet-stream: {:?}",
schema
))),
}?;
OperationParameterType::RawBody
}
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
// how to interpret the values.
if !media_type.encoding.is_empty() {
todo!("media type encoding not empty: {:#?}", media_type);
}
let name = sanitize(
&format!(
"{}-body",
operation.operation_id.as_ref().unwrap(),
),
Case::Pascal,
);
let typ = self
.type_space
.add_type_with_name(&schema.to_schema(), Some(name))?;
OperationParameterType::Type(typ)
}
};
Ok(Some(OperationParameter {
name: "body".to_string(),
api_name: "body".to_string(),
description: body.description.clone(),
typ,
kind: OperationParameterKind::Body(content_type),
}))
}
}
fn make_doc_comment(method: &OperationMethod) -> String {
@ -1810,13 +1924,13 @@ fn sort_params(raw_params: &mut [OperationParameter], names: &[String]) {
) => Ordering::Less,
(
OperationParameterKind::Path,
OperationParameterKind::Body,
OperationParameterKind::Body(_),
) => Ordering::Less,
// Query params are in lexicographic order.
(
OperationParameterKind::Query(_),
OperationParameterKind::Body,
OperationParameterKind::Body(_),
) => Ordering::Less,
(
OperationParameterKind::Query(_),
@ -1829,16 +1943,16 @@ fn sort_params(raw_params: &mut [OperationParameter], names: &[String]) {
// Body params are last and should be singular.
(
OperationParameterKind::Body,
OperationParameterKind::Body(_),
OperationParameterKind::Path,
) => Ordering::Greater,
(
OperationParameterKind::Body,
OperationParameterKind::Body(_),
OperationParameterKind::Query(_),
) => Ordering::Greater,
(
OperationParameterKind::Body,
OperationParameterKind::Body,
OperationParameterKind::Body(_),
OperationParameterKind::Body(_),
) => {
panic!("should only be one body")
}
@ -1861,90 +1975,3 @@ impl ParameterDataExt for openapiv3::ParameterData {
}
}
}
// TODO do I want/need this?
trait ExtractJsonMediaType {
fn is_binary(&self, components: &Option<Components>) -> Result<bool>;
fn content_json(&self) -> Result<openapiv3::MediaType>;
}
impl ExtractJsonMediaType for openapiv3::RequestBody {
fn content_json(&self) -> Result<openapiv3::MediaType> {
if self.content.len() != 1 {
todo!("expected one content entry, found {}", self.content.len());
}
if let Some(mt) = self.content.get("application/json") {
Ok(mt.clone())
} else {
todo!(
"could not find application/json, only found {}",
self.content.keys().next().unwrap()
);
}
}
fn is_binary(&self, components: &Option<Components>) -> Result<bool> {
if self.content.is_empty() {
/*
* XXX If there are no content types, I guess it is not binary?
*/
return Ok(false);
}
if self.content.len() != 1 {
todo!("expected one content entry, found {}", self.content.len());
}
if let Some(mt) = self.content.get("application/octet-stream") {
if !mt.encoding.is_empty() {
todo!("XXX encoding");
}
if let Some(s) = &mt.schema {
use openapiv3::{
SchemaKind, StringFormat, Type,
VariantOrUnknownOrEmpty::Item,
};
let s = s.item(components)?;
if s.schema_data.nullable {
todo!("XXX nullable binary?");
}
if s.schema_data.default.is_some() {
todo!("XXX default binary?");
}
if s.schema_data.discriminator.is_some() {
todo!("XXX binary discriminator?");
}
match &s.schema_kind {
SchemaKind::Type(Type::String(st)) => {
if st.min_length.is_some() || st.max_length.is_some() {
todo!("binary min/max length");
}
if !matches!(st.format, Item(StringFormat::Binary)) {
todo!(
"expected binary format string, got {:?}",
st.format
);
}
if st.pattern.is_some() {
todo!("XXX pattern");
}
if !st.enumeration.is_empty() {
todo!("XXX enumeration");
}
return Ok(true);
}
x => {
todo!("XXX schemakind type {:?}", x);
}
}
} else {
todo!("binary thing had no schema?");
}
}
Ok(false)
}
}

View File

@ -1,5 +1,5 @@
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub mod types {
use serde::{Deserialize, Serialize};
@ -365,11 +365,9 @@ impl Client {
}
pub mod builder {
#[allow(unused_imports)]
use super::encode_path;
use super::types;
#[allow(unused_imports)]
use super::{ByteStream, Error, ResponseValue};
use super::{encode_path, ByteStream, Error, RequestBuilderExt, ResponseValue};
///Builder for [`Client::control_hold`]
///
///[`Client::control_hold`]: super::Client::control_hold
@ -994,7 +992,15 @@ pub mod builder {
client.baseurl,
encode_path(&task.to_string()),
);
let request = client.client.post(url).body(body).build()?;
let request = client
.client
.post(url)
.header(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/octet-stream"),
)
.body(body)
.build()?;
let result = client.client.execute(request).await;
let response = result?;
match response.status().as_u16() {

View File

@ -1,5 +1,5 @@
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub mod types {
use serde::{Deserialize, Serialize};
@ -365,11 +365,9 @@ impl Client {
}
pub mod builder {
#[allow(unused_imports)]
use super::encode_path;
use super::types;
#[allow(unused_imports)]
use super::{ByteStream, Error, ResponseValue};
use super::{encode_path, ByteStream, Error, RequestBuilderExt, ResponseValue};
///Builder for [`Client::control_hold`]
///
///[`Client::control_hold`]: super::Client::control_hold
@ -994,7 +992,15 @@ pub mod builder {
client.baseurl,
encode_path(&task.to_string()),
);
let request = client.client.post(url).body(body).build()?;
let request = client
.client
.post(url)
.header(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/octet-stream"),
)
.body(body)
.build()?;
let result = client.client.execute(request).await;
let response = result?;
match response.status().as_u16() {

View File

@ -1,5 +1,5 @@
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub mod types {
use serde::{Deserialize, Serialize};
@ -388,7 +388,15 @@ impl Client {
self.baseurl,
encode_path(&task.to_string()),
);
let request = self.client.post(url).body(body).build()?;
let request = self
.client
.post(url)
.header(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/octet-stream"),
)
.body(body)
.build()?;
let result = self.client.execute(request).await;
let response = result?;
match response.status().as_u16() {

View File

@ -1,5 +1,5 @@
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub mod types {
use serde::{Deserialize, Serialize};
@ -173,11 +173,9 @@ impl Client {
}
pub mod builder {
#[allow(unused_imports)]
use super::encode_path;
use super::types;
#[allow(unused_imports)]
use super::{ByteStream, Error, ResponseValue};
use super::{encode_path, ByteStream, Error, RequestBuilderExt, ResponseValue};
///Builder for [`Client::enrol`]
///
///[`Client::enrol`]: super::Client::enrol

View File

@ -1,5 +1,5 @@
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub mod types {
use serde::{Deserialize, Serialize};
@ -173,11 +173,9 @@ impl Client {
}
pub mod builder {
#[allow(unused_imports)]
use super::encode_path;
use super::types;
#[allow(unused_imports)]
use super::{ByteStream, Error, ResponseValue};
use super::{encode_path, ByteStream, Error, RequestBuilderExt, ResponseValue};
///Builder for [`Client::enrol`]
///
///[`Client::enrol`]: super::Client::enrol

View File

@ -1,5 +1,5 @@
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub mod types {
use serde::{Deserialize, Serialize};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub mod types {
use serde::{Deserialize, Serialize};

View File

@ -1,5 +1,5 @@
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub mod types {
use serde::{Deserialize, Serialize};

View File

@ -1,5 +1,5 @@
#[allow(unused_imports)]
use progenitor_client::encode_path;
use progenitor_client::{encode_path, RequestBuilderExt};
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub mod types {
use serde::{Deserialize, Serialize};

View File

@ -85,7 +85,6 @@ fn main() -> Result<()> {
let args = Args::parse();
let api = load_api(&args.input)?;
//let mut builder = Generator::default();
let mut builder = Generator::new(
GenerationSettings::default()
.with_interface(args.interface.into())

View File

@ -15,7 +15,7 @@ mod positional {
let org = types::Name("org".to_string());
let project = types::Name("project".to_string());
let instance = types::Name("instance".to_string());
let stream = client.instance_disks_get_stream(
let stream = client.instance_disk_list_stream(
&org, &project, &instance, None, None,
);
let _ = stream.collect::<Vec<_>>();
@ -39,7 +39,7 @@ mod builder_untagged {
pub fn _ignore() {
let client = Client::new("");
let stream = client
.instance_disks_get()
.instance_disk_list()
.organization_name(types::Name("org".to_string()))
.project_name(types::Name("project".to_string()))
.instance_name(types::Name("instance".to_string()))
@ -64,7 +64,7 @@ mod builder_tagged {
fn _ignore() {
let client = Client::new("");
let stream = client
.instance_disks_get()
.instance_disk_list()
.organization_name(types::Name("org".to_string()))
.project_name(types::Name("project".to_string()))
.instance_name(types::Name("instance".to_string()))

File diff suppressed because it is too large Load Diff