diff --git a/Cargo.toml b/Cargo.toml index ebed5db..78e61d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ gitlab = { repository = "msrd0/gotham-restful", branch = "master" } [dependencies] base64 = { version = "0.12.0", optional = true } -chrono = { version = "0.4.11", optional = true } +chrono = { version = "0.4.11", features = ["serde"], optional = true } cookie = { version = "0.13.3", optional = true } futures-core = "0.3.4" futures-util = "0.3.4" diff --git a/derive/src/request_body.rs b/derive/src/request_body.rs index ddcc7e8..76c80aa 100644 --- a/derive/src/request_body.rs +++ b/derive/src/request_body.rs @@ -3,10 +3,11 @@ use proc_macro2::{Ident, TokenStream}; use quote::quote; use std::iter; use syn::{ - parenthesized, parse::{Parse, ParseStream}, punctuated::Punctuated, + spanned::Spanned, DeriveInput, + Error, Generics, Path, Result, @@ -19,9 +20,7 @@ impl Parse for MimeList { fn parse(input: ParseStream) -> Result { - let content; - let _paren = parenthesized!(content in input); - let list = Punctuated::parse_separated_nonempty(&content)?; + let list = Punctuated::parse_separated_nonempty(&input)?; Ok(Self(list)) } } @@ -61,10 +60,15 @@ pub fn expand_request_body(input : DeriveInput) -> Result let types = input.attrs.into_iter() .filter(|attr| attr.path.segments.iter().last().map(|segment| segment.ident.to_string()) == Some("supported_types".to_string())) - .flat_map(|attr| - syn::parse2::(attr.tokens) + .flat_map(|attr| { + let span = attr.span(); + attr.parse_args::() .map(|list| Box::new(list.0.into_iter().map(Ok)) as Box>>) - .unwrap_or_else(|err| Box::new(iter::once(Err(err))))) + .unwrap_or_else(|mut err| { + err.combine(Error::new(span, "Hint: Types list should look like #[supported_types(TEXT_PLAIN, APPLICATION_JSON)]")); + Box::new(iter::once(Err(err))) + }) + }) .collect_to_result()?; let types = match types { diff --git a/tests/openapi_specification.json b/tests/openapi_specification.json new file mode 100644 index 0000000..c9e6c53 --- /dev/null +++ b/tests/openapi_specification.json @@ -0,0 +1,202 @@ +{ + "components": { + "schemas": { + "Secret": { + "properties": { + "code": { + "format": "float", + "type": "number" + } + }, + "required": [ + "code" + ], + "title": "Secret", + "type": "object" + }, + "Secrets": { + "properties": { + "secrets": { + "items": { + "$ref": "#/components/schemas/Secret" + }, + "type": "array" + } + }, + "required": [ + "secrets" + ], + "title": "Secrets", + "type": "object" + } + }, + "securitySchemes": { + "authToken": { + "bearerFormat": "JWT", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "title": "This is just a test", + "version": "1.2.3" + }, + "openapi": "3.0.2", + "paths": { + "/img/{id}": { + "get": { + "operationId": "getImage", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "OK" + } + } + }, + "put": { + "operationId": "setImage", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "image/png": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/secret/search": { + "get": { + "parameters": [ + { + "in": "query", + "name": "date", + "required": true, + "schema": { + "format": "date", + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "hour", + "schema": { + "format": "int16", + "minimum": 0, + "type": "integer" + }, + "style": "form" + }, + { + "in": "query", + "name": "minute", + "schema": { + "format": "int16", + "minimum": 0, + "type": "integer" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secrets" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "authToken": [] + } + ] + } + }, + "/secret/{id}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "date-time", + "type": "string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secret" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "authToken": [] + } + ] + } + } + }, + "servers": [ + { + "url": "http://localhost:12345/api/v1" + } + ] +} \ No newline at end of file diff --git a/tests/openapi_specification.rs b/tests/openapi_specification.rs new file mode 100644 index 0000000..2171526 --- /dev/null +++ b/tests/openapi_specification.rs @@ -0,0 +1,116 @@ +#![cfg(all(feature = "auth", feature = "chrono", feature = "openapi"))] + +#[macro_use] extern crate gotham_derive; + +use chrono::{NaiveDate, NaiveDateTime}; +use gotham::{ + pipeline::{new_pipeline, single::single_pipeline}, + router::builder::*, + test::TestServer +}; +use gotham_restful::*; +use mime::IMAGE_PNG; +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] +mod util { include!("util/mod.rs"); } +use util::{test_get_response, test_openapi_response}; + + +const IMAGE_RESPONSE : &[u8] = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUA/wA0XsCoAAAAAXRSTlN/gFy0ywAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII="; + +#[derive(Resource)] +#[resource(read, change)] +struct ImageResource; + +#[derive(FromBody, RequestBody)] +#[supported_types(IMAGE_PNG)] +struct Image(Vec); + +#[read(ImageResource, operation_id = "getImage")] +fn get_image(_id : u64) -> Raw<&'static [u8]> +{ + Raw::new(IMAGE_RESPONSE, "image/png;base64".parse().unwrap()) +} + +#[change(ImageResource, operation_id = "setImage")] +fn set_image(_id : u64, _image : Image) +{ +} + + +#[derive(Resource)] +#[resource(read, search)] +struct SecretResource; + +#[derive(Deserialize, Clone)] +struct AuthData +{ + sub : String, + iat : u64, + exp : u64 +} + +type AuthStatus = gotham_restful::AuthStatus; + +#[derive(OpenapiType, Serialize)] +struct Secret +{ + code : f32 +} + +#[derive(OpenapiType, Serialize)] +struct Secrets +{ + secrets : Vec +} + +#[derive(Deserialize, OpenapiType, StateData, StaticResponseExtender)] +struct SecretQuery +{ + date : NaiveDate, + hour : Option, + minute : Option +} + +#[read(SecretResource)] +fn read_secret(auth : AuthStatus, _id : NaiveDateTime) -> AuthSuccess +{ + auth.ok()?; + Ok(Secret { code: 4.2 }) +} + +#[search(SecretResource)] +fn search_secret(auth : AuthStatus, _query : SecretQuery) -> AuthSuccess +{ + auth.ok()?; + Ok(Secrets { + secrets: vec![Secret { code: 4.2 }, Secret { code: 3.14 }] + }) +} + + +#[test] +fn openapi_supports_scope() +{ + let info = OpenapiInfo { + title: "This is just a test".to_owned(), + version: "1.2.3".to_owned(), + urls: vec!["http://localhost:12345/api/v1".to_owned()] + }; + let auth: AuthMiddleware = AuthMiddleware::new( + AuthSource::AuthorizationHeader, + AuthValidation::default(), + StaticAuthHandler::from_array(b"zlBsA2QXnkmpe0QTh8uCvtAEa4j33YAc") + ); + let (chain, pipelines) = single_pipeline(new_pipeline().add(auth).build()); + let server = TestServer::new(build_router(chain, pipelines, |router| { + router.with_openapi(info, |mut router| { + router.resource::("img"); + router.get_openapi("openapi"); + router.resource::("secret"); + }); + })).unwrap(); + + test_openapi_response(&server, "http://localhost/openapi", "tests/openapi_specification.json"); +} diff --git a/tests/openapi_supports_scope.json b/tests/openapi_supports_scope.json new file mode 100644 index 0000000..bdef1fd --- /dev/null +++ b/tests/openapi_supports_scope.json @@ -0,0 +1,78 @@ +{ + "components": {}, + "info": { + "title": "Test", + "version": "1.2.3" + }, + "openapi": "3.0.2", + "paths": { + "/bar/baz/foo3": { + "get": { + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "OK" + } + } + } + }, + "/bar/foo2": { + "get": { + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "OK" + } + } + } + }, + "/foo1": { + "get": { + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "OK" + } + } + } + }, + "/foo4": { + "get": { + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/openapi_supports_scope.rs b/tests/openapi_supports_scope.rs index 3b9aa2c..d126bb8 100644 --- a/tests/openapi_supports_scope.rs +++ b/tests/openapi_supports_scope.rs @@ -8,7 +8,7 @@ use mime::TEXT_PLAIN; #[allow(dead_code)] mod util { include!("util/mod.rs"); } -use util::test_get_response; +use util::{test_get_response, test_openapi_response}; const RESPONSE : &[u8] = b"This is the only valid response."; @@ -34,6 +34,7 @@ fn openapi_supports_scope() }; let server = TestServer::new(build_simple_router(|router| { router.with_openapi(info, |mut router| { + router.get_openapi("openapi"); router.resource::("foo1"); router.scope("/bar", |router| { router.resource::("foo2"); @@ -49,4 +50,5 @@ fn openapi_supports_scope() test_get_response(&server, "http://localhost/bar/foo2", RESPONSE); test_get_response(&server, "http://localhost/bar/baz/foo3", RESPONSE); test_get_response(&server, "http://localhost/foo4", RESPONSE); + test_openapi_response(&server, "http://localhost/openapi", "tests/openapi_supports_scope.json"); } diff --git a/tests/util/mod.rs b/tests/util/mod.rs index e09a37f..8846352 100644 --- a/tests/util/mod.rs +++ b/tests/util/mod.rs @@ -3,6 +3,8 @@ use gotham::{ test::TestServer }; use mime::Mime; +#[allow(unused_imports)] +use std::{fs::File, io::{Read, Write}, str}; pub fn test_get_response(server : &TestServer, path : &str, expected : &[u8]) { @@ -35,3 +37,21 @@ pub fn test_delete_response(server : &TestServer, path : &str, expected : &[u8]) let body : &[u8] = res.as_ref(); assert_eq!(body, expected); } + +#[cfg(feature = "openapi")] +pub fn test_openapi_response(server : &TestServer, path : &str, output_file : &str) +{ + let res = server.client().get(path).perform().unwrap().read_body().unwrap(); + let body = serde_json::to_string_pretty(&serde_json::from_slice::(res.as_ref()).unwrap()).unwrap(); + match File::open(output_file) { + Ok(mut file) => { + let mut expected = String::new(); + file.read_to_string(&mut expected).unwrap(); + assert_eq!(body, expected); + }, + Err(_) => { + let mut file = File::create(output_file).unwrap(); + file.write_all(body.as_bytes()).unwrap(); + } + }; +}