From 4d358a8381a9634801f0e1eda35e0fc7703ea7b3 Mon Sep 17 00:00:00 2001 From: Frederick Cousins <95751521+FrederickCousins@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:20:10 +0100 Subject: [PATCH] Treat "+json" structured-syntax-suffix media types as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify any "+json" media type as JSON for both request bodies and responses. Requests send the media type the spec declares rather than application/json. Adds a json-suffix.json sample spec covering a "+json" request, a "+json" 2XX response, and a problem+json default error. RFC 6839 ยง3.1: https://www.rfc-editor.org/rfc/rfc6839#section-3.1 --- progenitor-impl/src/method.rs | 72 ++- .../tests/output/src/json_suffix_builder.rs | 554 ++++++++++++++++++ .../output/src/json_suffix_builder_tagged.rs | 550 +++++++++++++++++ .../tests/output/src/json_suffix_cli.rs | 136 +++++ .../tests/output/src/json_suffix_httpmock.rs | 84 +++ .../output/src/json_suffix_positional.rs | 257 ++++++++ progenitor-impl/tests/test_output.rs | 5 + sample_openapi/json-suffix.json | 86 +++ 8 files changed, 1731 insertions(+), 13 deletions(-) create mode 100644 progenitor-impl/tests/output/src/json_suffix_builder.rs create mode 100644 progenitor-impl/tests/output/src/json_suffix_builder_tagged.rs create mode 100644 progenitor-impl/tests/output/src/json_suffix_cli.rs create mode 100644 progenitor-impl/tests/output/src/json_suffix_httpmock.rs create mode 100644 progenitor-impl/tests/output/src/json_suffix_positional.rs create mode 100644 sample_openapi/json-suffix.json diff --git a/progenitor-impl/src/method.rs b/progenitor-impl/src/method.rs index 6752823c0..42f261bcb 100644 --- a/progenitor-impl/src/method.rs +++ b/progenitor-impl/src/method.rs @@ -135,21 +135,28 @@ impl OperationParameterKind { #[derive(Debug, PartialEq, Eq)] pub enum BodyContentType { OctetStream, - Json, + Json(String), FormUrlencoded, Text(String), } +/// JSON is `application/json` or any media type with the `+json` structured +/// syntax suffix (RFC 6839). +fn is_json_media_type(media_type: &str) -> bool { + media_type == "application/json" || media_type.ends_with("+json") +} + impl FromStr for BodyContentType { type Err = Error; fn from_str(s: &str) -> Result { let offset = s.find(';').unwrap_or(s.len()); - match &s[..offset] { + let media_type = &s[..offset]; + match media_type { "application/octet-stream" => Ok(Self::OctetStream), - "application/json" => Ok(Self::Json), "application/x-www-form-urlencoded" => Ok(Self::FormUrlencoded), - "text/plain" | "text/x-markdown" => Ok(Self::Text(String::from(&s[..offset]))), + "text/plain" | "text/x-markdown" => Ok(Self::Text(String::from(media_type))), + _ if is_json_media_type(media_type) => Ok(Self::Json(String::from(media_type))), _ => Err(Error::UnexpectedFormat(format!( "unexpected content type: {}", s @@ -162,7 +169,7 @@ impl std::fmt::Display for BodyContentType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::OctetStream => "application/octet-stream", - Self::Json => "application/json", + Self::Json(typ) => typ, Self::FormUrlencoded => "application/x-www-form-urlencoded", Self::Text(typ) => typ, }) @@ -452,7 +459,8 @@ impl Generator { } // We categorize responses as "typed" based on the - // "application/json" content type, "upgrade" if it's a + // "application/json" content type (or any "+json" + // structured syntax suffix), "upgrade" if it's a // websocket channel without a meaningful content-type, // "raw" if there's any other response content type (we don't // investigate further), or "none" if there is no content. @@ -462,7 +470,9 @@ impl Generator { // content type of the response just as it currently examines // the status code. let typ = if let Some(mt) = response.content.iter().find_map(|(x, v)| { - (x == "application/json" || x.starts_with("application/json;")).then_some(v) + // Strip parameters (e.g. "; charset=utf-8") + let media_type = &x[..x.find(';').unwrap_or(x.len())]; + is_json_media_type(media_type).then_some(v) }) { assert!(mt.encoding.is_empty()); @@ -913,12 +923,28 @@ impl Generator { .body(body) }), ( - OperationParameterKind::Body(BodyContentType::Json), + OperationParameterKind::Body(BodyContentType::Json(mime_type)), OperationParameterType::Type(_), - ) => Some(quote! { - // Serialization errors are deferred. - .json(&body) - }), + ) => { + // reqwest's json() sets Content-Type: application/json + // only if none is already present, so for a "+json" type + // we set the spec's media type first. Plain + // application/json emits no header so existing output is + // unchanged. + let content_type = (mime_type != "application/json").then(|| { + quote! { + .header( + ::reqwest::header::CONTENT_TYPE, + ::reqwest::header::HeaderValue::from_static(#mime_type), + ) + } + }); + Some(quote! { + #content_type + // Serialization errors are deferred. + .json(&body) + }) + } ( OperationParameterKind::Body(BodyContentType::FormUrlencoded), OperationParameterType::Type(_), @@ -2140,7 +2166,7 @@ impl Generator { }?; OperationParameterType::RawBody } - BodyContentType::Json | BodyContentType::FormUrlencoded => { + 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. @@ -2332,3 +2358,23 @@ impl ParameterDataExt for openapiv3::ParameterData { } } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::BodyContentType; + + #[test] + fn test_json_suffix() { + let json = |s: &str| BodyContentType::from_str(s).unwrap().to_string(); + assert_eq!(json("application/json; charset=utf-8"), "application/json"); + assert_eq!(json("application/problem+json"), "application/problem+json"); + assert_eq!( + json("application/vnd.x+json; charset=utf-8"), + "application/vnd.x+json" + ); + assert!(BodyContentType::from_str("application/soap+xml").is_err()); + assert!(BodyContentType::from_str("application/jsonx").is_err()); + } +} diff --git a/progenitor-impl/tests/output/src/json_suffix_builder.rs b/progenitor-impl/tests/output/src/json_suffix_builder.rs new file mode 100644 index 000000000..b98f63a77 --- /dev/null +++ b/progenitor-impl/tests/output/src/json_suffix_builder.rs @@ -0,0 +1,554 @@ +#[allow(unused_imports)] +use progenitor_client::{encode_path, ClientHooks, OperationInfo, RequestBuilderExt}; +#[allow(unused_imports)] +pub use progenitor_client::{ByteStream, ClientInfo, Error, ResponseValue}; +/// Types used as operation parameters and responses. +#[allow(clippy::all)] +pub mod types { + /// Error types. + pub mod error { + /// Error from a `TryFrom` or `FromStr` implementation. + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } + } + + ///`Annotation` + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "type": "object", + /// "required": [ + /// "note" + /// ], + /// "properties": { + /// "note": { + /// "type": "string" + /// } + /// } + ///} + /// ``` + ///
+ #[derive( + :: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, schemars :: JsonSchema, + )] + pub struct Annotation { + pub note: ::std::string::String, + } + + impl Annotation { + pub fn builder() -> builder::Annotation { + Default::default() + } + } + + ///`Problem` + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "type": "object", + /// "required": [ + /// "status", + /// "title" + /// ], + /// "properties": { + /// "detail": { + /// "type": "string" + /// }, + /// "status": { + /// "type": "integer", + /// "format": "uint16", + /// "minimum": 0.0 + /// }, + /// "title": { + /// "type": "string" + /// }, + /// "type": { + /// "type": "string" + /// } + /// } + ///} + /// ``` + ///
+ #[derive( + :: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, schemars :: JsonSchema, + )] + pub struct Problem { + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub detail: ::std::option::Option<::std::string::String>, + pub status: u16, + pub title: ::std::string::String, + #[serde( + rename = "type", + default, + skip_serializing_if = "::std::option::Option::is_none" + )] + pub type_: ::std::option::Option<::std::string::String>, + } + + impl Problem { + pub fn builder() -> builder::Problem { + Default::default() + } + } + + ///`Thing` + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "type": "object", + /// "required": [ + /// "id", + /// "notes" + /// ], + /// "properties": { + /// "id": { + /// "type": "string" + /// }, + /// "notes": { + /// "type": "array", + /// "items": { + /// "type": "string" + /// } + /// } + /// } + ///} + /// ``` + ///
+ #[derive( + :: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, schemars :: JsonSchema, + )] + pub struct Thing { + pub id: ::std::string::String, + pub notes: ::std::vec::Vec<::std::string::String>, + } + + impl Thing { + pub fn builder() -> builder::Thing { + Default::default() + } + } + + /// Types for composing complex structures. + pub mod builder { + #[derive(Clone, Debug)] + pub struct Annotation { + note: ::std::result::Result<::std::string::String, ::std::string::String>, + } + + impl ::std::default::Default for Annotation { + fn default() -> Self { + Self { + note: Err("no value supplied for note".to_string()), + } + } + } + + impl Annotation { + pub fn note(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::string::String>, + T::Error: ::std::fmt::Display, + { + self.note = value + .try_into() + .map_err(|e| format!("error converting supplied value for note: {e}")); + self + } + } + + impl ::std::convert::TryFrom for super::Annotation { + type Error = super::error::ConversionError; + fn try_from( + value: Annotation, + ) -> ::std::result::Result { + Ok(Self { note: value.note? }) + } + } + + impl ::std::convert::From for Annotation { + fn from(value: super::Annotation) -> Self { + Self { + note: Ok(value.note), + } + } + } + + #[derive(Clone, Debug)] + pub struct Problem { + detail: ::std::result::Result< + ::std::option::Option<::std::string::String>, + ::std::string::String, + >, + status: ::std::result::Result, + title: ::std::result::Result<::std::string::String, ::std::string::String>, + type_: ::std::result::Result< + ::std::option::Option<::std::string::String>, + ::std::string::String, + >, + } + + impl ::std::default::Default for Problem { + fn default() -> Self { + Self { + detail: Ok(Default::default()), + status: Err("no value supplied for status".to_string()), + title: Err("no value supplied for title".to_string()), + type_: Ok(Default::default()), + } + } + } + + impl Problem { + pub fn detail(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::option::Option<::std::string::String>>, + T::Error: ::std::fmt::Display, + { + self.detail = value + .try_into() + .map_err(|e| format!("error converting supplied value for detail: {e}")); + self + } + pub fn status(mut self, value: T) -> Self + where + T: ::std::convert::TryInto, + T::Error: ::std::fmt::Display, + { + self.status = value + .try_into() + .map_err(|e| format!("error converting supplied value for status: {e}")); + self + } + pub fn title(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::string::String>, + T::Error: ::std::fmt::Display, + { + self.title = value + .try_into() + .map_err(|e| format!("error converting supplied value for title: {e}")); + self + } + pub fn type_(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::option::Option<::std::string::String>>, + T::Error: ::std::fmt::Display, + { + self.type_ = value + .try_into() + .map_err(|e| format!("error converting supplied value for type_: {e}")); + self + } + } + + impl ::std::convert::TryFrom for super::Problem { + type Error = super::error::ConversionError; + fn try_from( + value: Problem, + ) -> ::std::result::Result { + Ok(Self { + detail: value.detail?, + status: value.status?, + title: value.title?, + type_: value.type_?, + }) + } + } + + impl ::std::convert::From for Problem { + fn from(value: super::Problem) -> Self { + Self { + detail: Ok(value.detail), + status: Ok(value.status), + title: Ok(value.title), + type_: Ok(value.type_), + } + } + } + + #[derive(Clone, Debug)] + pub struct Thing { + id: ::std::result::Result<::std::string::String, ::std::string::String>, + notes: ::std::result::Result< + ::std::vec::Vec<::std::string::String>, + ::std::string::String, + >, + } + + impl ::std::default::Default for Thing { + fn default() -> Self { + Self { + id: Err("no value supplied for id".to_string()), + notes: Err("no value supplied for notes".to_string()), + } + } + } + + impl Thing { + pub fn id(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::string::String>, + T::Error: ::std::fmt::Display, + { + self.id = value + .try_into() + .map_err(|e| format!("error converting supplied value for id: {e}")); + self + } + pub fn notes(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::vec::Vec<::std::string::String>>, + T::Error: ::std::fmt::Display, + { + self.notes = value + .try_into() + .map_err(|e| format!("error converting supplied value for notes: {e}")); + self + } + } + + impl ::std::convert::TryFrom for super::Thing { + type Error = super::error::ConversionError; + fn try_from( + value: Thing, + ) -> ::std::result::Result { + Ok(Self { + id: value.id?, + notes: value.notes?, + }) + } + } + + impl ::std::convert::From for Thing { + fn from(value: super::Thing) -> Self { + Self { + id: Ok(value.id), + notes: Ok(value.notes), + } + } + } + } +} + +#[derive(Clone, Debug)] +///Client for JSON structured syntax suffix test +/// +///Minimal API for testing RFC 6839 "+json" structured syntax suffix media +/// types on request bodies, responses, and errors +/// +///Version: v1 +pub struct Client { + pub(crate) baseurl: String, + pub(crate) client: reqwest::Client, +} + +impl Client { + /// Create a new client. + /// + /// `baseurl` is the base URL provided to the internal + /// `reqwest::Client`, and should include a scheme and hostname, + /// as well as port and a path stem if applicable. + pub fn new(baseurl: &str) -> Self { + #[cfg(not(target_arch = "wasm32"))] + let client = { + let dur = ::std::time::Duration::from_secs(15u64); + reqwest::ClientBuilder::new() + .connect_timeout(dur) + .timeout(dur) + }; + #[cfg(target_arch = "wasm32")] + let client = reqwest::ClientBuilder::new(); + Self::new_with_client(baseurl, client.build().unwrap()) + } + + /// Construct a new client with an existing `reqwest::Client`, + /// allowing more control over its configuration. + /// + /// `baseurl` is the base URL provided to the internal + /// `reqwest::Client`, and should include a scheme and hostname, + /// as well as port and a path stem if applicable. + pub fn new_with_client(baseurl: &str, client: reqwest::Client) -> Self { + Self { + baseurl: baseurl.to_string(), + client, + } + } +} + +impl ClientInfo<()> for Client { + fn api_version() -> &'static str { + "v1" + } + + fn baseurl(&self) -> &str { + self.baseurl.as_str() + } + + fn client(&self) -> &reqwest::Client { + &self.client + } + + fn inner(&self) -> &() { + &() + } +} + +impl ClientHooks<()> for &Client {} +impl Client { + ///Sends a `POST` request to `/things/{id}/annotate` + /// + ///```ignore + /// let response = client.annotate_thing() + /// .id(id) + /// .body(body) + /// .send() + /// .await; + /// ``` + pub fn annotate_thing(&self) -> builder::AnnotateThing<'_> { + builder::AnnotateThing::new(self) + } +} + +/// Types for composing operation parameters. +#[allow(clippy::all)] +pub mod builder { + use super::types; + #[allow(unused_imports)] + use super::{ + encode_path, ByteStream, ClientHooks, ClientInfo, Error, OperationInfo, RequestBuilderExt, + ResponseValue, + }; + ///Builder for [`Client::annotate_thing`] + /// + ///[`Client::annotate_thing`]: super::Client::annotate_thing + #[derive(Debug, Clone)] + pub struct AnnotateThing<'a> { + client: &'a super::Client, + id: Result<::std::string::String, String>, + body: Result, + } + + impl<'a> AnnotateThing<'a> { + pub fn new(client: &'a super::Client) -> Self { + Self { + client: client, + id: Err("id was not initialized".to_string()), + body: Ok(::std::default::Default::default()), + } + } + + pub fn id(mut self, value: V) -> Self + where + V: std::convert::TryInto<::std::string::String>, + { + self.id = value.try_into().map_err(|_| { + "conversion to `:: std :: string :: String` for id failed".to_string() + }); + self + } + + pub fn body(mut self, value: V) -> Self + where + V: std::convert::TryInto, + >::Error: std::fmt::Display, + { + self.body = value + .try_into() + .map(From::from) + .map_err(|s| format!("conversion to `Annotation` for body failed: {}", s)); + self + } + + pub fn body_map(mut self, f: F) -> Self + where + F: std::ops::FnOnce(types::builder::Annotation) -> types::builder::Annotation, + { + self.body = self.body.map(f); + self + } + + ///Sends a `POST` request to `/things/{id}/annotate` + pub async fn send(self) -> Result, Error> { + let Self { client, id, body } = self; + let id = id.map_err(Error::InvalidRequest)?; + let body = body + .and_then(|v| types::Annotation::try_from(v).map_err(|e| e.to_string())) + .map_err(Error::InvalidRequest)?; + let url = format!( + "{}/things/{}/annotate", + client.baseurl, + encode_path(&id.to_string()), + ); + let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(super::Client::api_version()), + ); + #[allow(unused_mut)] + let mut request = client + .client + .post(url) + .header( + ::reqwest::header::ACCEPT, + ::reqwest::header::HeaderValue::from_static("application/json"), + ) + .header( + ::reqwest::header::CONTENT_TYPE, + ::reqwest::header::HeaderValue::from_static( + "application/vnd.example.annotation+json", + ), + ) + .json(&body) + .headers(header_map) + .build()?; + let info = OperationInfo { + operation_id: "annotate_thing", + }; + client.pre(&mut request, &info).await?; + let result = client.exec(request, &info).await; + client.post(&result, &info).await?; + let response = result?; + match response.status().as_u16() { + 200..=299 => ResponseValue::from_response(response).await, + _ => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + _ => Err(Error::UnexpectedResponse(response)), + } + } + } +} + +/// Items consumers will typically use such as the Client. +pub mod prelude { + pub use self::super::Client; +} diff --git a/progenitor-impl/tests/output/src/json_suffix_builder_tagged.rs b/progenitor-impl/tests/output/src/json_suffix_builder_tagged.rs new file mode 100644 index 000000000..50dfe0dac --- /dev/null +++ b/progenitor-impl/tests/output/src/json_suffix_builder_tagged.rs @@ -0,0 +1,550 @@ +#[allow(unused_imports)] +use progenitor_client::{encode_path, ClientHooks, OperationInfo, RequestBuilderExt}; +#[allow(unused_imports)] +pub use progenitor_client::{ByteStream, ClientInfo, Error, ResponseValue}; +/// Types used as operation parameters and responses. +#[allow(clippy::all)] +pub mod types { + /// Error types. + pub mod error { + /// Error from a `TryFrom` or `FromStr` implementation. + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } + } + + ///`Annotation` + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "type": "object", + /// "required": [ + /// "note" + /// ], + /// "properties": { + /// "note": { + /// "type": "string" + /// } + /// } + ///} + /// ``` + ///
+ #[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] + pub struct Annotation { + pub note: ::std::string::String, + } + + impl Annotation { + pub fn builder() -> builder::Annotation { + Default::default() + } + } + + ///`Problem` + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "type": "object", + /// "required": [ + /// "status", + /// "title" + /// ], + /// "properties": { + /// "detail": { + /// "type": "string" + /// }, + /// "status": { + /// "type": "integer", + /// "format": "uint16", + /// "minimum": 0.0 + /// }, + /// "title": { + /// "type": "string" + /// }, + /// "type": { + /// "type": "string" + /// } + /// } + ///} + /// ``` + ///
+ #[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] + pub struct Problem { + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub detail: ::std::option::Option<::std::string::String>, + pub status: u16, + pub title: ::std::string::String, + #[serde( + rename = "type", + default, + skip_serializing_if = "::std::option::Option::is_none" + )] + pub type_: ::std::option::Option<::std::string::String>, + } + + impl Problem { + pub fn builder() -> builder::Problem { + Default::default() + } + } + + ///`Thing` + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "type": "object", + /// "required": [ + /// "id", + /// "notes" + /// ], + /// "properties": { + /// "id": { + /// "type": "string" + /// }, + /// "notes": { + /// "type": "array", + /// "items": { + /// "type": "string" + /// } + /// } + /// } + ///} + /// ``` + ///
+ #[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] + pub struct Thing { + pub id: ::std::string::String, + pub notes: ::std::vec::Vec<::std::string::String>, + } + + impl Thing { + pub fn builder() -> builder::Thing { + Default::default() + } + } + + /// Types for composing complex structures. + pub mod builder { + #[derive(Clone, Debug)] + pub struct Annotation { + note: ::std::result::Result<::std::string::String, ::std::string::String>, + } + + impl ::std::default::Default for Annotation { + fn default() -> Self { + Self { + note: Err("no value supplied for note".to_string()), + } + } + } + + impl Annotation { + pub fn note(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::string::String>, + T::Error: ::std::fmt::Display, + { + self.note = value + .try_into() + .map_err(|e| format!("error converting supplied value for note: {e}")); + self + } + } + + impl ::std::convert::TryFrom for super::Annotation { + type Error = super::error::ConversionError; + fn try_from( + value: Annotation, + ) -> ::std::result::Result { + Ok(Self { note: value.note? }) + } + } + + impl ::std::convert::From for Annotation { + fn from(value: super::Annotation) -> Self { + Self { + note: Ok(value.note), + } + } + } + + #[derive(Clone, Debug)] + pub struct Problem { + detail: ::std::result::Result< + ::std::option::Option<::std::string::String>, + ::std::string::String, + >, + status: ::std::result::Result, + title: ::std::result::Result<::std::string::String, ::std::string::String>, + type_: ::std::result::Result< + ::std::option::Option<::std::string::String>, + ::std::string::String, + >, + } + + impl ::std::default::Default for Problem { + fn default() -> Self { + Self { + detail: Ok(Default::default()), + status: Err("no value supplied for status".to_string()), + title: Err("no value supplied for title".to_string()), + type_: Ok(Default::default()), + } + } + } + + impl Problem { + pub fn detail(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::option::Option<::std::string::String>>, + T::Error: ::std::fmt::Display, + { + self.detail = value + .try_into() + .map_err(|e| format!("error converting supplied value for detail: {e}")); + self + } + pub fn status(mut self, value: T) -> Self + where + T: ::std::convert::TryInto, + T::Error: ::std::fmt::Display, + { + self.status = value + .try_into() + .map_err(|e| format!("error converting supplied value for status: {e}")); + self + } + pub fn title(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::string::String>, + T::Error: ::std::fmt::Display, + { + self.title = value + .try_into() + .map_err(|e| format!("error converting supplied value for title: {e}")); + self + } + pub fn type_(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::option::Option<::std::string::String>>, + T::Error: ::std::fmt::Display, + { + self.type_ = value + .try_into() + .map_err(|e| format!("error converting supplied value for type_: {e}")); + self + } + } + + impl ::std::convert::TryFrom for super::Problem { + type Error = super::error::ConversionError; + fn try_from( + value: Problem, + ) -> ::std::result::Result { + Ok(Self { + detail: value.detail?, + status: value.status?, + title: value.title?, + type_: value.type_?, + }) + } + } + + impl ::std::convert::From for Problem { + fn from(value: super::Problem) -> Self { + Self { + detail: Ok(value.detail), + status: Ok(value.status), + title: Ok(value.title), + type_: Ok(value.type_), + } + } + } + + #[derive(Clone, Debug)] + pub struct Thing { + id: ::std::result::Result<::std::string::String, ::std::string::String>, + notes: ::std::result::Result< + ::std::vec::Vec<::std::string::String>, + ::std::string::String, + >, + } + + impl ::std::default::Default for Thing { + fn default() -> Self { + Self { + id: Err("no value supplied for id".to_string()), + notes: Err("no value supplied for notes".to_string()), + } + } + } + + impl Thing { + pub fn id(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::string::String>, + T::Error: ::std::fmt::Display, + { + self.id = value + .try_into() + .map_err(|e| format!("error converting supplied value for id: {e}")); + self + } + pub fn notes(mut self, value: T) -> Self + where + T: ::std::convert::TryInto<::std::vec::Vec<::std::string::String>>, + T::Error: ::std::fmt::Display, + { + self.notes = value + .try_into() + .map_err(|e| format!("error converting supplied value for notes: {e}")); + self + } + } + + impl ::std::convert::TryFrom for super::Thing { + type Error = super::error::ConversionError; + fn try_from( + value: Thing, + ) -> ::std::result::Result { + Ok(Self { + id: value.id?, + notes: value.notes?, + }) + } + } + + impl ::std::convert::From for Thing { + fn from(value: super::Thing) -> Self { + Self { + id: Ok(value.id), + notes: Ok(value.notes), + } + } + } + } +} + +#[derive(Clone, Debug)] +///Client for JSON structured syntax suffix test +/// +///Minimal API for testing RFC 6839 "+json" structured syntax suffix media +/// types on request bodies, responses, and errors +/// +///Version: v1 +pub struct Client { + pub(crate) baseurl: String, + pub(crate) client: reqwest::Client, +} + +impl Client { + /// Create a new client. + /// + /// `baseurl` is the base URL provided to the internal + /// `reqwest::Client`, and should include a scheme and hostname, + /// as well as port and a path stem if applicable. + pub fn new(baseurl: &str) -> Self { + #[cfg(not(target_arch = "wasm32"))] + let client = { + let dur = ::std::time::Duration::from_secs(15u64); + reqwest::ClientBuilder::new() + .connect_timeout(dur) + .timeout(dur) + }; + #[cfg(target_arch = "wasm32")] + let client = reqwest::ClientBuilder::new(); + Self::new_with_client(baseurl, client.build().unwrap()) + } + + /// Construct a new client with an existing `reqwest::Client`, + /// allowing more control over its configuration. + /// + /// `baseurl` is the base URL provided to the internal + /// `reqwest::Client`, and should include a scheme and hostname, + /// as well as port and a path stem if applicable. + pub fn new_with_client(baseurl: &str, client: reqwest::Client) -> Self { + Self { + baseurl: baseurl.to_string(), + client, + } + } +} + +impl ClientInfo<()> for Client { + fn api_version() -> &'static str { + "v1" + } + + fn baseurl(&self) -> &str { + self.baseurl.as_str() + } + + fn client(&self) -> &reqwest::Client { + &self.client + } + + fn inner(&self) -> &() { + &() + } +} + +impl ClientHooks<()> for &Client {} +impl Client { + ///Sends a `POST` request to `/things/{id}/annotate` + /// + ///```ignore + /// let response = client.annotate_thing() + /// .id(id) + /// .body(body) + /// .send() + /// .await; + /// ``` + pub fn annotate_thing(&self) -> builder::AnnotateThing<'_> { + builder::AnnotateThing::new(self) + } +} + +/// Types for composing operation parameters. +#[allow(clippy::all)] +pub mod builder { + use super::types; + #[allow(unused_imports)] + use super::{ + encode_path, ByteStream, ClientHooks, ClientInfo, Error, OperationInfo, RequestBuilderExt, + ResponseValue, + }; + ///Builder for [`Client::annotate_thing`] + /// + ///[`Client::annotate_thing`]: super::Client::annotate_thing + #[derive(Debug, Clone)] + pub struct AnnotateThing<'a> { + client: &'a super::Client, + id: Result<::std::string::String, String>, + body: Result, + } + + impl<'a> AnnotateThing<'a> { + pub fn new(client: &'a super::Client) -> Self { + Self { + client: client, + id: Err("id was not initialized".to_string()), + body: Ok(::std::default::Default::default()), + } + } + + pub fn id(mut self, value: V) -> Self + where + V: std::convert::TryInto<::std::string::String>, + { + self.id = value.try_into().map_err(|_| { + "conversion to `:: std :: string :: String` for id failed".to_string() + }); + self + } + + pub fn body(mut self, value: V) -> Self + where + V: std::convert::TryInto, + >::Error: std::fmt::Display, + { + self.body = value + .try_into() + .map(From::from) + .map_err(|s| format!("conversion to `Annotation` for body failed: {}", s)); + self + } + + pub fn body_map(mut self, f: F) -> Self + where + F: std::ops::FnOnce(types::builder::Annotation) -> types::builder::Annotation, + { + self.body = self.body.map(f); + self + } + + ///Sends a `POST` request to `/things/{id}/annotate` + pub async fn send(self) -> Result, Error> { + let Self { client, id, body } = self; + let id = id.map_err(Error::InvalidRequest)?; + let body = body + .and_then(|v| types::Annotation::try_from(v).map_err(|e| e.to_string())) + .map_err(Error::InvalidRequest)?; + let url = format!( + "{}/things/{}/annotate", + client.baseurl, + encode_path(&id.to_string()), + ); + let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(super::Client::api_version()), + ); + #[allow(unused_mut)] + let mut request = client + .client + .post(url) + .header( + ::reqwest::header::ACCEPT, + ::reqwest::header::HeaderValue::from_static("application/json"), + ) + .header( + ::reqwest::header::CONTENT_TYPE, + ::reqwest::header::HeaderValue::from_static( + "application/vnd.example.annotation+json", + ), + ) + .json(&body) + .headers(header_map) + .build()?; + let info = OperationInfo { + operation_id: "annotate_thing", + }; + client.pre(&mut request, &info).await?; + let result = client.exec(request, &info).await; + client.post(&result, &info).await?; + let response = result?; + match response.status().as_u16() { + 200..=299 => ResponseValue::from_response(response).await, + _ => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + _ => Err(Error::UnexpectedResponse(response)), + } + } + } +} + +/// Items consumers will typically use such as the Client and +/// extension traits. +pub mod prelude { + #[allow(unused_imports)] + pub use super::Client; +} diff --git a/progenitor-impl/tests/output/src/json_suffix_cli.rs b/progenitor-impl/tests/output/src/json_suffix_cli.rs new file mode 100644 index 000000000..896c7e491 --- /dev/null +++ b/progenitor-impl/tests/output/src/json_suffix_cli.rs @@ -0,0 +1,136 @@ +use crate::json_suffix_builder::*; +use anyhow::Context as _; +pub struct Cli { + client: Client, + config: T, +} + +impl Cli { + pub fn new(client: Client, config: T) -> Self { + Self { client, config } + } + + pub fn get_command(cmd: CliCommand) -> ::clap::Command { + match cmd { + CliCommand::AnnotateThing => Self::cli_annotate_thing(), + } + } + + pub fn cli_annotate_thing() -> ::clap::Command { + ::clap::Command::new("") + .arg( + ::clap::Arg::new("id") + .long("id") + .value_parser(::clap::value_parser!(::std::string::String)) + .required(true), + ) + .arg( + ::clap::Arg::new("note") + .long("note") + .value_parser(::clap::value_parser!(::std::string::String)) + .required_unless_present("json-body"), + ) + .arg( + ::clap::Arg::new("json-body") + .long("json-body") + .value_name("JSON-FILE") + .required(false) + .value_parser(::clap::value_parser!(std::path::PathBuf)) + .help("Path to a file that contains the full json body."), + ) + .arg( + ::clap::Arg::new("json-body-template") + .long("json-body-template") + .action(::clap::ArgAction::SetTrue) + .help("XXX"), + ) + } + + pub async fn execute( + &self, + cmd: CliCommand, + matches: &::clap::ArgMatches, + ) -> anyhow::Result<()> { + match cmd { + CliCommand::AnnotateThing => self.execute_annotate_thing(matches).await, + } + } + + pub async fn execute_annotate_thing(&self, matches: &::clap::ArgMatches) -> anyhow::Result<()> { + let mut request = self.client.annotate_thing(); + if let Some(value) = matches.get_one::<::std::string::String>("id") { + request = request.id(value.clone()); + } + + if let Some(value) = matches.get_one::<::std::string::String>("note") { + request = request.body_map(|body| body.note(value.clone())) + } + + if let Some(value) = matches.get_one::("json-body") { + let body_txt = std::fs::read_to_string(value) + .with_context(|| format!("failed to read {}", value.display()))?; + let body_value = serde_json::from_str::(&body_txt) + .with_context(|| format!("failed to parse {}", value.display()))?; + request = request.body(body_value); + } + + self.config.execute_annotate_thing(matches, &mut request)?; + let result = request.send().await; + match result { + Ok(r) => { + self.config.success_item(&r); + Ok(()) + } + Err(r) => { + self.config.error(&r); + Err(anyhow::Error::new(r)) + } + } + } +} + +pub trait CliConfig { + fn success_item(&self, value: &ResponseValue) + where + T: std::clone::Clone + schemars::JsonSchema + serde::Serialize + std::fmt::Debug; + fn success_no_item(&self, value: &ResponseValue<()>); + fn error(&self, value: &Error) + where + T: std::clone::Clone + schemars::JsonSchema + serde::Serialize + std::fmt::Debug; + fn list_start(&self) + where + T: std::clone::Clone + schemars::JsonSchema + serde::Serialize + std::fmt::Debug; + fn list_item(&self, value: &T) + where + T: std::clone::Clone + schemars::JsonSchema + serde::Serialize + std::fmt::Debug; + fn list_end_success(&self) + where + T: std::clone::Clone + schemars::JsonSchema + serde::Serialize + std::fmt::Debug; + fn list_end_error(&self, value: &Error) + where + T: std::clone::Clone + schemars::JsonSchema + serde::Serialize + std::fmt::Debug; + fn execute_annotate_thing( + &self, + matches: &::clap::ArgMatches, + request: &mut builder::AnnotateThing, + ) -> anyhow::Result<()> { + Ok(()) + } +} + +#[derive(Copy, Clone, Debug)] +pub enum CliCommand { + AnnotateThing, +} + +impl CliCommand { + pub fn iter() -> impl Iterator { + vec![CliCommand::AnnotateThing].into_iter() + } + + pub fn operation_id(&self) -> &'static str { + match self { + CliCommand::AnnotateThing => "annotate_thing", + } + } +} diff --git a/progenitor-impl/tests/output/src/json_suffix_httpmock.rs b/progenitor-impl/tests/output/src/json_suffix_httpmock.rs new file mode 100644 index 000000000..1b7070384 --- /dev/null +++ b/progenitor-impl/tests/output/src/json_suffix_httpmock.rs @@ -0,0 +1,84 @@ +pub mod operations { + #![doc = r" [`When`](::httpmock::When) and [`Then`](::httpmock::Then)"] + #![doc = r" wrappers for each operation. Each can be converted to"] + #![doc = r" its inner type with a call to `into_inner()`. This can"] + #![doc = r" be used to explicitly deviate from permitted values."] + use crate::json_suffix_builder::*; + pub struct AnnotateThingWhen(::httpmock::When); + impl AnnotateThingWhen { + pub fn new(inner: ::httpmock::When) -> Self { + Self( + inner + .method(::httpmock::Method::POST) + .path_matches(regex::Regex::new("^/things/[^/]*/annotate$").unwrap()), + ) + } + + pub fn into_inner(self) -> ::httpmock::When { + self.0 + } + + pub fn id(self, value: &str) -> Self { + let re = + regex::Regex::new(&format!("^/things/{}/annotate$", value.to_string())).unwrap(); + Self(self.0.path_matches(re)) + } + + pub fn body(self, value: &types::Annotation) -> Self { + Self(self.0.json_body_obj(value)) + } + } + + pub struct AnnotateThingThen(::httpmock::Then); + impl AnnotateThingThen { + pub fn new(inner: ::httpmock::Then) -> Self { + Self(inner) + } + + pub fn into_inner(self) -> ::httpmock::Then { + self.0 + } + + pub fn default_response(self, status: u16, value: &types::Problem) -> Self { + Self( + self.0 + .status(status) + .header("content-type", "application/json") + .json_body_obj(value), + ) + } + + pub fn success(self, status: u16, value: &types::Thing) -> Self { + assert_eq!(status / 100u16, 2u16); + Self( + self.0 + .status(status) + .header("content-type", "application/json") + .json_body_obj(value), + ) + } + } +} + +#[doc = r" An extension trait for [`MockServer`](::httpmock::MockServer) that"] +#[doc = r" adds a method for each operation. These are the equivalent of"] +#[doc = r" type-checked [`mock()`](::httpmock::MockServer::mock) calls."] +pub trait MockServerExt { + fn annotate_thing(&self, config_fn: F) -> ::httpmock::Mock<'_> + where + F: FnOnce(operations::AnnotateThingWhen, operations::AnnotateThingThen); +} + +impl MockServerExt for ::httpmock::MockServer { + fn annotate_thing(&self, config_fn: F) -> ::httpmock::Mock<'_> + where + F: FnOnce(operations::AnnotateThingWhen, operations::AnnotateThingThen), + { + self.mock(|when, then| { + config_fn( + operations::AnnotateThingWhen::new(when), + operations::AnnotateThingThen::new(then), + ) + }) + } +} diff --git a/progenitor-impl/tests/output/src/json_suffix_positional.rs b/progenitor-impl/tests/output/src/json_suffix_positional.rs new file mode 100644 index 000000000..d16141e3e --- /dev/null +++ b/progenitor-impl/tests/output/src/json_suffix_positional.rs @@ -0,0 +1,257 @@ +#[allow(unused_imports)] +use progenitor_client::{encode_path, ClientHooks, OperationInfo, RequestBuilderExt}; +#[allow(unused_imports)] +pub use progenitor_client::{ByteStream, ClientInfo, Error, ResponseValue}; +/// Types used as operation parameters and responses. +#[allow(clippy::all)] +pub mod types { + /// Error types. + pub mod error { + /// Error from a `TryFrom` or `FromStr` implementation. + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } + } + + ///`Annotation` + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "type": "object", + /// "required": [ + /// "note" + /// ], + /// "properties": { + /// "note": { + /// "type": "string" + /// } + /// } + ///} + /// ``` + ///
+ #[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] + pub struct Annotation { + pub note: ::std::string::String, + } + + ///`Problem` + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "type": "object", + /// "required": [ + /// "status", + /// "title" + /// ], + /// "properties": { + /// "detail": { + /// "type": "string" + /// }, + /// "status": { + /// "type": "integer", + /// "format": "uint16", + /// "minimum": 0.0 + /// }, + /// "title": { + /// "type": "string" + /// }, + /// "type": { + /// "type": "string" + /// } + /// } + ///} + /// ``` + ///
+ #[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] + pub struct Problem { + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub detail: ::std::option::Option<::std::string::String>, + pub status: u16, + pub title: ::std::string::String, + #[serde( + rename = "type", + default, + skip_serializing_if = "::std::option::Option::is_none" + )] + pub type_: ::std::option::Option<::std::string::String>, + } + + ///`Thing` + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "type": "object", + /// "required": [ + /// "id", + /// "notes" + /// ], + /// "properties": { + /// "id": { + /// "type": "string" + /// }, + /// "notes": { + /// "type": "array", + /// "items": { + /// "type": "string" + /// } + /// } + /// } + ///} + /// ``` + ///
+ #[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] + pub struct Thing { + pub id: ::std::string::String, + pub notes: ::std::vec::Vec<::std::string::String>, + } +} + +#[derive(Clone, Debug)] +///Client for JSON structured syntax suffix test +/// +///Minimal API for testing RFC 6839 "+json" structured syntax suffix media +/// types on request bodies, responses, and errors +/// +///Version: v1 +pub struct Client { + pub(crate) baseurl: String, + pub(crate) client: reqwest::Client, +} + +impl Client { + /// Create a new client. + /// + /// `baseurl` is the base URL provided to the internal + /// `reqwest::Client`, and should include a scheme and hostname, + /// as well as port and a path stem if applicable. + pub fn new(baseurl: &str) -> Self { + #[cfg(not(target_arch = "wasm32"))] + let client = { + let dur = ::std::time::Duration::from_secs(15u64); + reqwest::ClientBuilder::new() + .connect_timeout(dur) + .timeout(dur) + }; + #[cfg(target_arch = "wasm32")] + let client = reqwest::ClientBuilder::new(); + Self::new_with_client(baseurl, client.build().unwrap()) + } + + /// Construct a new client with an existing `reqwest::Client`, + /// allowing more control over its configuration. + /// + /// `baseurl` is the base URL provided to the internal + /// `reqwest::Client`, and should include a scheme and hostname, + /// as well as port and a path stem if applicable. + pub fn new_with_client(baseurl: &str, client: reqwest::Client) -> Self { + Self { + baseurl: baseurl.to_string(), + client, + } + } +} + +impl ClientInfo<()> for Client { + fn api_version() -> &'static str { + "v1" + } + + fn baseurl(&self) -> &str { + self.baseurl.as_str() + } + + fn client(&self) -> &reqwest::Client { + &self.client + } + + fn inner(&self) -> &() { + &() + } +} + +impl ClientHooks<()> for &Client {} +#[allow(clippy::all)] +impl Client { + ///Sends a `POST` request to `/things/{id}/annotate` + pub async fn annotate_thing<'a>( + &'a self, + id: &'a str, + body: &'a types::Annotation, + ) -> Result, Error> { + let url = format!( + "{}/things/{}/annotate", + self.baseurl, + encode_path(&id.to_string()), + ); + let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); + #[allow(unused_mut)] + let mut request = self + .client + .post(url) + .header( + ::reqwest::header::ACCEPT, + ::reqwest::header::HeaderValue::from_static("application/json"), + ) + .header( + ::reqwest::header::CONTENT_TYPE, + ::reqwest::header::HeaderValue::from_static( + "application/vnd.example.annotation+json", + ), + ) + .json(&body) + .headers(header_map) + .build()?; + let info = OperationInfo { + operation_id: "annotate_thing", + }; + self.pre(&mut request, &info).await?; + let result = self.exec(request, &info).await; + self.post(&result, &info).await?; + let response = result?; + match response.status().as_u16() { + 200..=299 => ResponseValue::from_response(response).await, + _ => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + _ => Err(Error::UnexpectedResponse(response)), + } + } +} + +/// Items consumers will typically use such as the Client. +pub mod prelude { + #[allow(unused_imports)] + pub use super::Client; +} diff --git a/progenitor-impl/tests/test_output.rs b/progenitor-impl/tests/test_output.rs index 6e6f18b51..86e023e62 100644 --- a/progenitor-impl/tests/test_output.rs +++ b/progenitor-impl/tests/test_output.rs @@ -163,6 +163,11 @@ fn test_cli_gen() { verify_apis("cli-gen.json"); } +#[test] +fn test_json_suffix() { + verify_apis("json-suffix.json"); +} + #[test] fn test_nexus_with_different_timeout() { const OPENAPI_FILE: &'static str = "nexus.json"; diff --git a/sample_openapi/json-suffix.json b/sample_openapi/json-suffix.json new file mode 100644 index 000000000..70607f2c3 --- /dev/null +++ b/sample_openapi/json-suffix.json @@ -0,0 +1,86 @@ +{ + "openapi": "3.0.0", + "info": { + "description": "Minimal API for testing RFC 6839 \"+json\" structured syntax suffix media types on request bodies, responses, and errors", + "title": "JSON structured syntax suffix test", + "version": "v1" + }, + "paths": { + "/things/{id}/annotate": { + "post": { + "operationId": "annotate_thing", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/vnd.example.annotation+json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Annotation" + } + } + } + }, + "responses": { + "2XX": { + "description": "the annotated thing", + "content": { + "application/vnd.example.thing+json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Thing" + } + } + } + }, + "default": { + "description": "an RFC 9457 problem", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Annotation": { + "type": "object", + "required": ["note"], + "properties": { + "note": { "type": "string" } + } + }, + "Thing": { + "type": "object", + "required": ["id", "notes"], + "properties": { + "id": { "type": "string" }, + "notes": { "type": "array", "items": { "type": "string" } } + } + }, + "Problem": { + "type": "object", + "required": ["title", "status"], + "properties": { + "type": { "type": "string" }, + "title": { "type": "string" }, + "status": { "type": "integer", "format": "uint16", "minimum": 0 }, + "detail": { "type": "string" } + } + } + } + } +}