diff --git a/dropshot/examples/petstore.rs b/dropshot/examples/petstore.rs index 4794d7bb..e0cae46c 100644 --- a/dropshot/examples/petstore.rs +++ b/dropshot/examples/petstore.rs @@ -15,19 +15,9 @@ fn main() -> Result<(), String> { api.register(update_pet_with_form).unwrap(); api.register(find_pets_by_tags).unwrap(); - api.print_openapi( - &mut std::io::stdout(), - &"Pet Shop", - None, - None, - None, - None, - None, - None, - None, - &"", - ) - .map_err(|e| e.to_string())?; + api.openapi("Pet Shop", "") + .write(&mut std::io::stdout()) + .map_err(|e| e.to_string())?; Ok(()) } diff --git a/dropshot/src/api_description.rs b/dropshot/src/api_description.rs index 4af88189..30bb1e6c 100644 --- a/dropshot/src/api_description.rs +++ b/dropshot/src/api_description.rs @@ -252,11 +252,30 @@ impl ApiDescription { Ok(()) } + /** + * Build the OpenAPI definition describing this API. Returns an + * [`OpenApiDefinition`] which can be used to specify the contents of the + * definition and select an output format. + * + * The arguments to this function will be used for the mandatory `title` and + * `version` properties that the `Info` object in an OpenAPI definition must + * contain. + */ + pub fn openapi(&self, title: S1, version: S2) -> OpenApiDefinition + where + S1: AsRef, + S2: AsRef, + { + OpenApiDefinition::new(self, title.as_ref(), version.as_ref()) + } + /** * Emit the OpenAPI Spec document describing this API in its JSON form. + * + * This routine is deprecated in favour of the new openapi() builder + * routine. */ - // TODO: There's a bunch of error handling we need here such as checking - // for duplicate parameter names. + #[deprecated(note = "switch to openapi()")] pub fn print_openapi( &self, out: &mut dyn std::io::Write, @@ -270,24 +289,42 @@ impl ApiDescription { license_url: Option<&dyn ToString>, version: &dyn ToString, ) -> serde_json::Result<()> { + let mut oapi = self.openapi(title.to_string(), version.to_string()); + if let Some(s) = description { + oapi.description(s.to_string()); + } + if let Some(s) = terms_of_service { + oapi.terms_of_service(s.to_string()); + } + if let Some(s) = contact_name { + oapi.contact_name(s.to_string()); + } + if let Some(s) = contact_url { + oapi.contact_url(s.to_string()); + } + if let Some(s) = contact_email { + oapi.contact_email(s.to_string()); + } + if let (Some(name), Some(url)) = (license_name, license_url) { + oapi.license(name.to_string(), url.to_string()); + } else if let Some(name) = license_name { + oapi.license_name(name.to_string()); + } + + oapi.write(out) + } + + /** + * Internal routine for constructing the OpenAPI definition describing this + * API in its JSON form. + */ + // TODO: There's a bunch of error handling we need here such as checking + // for duplicate parameter names. + fn gen_openapi(&self, info: openapiv3::Info) -> openapiv3::OpenAPI { let mut openapi = openapiv3::OpenAPI::default(); openapi.openapi = "3.0.3".to_string(); - openapi.info = openapiv3::Info { - title: title.to_string(), - description: description.map(ToString::to_string), - terms_of_service: terms_of_service.map(ToString::to_string), - contact: Some(openapiv3::Contact { - name: contact_name.map(ToString::to_string), - url: contact_url.map(ToString::to_string), - email: contact_email.map(ToString::to_string), - }), - license: license_name.map(|name| openapiv3::License { - name: name.to_string(), - url: license_url.map(ToString::to_string), - }), - version: version.to_string(), - }; + openapi.info = info; let settings = schemars::gen::SchemaSettings::openapi3(); let mut generator = schemars::gen::SchemaGenerator::new(settings); @@ -480,7 +517,7 @@ impl ApiDescription { schemas.insert(key.clone(), j2oas_schema(None, schema)); }); - serde_json::to_writer_pretty(out, &openapi) + openapi } /* @@ -873,6 +910,160 @@ fn j2oas_object( } } +/** + * This object is used to specify configuration for building an OpenAPI + * definition document. It is constructed using [`ApiDescription::openapi()`]. + * Additional optional properties may be added and then the OpenAPI definition + * document may be generated via [`write()`](`OpenApiDefinition::write`) or + * [`json()`](`OpenApiDefinition::json`). + */ +pub struct OpenApiDefinition<'a> { + api: &'a ApiDescription, + info: openapiv3::Info, +} + +impl<'a> OpenApiDefinition<'a> { + fn new( + api: &'a ApiDescription, + title: &str, + version: &str, + ) -> OpenApiDefinition<'a> { + let info = openapiv3::Info { + title: title.to_string(), + version: version.to_string(), + ..Default::default() + }; + OpenApiDefinition { + api, + info, + } + } + + /** + * Provide a short description of the API. CommonMark syntax may be + * used for rich text representation. + * + * This routine will set the `description` field of the `Info` object in the + * OpenAPI definition. + */ + pub fn description>(&mut self, description: S) -> &mut Self { + self.info.description = Some(description.as_ref().to_string()); + self + } + + /** + * Include a Terms of Service URL for the API. Must be in the format of a + * URL. + * + * This routine will set the `termsOfService` field of the `Info` object in + * the OpenAPI definition. + */ + pub fn terms_of_service>(&mut self, url: S) -> &mut Self { + self.info.terms_of_service = Some(url.as_ref().to_string()); + self + } + + fn contact_mut(&mut self) -> &mut openapiv3::Contact { + if self.info.contact.is_none() { + self.info.contact = Some(openapiv3::Contact::default()); + } + self.info.contact.as_mut().unwrap() + } + + /** + * Set the identifying name of the contact person or organisation + * responsible for the API. + * + * This routine will set the `name` property of the `Contact` object within + * the `Info` object in the OpenAPI definition. + */ + pub fn contact_name>(&mut self, name: S) -> &mut Self { + self.contact_mut().name = Some(name.as_ref().to_string()); + self + } + + /** + * Set a contact URL for the API. Must be in the format of a URL. + * + * This routine will set the `url` property of the `Contact` object within + * the `Info` object in the OpenAPI definition. + */ + pub fn contact_url>(&mut self, url: S) -> &mut Self { + self.contact_mut().url = Some(url.as_ref().to_string()); + self + } + + /** + * Set the email address of the contact person or organisation responsible + * for the API. Must be in the format of an email address. + * + * This routine will set the `email` property of the `Contact` object within + * the `Info` object in the OpenAPI definition. + */ + pub fn contact_email>(&mut self, email: S) -> &mut Self { + self.contact_mut().email = Some(email.as_ref().to_string()); + self + } + + fn license_mut(&mut self, name: &str) -> &mut openapiv3::License { + if self.info.license.is_none() { + self.info.license = Some(openapiv3::License { + name: name.to_string(), + url: None, + }) + } + self.info.license.as_mut().unwrap() + } + + /** + * Provide the name of the licence used for the API, and a URL (must be in + * URL format) displaying the licence text. + * + * This routine will set the `name` and optional `url` properties of the + * `License` object within the `Info` object in the OpenAPI definition. + */ + pub fn license(&mut self, name: S1, url: S2) -> &mut Self + where + S1: AsRef, + S2: AsRef, + { + self.license_mut(name.as_ref()).url = Some(url.as_ref().to_string()); + self + } + + /** + * Provide the name of the licence used for the API. + * + * This routine will set the `name` property of the License object within + * the `Info` object in the OpenAPI definition. + */ + pub fn license_name>(&mut self, name: S) -> &mut Self { + self.license_mut(name.as_ref()); + self + } + + /** + * Build a JSON object containing the OpenAPI definition for this API. + */ + pub fn json(&self) -> serde_json::Result { + serde_json::to_value(&self.api.gen_openapi(self.info.clone())) + } + + /** + * Build a JSON object containing the OpenAPI definition for this API and + * write it to the provided stream. + */ + pub fn write( + &self, + out: &mut dyn std::io::Write, + ) -> serde_json::Result<()> { + serde_json::to_writer_pretty( + out, + &self.api.gen_openapi(self.info.clone()), + ) + } +} + #[cfg(test)] mod test { use super::super::error::HttpError; diff --git a/dropshot/src/lib.rs b/dropshot/src/lib.rs index 7d037a57..b5d9b627 100644 --- a/dropshot/src/lib.rs +++ b/dropshot/src/lib.rs @@ -87,7 +87,7 @@ * provides a few resources using shared state. * * For a given `ApiDescription`, you can also print out an OpenAPI spec - * describing the API. See [`ApiDescription::print_openapi`]. + * describing the API. See [`ApiDescription::openapi`]. * * * ## API Handler Functions @@ -504,6 +504,7 @@ pub use api_description::ApiEndpoint; pub use api_description::ApiEndpointParameter; pub use api_description::ApiEndpointParameterLocation; pub use api_description::ApiEndpointResponse; +pub use api_description::OpenApiDefinition; pub use config::ConfigDropshot; pub use error::HttpError; pub use error::HttpErrorResponseBody; diff --git a/dropshot/tests/test_openapi.json b/dropshot/tests/test_openapi.json index 1d34aaee..25cc0c0b 100644 --- a/dropshot/tests/test_openapi.json +++ b/dropshot/tests/test_openapi.json @@ -2,7 +2,6 @@ "openapi": "3.0.3", "info": { "title": "test", - "contact": {}, "version": "threeve" }, "paths": { diff --git a/dropshot/tests/test_openapi.rs b/dropshot/tests/test_openapi.rs index d05413d1..f6571c27 100644 --- a/dropshot/tests/test_openapi.rs +++ b/dropshot/tests/test_openapi.rs @@ -126,7 +126,7 @@ async fn handler6( } #[test] -fn test_openapi() -> Result<(), String> { +fn test_openapi_old() -> Result<(), String> { let mut api = ApiDescription::new(); api.register(handler1)?; api.register(handler2)?; @@ -137,6 +137,7 @@ fn test_openapi() -> Result<(), String> { let mut output = Cursor::new(Vec::new()); + #[allow(deprecated)] let _ = api.print_openapi( &mut output, &"test", @@ -154,3 +155,47 @@ fn test_openapi() -> Result<(), String> { expectorate::assert_contents("tests/test_openapi.json", actual); Ok(()) } + +#[test] +fn test_openapi() -> Result<(), String> { + let mut api = ApiDescription::new(); + api.register(handler1)?; + api.register(handler2)?; + api.register(handler3)?; + api.register(handler4)?; + api.register(handler5)?; + api.register(handler6)?; + + let mut output = Cursor::new(Vec::new()); + + let _ = api.openapi("test", "threeve").write(&mut output); + let actual = from_utf8(&output.get_ref()).unwrap(); + + expectorate::assert_contents("tests/test_openapi.json", actual); + Ok(()) +} + +#[test] +fn test_openapi_fuller() -> Result<(), String> { + let mut api = ApiDescription::new(); + api.register(handler1)?; + api.register(handler2)?; + api.register(handler3)?; + api.register(handler4)?; + api.register(handler5)?; + api.register(handler6)?; + + let mut output = Cursor::new(Vec::new()); + + let _ = api + .openapi("test", "1985.7") + .description("gusty winds may exist") + .contact_name("old mate") + .license_name("CDDL") + .terms_of_service("no hat, no cane? no service!") + .write(&mut output); + let actual = from_utf8(&output.get_ref()).unwrap(); + + expectorate::assert_contents("tests/test_openapi_fuller.json", actual); + Ok(()) +} diff --git a/dropshot/tests/test_openapi_fuller.json b/dropshot/tests/test_openapi_fuller.json new file mode 100644 index 00000000..48679291 --- /dev/null +++ b/dropshot/tests/test_openapi_fuller.json @@ -0,0 +1,289 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "test", + "description": "gusty winds may exist", + "termsOfService": "no hat, no cane? no service!", + "contact": { + "name": "old mate" + }, + "license": { + "name": "CDDL" + }, + "version": "1985.7" + }, + "paths": { + "/impairment": { + "get": { + "operationId": "handler6", + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of items returned by a single call", + "type": "integer", + "format": "uint64", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page_token", + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "a_number", + "schema": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "ResultsPage_for_ResponseItem", + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/ResponseItem" + } + }, + "next_page": { + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + } + } + } + } + } + } + }, + "/test/camera": { + "post": { + "operationId": "handler4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "BodyParam", + "type": "object", + "properties": { + "_x": { + "type": "string" + } + }, + "required": [ + "_x" + ] + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "object" + } + } + } + } + } + } + }, + "/test/man/{x}": { + "delete": { + "operationId": "handler3", + "parameters": [ + { + "in": "path", + "name": "x", + "required": true, + "schema": { + "type": "string" + }, + "style": "simple" + } + ], + "responses": { + "204": { + "description": "successful deletion" + } + } + } + }, + "/test/person": { + "get": { + "description": "This is a multi-line comment. It uses Rust-style.", + "operationId": "handler1", + "responses": { + "200": { + "description": "successful operation" + } + } + } + }, + "/test/tv/{x}": { + "post": { + "tags": [ + "person", + "woman", + "man", + "camera", + "tv" + ], + "operationId": "handler5", + "parameters": [ + { + "in": "path", + "name": "x", + "required": true, + "schema": { + "type": "string" + }, + "style": "simple" + }, + { + "in": "query", + "name": "_destro", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "style": "form" + }, + { + "in": "query", + "name": "_tomax", + "required": true, + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "_xamot", + "schema": { + "type": "string" + }, + "style": "form" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "BodyParam", + "type": "object", + "properties": { + "_x": { + "type": "string" + } + }, + "required": [ + "_x" + ] + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "successfully enqueued operation" + } + } + } + }, + "/test/woman": { + "put": { + "description": "This is a multi-line comment. It uses C-style.", + "operationId": "handler2", + "parameters": [ + { + "in": "query", + "name": "_destro", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "style": "form" + }, + { + "in": "query", + "name": "_tomax", + "required": true, + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "_xamot", + "schema": { + "type": "string" + }, + "style": "form" + } + ], + "responses": { + "204": { + "description": "resource updated" + } + } + } + } + }, + "components": { + "schemas": { + "ResponseItem": { + "type": "object", + "properties": { + "word": { + "type": "string" + } + }, + "required": [ + "word" + ] + } + } + } +} \ No newline at end of file