Retrieve and merge OpenApi objects

- Allow customization of OpenApi object.
- Allow merging of OpenApi objects.
- Retrieve OpenApi object after generating. (Closes: #28)
- Create `mount_endpoints_and_merged_docs` marco in order to streamline code structure for
bigger projects. (Closes: #30)
- Added new example for structuring bigger projects.
- Allowed changing path where OpenApi file is hosted.
- Added `log v0.4` as a dependency.
This commit is contained in:
ralpha
2021-09-07 23:01:53 +02:00
parent 63e3acd69e
commit af52ba1c39
25 changed files with 833 additions and 43 deletions
+1
View File
@@ -2,3 +2,4 @@
**/*.rs.bk
Cargo.lock
/.idea
.vscode
+2 -1
View File
@@ -3,5 +3,6 @@ members = [
"okapi",
"rocket-okapi",
"rocket-okapi-codegen",
"examples/json-web-api"
"examples/json-web-api",
"examples/custom_schema",
]
+10 -9
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
/target
**/*.rs.bk
Cargo.lock
/.idea
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "custom_schema"
version = "0.1.0"
authors = ["Ralph Bisschops <ralph.bisschops.dev@gmail.com>"]
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"
+5
View File
@@ -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.
+117
View File
@@ -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<String>,
// HTTP Status Code returned
#[serde(skip)]
pub http_status_code: u16,
}
impl OpenApiResponderInner for Error {
fn responses(_generator: &mut OpenApiGenerator) -> Result<Responses, OpenApiError> {
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("<no message>")
)
}
}
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<rocket::serde::json::Error<'_>> 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,
},
}
}
}
+98
View File
@@ -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<T> = std::result::Result<rocket::serde::json::Json<T>, error::Error>;
pub type DataResult<'a, T> =
std::result::Result<rocket::serde::json::Json<T>, 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<Build> {
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()
}
}
+41
View File
@@ -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<rocket::Route>, 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 = "<message>")]
fn create_message(message: crate::DataResult<'_, Message>) -> crate::Result<Message> {
let message = message?.into_inner();
Ok(Json(message))
}
/// # Get a message by id
///
/// Returns the message with the requested id.
#[openapi(tag = "Message")]
#[get("/<id>")]
fn get_message(id: u64) -> crate::Result<Message> {
Ok(Json(Message {
message_id: id,
content: "Hey, how are you?".to_owned(),
}))
}
+44
View File
@@ -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<rocket::Route>, 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<String>,
}
/// # Create post
///
/// Returns the created post.
#[openapi(tag = "Posts")]
#[post("/", data = "<post>")]
fn create_post(post: crate::DataResult<'_, Post>) -> crate::Result<Post> {
let post = post?.into_inner();
Ok(Json(post))
}
/// # Get a post by id
///
/// Returns the post with the requested id.
#[openapi(tag = "Posts")]
#[get("/<id>")]
fn get_post(id: u64) -> crate::Result<Post> {
Ok(Json(Post {
post_id: id,
title: "Your post".to_owned(),
summary: Some("Best summary ever.".to_owned()),
}))
}
+3 -1
View File
@@ -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`.
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`.
+3
View File
@@ -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
+1
View File
@@ -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"
+2
View File
@@ -2,5 +2,7 @@
#![deny(clippy::all)]
pub type Map<K, V> = schemars::Map<K, V>;
pub type MapEntry<'a, K, V> = schemars::MapEntry<'a, K, V>;
pub mod merge;
pub mod openapi3;
+279
View File
@@ -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<S: AsRef<str>>(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<S: Display>(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<S: Display>(spec_list: &[(S, OpenApi)]) -> Result<OpenApi, MergeError> {
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<S: Display>(
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<Map<String, _>` 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<String, PathItem>`/`&Map<String, PathItem>`:
/// 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<S: Display>(
s1: &mut Map<String, PathItem>,
path_prefix: &S,
s2: &Map<String, PathItem>,
) -> 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<Components>,
s2: &Option<Components>,
) -> 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<Tag>, s2: &[Tag]) -> Result<Vec<Tag>, MergeError> {
// Create a `Map` so we can easily merge tag names.
let mut new_tags: Map<String, Tag> = 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<String>`/`&Option<String>`:
/// - If one is `None`: Use other
/// - If both are `Some`: Merge `String`
/// - Otherwise: Use first value
pub fn merge_opt_string(s1: &mut Option<String>, s2: &Option<String>) {
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<T>`/`&Option<T>`:
/// - If one is `None`: Use other
/// - Otherwise: Use first value
pub fn merge_option<T: Clone>(s1: &mut Option<T>, s2: &Option<T>) {
if s1.is_none() {
*s1 = s2.clone();
}
}
/// Merge `Map<String, _>`/`&Map<String, _>`:
/// Merge together. If key already exists, use s1 version.
pub fn merge_map<T: Clone + PartialEq>(s1: &mut Map<String, T>, s2: &Map<String, T>, 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<T: Clone>(s1: &mut Vec<T>, s2: &[T]) {
// Add all s2 values
for value in s2 {
s1.push(value.clone());
}
}
+17 -3
View File
@@ -4,9 +4,9 @@ pub use schemars::schema::SchemaObject;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
type Object = Map<String, Value>;
type SecurityRequirement = Map<String, Vec<String>>;
pub type Object = Map<String, Value>;
pub type SecurityRequirement = Map<String, Vec<String>>;
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "derive_json_schema", derive(JsonSchema))]
@@ -29,6 +29,19 @@ impl<T> From<T> for RefOr<T> {
}
}
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<String>,
/// URL to the terms of service.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terms_of_service: Option<String>,
#[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")]
+79 -9
View File
@@ -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<rocket::Route>` (adds route for `openapi.json`)
//! - `parse_openapi_routes![...]`: `(Vec<rocket::Route>, 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<rocket::Route>`, 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<rocket::Route>, OpenApi)`:
/// - `Vec<rocket::Route>`: 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 {
+2 -1
View File
@@ -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)]
@@ -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;
@@ -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<TokenStream2> {
/// Parses routes and returns a function that takes `OpenApiSettings` and returns `OpenApi` spec.
pub fn create_openapi_spec(routes: TokenStream) -> Result<TokenStream2> {
let paths = <Punctuated<Path, Comma>>::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<TokenStream2> {
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
}
})
}
+25
View File
@@ -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<rocket::Route>`.
/// It optionally adds the `openapi.json` route to the list of routes.
pub fn parse_routes(routes: TokenStream, add_openapi_json: bool) -> Result<TokenStream2> {
let paths = <Punctuated<Path, Comma>>::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
}
})
}
+5
View File
@@ -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`.
+70 -1
View File
@@ -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<Build>`,
/// - 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<rocket::Route>, 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),*
}
}
}
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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`.