diff --git a/Cargo.toml b/Cargo.toml index 8ac0fdc..cddfb7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "rocket-okapi-codegen", "examples/json-web-api", "examples/custom_schema", + "examples/nested", "examples/uuid_usage", "examples/special-types", "examples/secure_request_guard", diff --git a/examples/nested/.gitignore b/examples/nested/.gitignore new file mode 100644 index 0000000..2e4fa7f --- /dev/null +++ b/examples/nested/.gitignore @@ -0,0 +1,4 @@ +/target +**/*.rs.bk +Cargo.lock +/.idea diff --git a/examples/nested/Cargo.toml b/examples/nested/Cargo.toml new file mode 100644 index 0000000..7904cff --- /dev/null +++ b/examples/nested/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nested" +version = "0.1.0" +authors = ["Maxime Borges ", "Ralph Bisschops "] +edition = "2021" + +[dependencies] +rocket = { version = "=0.5.0", default-features = false, features = ["json"] } +rocket_okapi = { path = "../../rocket-okapi", features = ["rapidoc"] } +serde = "1.0" +serde_json = "1.0" +indexmap = "1.8.2" diff --git a/examples/nested/src/api/message.rs b/examples/nested/src/api/message.rs new file mode 100644 index 0000000..2b25725 --- /dev/null +++ b/examples/nested/src/api/message.rs @@ -0,0 +1,42 @@ +use rocket::form::FromForm; +use rocket::{get, post, serde::json::Json}; +use rocket_okapi::okapi::openapi3::OpenApi; +use rocket_okapi::okapi::schemars::{self, JsonSchema}; +use rocket_okapi::openapi; +use rocket_okapi::openapi_get_routes_spec; +use rocket_okapi::settings::OpenApiSettings; +use serde::{Deserialize, Serialize}; + +pub fn get_routes_and_docs(settings: &OpenApiSettings) -> (Vec, OpenApi) { + openapi_get_routes_spec![settings: 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/nested/src/api/mod.rs b/examples/nested/src/api/mod.rs new file mode 100644 index 0000000..03b2ae0 --- /dev/null +++ b/examples/nested/src/api/mod.rs @@ -0,0 +1,13 @@ +mod message; +mod post; + +use rocket_okapi::{ + get_nested_endpoints_and_docs, okapi::openapi3::OpenApi, settings::OpenApiSettings, +}; + +pub fn get_routes_and_docs(settings: &OpenApiSettings) -> (Vec, OpenApi) { + get_nested_endpoints_and_docs! { + "/posts" => post::get_routes_and_docs(settings), + "/message" => message::get_routes_and_docs(settings), + } +} diff --git a/examples/nested/src/api/post.rs b/examples/nested/src/api/post.rs new file mode 100644 index 0000000..452423b --- /dev/null +++ b/examples/nested/src/api/post.rs @@ -0,0 +1,45 @@ +use rocket::form::FromForm; +use rocket::{get, post, serde::json::Json}; +use rocket_okapi::okapi::openapi3::OpenApi; +use rocket_okapi::okapi::schemars::{self, JsonSchema}; +use rocket_okapi::openapi; +use rocket_okapi::openapi_get_routes_spec; +use rocket_okapi::settings::OpenApiSettings; +use serde::{Deserialize, Serialize}; + +pub fn get_routes_and_docs(settings: &OpenApiSettings) -> (Vec, OpenApi) { + openapi_get_routes_spec![settings: 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/nested/src/error.rs b/examples/nested/src/error.rs new file mode 100644 index 0000000..88a2747 --- /dev/null +++ b/examples/nested/src/error.rs @@ -0,0 +1,117 @@ +use rocket::{ + http::{ContentType, Status}, + request::Request, + response::{self, Responder, Response}, +}; +use rocket_okapi::okapi::openapi3::Responses; +use rocket_okapi::okapi::schemars::{self, Map}; +use rocket_okapi::{gen::OpenApiGenerator, response::OpenApiResponderInner, OpenApiError}; + +/// 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 rocket_okapi::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/nested/src/main.rs b/examples/nested/src/main.rs new file mode 100644 index 0000000..6df342e --- /dev/null +++ b/examples/nested/src/main.rs @@ -0,0 +1,128 @@ +use rocket::{Build, Rocket}; +use rocket_okapi::okapi::openapi3::OpenApi; +use rocket_okapi::settings::UrlObject; +use rocket_okapi::{mount_endpoints_and_merged_docs, rapidoc::*}; + +mod api; +mod error; + +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( + "/rapidoc/", + make_rapidoc(&RapiDocConfig { + title: Some("My special documentation | RapiDoc".to_owned()), + 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, + "/external" => custom_route_spec, + "/api" => api::get_routes_and_docs(&openapi_settings), + }; + + building_rocket +} + +fn custom_openapi_spec() -> OpenApi { + use indexmap::indexmap; + use rocket_okapi::okapi::openapi3::*; + use rocket_okapi::okapi::schemars::schema::*; + OpenApi { + openapi: OpenApi::default_version(), + info: Info { + title: "The best API ever".to_owned(), + description: Some("This is the best API ever, 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() + }, + ], + // Add paths that do not exist in Rocket (or add extra info to existing paths) + paths: { + indexmap! { + "/home".to_owned() => PathItem{ + get: Some( + Operation { + tags: vec!["HomePage".to_owned()], + summary: Some("This is my homepage".to_owned()), + responses: Responses{ + responses: indexmap!{ + "200".to_owned() => RefOr::Object( + Response{ + description: "Return the page, no error.".to_owned(), + content: indexmap!{ + "text/html".to_owned() => MediaType{ + schema: Some(SchemaObject{ + instance_type: Some(SingleOrVec::Single(Box::new( + InstanceType::String + ))), + ..Default::default() + }), + ..Default::default() + } + }, + ..Default::default() + } + ) + }, + ..Default::default() + }, + ..Default::default() + } + ), + ..Default::default() + } + } + }, + ..Default::default() + } +} diff --git a/rocket-okapi/src/lib.rs b/rocket-okapi/src/lib.rs index b119dcc..09419cb 100644 --- a/rocket-okapi/src/lib.rs +++ b/rocket-okapi/src/lib.rs @@ -182,6 +182,72 @@ macro_rules! mount_endpoints_and_merged_docs { }}; } +/// Get and merge nested endpoints and OpenAPI documentation. +/// +/// This macro enables to split endpoints definition in smaller pieces to make code look +/// cleaner and improves readability for bigger codebases. +/// +/// The macro expects the following arguments: +/// - List of (0 or more): +/// - path: `&str`, `String` or [`Uri`](rocket::http::uri::Uri). +/// Anything accepted by `mount()` (`base_path` should not be included). +/// - `=>`: divider +/// - route_and_docs: `(Vec, OpenApi)` +/// +/// Example: +/// ```rust,ignore +/// let settings = OpenApiSettings::default(); +/// let custom_route_spec = (vec![], custom_spec()); +/// mount_endpoints_and_merged_docs! { +/// building_rocket, "/v1".to_owned(), settings, +/// "/" => custom_route_spec, +/// "/api" => api::get_routes_and_docs(), +/// }; +/// +/// mod api { +/// pub fn get_routes_and_docs(settings: &OpenApiSettings) -> (Vec, OpenApi) { +/// get_nested_endpoints_and_docs! { +/// "/posts" => post::get_routes_and_docs(settings), +/// "/message" => message::get_routes_and_docs(settings), +/// } +/// } +/// mod posts { +/// pub fn get_routes_and_docs(settings: &OpenApiSettings) -> (Vec, OpenApi) { +/// openapi_get_routes_spec![settings: create_post, get_post] +/// } +/// } +/// mod messages { +/// pub fn get_routes_and_docs(settings: &OpenApiSettings) -> (Vec, OpenApi) { +/// openapi_get_routes_spec![settings: create_message, get_message] +/// } +/// } +/// } +/// ``` +/// +#[macro_export] +macro_rules! get_nested_endpoints_and_docs { + ($($path_prefix:expr => $route_and_docs:expr),* $(,)*) => {{ + let mut routes = Vec::new(); + let mut openapi_specs = rocket_okapi::okapi::openapi3::OpenApi::new(); + + $({ + let (new_routes, new_specs) = $route_and_docs; + // Prepend the path prefix to all routes + let new_routes = new_routes + .into_iter() + .map(|r: rocket::Route| r.map_base(|base| format!("{}{}", $path_prefix, base)).unwrap()) + .collect::>(); + routes.extend(new_routes); + // Merge OpenAPI specs + if let Err(err) = rocket_okapi::okapi::merge::merge_specs(&mut openapi_specs, &$path_prefix, &new_specs) { + panic!("Failed to merge specs: {}", err) + } + })* + + (routes, openapi_specs) + }}; +} + /// A replacement macro for `rocket::routes`. This also takes a optional settings object. /// /// The key differences are that this macro will add an additional element to the