diff --git a/.gitignore b/.gitignore index 2e4fa7f..66046e1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ **/*.rs.bk Cargo.lock /.idea +.vscode \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 420da40..87b1609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,5 +3,6 @@ members = [ "okapi", "rocket-okapi", "rocket-okapi-codegen", - "examples/json-web-api" + "examples/json-web-api", + "examples/custom_schema", ] diff --git a/README.md b/README.md index b052483..7cd5625 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,12 @@ rocket-okapi: [![Download](https://img.shields.io/crates/v/rocket_okapi)](https: Automated OpenAPI (AKA Swagger) document generation for Rust/Rocket projects. -Never have outdated documentation again. Okapi will generate documentation for you at compile time. +Never have outdated documentation again. +Okapi will generate documentation for you while setting up the server. It uses a combination of [Rust Doc comments](https://doc.rust-lang.org/reference/comments.html#doc-comments) and programming logic to document your API. -The generated [OpenAPI][OpenAPI_3.0.0] files can then be used to in by various programs to +The generated [OpenAPI][OpenAPI_3.0.0] files can then be used by various programs to visualize the documentation. Rocket-okapi currently includes [RapiDoc][RapiDoc] and [Swagger UI][Swagger_UI], but others can be used too. @@ -95,14 +96,14 @@ pub fn make_rocket() -> rocket::Rocket { ``` ## TODO -- Tests -- Documentation -- Benchmark/optimise memory usage and allocations +- [ ] Tests +- [ ] Documentation +- [ ] Benchmark/optimise memory usage and allocations - Note to self: https://crates.io/crates/graphannis-malloc_size_of looks useful -- Implement `OpenApiFrom___`/`OpenApiResponder` for more rocket/rocket-contrib types -- Allow customizing openapi generation settings, e.g. - - custom json schema generation settings - - change path the document is hosted at +- [ ] Implement `OpenApiFrom___`/`OpenApiResponder` for more rocket/rocket-contrib types +- [ ] Allow customizing openapi generation settings, e.g. + - [ ] custom json schema generation settings + - [x] change path the document is hosted at ## License diff --git a/examples/custom_schema/.gitignore b/examples/custom_schema/.gitignore new file mode 100644 index 0000000..2e4fa7f --- /dev/null +++ b/examples/custom_schema/.gitignore @@ -0,0 +1,4 @@ +/target +**/*.rs.bk +Cargo.lock +/.idea diff --git a/examples/custom_schema/Cargo.toml b/examples/custom_schema/Cargo.toml new file mode 100644 index 0000000..852f45d --- /dev/null +++ b/examples/custom_schema/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "custom_schema" +version = "0.1.0" +authors = ["Ralph Bisschops "] +edition = "2018" + +[dependencies] +rocket = { version = "0.5.0-rc.1", default-features = false, features = ["json"] } +schemars = { version = "0.8", features = ["preserve_order"] } +okapi = { version = "0.6.0-alpha-1", path = "../../okapi" } +rocket_okapi = { version = "0.8.0-alpha-1", path = "../../rocket-okapi", features = ["swagger", "rapidoc"] } +serde = "1.0" +serde_json = "1.0" diff --git a/examples/custom_schema/README.md b/examples/custom_schema/README.md new file mode 100644 index 0000000..1a0c9fb --- /dev/null +++ b/examples/custom_schema/README.md @@ -0,0 +1,5 @@ +# Custom Schema + +This example shows how to add custom info and merge OpenAPI files before hosting the file. +This is useful for when projects grow bigger and you want to separate the routes into +separate modules. \ No newline at end of file diff --git a/examples/custom_schema/src/error.rs b/examples/custom_schema/src/error.rs new file mode 100644 index 0000000..ede42ad --- /dev/null +++ b/examples/custom_schema/src/error.rs @@ -0,0 +1,117 @@ +use okapi::openapi3::Responses; +use rocket::{ + http::{ContentType, Status}, + request::Request, + response::{self, Responder, Response}, +}; +use rocket_okapi::{gen::OpenApiGenerator, response::OpenApiResponderInner, OpenApiError}; +use schemars::Map; + +/// Error messages returned to user +#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +pub struct Error { + /// The title of the error message + pub err: String, + /// The description of the error + pub msg: Option, + // HTTP Status Code returned + #[serde(skip)] + pub http_status_code: u16, +} + +impl OpenApiResponderInner for Error { + fn responses(_generator: &mut OpenApiGenerator) -> Result { + use okapi::openapi3::{RefOr, Response as OpenApiReponse}; + + let mut responses = Map::new(); + responses.insert( + "400".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [400 Bad Request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400)\n\ + The request given is wrongly formatted or data asked could not be fulfilled. \ + " + .to_string(), + ..Default::default() + }), + ); + responses.insert( + "404".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [404 Not Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404)\n\ + This response is given when you request a page that does not exists.\ + " + .to_string(), + ..Default::default() + }), + ); + responses.insert( + "422".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [422 Unprocessable Entity](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422)\n\ + This response is given when you request body is not correctly formatted. \ + ".to_string(), + ..Default::default() + }), + ); + responses.insert( + "500".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [500 Internal Server Error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500)\n\ + This response is given when something wend wrong on the server. \ + ".to_string(), + ..Default::default() + }), + ); + Ok(Responses { + responses, + ..Default::default() + }) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "Error `{}`: {}", + self.err, + self.msg.as_deref().unwrap_or("") + ) + } +} + +impl std::error::Error for Error {} + +impl<'r> Responder<'r, 'static> for Error { + fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { + // Convert object to json + let body = serde_json::to_string(&self).unwrap(); + Response::build() + .sized_body(body.len(), std::io::Cursor::new(body)) + .header(ContentType::JSON) + .status(Status::new(self.http_status_code)) + .ok() + } +} + +impl From> for Error { + fn from(err: rocket::serde::json::Error) -> Self { + use rocket::serde::json::Error::*; + match err { + Io(io_error) => Error { + err: "IO Error".to_owned(), + msg: Some(io_error.to_string()), + http_status_code: 422, + }, + Parse(_raw_data, parse_error) => Error { + err: "Parse Error".to_owned(), + msg: Some(parse_error.to_string()), + http_status_code: 422, + }, + } + } +} diff --git a/examples/custom_schema/src/main.rs b/examples/custom_schema/src/main.rs new file mode 100644 index 0000000..e45a69b --- /dev/null +++ b/examples/custom_schema/src/main.rs @@ -0,0 +1,98 @@ +use okapi::openapi3::OpenApi; +use rocket::{Build, Rocket}; +use rocket_okapi::settings::UrlObject; +use rocket_okapi::{mount_endpoints_and_merged_docs, rapidoc::*, swagger_ui::*}; + +mod error; +mod message; +mod post; + +pub type Result = std::result::Result, error::Error>; +pub type DataResult<'a, T> = + std::result::Result, rocket::serde::json::Error<'a>>; + +#[rocket::main] +async fn main() { + let launch_result = create_server().launch().await; + match launch_result { + Ok(()) => println!("Rocket shut down gracefully."), + Err(err) => println!("Rocket had an error: {}", err), + }; +} + +pub fn create_server() -> Rocket { + let mut building_rocket = rocket::build() + .mount( + "/swagger-ui/", + make_swagger_ui(&SwaggerUIConfig { + url: "../v1/openapi.json".to_owned(), + ..Default::default() + }), + ) + .mount( + "/rapidoc/", + make_rapidoc(&RapiDocConfig { + general: GeneralConfig { + spec_urls: vec![UrlObject::new("General", "../v1/openapi.json")], + ..Default::default() + }, + hide_show: HideShowConfig { + allow_spec_url_load: false, + allow_spec_file_load: false, + ..Default::default() + }, + ..Default::default() + }), + ); + + let openapi_settings = rocket_okapi::settings::OpenApiSettings::default(); + let custom_route_spec = (vec![], custom_openapi_spec()); + mount_endpoints_and_merged_docs! { + building_rocket, "/v1/".to_owned(), openapi_settings, + "/v1/" => custom_route_spec, + "/v1/post" => post::get_routes_and_docs(), + "/v1/message" => message::get_routes_and_docs(), + }; + + building_rocket +} + +fn custom_openapi_spec() -> OpenApi { + use okapi::openapi3::*; + OpenApi { + openapi: OpenApi::default_version(), + info: Info { + title: "The best API ever".to_owned(), + description: Some("This is the best API every, please use me!".to_owned()), + terms_of_service: Some( + "https://github.com/GREsau/okapi/blob/master/LICENSE".to_owned(), + ), + contact: Some(Contact { + name: Some("okapi example".to_owned()), + url: Some("https://github.com/GREsau/okapi".to_owned()), + email: None, + ..Default::default() + }), + license: Some(License { + name: "MIT".to_owned(), + url: Some("https://github.com/GREsau/okapi/blob/master/LICENSE".to_owned()), + ..Default::default() + }), + version: env!("CARGO_PKG_VERSION").to_owned(), + ..Default::default() + }, + servers: vec![ + Server { + url: "http://127.0.0.1:8000/".to_owned(), + description: Some("Localhost".to_owned()), + ..Default::default() + }, + Server { + url: "https://example.com/".to_owned(), + description: Some("Possible Remote".to_owned()), + ..Default::default() + }, + ], + ..Default::default() + } +} diff --git a/examples/custom_schema/src/message.rs b/examples/custom_schema/src/message.rs new file mode 100644 index 0000000..d0f47cc --- /dev/null +++ b/examples/custom_schema/src/message.rs @@ -0,0 +1,41 @@ +use okapi::openapi3::OpenApi; +use rocket::form::FromForm; +use rocket::{get, post, serde::json::Json}; +use rocket_okapi::openapi; +use rocket_okapi::parse_openapi_routes; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub fn get_routes_and_docs() -> (Vec, OpenApi) { + parse_openapi_routes![create_message, get_message] +} + +#[derive(Serialize, Deserialize, JsonSchema, FromForm)] +struct Message { + /// The unique identifier for the message. + message_id: u64, + /// Content of the message. + content: String, +} + +/// # Create a message +/// +/// Returns the created message. +#[openapi(tag = "Message")] +#[post("/", data = "")] +fn create_message(message: crate::DataResult<'_, Message>) -> crate::Result { + let message = message?.into_inner(); + Ok(Json(message)) +} + +/// # Get a message by id +/// +/// Returns the message with the requested id. +#[openapi(tag = "Message")] +#[get("/")] +fn get_message(id: u64) -> crate::Result { + Ok(Json(Message { + message_id: id, + content: "Hey, how are you?".to_owned(), + })) +} diff --git a/examples/custom_schema/src/post.rs b/examples/custom_schema/src/post.rs new file mode 100644 index 0000000..c8c1772 --- /dev/null +++ b/examples/custom_schema/src/post.rs @@ -0,0 +1,44 @@ +use okapi::openapi3::OpenApi; +use rocket::form::FromForm; +use rocket::{get, post, serde::json::Json}; +use rocket_okapi::openapi; +use rocket_okapi::parse_openapi_routes; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub fn get_routes_and_docs() -> (Vec, OpenApi) { + parse_openapi_routes![create_post, get_post] +} + +#[derive(Serialize, Deserialize, JsonSchema, FromForm)] +struct Post { + /// The unique identifier for the post. + post_id: u64, + /// The title of the post. + title: String, + /// A short summary of the post. + summary: Option, +} + +/// # Create post +/// +/// Returns the created post. +#[openapi(tag = "Posts")] +#[post("/", data = "")] +fn create_post(post: crate::DataResult<'_, Post>) -> crate::Result { + let post = post?.into_inner(); + Ok(Json(post)) +} + +/// # Get a post by id +/// +/// Returns the post with the requested id. +#[openapi(tag = "Posts")] +#[get("/")] +fn get_post(id: u64) -> crate::Result { + Ok(Json(Post { + post_id: id, + title: "Your post".to_owned(), + summary: Some("Best summary ever.".to_owned()), + })) +} diff --git a/examples/json-web-api/README.md b/examples/json-web-api/README.md index 048fe55..1ccb87f 100644 --- a/examples/json-web-api/README.md +++ b/examples/json-web-api/README.md @@ -1,3 +1,5 @@ # JSON Web API -A simple web API written using [Rocket](https://rocket.rs/) including openapi. The openapi document will be hosted at `/openapi.json`, and the Swagger UI will be at `/swagger-ui`. \ No newline at end of file +A simple web API written using [Rocket](https://rocket.rs/) including openapi. +The openapi document will be hosted at `/openapi.json`, +and the Swagger UI will be at `/swagger-ui`. The RapiDoc UI will be at `/rapidoc`. \ No newline at end of file diff --git a/okapi/CHANGELOG.md b/okapi/CHANGELOG.md index b5bd279..a6d22a7 100644 --- a/okapi/CHANGELOG.md +++ b/okapi/CHANGELOG.md @@ -6,6 +6,9 @@ This project follows the [Semantic Versioning standard](https://semver.org/). ### Added - Forbid unsafe code in this crate. (#36) +- Allow customization of OpenApi object. +- Allow merging of OpenApi objects. +- Added `log v0.4` as a dependency. ### Changed diff --git a/okapi/Cargo.toml b/okapi/Cargo.toml index 1761312..13a9c86 100644 --- a/okapi/Cargo.toml +++ b/okapi/Cargo.toml @@ -17,3 +17,4 @@ derive_json_schema = ["schemars/derive_json_schema"] schemars = "0.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +log = "0.4" diff --git a/okapi/src/lib.rs b/okapi/src/lib.rs index 0a631ec..e629ba2 100644 --- a/okapi/src/lib.rs +++ b/okapi/src/lib.rs @@ -2,5 +2,7 @@ #![deny(clippy::all)] pub type Map = schemars::Map; +pub type MapEntry<'a, K, V> = schemars::MapEntry<'a, K, V>; +pub mod merge; pub mod openapi3; diff --git a/okapi/src/merge.rs b/okapi/src/merge.rs new file mode 100644 index 0000000..39aba64 --- /dev/null +++ b/okapi/src/merge.rs @@ -0,0 +1,279 @@ +use crate::openapi3::{Components, Info, OpenApi, PathItem, Tag}; +use crate::{Map, MapEntry}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fmt::Display; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)] +pub struct MergeError { + pub msg: String, +} + +impl Display for MergeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.msg) + } +} + +impl MergeError { + fn new>(msg: S) -> Self { + MergeError { + msg: msg.as_ref().to_owned(), + } + } +} + +impl OpenApi { + /// Merge the given OpenAPI spec into the current one. + pub fn merge_spec(mut self, path_prefix: &S, s2: &Self) -> Result<(), MergeError> { + merge_specs(&mut self, path_prefix, s2) + } +} + +/// Marge the list of all specs together into on big OpenApi object. +pub fn marge_spec_list(spec_list: &[(S, OpenApi)]) -> Result { + let mut openapi_docs = OpenApi::new(); + for (path_prefix, spec) in spec_list { + merge_specs(&mut openapi_docs, path_prefix, spec)?; + } + Ok(openapi_docs) +} + +/// Merge the given OpenAPI spec into the current one. +pub fn merge_specs( + s1: &mut OpenApi, + path_prefix: &S, + s2: &OpenApi, +) -> Result<(), MergeError> { + // Check if specs are same version + if s1.openapi != s2.openapi { + return Err(MergeError::new("OpenAPI specs version do not match.")); + } + merge_spec_info(&mut s1.info, &s2.info)?; + merge_vec(&mut s1.servers, &s2.servers); + merge_paths(&mut s1.paths, path_prefix, &s2.paths)?; + merge_components(&mut s1.components, &s2.components)?; + // This is a `Vec` but just merge the `Vec` items together. + // Do not merge the `Map` items together. + merge_vec(&mut s1.security, &s2.security); + merge_tags(&mut s1.tags, &s2.tags)?; + // Replace the external_docs info as 1 block, so don't mix + merge_option(&mut s1.external_docs, &s2.external_docs); + merge_map(&mut s1.extensions, &s2.extensions, "extensions"); + Ok(()) +} + +pub fn merge_spec_info(s1: &mut Info, s2: &Info) -> Result<(), MergeError> { + s1.title = merge_string(&s1.title, &s2.title); + merge_opt_string(&mut s1.description, &s2.description); + merge_opt_string(&mut s1.terms_of_service, &s2.terms_of_service); + // Replace the contact info as 1 block, so don't mix + merge_option(&mut s1.contact, &s2.contact); + // Replace the license info as 1 block, so don't mix + merge_option(&mut s1.license, &s2.license); + s1.version = merge_string(&s1.version, &s2.version); + merge_map(&mut s1.extensions, &s2.extensions, "extensions"); + Ok(()) +} + +/// Merge `Map`/`&Map`: +/// Merge together. If key already exists, use s1 version. +/// Use `path_prefix` in order to specify the mounting points for the routes. +pub fn merge_paths( + s1: &mut Map, + path_prefix: &S, + s2: &Map, +) -> Result<(), MergeError> { + // Add all s2 values + // (if key does not already exists) + for (key, value) in s2 { + let new_key = if key.starts_with('/') { + format!("{}{}", path_prefix, key) + } else { + log::error!( + "All routes should have a leading '/' but non found in `{}`.", + key + ); + format!("{}/{}", path_prefix, key) + }; + match s1.entry(new_key) { + MapEntry::Occupied(mut entry) => { + // Merge `PathItem` so get/post/put routes are getting merged + let current_value = entry.get_mut(); + merge_path_item(current_value, value)?; + } + MapEntry::Vacant(entry) => { + entry.insert(value.clone()); + } + } + } + Ok(()) +} + +pub fn merge_path_item(s1: &mut PathItem, s2: &PathItem) -> Result<(), MergeError> { + merge_opt_string(&mut s1.reference, &s2.reference); + merge_opt_string(&mut s1.summary, &s2.summary); + merge_opt_string(&mut s1.description, &s2.description); + + merge_option(&mut s1.get, &s2.get); + merge_option(&mut s1.put, &s2.put); + merge_option(&mut s1.post, &s2.post); + merge_option(&mut s1.delete, &s2.delete); + merge_option(&mut s1.options, &s2.options); + merge_option(&mut s1.head, &s2.head); + merge_option(&mut s1.patch, &s2.patch); + merge_option(&mut s1.trace, &s2.trace); + + merge_option(&mut s1.servers, &s2.servers); + merge_vec(&mut s1.parameters, &s2.parameters); + merge_map(&mut s1.extensions, &s2.extensions, "extensions"); + Ok(()) +} + +pub fn merge_components( + s1: &mut Option, + s2: &Option, +) -> Result<(), MergeError> { + if s1.is_none() { + *s1 = s2.clone(); + Ok(()) + } else if s2.is_none() { + // Use/keep s1 + Ok(()) + } else { + if let Some(s1) = s1 { + let s2 = s2.as_ref().unwrap(); + merge_map(&mut s1.schemas, &s2.schemas, "schemas"); + merge_map(&mut s1.responses, &s2.responses, "responses"); + merge_map(&mut s1.parameters, &s2.parameters, "parameters"); + merge_map(&mut s1.examples, &s2.examples, "examples"); + merge_map(&mut s1.request_bodies, &s2.request_bodies, "request_bodies"); + merge_map(&mut s1.headers, &s2.headers, "headers"); + merge_map( + &mut s1.security_schemes, + &s2.security_schemes, + "security_schemes", + ); + merge_map(&mut s1.links, &s2.links, "links"); + merge_map(&mut s1.callbacks, &s2.callbacks, "callbacks"); + merge_map(&mut s1.extensions, &s2.extensions, "extensions"); + } + Ok(()) + } +} + +pub fn merge_tags(s1: &mut Vec, s2: &[Tag]) -> Result, MergeError> { + // Create a `Map` so we can easily merge tag names. + let mut new_tags: Map = Map::new(); + // Add all s1 tags + for tag in s1 { + match new_tags.entry(tag.name.clone()) { + MapEntry::Occupied(mut entry) => { + let current_value = entry.get_mut(); + merge_tag(current_value, tag)?; + } + MapEntry::Vacant(entry) => { + entry.insert(tag.clone()); + } + } + } + // Add all s2 tags + for tag in s2 { + match new_tags.entry(tag.name.clone()) { + MapEntry::Occupied(mut entry) => { + let current_value = entry.get_mut(); + merge_tag(current_value, tag)?; + } + MapEntry::Vacant(entry) => { + entry.insert(tag.clone()); + } + } + } + // Convert `Map` to `Vec` + let mut new_tags_vec = Vec::new(); + + // Remove/add in reverser order + for _i in 0..new_tags.len() { + if let Some((_name, tag)) = new_tags.pop() { + new_tags_vec.push(tag); + } else { + unreachable!("List sizes or same list do not match."); + } + } + // Switch the order to to keep the order consistent. + new_tags_vec.reverse(); + Ok(new_tags_vec) +} + +pub fn merge_tag(s1: &mut Tag, s2: &Tag) -> Result<(), MergeError> { + if s1.name != s2.name { + return Err(MergeError::new("Tried to merge Tags with different names.")); + } + merge_opt_string(&mut s1.description, &s2.description); + merge_option(&mut s1.external_docs, &s2.external_docs); + merge_map(&mut s1.extensions, &s2.extensions, "extensions"); + Ok(()) +} + +/// Merge `String`/`&str`: +/// - If one is empty: Use other +/// - Otherwise: Use first value +pub fn merge_string(s1: &str, s2: &str) -> String { + if s1.is_empty() { + s2.to_owned() + } else { + s1.to_owned() + } +} + +/// Merge `Option`/`&Option`: +/// - If one is `None`: Use other +/// - If both are `Some`: Merge `String` +/// - Otherwise: Use first value +pub fn merge_opt_string(s1: &mut Option, s2: &Option) { + if s1.is_none() { + *s1 = s2.clone(); + } else if s1.is_some() && s2.is_some() { + *s1 = Some(merge_string(s1.as_ref().unwrap(), s2.as_ref().unwrap())) + } +} + +/// Merge `Option`/`&Option`: +/// - If one is `None`: Use other +/// - Otherwise: Use first value +pub fn merge_option(s1: &mut Option, s2: &Option) { + if s1.is_none() { + *s1 = s2.clone(); + } +} + +/// Merge `Map`/`&Map`: +/// Merge together. If key already exists, use s1 version. +pub fn merge_map(s1: &mut Map, s2: &Map, name: &str) { + // Add all s2 values + // (if key does not already exists) + for (key, value) in s2 { + if let Some(s1_value) = s1.get(key) { + // Check if this is the same element + if value != s1_value { + log::warn!( + "Found conflicting {} keys while merging, \ + they have the same name but different values: `{}`", + name, + key + ); + } + } else { + s1.insert(key.clone(), value.clone()); + } + } +} + +/// Merge `Vec<_>`/`&Vec<_>`: +/// Append lists, `s1` first and `s2` after that. +pub fn merge_vec(s1: &mut Vec, s2: &[T]) { + // Add all s2 values + for value in s2 { + s1.push(value.clone()); + } +} diff --git a/okapi/src/openapi3.rs b/okapi/src/openapi3.rs index fb62d92..5ba4611 100644 --- a/okapi/src/openapi3.rs +++ b/okapi/src/openapi3.rs @@ -4,9 +4,9 @@ pub use schemars::schema::SchemaObject; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -type Object = Map; -type SecurityRequirement = Map>; +pub type Object = Map; +pub type SecurityRequirement = Map>; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[cfg_attr(feature = "derive_json_schema", derive(JsonSchema))] @@ -29,6 +29,19 @@ impl From for RefOr { } } +impl OpenApi { + pub fn new() -> Self { + OpenApi { + openapi: Self::default_version(), + ..Default::default() + } + } + + pub fn default_version() -> String { + "3.0.0".to_owned() + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)] #[cfg_attr(feature = "derive_json_schema", derive(JsonSchema))] #[serde(rename_all = "camelCase")] @@ -57,6 +70,7 @@ pub struct Info { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + /// URL to the terms of service. #[serde(default, skip_serializing_if = "Option::is_none")] pub terms_of_service: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -242,7 +256,7 @@ pub struct Response { #[serde(rename_all = "camelCase")] pub struct Parameter { pub name: String, - // TODO this should probaby be an enum, not String + // TODO this should probably be an enum, not String #[serde(rename = "in")] pub location: String, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/rocket-okapi-codegen/src/lib.rs b/rocket-okapi-codegen/src/lib.rs index b2eac2e..937d8b5 100644 --- a/rocket-okapi-codegen/src/lib.rs +++ b/rocket-okapi-codegen/src/lib.rs @@ -1,17 +1,26 @@ #![forbid(unsafe_code)] #![deny(clippy::all)] -#[macro_use] -extern crate quote; -#[macro_use] -extern crate syn; - -extern crate proc_macro; +//! This crate is used by [`rocket_okapi`](https://crates.io/crates/rocket_okapi) +//! for code generation. This crate includes the procedural macros like: +//! - `#[openapi]`: To generate the documentation for an endpoint/route. +//! - `routes_with_openapi![...]`: To generate and add the `openapi.json` route. +//! - `parse_openapi_routes![...]`: To generate and return a list of routes and the openapi spec. +//! - `create_openapi_spec![...]`: To generate and return the openapi spec. +//! +//! The last 3 macros have very similar behavior, but differ in what they return. +//! Here is a list of the marcos and what they return: +//! - `routes_with_openapi![...]`: `Vec` (adds route for `openapi.json`) +//! - `parse_openapi_routes![...]`: `(Vec, okapi::openapi3::OpenApi)` +//! - `create_openapi_spec![...]`: `okapi::openapi3::OpenApi` +//! mod openapi_attr; -mod routes_with_openapi; +mod openapi_spec; +mod parse_routes; use proc_macro::TokenStream; +use quote::quote; use syn::Ident; /// A proc macro to be used in tandem with one of `Rocket`'s endpoint macros. It requires that all @@ -38,10 +47,71 @@ pub fn openapi(args: TokenStream, mut input: TokenStream) -> TokenStream { /// A replacement macro for `rocket::routes`. The key differences are that this macro will add an /// additional element to the resulting `Vec`, which serves a static file called -/// `openapi.json`. This file can then be used to display the routes in the swagger ui. +/// `openapi.json`. This file can then be used to display the routes in the Swagger/RapiDoc UI. #[proc_macro] pub fn routes_with_openapi(input: TokenStream) -> TokenStream { - routes_with_openapi::parse(input) + let settings = quote! { + ::rocket_okapi::settings::OpenApiSettings::new() + }; + let spec = + openapi_spec::create_openapi_spec(input.clone()).unwrap_or_else(|e| e.to_compile_error()); + let routes = parse_routes::parse_routes(input, true).unwrap_or_else(|e| e.to_compile_error()); + (quote! { + { + let settings = #settings; + let spec: ::okapi::openapi3::OpenApi = #spec(settings.clone()); + let routes: Vec<::rocket::Route> = #routes(spec, settings); + routes + } + }) + .into() +} + +/// A replacement macro for `rocket::routes`. This parses the routes and provides +/// a tuple with 2 parts `(Vec, OpenApi)`: +/// - `Vec`: A list of all the routes that `rocket::routes![]` would have provided. +/// - `OpenApi`: The `okapi::openapi3::OpenApi` spec for all the routes. +/// +/// NOTE: This marco is different from `routes_with_openapi` in that it does not add +/// the `openapi.json` file to the list of routes. This is done so the `OpenApi` spec can be changed +/// before serving it. +#[proc_macro] +pub fn parse_openapi_routes(input: TokenStream) -> TokenStream { + let settings = quote! { + ::rocket_okapi::settings::OpenApiSettings::new() + }; + let spec = + openapi_spec::create_openapi_spec(input.clone()).unwrap_or_else(|e| e.to_compile_error()); + let routes = parse_routes::parse_routes(input, false).unwrap_or_else(|e| e.to_compile_error()); + (quote! { + { + let settings = #settings; + let spec: ::okapi::openapi3::OpenApi = #spec(settings.clone()); + let routes: Vec<::rocket::Route> = #routes(spec.clone(), settings); + (routes, spec) + } + }) + .into() +} + +/// Generate `OpenApi` spec only, no parsing of routes. +/// This can be used in cases where you are only interested in openAPI spec, but not in the routes. +/// A use case could be inside of `build.rs` scripts or where you want to alter OpenAPI object +/// at runtime. +#[proc_macro] +pub fn create_openapi_spec(input: TokenStream) -> TokenStream { + let settings = quote! { + ::rocket_okapi::settings::OpenApiSettings::new() + }; + let spec = openapi_spec::create_openapi_spec(input).unwrap_or_else(|e| e.to_compile_error()); + (quote! { + { + let settings = #settings; + let spec: ::okapi::openapi3::OpenApi = #spec(settings); + (spec) + } + }) + .into() } fn get_add_operation_fn_name(route_fn_name: &Ident) -> Ident { diff --git a/rocket-okapi-codegen/src/openapi_attr/mod.rs b/rocket-okapi-codegen/src/openapi_attr/mod.rs index bbb1324..e957405 100644 --- a/rocket-okapi-codegen/src/openapi_attr/mod.rs +++ b/rocket-okapi-codegen/src/openapi_attr/mod.rs @@ -5,10 +5,11 @@ use crate::get_add_operation_fn_name; use darling::FromMeta; use proc_macro::TokenStream; use proc_macro2::Span; +use quote::quote; use quote::ToTokens; use rocket_http::Method; use std::collections::BTreeMap as Map; -use syn::{AttributeArgs, FnArg, Ident, ItemFn, ReturnType, Type, TypeTuple}; +use syn::{parse_macro_input, AttributeArgs, FnArg, Ident, ItemFn, ReturnType, Type, TypeTuple}; #[derive(Debug, Default, FromMeta)] #[darling(default)] diff --git a/rocket-okapi-codegen/src/openapi_attr/route_attr.rs b/rocket-okapi-codegen/src/openapi_attr/route_attr.rs index 24dbd51..10d629c 100644 --- a/rocket-okapi-codegen/src/openapi_attr/route_attr.rs +++ b/rocket-okapi-codegen/src/openapi_attr/route_attr.rs @@ -1,5 +1,6 @@ use darling::{Error, FromMeta}; use proc_macro::TokenStream; +use quote::{quote, quote_spanned}; use rocket_http::{ext::IntoOwned, uri::Origin, MediaType, Method}; use std::str::FromStr; use syn::spanned::Spanned; diff --git a/rocket-okapi-codegen/src/routes_with_openapi/mod.rs b/rocket-okapi-codegen/src/openapi_spec.rs similarity index 78% rename from rocket-okapi-codegen/src/routes_with_openapi/mod.rs rename to rocket-okapi-codegen/src/openapi_spec.rs index f73d5c7..d7fbebc 100644 --- a/rocket-okapi-codegen/src/routes_with_openapi/mod.rs +++ b/rocket-okapi-codegen/src/openapi_spec.rs @@ -1,21 +1,16 @@ use crate::get_add_operation_fn_name; use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; +use quote::quote; use syn::{parse::Parser, punctuated::Punctuated, token::Comma, Path, Result}; -pub fn parse(routes: TokenStream) -> TokenStream { - parse_inner(routes) - .unwrap_or_else(|e| e.to_compile_error()) - .into() -} - -fn parse_inner(routes: TokenStream) -> Result { +/// Parses routes and returns a function that takes `OpenApiSettings` and returns `OpenApi` spec. +pub fn create_openapi_spec(routes: TokenStream) -> Result { let paths = >::parse_terminated.parse(routes)?; - let add_operations = create_add_operations(paths.clone()); + let add_operations = create_add_operations(paths); Ok(quote! { - { - let settings = ::rocket_okapi::settings::OpenApiSettings::new(); - let mut gen = ::rocket_okapi::gen::OpenApiGenerator::new(settings.clone()); + |settings: ::rocket_okapi::settings::OpenApiSettings| -> ::okapi::openapi3::OpenApi { + let mut gen = ::rocket_okapi::gen::OpenApiGenerator::new(settings); #add_operations let mut spec = gen.into_openapi(); let mut info = ::okapi::openapi3::Info { @@ -36,15 +31,13 @@ fn parse_inner(routes: TokenStream) -> Result { if !env!("CARGO_PKG_HOMEPAGE").is_empty() { info.contact = Some(::okapi::openapi3::Contact{ name: Some("Homepage".to_owned()), - url: Some(env!("CARGO_PKG_REPOSITORY").to_owned()), + url: Some(env!("CARGO_PKG_HOMEPAGE").to_owned()), ..okapi::openapi3::Contact::default() }); } spec.info = info; - let mut routes = ::rocket::routes![#paths]; - routes.push(::rocket_okapi::handlers::OpenApiHandler::new(spec).into_route(&settings.json_path)); - routes + spec } }) } diff --git a/rocket-okapi-codegen/src/parse_routes.rs b/rocket-okapi-codegen/src/parse_routes.rs new file mode 100644 index 0000000..f428414 --- /dev/null +++ b/rocket-okapi-codegen/src/parse_routes.rs @@ -0,0 +1,25 @@ +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{parse::Parser, punctuated::Punctuated, token::Comma, Path, Result}; + +/// Parses routes and returns a function that takes `OpenApi` and `OpenApiSettings` and +/// returns `Vec`. +/// It optionally adds the `openapi.json` route to the list of routes. +pub fn parse_routes(routes: TokenStream, add_openapi_json: bool) -> Result { + let paths = >::parse_terminated.parse(routes)?; + // This returns a function so the spec does not have to be generated multiple times. + Ok(quote! { + |spec: ::okapi::openapi3::OpenApi, settings: ::rocket_okapi::settings::OpenApiSettings| + -> Vec<::rocket::Route> { + let mut routes = ::rocket::routes![#paths]; + if #add_openapi_json { + routes.push( + ::rocket_okapi::handlers::OpenApiHandler::new(spec) + .into_route(&settings.json_path) + ); + } + routes + } + }) +} diff --git a/rocket-okapi/CHANGELOG.md b/rocket-okapi/CHANGELOG.md index 20e400d..d486e63 100644 --- a/rocket-okapi/CHANGELOG.md +++ b/rocket-okapi/CHANGELOG.md @@ -9,6 +9,11 @@ This project follows the [Semantic Versioning standard](https://semver.org/). viewer. (Based on #33) - Added RapiDoc v9.0.0 - Forbid unsafe code in this crate. (#36) +- Retrieve OpenApi object after generating. (#28) +- Create `mount_endpoints_and_merged_docs` marco in order to streamline code structure for +bigger projects. (#30) +- Added new example for structuring bigger projects. +- Allowed changing path where OpenApi file is hosted. ### Changed - Swagger UI is now only available under the feature `swagger`. diff --git a/rocket-okapi/src/lib.rs b/rocket-okapi/src/lib.rs index 0afcd93..e7d32e0 100644 --- a/rocket-okapi/src/lib.rs +++ b/rocket-okapi/src/lib.rs @@ -42,7 +42,7 @@ //! } //! //! fn get_docs() -> SwaggerUIConfig { -//! use rocket_okapi::swagger_ui::UrlObject; +//! use rocket_okapi::settings::UrlObject; //! //! SwaggerUIConfig { //! url: "/my_resource/openapi.json".to_string(), @@ -98,3 +98,72 @@ pub struct OperationInfo { /// Contains information to be showed in the documentation about this endpoint. pub operation: okapi::openapi3::Operation, } + +/// Convert OpenApi object to routable endpoint. +pub fn get_openapi_route( + spec: okapi::openapi3::OpenApi, + settings: &settings::OpenApiSettings, +) -> rocket::Route { + handlers::OpenApiHandler::new(spec).into_route(&settings.json_path) +} + +/// Mount endpoints and mount merged OpenAPI documentation. +/// +/// This marco just makes to code look cleaner and improves readability +/// for bigger codebases. +/// +/// The macro expects the following arguments: +/// - rocket_builder: `Rocket`, +/// - docs_path: `&str`, `String` or [`Uri`](rocket::http::uri::Uri). +/// Anything accepted by [`mount()`](https://docs.rs/rocket/0.5.0-rc.1/rocket/struct.Rocket.html#method.mount) +/// - openapi_settings: (optional) `OpenApiSettings`, +/// - List of (0 or more): +/// - path: `&str`, `String` or [`Uri`](rocket::http::uri::Uri). +/// Anything accepted by `mount()` +/// - route_and_docs: `(Vec, OpenApi)` +/// +/// Example: +/// ```rust,ignore +/// let custom_route_spec = (vec![], custom_spec()); +/// mount_endpoints_and_merged_docs! { +/// building_rocket, "/".to_owned(), +/// "/" => custom_route_spec, +/// "/v1/post" => post::get_routes_and_docs(), +/// "/v1/message" => message::get_routes_and_docs(), +/// }; +/// ``` +/// +#[macro_export] +macro_rules! mount_endpoints_and_merged_docs { + ($rocket_builder:ident, $docs_path:expr, $openapi_settings:ident, + $($path:expr => $route_and_docs:expr),* $(,)*) => {{ + let mut openapi_list: Vec<(_, okapi::openapi3::OpenApi)> = Vec::new(); + $({ + let (routes, openapi) = $route_and_docs; + $rocket_builder = $rocket_builder.mount($path, routes); + openapi_list.push(($path, openapi)); + })* + // Combine all OpenApi documentation into one struct. + let openapi_docs = match okapi::merge::marge_spec_list(&openapi_list){ + Ok(docs) => docs, + Err(err) => panic!("Could not merge OpenAPI spec: {}", err), + }; + // Add OpenApi route + $rocket_builder = $rocket_builder.mount( + $docs_path, + vec![rocket_okapi::get_openapi_route( + openapi_docs, + &$openapi_settings, + )], + ); + }}; + + ($rocket_builder:ident, $docs_path:expr, + $($path:expr => $route_and_docs:expr),* $(,)*) => { + let openapi_settings = rocket_okapi::settings::OpenApiSettings::default(); + mount_endpoints_and_merged_docs!{ + $rocket_builder, $docs_path, openapi_settings, + $($path:expr => $route_and_docs:expr),* + } + } +} diff --git a/rocket-okapi/src/rapidoc.rs b/rocket-okapi/src/rapidoc.rs index cb05752..fe64fd9 100644 --- a/rocket-okapi/src/rapidoc.rs +++ b/rocket-okapi/src/rapidoc.rs @@ -20,7 +20,7 @@ macro_rules! static_file { /// /// ```rust /// use std::collections::HashMap; -/// use df_ls_diagnostics::hash_map; +/// use rocket_okapi::hash_map; /// /// let my_hash_map = hash_map!{ /// "token_name".to_owned() => "CREATURE", diff --git a/rocket-okapi/src/settings.rs b/rocket-okapi/src/settings.rs index 25e2b03..9be28e3 100644 --- a/rocket-okapi/src/settings.rs +++ b/rocket-okapi/src/settings.rs @@ -1,10 +1,10 @@ use schemars::gen::SchemaSettings; use serde::{Deserialize, Serialize}; -/// Settings which are used to customise the behaviour of the `OpenApiGenerator`. +/// Settings which are used to customize the behavior of the `OpenApiGenerator`. #[derive(Debug, Clone)] pub struct OpenApiSettings { - /// Settings to customise how JSON Schemas are generated. + /// Settings to customize how JSON Schemas are generated. pub schema_settings: SchemaSettings, /// The path to the json file that contains the API specification. Then default is /// `openapi.json`.