├── .gitignore ├── .travis.yml ├── tests ├── compile-fail │ ├── 405.rs │ ├── 404.rs │ ├── 410.rs │ ├── 410b.rs │ ├── 401.rs │ └── 400.rs ├── util │ ├── lib.rs │ └── Cargo.toml ├── run-pass │ ├── 405.rs │ ├── 410.rs │ ├── 406.rs │ ├── 400.rs │ ├── 411.rs │ ├── 404.rs │ └── 401.rs ├── compilation.rs ├── status.rs ├── schema.rs └── schemata │ └── httpbin.yml ├── tapioca-codegen ├── Cargo.toml └── src │ ├── infer │ ├── mod.rs │ ├── body.rs │ ├── query.rs │ ├── params.rs │ ├── path.rs │ ├── auth.rs │ ├── datatype.rs │ ├── method.rs │ ├── schema.rs │ └── response.rs │ ├── lib.rs │ └── parse.rs ├── src ├── response │ ├── status.rs │ ├── body.rs │ └── mod.rs ├── lib.rs ├── auth.rs ├── query.rs └── datatype │ └── mod.rs ├── LICENCE ├── Cargo.toml ├── examples ├── httpbin.rs └── uber.rs └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | **/.tapioca-cache 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: 3 | - beta 4 | - nightly 5 | matrix: 6 | allow_failures: 7 | - rust: beta 8 | cache: cargo 9 | -------------------------------------------------------------------------------- /tests/compile-fail/405.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | fn main() { 7 | let auth = httpbin::ServerAuth::new(); 8 | 9 | httpbin::post::get(auth); //~ cannot find function `get` 10 | } 11 | -------------------------------------------------------------------------------- /tests/compile-fail/404.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | fn main() { 7 | let auth = httpbin::ServerAuth::new(); 8 | 9 | httpbin::nonexistent_path::get(auth); //~ Could not find `nonexistent_path` 10 | } 11 | -------------------------------------------------------------------------------- /tests/util/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(decl_macro)] 2 | #![feature(use_extern_macros)] 3 | 4 | #[macro_use] 5 | extern crate tapioca; 6 | 7 | infer_api!(httpbin, "https://raw.githubusercontent.com/OJFord/tapioca/master/tests/schemata/httpbin.yml"); 8 | 9 | #[macro_export] 10 | macro_rules! infer_test_api { 11 | (httpbin) => { 12 | use tapioca_testutil::Response; 13 | use tapioca_testutil::httpbin; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/util/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tapioca-testutil" 3 | version = "0.0.1" 4 | authors = ["Oliver Ford "] 5 | repository = "https://github.com/OJFord/tapioca.git" 6 | license-file = "../../LICENCE" 7 | 8 | [lib] 9 | path = "lib.rs" 10 | 11 | [dependencies] 12 | serde = "1.0" 13 | serde_derive = "1.0" 14 | 15 | [dependencies.tapioca] 16 | path = "../../" 17 | 18 | [dependencies.tapioca-codegen] 19 | path = "../../tapioca-codegen" 20 | -------------------------------------------------------------------------------- /tests/run-pass/405.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | fn main() { 7 | let auth = httpbin::ServerAuth::new(); 8 | 9 | let query = httpbin::post::post::QueryParams { 10 | echo: Some("foobar".into()), 11 | }; 12 | 13 | match httpbin::post::post(&query, auth) { 14 | Ok(response) => assert!(true), 15 | Err(response) => assert!(false), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/run-pass/410.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | use httpbin::status__code_; 7 | 8 | static code: &i32 = &200; 9 | 10 | fn main() { 11 | let auth = httpbin::ServerAuth::new(); 12 | let dummy_created_id = status__code_::ResourceId_code::from_static(code); 13 | 14 | status__code_::get(&dummy_created_id, auth); 15 | status__code_::delete(dummy_created_id, auth); 16 | } 17 | 18 | -------------------------------------------------------------------------------- /tapioca-codegen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tapioca-codegen" 3 | version = "0.0.1" 4 | authors = ["Oliver Ford "] 5 | description = "Type-safe REST client using the OpenAPI Specification" 6 | repository = "https://github.com/OJFord/tapioca/tree/master/tapioca-codegen" 7 | license-file = "../LICENCE" 8 | 9 | [lib] 10 | proc-macro = true 11 | 12 | [dependencies] 13 | Inflector = "0.10.0" 14 | quote = "0.3" 15 | regex = "1.5.5" 16 | reqwest = {version="0.9", features=["hyper-011"]} 17 | syn = "0.11" 18 | yaml-rust = "0.4.1" 19 | -------------------------------------------------------------------------------- /tests/compile-fail/410.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | use httpbin::status__code_; 7 | 8 | static code: &i32 = &200; 9 | 10 | fn main() { 11 | let auth = httpbin::ServerAuth::new(); 12 | let dummy_created_id = status__code_::ResourceId_code::from_static(code); 13 | 14 | status__code_::get(&dummy_created_id, auth); 15 | status__code_::delete(dummy_created_id, auth); 16 | status__code_::get(&dummy_created_id, auth); //~ use of moved value 17 | } 18 | -------------------------------------------------------------------------------- /tests/run-pass/406.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | fn main() { 7 | let auth = httpbin::ServerAuth::new(); 8 | 9 | match httpbin::anything::get(auth) { 10 | Ok(response) => match response.body() { 11 | httpbin::anything::get::OkBody::Status200(body) => assert!( 12 | body.headers.accept.contains("application/json") 13 | ), 14 | _ => assert!(false), 15 | }, 16 | Err(_) => assert!(false), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/run-pass/400.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | fn main() { 7 | let auth = httpbin::ServerAuth::new(); 8 | 9 | let req_body = httpbin::patch::patch::RequestBody { 10 | musthave: "Hello, world!".into(), 11 | ifyouwant: Some(vec!["foo".into(), "bar".into(), "baz".into()]), 12 | }; 13 | 14 | match httpbin::patch::patch(&req_body, auth) { 15 | Ok(response) => assert!(response.status_code().is_success()), 16 | _ => assert!(false), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/compilation.rs: -------------------------------------------------------------------------------- 1 | extern crate compiletest_rs as ct; 2 | 3 | use std::path::PathBuf; 4 | 5 | fn test_compile(mode: &str) { 6 | let mut config = ct::default_config(); 7 | 8 | config.mode = mode.parse().unwrap(); 9 | config.src_base = PathBuf::from(format!("tests/{}", mode)); 10 | config.target_rustcflags = Some("\ 11 | -L target/debug \ 12 | -L target/debug/deps \ 13 | ".into()); 14 | 15 | ct::run_tests(&config); 16 | } 17 | 18 | #[test] 19 | fn compilation_errors() { 20 | test_compile("compile-fail"); 21 | } 22 | 23 | #[test] 24 | fn compilation_ok() { 25 | test_compile("run-pass"); 26 | } 27 | -------------------------------------------------------------------------------- /tests/compile-fail/410b.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | use httpbin::status__code_; 7 | 8 | const code: &'static i32 = &200; 9 | 10 | fn main() { 11 | let auth = httpbin::ServerAuth::new(); 12 | let dummy_created_id = status__code_::ResourceId_code::from_static(code); 13 | 14 | status__code_::delete(&dummy_created_id, auth); //~ mismatched types 15 | status__code_::get(&dummy_created_id, auth); 16 | 17 | status__code_::delete(dummy_created_id.clone(), auth); //~ no method named `clone` 18 | status__code_::get(&dummy_created_id, auth); 19 | } 20 | -------------------------------------------------------------------------------- /tests/run-pass/411.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | fn main() { 7 | let auth = httpbin::ServerAuth::new(); 8 | let body = httpbin::patch::patch::RequestBody { 9 | musthave: "foobar".into(), 10 | ifyouwant: None, 11 | }; 12 | 13 | match httpbin::patch::patch(&body, auth) { 14 | Ok(response) => match response.body() { 15 | httpbin::patch::patch::OkBody::Status200(body) 16 | => assert_eq!(body.headers.content_length, Some("38".into())), 17 | _ => assert!(false), 18 | }, 19 | Err(_) => assert!(false), 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/compile-fail/401.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | use httpbin::basic_auth__user__hunter_2 as basic_auth; 7 | use basic_auth::get::OpAuth::HttpBasic; 8 | 9 | static USER: &str = "baz"; 10 | 11 | fn main() { 12 | let server_auth = httpbin::ServerAuth::new(); 13 | let user_id = basic_auth::ResourceId_user::from_static(USER); 14 | let op_auth = HttpBasic((USER.into(), "".into()).into()); 15 | 16 | basic_auth::get(&user_id); //~ takes 2 parameters but 1 parameter was supplied 17 | basic_auth::get(&user_id, server_auth); //~ mismatched types 18 | basic_auth::get(&user_id, op_auth); // [OK] 19 | } 20 | -------------------------------------------------------------------------------- /src/response/status.rs: -------------------------------------------------------------------------------- 1 | use response::ClientResponse; 2 | 3 | pub use reqwest::StatusCode; 4 | 5 | pub trait Status { 6 | type OfType; 7 | 8 | fn of(&Option<&Self::OfType>) -> Self; 9 | 10 | fn is_ok(&self) -> bool; 11 | fn is_err(&self) -> bool; 12 | } 13 | 14 | impl<'a> Status for StatusCode { 15 | type OfType = ClientResponse; 16 | 17 | fn of(response: &Option<&Self::OfType>) -> Self { 18 | match *response { 19 | Some(r) => r.status(), 20 | None => Self::from_u16(520).unwrap(), 21 | } 22 | } 23 | 24 | fn is_ok(&self) -> bool { 25 | self.is_success() 26 | || self.is_informational() 27 | || self.is_redirection() 28 | } 29 | 30 | fn is_err(&self) -> bool { 31 | !self.is_ok() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/run-pass/404.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | use httpbin::ip; 7 | 8 | fn main() { 9 | let auth = httpbin::ServerAuth::new(); 10 | 11 | match ip::get(auth) { 12 | Ok(response) => match response.body() { 13 | ip::get::OkBody::Status200(body) => { 14 | let ipv4_parts: Vec<&str> = body.origin 15 | .split('.').collect(); 16 | assert_eq!(ipv4_parts.len(), 4); 17 | }, 18 | _ => assert!(false, "This test might be broken"), 19 | }, 20 | Err(response) => match response.body() { 21 | ip::get::ErrBody::NetworkFailure() => main(), 22 | _ => assert!(false, "This test might be broken"), 23 | }, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/compile-fail/400.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | fn main() { 7 | let auth = httpbin::ServerAuth::new(); 8 | 9 | httpbin::patch::patch(auth); //~ takes 2 parameters but 1 parameter was supplied 10 | 11 | let req_body = httpbin::patch::patch::RequestBody { 12 | musthave: None, //~ mismatched types 13 | ifyouwant: Some(vec!["foo".into(), "bar".into(), "baz".into()]), 14 | }; 15 | match httpbin::patch::patch(&req_body, auth) { 16 | Ok(response) => match response.body() { 17 | httpbin::patch::patch::OkBody::Status200(res_body) => 18 | assert_eq!(res_body.json.musthave, req_body.musthave), 19 | _ => assert!(false), 20 | }, 21 | Err(_) => assert!(false), 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/response/body.rs: -------------------------------------------------------------------------------- 1 | use response::Status; 2 | use response::StatusCode; 3 | use response::ClientResponse; 4 | 5 | pub trait ResponseBody { 6 | fn from(&mut Option<&mut ClientResponse>) -> Self; 7 | } 8 | 9 | // O: ResponseBody, E: ResponseBody c.f. rust-lang/rust#21903 10 | pub type ResponseResultBody = Result; 11 | 12 | impl ResponseBody for ResponseResultBody 13 | where O: ResponseBody, E: ResponseBody, 14 | { 15 | fn from(maybe_response: &mut Option<&mut ClientResponse>) -> Self { 16 | let error = match *maybe_response { 17 | Some(ref response) => StatusCode::of(&Some(response)).is_err(), 18 | None => true, 19 | }; 20 | 21 | if error { 22 | Err(::from(maybe_response)) 23 | } else { 24 | Ok(::from(maybe_response)) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(feature="clippy", feature(plugin))] 2 | #![cfg_attr(feature="clippy", plugin(clippy))] 3 | 4 | extern crate base64; 5 | extern crate reqwest; 6 | 7 | pub extern crate serde; 8 | pub extern crate serde_json; 9 | 10 | pub use reqwest::hyper_011::header; 11 | pub use reqwest::Body; 12 | pub use reqwest::Client; 13 | pub use reqwest::Url; 14 | pub use serde::Deserialize; 15 | 16 | pub mod auth; 17 | pub mod response; 18 | pub mod datatype; 19 | pub mod query; 20 | 21 | pub type HeaderResult = Result; 22 | 23 | #[macro_export] 24 | macro_rules! infer_api { 25 | ($name:ident, $url:expr) => { 26 | #[macro_use] 27 | extern crate serde_derive; 28 | extern crate tapioca_codegen; 29 | 30 | pub use tapioca::response::Response; 31 | 32 | pub mod $name { 33 | ::tapioca_codegen::infer!($url); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/mod.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use ::quote::Tokens; 3 | use ::yaml_rust::Yaml; 4 | 5 | mod auth; 6 | mod body; 7 | mod datatype; 8 | mod method; 9 | mod query; 10 | mod params; 11 | mod path; 12 | mod response; 13 | mod schema; 14 | 15 | const SCHEMA_VERSION_KEY: &'static str = "openapi"; 16 | 17 | type InferResult = Result>; 18 | type MaybeTokens = Option; 19 | type StructBoundArgImpl = InferResult<(MaybeTokens, MaybeTokens, MaybeTokens, MaybeTokens)>; 20 | type TokensResult = InferResult; 21 | 22 | pub(super) fn infer_schema(schema: &Yaml) -> TokensResult { 23 | match schema[SCHEMA_VERSION_KEY].as_str() { 24 | None => Err(From::from("Unspecified schema version.")), 25 | Some("3.0.0") => schema::infer_v3(&schema), 26 | Some(version) => Err(From::from(format!("Unsupported schema version: {}", version))), 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/body.rs: -------------------------------------------------------------------------------- 1 | use ::syn::Ident; 2 | use ::yaml_rust::Yaml; 3 | 4 | use infer::datatype; 5 | use infer::StructBoundArgImpl; 6 | 7 | fn infer_v3_json(structs_mod: &Ident, schema: &Yaml) -> StructBoundArgImpl { 8 | let (inferred_type, aux_types) = datatype::infer_v3(&schema)?; 9 | Ok(( 10 | Some(quote! { 11 | #(#aux_types)* 12 | pub type RequestBody = #inferred_type; 13 | }), 14 | None, 15 | Some(quote!{ body: &#structs_mod::RequestBody }), 16 | Some(quote!{ 17 | .json(body) 18 | .header(header::ContentLength( 19 | ::tapioca::serde_json::to_vec(body).unwrap().len() as u64 20 | )) 21 | }) 22 | )) 23 | } 24 | 25 | pub(super) fn infer_v3(structs_mod: &Ident, schema: &Yaml) -> StructBoundArgImpl { 26 | match schema["content"]["application/json"]["schema"] { 27 | Yaml::BadValue => Err(From::from("only JSON bodies supported at this time")), 28 | ref schema => infer_v3_json(structs_mod, &schema), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Oliver J. Ford 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/run-pass/401.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | use httpbin::basic_auth__user__hunter_2 as basic_auth; 7 | use basic_auth::get::OpAuth::HttpBasic; 8 | 9 | static USER: &str = "baz"; 10 | 11 | fn main() { 12 | let user_id = basic_auth::ResourceId_user::from_static(USER); 13 | let auth = HttpBasic((USER.into(), "wrong".into()).into()); 14 | 15 | match basic_auth::get(&user_id, auth) { 16 | Ok(_) => assert!(false), 17 | Err(response) => match response.body() { 18 | basic_auth::get::ErrBody::Status401(_) => assert!(true), 19 | _ => assert!(false), 20 | }, 21 | } 22 | 23 | let user_id = basic_auth::ResourceId_user::from_static(USER); 24 | let auth = HttpBasic((USER.into(), "hunter2".into()).into()); 25 | match basic_auth::get(&user_id, auth) { 26 | Ok(response) => match response.body() { 27 | basic_auth::get::OkBody::Status200(body) => assert!(body.authenticated), 28 | _ => assert!(false), 29 | }, 30 | Err(_) => assert!(false), 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tapioca" 3 | version = "0.0.1" 4 | authors = ["Oliver Ford "] 5 | repository = "https://github.com/OJFord/tapioca.git" 6 | description = "Type-safe REST client using the OpenAPI Specification" 7 | readme = "README.md" 8 | license-file = "LICENCE" 9 | keywords = ["REST", "API", "OAS", "OpenAPI"] 10 | categories = ["api-bindings", "development-tools", "web-programming::http-client"] 11 | 12 | [lib] 13 | name = "tapioca" 14 | path = "src/lib.rs" 15 | crate-type = ["rlib"] 16 | 17 | [badges] 18 | travis-ci = {repository="OJFord/tapioca"} 19 | is-it-maintained-issue-resolution = {repository="OJFord/tapioca"} 20 | is-it-maintained-open-issues = {repository="OJFord/tapioca"} 21 | 22 | [dependencies] 23 | base64 = "0.6" 24 | clippy = {version="0.0", optional=true} 25 | reqwest = {version="0.9", features=["hyper-011"]} 26 | serde = "1.0" 27 | serde_derive = "1.0" 28 | serde_json = "1.0" 29 | 30 | [dependencies.tapioca-codegen] 31 | version = "0.0.1" 32 | path = "tapioca-codegen" 33 | 34 | [dev-dependencies] 35 | compiletest_rs = "0.2" 36 | 37 | [dev-dependencies.tapioca-testutil] 38 | version = "0.0.1" 39 | path = "tests/util" 40 | -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- 1 | use ::base64; 2 | use ::header; 3 | use ::HeaderResult; 4 | 5 | #[derive(Clone, Debug)] 6 | pub struct HttpBasic { 7 | pub user: String, 8 | pub password: String, 9 | } 10 | 11 | impl From<(String, String)> for HttpBasic { 12 | fn from((user, password): (String, String)) -> Self { 13 | Self { 14 | user, 15 | password, 16 | } 17 | } 18 | } 19 | 20 | impl ::std::fmt::Display for HttpBasic { 21 | fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { 22 | let raw = format!("{}:{}", self.user, self.password); 23 | let encoded = base64::encode(raw.as_str()); 24 | f.write_str(format!("Basic {}", encoded).as_str()) 25 | } 26 | } 27 | 28 | impl header::Header for HttpBasic { 29 | fn header_name() -> &'static str { 30 | "Authorization" 31 | } 32 | 33 | fn parse_header(raw: &header::Raw) -> HeaderResult { 34 | let encoded = &raw[0]; 35 | let decoded = String::from_utf8(base64::decode(encoded).unwrap())?; 36 | let parts = decoded.split(':').collect::>(); 37 | 38 | Ok(Self { user: parts[0].into(), password: parts[1].into() }) 39 | } 40 | 41 | fn fmt_header(&self, f: &mut header::Formatter) -> ::std::fmt::Result { 42 | f.fmt_line(self) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/query.rs: -------------------------------------------------------------------------------- 1 | pub type QueryPair = (String, String); 2 | 3 | pub trait QueryString { 4 | fn as_query_kv(&self) -> Vec; 5 | 6 | fn as_query(&self) -> String { 7 | self.as_query_kv().iter() 8 | .map(|&(ref k, ref v)| format!("{}={}", k, v)) 9 | .collect::>() 10 | .join("&") 11 | } 12 | } 13 | 14 | pub trait QueryParameter { 15 | fn as_query_kv(&self, &str) -> Vec; 16 | } 17 | 18 | impl QueryString for Option { 19 | fn as_query_kv(&self) -> Vec { 20 | match *self { 21 | Some(ref thing) => thing.as_query_kv(), 22 | None => Vec::new(), 23 | } 24 | } 25 | } 26 | 27 | impl QueryParameter for Option { 28 | fn as_query_kv(&self, key: &str) -> Vec { 29 | match *self { 30 | Some(ref thing) => thing.as_query_kv(key), 31 | None => vec![(key.into(), "".into())], 32 | } 33 | } 34 | } 35 | 36 | impl QueryParameter for Vec { 37 | fn as_query_kv(&self, key: &str) -> Vec { 38 | let mut query_pairs: Vec = Vec::new(); 39 | for item in self.iter() { 40 | query_pairs.append(&mut item.as_query_kv(key)); 41 | } 42 | query_pairs 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tapioca-codegen/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit="256"] 2 | 3 | extern crate inflector; 4 | extern crate proc_macro; 5 | #[macro_use] extern crate quote; 6 | extern crate regex; 7 | extern crate reqwest; 8 | extern crate syn; 9 | extern crate yaml_rust; 10 | 11 | use proc_macro::TokenStream; 12 | 13 | use std::collections::hash_map::DefaultHasher; 14 | use std::hash::{Hash, Hasher}; 15 | 16 | mod parse; 17 | mod infer; 18 | 19 | #[proc_macro] 20 | pub fn infer(input: TokenStream) -> TokenStream { 21 | let source = input.to_string(); 22 | let schema_url = source.split('"') 23 | .nth(1).expect("Expected a quoted URL"); 24 | 25 | impl_schema(&schema_url).parse().unwrap() 26 | } 27 | 28 | fn impl_schema(schema_url: &str) -> quote::Tokens { 29 | let mut url_hasher = DefaultHasher::new(); 30 | schema_url.hash(&mut url_hasher); 31 | 32 | let schema_fname = format!("{}.yml", url_hasher.finish()); 33 | let schema = match parse::parse_schema(&schema_fname) { 34 | Ok(s) => s, 35 | Err(_) => { 36 | match parse::fetch_schema(&schema_fname, &schema_url) { 37 | Ok(s) => s, 38 | Err(e) => panic!("Unable to find schema: {}", e.description()), 39 | } 40 | } 41 | }; 42 | 43 | match infer::infer_schema(&schema) { 44 | Ok(tokens) => tokens, 45 | Err(error) => panic!("Failed to infer schema: {}", error), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/httpbin.rs: -------------------------------------------------------------------------------- 1 | #![feature(associated_consts)] 2 | #![feature(use_extern_macros)] 3 | 4 | #[macro_use] 5 | extern crate tapioca; 6 | 7 | infer_api!(httpbin, "https://raw.githubusercontent.com/OJFord/tapioca/master/tests/schemata/httpbin.yml"); 8 | 9 | use httpbin::basic_auth__user__hunter_2 as basic_auth; 10 | use basic_auth::get::OpAuth::HttpBasic; 11 | 12 | static USER: &str = "baz"; 13 | 14 | fn main() { 15 | let auth = httpbin::ServerAuth::new(); 16 | 17 | match httpbin::ip::get(auth) { 18 | Ok(response) => match response.body() { 19 | httpbin::ip::get::OkBody::Status200(body) => println!("Your IP is {}", body.origin), 20 | _ => println!("httbin.org did something unexpected"), 21 | }, 22 | Err(_) => println!("httpbin.org errored"), 23 | } 24 | 25 | let user_id = basic_auth::ResourceId_user::from_static(USER); 26 | let auth = HttpBasic((USER.into(), "hunter2".into()).into()); 27 | match basic_auth::get(&user_id, auth.into()) { 28 | Ok(response) => match response.body() { 29 | basic_auth::get::OkBody::Status200(body) => if body.authenticated { 30 | println!("User '{}' authenticated OK!", body.user) 31 | } else { 32 | println!("Authentication failed for user '{}'!", body.user) 33 | }, 34 | _ => println!("httbin.org did something unexpected"), 35 | }, 36 | Err(_) => println!("httpbin.org errored"), 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/datatype/mod.rs: -------------------------------------------------------------------------------- 1 | use query::QueryPair; 2 | use query::QueryParameter; 3 | 4 | pub type Required = T; 5 | pub type Optional = Option; 6 | 7 | pub(crate) trait TapiocaDatatype: QueryParameter {} 8 | 9 | 10 | impl TapiocaDatatype for bool {} 11 | impl QueryParameter for bool { 12 | fn as_query_kv(&self, key: &str) -> Vec { 13 | if *self { 14 | vec![(key.into(), "".into())] 15 | } else { 16 | Vec::new() 17 | } 18 | } 19 | } 20 | 21 | impl TapiocaDatatype for f32 {} 22 | impl QueryParameter for f32 { 23 | fn as_query_kv(&self, key: &str) -> Vec { 24 | vec![(key.into(), self.to_string())] 25 | } 26 | } 27 | 28 | impl TapiocaDatatype for f64 {} 29 | impl QueryParameter for f64 { 30 | fn as_query_kv(&self, key: &str) -> Vec { 31 | vec![(key.into(), self.to_string())] 32 | } 33 | } 34 | 35 | impl TapiocaDatatype for i32 {} 36 | impl QueryParameter for i32 { 37 | fn as_query_kv(&self, key: &str) -> Vec { 38 | vec![(key.into(), self.to_string())] 39 | } 40 | } 41 | 42 | impl TapiocaDatatype for i64 {} 43 | impl QueryParameter for i64 { 44 | fn as_query_kv(&self, key: &str) -> Vec { 45 | vec![(key.into(), self.to_string())] 46 | } 47 | } 48 | 49 | impl TapiocaDatatype for String {} 50 | impl QueryParameter for String { 51 | fn as_query_kv(&self, key: &str) -> Vec { 52 | vec![(key.into(), self.clone())] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tapioca-codegen/src/parse.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fs; 3 | use std::io::prelude::*; 4 | use ::reqwest; 5 | use ::yaml_rust::{Yaml, YamlLoader}; 6 | 7 | const CACHE_DIR: &'static str = ".tapioca-cache"; 8 | 9 | type Schema = Yaml; 10 | type SchemaResult = Result>; 11 | 12 | fn cache_path(schema_fname: &String) -> String { 13 | fs::create_dir_all(&CACHE_DIR).unwrap(); 14 | format!("{}/{}", &CACHE_DIR, &schema_fname) 15 | } 16 | 17 | fn parse_first_doc(buf: &str) -> SchemaResult { 18 | let docs = YamlLoader::load_from_str(buf.as_ref())?; 19 | if docs.len() == 0 { 20 | Err(From::from("Could not parse YAML.")) 21 | } else { 22 | Ok(docs[0].clone()) 23 | } 24 | } 25 | 26 | pub(super) fn parse_schema(schema_fname: &String) -> SchemaResult { 27 | let mut file = fs::File::open(cache_path(&schema_fname))?; 28 | let mut buf = String::new(); 29 | 30 | file.read_to_string(&mut buf)?; 31 | parse_first_doc(&buf) 32 | } 33 | 34 | pub(super) fn fetch_schema(schema_fname: &String, schema_url: &str) -> SchemaResult { 35 | let mut file = fs::File::create(cache_path(&schema_fname))?; 36 | let mut buf = String::new(); 37 | 38 | let mut resp = reqwest::get(schema_url)?; 39 | if resp.status().is_success() { 40 | resp.read_to_string(&mut buf)?; 41 | file.write_all(buf.as_ref())?; 42 | 43 | parse_first_doc(&buf) 44 | } else { 45 | Err(From::from(format!("Failed to fetch schema: {}", resp.status()))) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/status.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | use httpbin::redirect_to; 7 | use httpbin::redirect_to::get::QueryParams; 8 | 9 | #[test] 10 | fn ok_err_matching() { 11 | let auth = httpbin::ServerAuth::new(); 12 | 13 | let query200 = QueryParams { 14 | url: "http://httpbin.org/status/200".into(), 15 | }; 16 | 17 | let query400 = QueryParams { 18 | url: "http://httpbin.org/status/400".into(), 19 | }; 20 | 21 | match redirect_to::get(&query200, auth) { 22 | Ok(_) => assert!(true), 23 | Err(_) => assert!(false), 24 | } 25 | 26 | match redirect_to::get(&query400, auth) { 27 | Ok(_) => assert!(false), 28 | Err(_) => assert!(true), 29 | } 30 | } 31 | 32 | #[test] 33 | fn status_body_matching() { 34 | let auth = httpbin::ServerAuth::new(); 35 | 36 | let query200 = QueryParams { 37 | url: "http://httpbin.org/status/200".into(), 38 | }; 39 | 40 | let query400 = QueryParams { 41 | url: "http://httpbin.org/status/400".into(), 42 | }; 43 | 44 | match redirect_to::get(&query200, auth) { 45 | Ok(response) => match response.body() { 46 | redirect_to::get::OkBody::Status200(_) => assert!(true), 47 | _ => assert!(false), 48 | }, 49 | Err(_) => assert!(false), 50 | } 51 | 52 | match redirect_to::get(&query400, auth) { 53 | Ok(_) => assert!(false), 54 | Err(response) => match response.body() { 55 | redirect_to::get::ErrBody::Status400(_) => assert!(true), 56 | _ => assert!(false), 57 | }, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/response/mod.rs: -------------------------------------------------------------------------------- 1 | pub use self::body::ResponseBody; 2 | pub use self::body::ResponseResultBody; 3 | pub use self::status::Status; 4 | pub use self::status::StatusCode; 5 | 6 | pub mod body; 7 | pub mod status; 8 | 9 | pub type ClientResponse = ::reqwest::Response; 10 | // O: Response, E: Response c.f. rust-lang/rust#21903 11 | pub type ResponseResult = Result; 12 | 13 | pub trait Response { 14 | type BodyType: ResponseBody; 15 | 16 | fn from(&mut Option<&mut ClientResponse>) -> Self; 17 | 18 | fn body(self) -> Self::BodyType; 19 | fn status_code(&self) -> StatusCode; 20 | 21 | fn is_ok(&self) -> bool { 22 | self.status_code().is_ok() 23 | } 24 | } 25 | 26 | impl Response for ResponseResult 27 | where O: Response, E: Response 28 | { 29 | type BodyType = ResponseResultBody; 30 | 31 | fn from(maybe_response: &mut Option<&mut ClientResponse>) -> Self { 32 | let error = match *maybe_response { 33 | Some(ref response) => StatusCode::of(&Some(response)).is_err(), 34 | None => true, 35 | }; 36 | 37 | match *maybe_response { 38 | Some(_) => if error { 39 | Err(E::from(maybe_response)) 40 | } else { 41 | Ok(O::from(maybe_response)) 42 | }, 43 | None => Err(E::from(&mut None)), 44 | } 45 | } 46 | 47 | fn body(self) -> Self::BodyType { 48 | match self { 49 | Ok(s) => Ok(O::body(s)), 50 | Err(s) => Err(E::body(s)), 51 | } 52 | } 53 | 54 | fn status_code(&self) -> StatusCode { 55 | match *self { 56 | Ok(ref s) => O::status_code(s), 57 | Err(ref s) => E::status_code(s), 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/uber.rs: -------------------------------------------------------------------------------- 1 | #![feature(associated_consts)] 2 | #![feature(use_extern_macros)] 3 | 4 | #[macro_use] 5 | extern crate tapioca; 6 | 7 | infer_api!(uber, "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/OpenAPI.next/examples/v3.0/uber.yaml"); 8 | 9 | fn main() { 10 | use uber::products::get::{OpAuth, QueryParams, OkBody, ErrBody}; 11 | 12 | let auth = OpAuth::Apikey("abc".into()); 13 | 14 | let query_params = QueryParams { 15 | latitude: 10.3, 16 | longitude: 237.8, 17 | }; 18 | 19 | match uber::products::get(&query_params, auth) { 20 | Ok(result) => match result.body() { 21 | OkBody::Status200(body) => { 22 | let list = body.products 23 | .unwrap_or_else(|| vec![]); 24 | 25 | if !list.is_empty() { 26 | let first = &list[0]; 27 | let default = String::from("Unknown"); 28 | let first_id = first.product_id 29 | .as_ref() 30 | .unwrap_or(&default); 31 | 32 | println!("First product: {}", first_id); 33 | } else { 34 | println!("No products!"); 35 | } 36 | }, 37 | OkBody::UnspecifiedCode(body) => 38 | println!("Grr.. the server returned something not in its schema: {}", body), 39 | OkBody::MalformedJSON(body) => println!("Bad response: {}", body), 40 | }, 41 | Err(result) => match result.body() { 42 | ErrBody::UnspecifiedCode(body) => { 43 | let message = body.message 44 | .unwrap_or_else(|| String::from("[None given]")); 45 | 46 | println!("Error message: {}", message); 47 | }, 48 | ErrBody::NetworkFailure() => println!("Request failed!"), 49 | ErrBody::MalformedJSON(body) => println!("Bad response: {}", body), 50 | }, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/schema.rs: -------------------------------------------------------------------------------- 1 | #![feature(use_extern_macros)] 2 | extern crate tapioca_testutil; 3 | 4 | tapioca_testutil::infer_test_api!(httpbin); 5 | 6 | #[test] 7 | fn response_ref() { 8 | use httpbin::anything_ref; 9 | 10 | let auth = httpbin::ServerAuth::new(); 11 | let test_vec: Vec = vec!["foobar".into(), "bazzer".into()]; 12 | let query = anything_ref::get::QueryParams { 13 | array: test_vec.clone(), 14 | }; 15 | 16 | match anything_ref::get(&query, auth) { 17 | Ok(response) => match response.body() { 18 | anything_ref::get::OkBody::Status200(body) => assert_eq!(body.args.array, test_vec), 19 | _ => assert!(false), 20 | }, 21 | _ => assert!(false), 22 | } 23 | } 24 | 25 | #[test] 26 | fn response_array() { 27 | use httpbin::anything_array; 28 | 29 | let auth = httpbin::ServerAuth::new(); 30 | let test_vec: Vec = vec![1.2, 2.3, 4.5]; 31 | let query = anything_array::get::QueryParams { 32 | array: test_vec.clone(), 33 | }; 34 | 35 | match anything_array::get(&query, auth) { 36 | Ok(response) => match response.body() { 37 | anything_array::get::OkBody::Status200(body) => assert_eq!( 38 | body.args.array, 39 | test_vec.iter().map(ToString::to_string).collect::>() 40 | ), 41 | _ => assert!(false), 42 | }, 43 | _ => assert!(false), 44 | } 45 | } 46 | 47 | #[test] 48 | fn request() { 49 | use httpbin::patch; 50 | 51 | let auth = httpbin::ServerAuth::new(); 52 | let req_body = patch::patch::RequestBody { 53 | musthave: "foobar".into(), 54 | ifyouwant: Some(vec![]), 55 | }; 56 | 57 | match patch::patch(&req_body, auth) { 58 | Ok(response) => match response.body() { 59 | patch::patch::OkBody::Status200(body) => assert_eq!(body.json, req_body), 60 | _ => assert!(false), 61 | }, 62 | _ => assert!(false), 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/query.rs: -------------------------------------------------------------------------------- 1 | use ::inflector::Inflector; 2 | use ::quote::Tokens; 3 | use ::syn::Ident; 4 | use ::yaml_rust::Yaml; 5 | 6 | use infer::datatype; 7 | use infer::StructBoundArgImpl; 8 | 9 | fn ident(param: &str) -> Ident { 10 | Ident::new(param.to_snake_case()) 11 | } 12 | 13 | pub(super) fn infer_v3(structs_mod: &Ident, schema: &Yaml) -> StructBoundArgImpl { 14 | let mut idents: Vec = Vec::new(); 15 | let mut types: Vec = Vec::new(); 16 | let mut name_strs: Vec = Vec::new(); 17 | 18 | for schema in schema.as_vec().unwrap() { 19 | let name = schema["name"].as_str() 20 | .expect("Parameter name must be a string."); 21 | let (param_type, _) = datatype::infer_v3(&schema["schema"])?; 22 | 23 | let mandate: Tokens; 24 | if let Some(true) = schema["required"].as_bool() { 25 | mandate = quote!(::tapioca::datatype::Required); 26 | } else { 27 | mandate = quote!(::tapioca::datatype::Optional); 28 | } 29 | 30 | idents.push(ident(name)); 31 | types.push(quote!{ #mandate<#param_type> }); 32 | name_strs.push(quote!{ #name }); 33 | } 34 | 35 | let idents2 = idents.clone(); 36 | Ok(( 37 | Some(quote! { 38 | use ::tapioca::query::QueryPair; 39 | use ::tapioca::query::QueryParameter; 40 | use ::tapioca::query::QueryString; 41 | 42 | #[derive(Debug)] 43 | pub struct QueryParams { 44 | #(pub #idents: #types),* 45 | } 46 | 47 | impl QueryString for QueryParams { 48 | fn as_query_kv(&self) -> Vec { 49 | let mut params: Vec = Vec::new(); 50 | #(params.append(&mut self.#idents2.as_query_kv(#name_strs));)* 51 | params 52 | } 53 | } 54 | }), 55 | None, 56 | Some(quote! { query_parameters: &#structs_mod::QueryParams }), 57 | Some(quote! { 58 | .query_pairs_mut() 59 | .extend_pairs(query_parameters.as_query_kv()) 60 | .finish() 61 | }) 62 | )) 63 | } 64 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/params.rs: -------------------------------------------------------------------------------- 1 | use ::quote::Tokens; 2 | use ::syn::Ident; 3 | use ::yaml_rust::Yaml; 4 | 5 | use infer::datatype; 6 | use infer::StructBoundArgImpl; 7 | 8 | pub(super) fn infer_v3(method: &str, schema: &Yaml) -> StructBoundArgImpl { 9 | let method = method.to_uppercase(); 10 | 11 | let mut idents: Vec = Vec::new(); 12 | let mut types: Vec = Vec::new(); 13 | let mut supporting_types: Vec = Vec::new(); 14 | let mut placeholders: Vec = Vec::new(); 15 | 16 | for schema in schema.as_vec().unwrap() { 17 | let name = schema["name"].as_str() 18 | .expect("Parameter name must be a string."); 19 | let (param_type, maybe_at) = datatype::infer_v3(&schema["schema"])?; 20 | 21 | let from_static_type = match param_type.as_str() { 22 | "String" => quote!(str), 23 | _ => param_type.clone(), 24 | }; 25 | let struct_ident = Ident::new(format!("ResourceId_{}", name)); 26 | supporting_types.push(quote! { 27 | #[allow(non_camel_case_types)] 28 | pub struct #struct_ident(::tapioca::datatype::Required<#param_type>); 29 | 30 | impl #struct_ident { 31 | #[allow(dead_code)] 32 | pub fn from_static(id: &'static #from_static_type) -> Self { 33 | Self { 0: id.clone().into() } 34 | } 35 | } 36 | 37 | impl ToString for #struct_ident { 38 | fn to_string(&self) -> String { 39 | self.0.to_string() 40 | } 41 | } 42 | }); 43 | 44 | idents.push(Ident::new(name)); 45 | types.push(quote!{ #struct_ident }); 46 | placeholders.push(format!("{{{}}}", name)); 47 | 48 | if let Some(supporting_type) = maybe_at { 49 | supporting_types.push(supporting_type); 50 | } 51 | } 52 | 53 | let params = idents.clone(); 54 | 55 | let endpoint_id_arg = match (method.as_str(), idents.pop(), types.pop()) { 56 | ("DELETE", Some(endp_ident), Some(endp_type)) 57 | // The resource ID value is moved here, to avoid its reuse 58 | // !FIXME: this assumes that the DELETE request succeeds 59 | => quote!(#endp_ident: #endp_type), 60 | 61 | (_, Some(endp_ident), Some(endp_type)) 62 | // We take a reference to the ID, as for any others if nested 63 | => quote!(#endp_ident: &#endp_type), 64 | 65 | (_, None, _) 66 | | (_, _, None) => panic!("params::infer called without any params to infer"), 67 | }; 68 | 69 | Ok(( 70 | Some(quote!{ #(#supporting_types)* }), 71 | None, 72 | Some(quote!{ #(#idents: &#types,)* #endpoint_id_arg }), 73 | Some(quote! { 74 | .path_segments_mut().unwrap() 75 | .clear() 76 | .push(Url::parse(self::API_URL).unwrap().path()) 77 | .extend(self::API_PATH.split('/').map(|p| match p { 78 | #(#placeholders => #params.to_string(),)* 79 | _ => p.to_string(), 80 | })) 81 | }) 82 | )) 83 | } 84 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/path.rs: -------------------------------------------------------------------------------- 1 | use ::inflector::Inflector; 2 | use ::regex::Regex; 3 | use ::syn::Ident; 4 | use ::quote::Tokens; 5 | use ::yaml_rust::Yaml; 6 | 7 | use infer::method; 8 | use infer::TokensResult; 9 | 10 | fn mod_ident(path: &str) -> Ident { 11 | let rustified = path.replace('/', " ").trim().to_snake_case(); 12 | let re = Regex::new(r"\{(?P[^}]+)_\}").unwrap(); 13 | let ident = re.replace_all(rustified.as_str(), "_${resource}_"); 14 | 15 | Ident::new(ident) 16 | } 17 | 18 | pub(super) fn infer_v3(path: &str, schema: &Yaml) -> TokensResult { 19 | let path_mod = mod_ident(path); 20 | 21 | let method_schemata = schema.as_hash().expect("Path must be a map."); 22 | let mut method_impls: Vec = Vec::new(); 23 | let mut path_level_structs = quote!(); 24 | 25 | for (method, method_schema) in method_schemata { 26 | let method = method.as_str().expect("Method must be a string."); 27 | let (method_impl, maybe_param_structs) = method::infer_v3(&method, &method_schema)?; 28 | 29 | method_impls.push(method_impl); 30 | if let Some(param_structs) = maybe_param_structs { 31 | path_level_structs = param_structs; 32 | } 33 | } 34 | 35 | Ok(quote! { 36 | #[allow(non_snake_case)] 37 | pub mod #path_mod { 38 | #[allow(unused_imports)] 39 | use ::tapioca::Body; 40 | use ::tapioca::Client; 41 | use ::tapioca::Url; 42 | use ::tapioca::header; 43 | use ::tapioca::response::Response; 44 | #[allow(unused_imports)] 45 | use ::tapioca::query::QueryString; 46 | 47 | #[allow(unused_imports)] 48 | use super::auth_scheme; 49 | #[allow(unused_imports)] 50 | use super::schema_ref; 51 | use super::API_URL; 52 | #[allow(unused_imports)] 53 | use super::ServerAuth; 54 | 55 | const API_PATH: &'static str = #path; 56 | 57 | #path_level_structs 58 | 59 | #(#method_impls)* 60 | } 61 | }) 62 | } 63 | 64 | #[cfg(tests)] 65 | mod tests { 66 | use super::*; 67 | 68 | #[test] 69 | fn leading_slash() { 70 | assert_eq!(mod_ident("/foo"), Ident::new("foo")); 71 | } 72 | 73 | #[test] 74 | fn both_slash() { 75 | assert_eq!(mod_ident("/foo/"), Ident::new("foo")); 76 | } 77 | 78 | #[test] 79 | fn no_slash() { 80 | assert_eq!(mod_ident("foo"), Ident::new("foo")); 81 | } 82 | 83 | #[test] 84 | fn trailing_slash() { 85 | assert_eq!(mod_ident("foo/"), Ident::new("foo")); 86 | } 87 | 88 | #[test] 89 | fn multipart() { 90 | assert_eq!(mod_ident("/foo/bar"), Ident::new("foo_bar")); 91 | } 92 | 93 | #[test] 94 | fn resource() { 95 | assert_eq!(mod_ident("/foo/{id}"), Ident::new("foo__id_")); 96 | } 97 | 98 | #[test] 99 | fn multi_resource() { 100 | assert_eq!(mod_ident("/foo/{id}/{bar}"), Ident::new("foo__id___bar_")); 101 | } 102 | 103 | #[test] 104 | fn multipart_resource() { 105 | assert_eq!(mod_ident("/foo/{id}/bar"), Ident::new("foo__id__bar")); 106 | } 107 | 108 | #[test] 109 | fn multipart_multiresource() { 110 | assert_eq!(mod_ident("/foo/{id}/bar/{bar_id}"), Ident::new("foo__id__bar__bar_id_")); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/auth.rs: -------------------------------------------------------------------------------- 1 | use ::inflector::Inflector; 2 | use ::quote::Tokens; 3 | use ::syn::Ident; 4 | use ::yaml_rust::Yaml; 5 | 6 | use infer::TokensResult; 7 | 8 | fn infer_v3_http(scheme_ident: &Ident, schema: &Yaml) -> TokensResult { 9 | match schema["scheme"].as_str().expect("http security scheme must be a string") 10 | .to_title_case().as_str() 11 | { 12 | "Basic" => Ok(quote! { 13 | pub type #scheme_ident = ::tapioca::auth::HttpBasic; 14 | }), 15 | _ => Err(From::from("currently supported HTTP auth schemes are: Basic")), 16 | } 17 | } 18 | 19 | fn infer_v3_api_key(scheme_ident: &Ident, schema: &Yaml) -> TokensResult { 20 | let header_name = schema["name"].as_str().expect("apiKey header name must be a string"); 21 | 22 | Ok(quote! { 23 | #[derive(Clone, Debug)] 24 | pub struct #scheme_ident(String); 25 | 26 | impl From<&'static str> for #scheme_ident { 27 | fn from(key: &'static str) -> Self { 28 | Self { 0: key.into() } 29 | } 30 | } 31 | 32 | impl From for #scheme_ident { 33 | fn from(key: String) -> Self { 34 | Self { 0: key } 35 | } 36 | } 37 | 38 | impl header::Header for #scheme_ident { 39 | fn header_name() -> &'static str { 40 | #header_name 41 | } 42 | 43 | fn parse_header(raw: &[Vec]) -> HeaderResult<#scheme_ident> { 44 | Ok(Self { 0: String::from_utf8(raw[0].clone())? }) 45 | } 46 | } 47 | 48 | impl header::HeaderFormat for #scheme_ident { 49 | fn fmt_header(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { 50 | f.write_str(self.0.as_str()) 51 | } 52 | } 53 | }) 54 | } 55 | 56 | pub(super) fn infer_v3_component(scheme_name: &str, schema: &Yaml) -> TokensResult { 57 | let ident = Ident::from(scheme_name.to_class_case()); 58 | 59 | match schema["type"].as_str().expect("security scheme type must be a string") 60 | .to_camel_case().as_str() 61 | { 62 | "http" => infer_v3_http(&ident, &schema), 63 | "apiKey" => infer_v3_api_key(&ident, &schema), 64 | _ => Err(From::from("currently supported auth types are: http; apiKey")), 65 | } 66 | } 67 | 68 | pub(super) fn infer_v3(struct_name: &Ident, schema: &Yaml) -> (Tokens, Vec) { 69 | let mut scheme_variants: Vec = Vec::new(); 70 | let mut scheme_models: Vec = Vec::new(); 71 | let mut scopes_models: Vec = Vec::new(); 72 | 73 | for scheme in schema.as_vec().expect("security requirements must be an array") { 74 | let scheme = scheme.as_hash().expect("security requirement must be a map"); 75 | let scheme_id = scheme.keys().collect::>().pop().unwrap(); 76 | let scopes = &scheme[scheme_id]; 77 | 78 | let classname = scheme_id.as_str() 79 | .expect("security scheme identifier must be a string"); 80 | let ident = Ident::from(classname.to_class_case()); 81 | 82 | scheme_variants.push(ident.clone()); 83 | scheme_models.push(quote!{ auth_scheme::#ident }); 84 | 85 | let mut scopes_model = quote!(); 86 | for scope in scopes.as_vec().expect("scope must be an array") { 87 | let classname = scope.as_str().expect("scope must be a string") 88 | .to_class_case(); 89 | let ident = Ident::from(classname); 90 | 91 | scopes_model.append(quote!{ auth_scheme::scope::#ident, }); 92 | } 93 | 94 | scopes_models.push(scopes_model); 95 | } 96 | 97 | let scheme_variants2 = scheme_variants.clone(); 98 | ( 99 | quote! { 100 | #[derive(Clone, Debug)] 101 | pub enum #struct_name { 102 | #(#scheme_variants(#scheme_models<#scopes_models>),)* 103 | } 104 | }, 105 | scheme_variants2 106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/datatype.rs: -------------------------------------------------------------------------------- 1 | use ::std::collections::hash_map::DefaultHasher; 2 | use ::std::hash::{Hash, Hasher}; 3 | use ::inflector::Inflector; 4 | use ::quote::Tokens; 5 | use ::syn::Ident; 6 | use ::yaml_rust::Yaml; 7 | 8 | use infer::Error; 9 | 10 | type TypeSupportResult = Result< 11 | (Tokens, Option), 12 | Box 13 | >; 14 | 15 | pub(super) fn infer_v3(schema: &Yaml) -> TypeSupportResult { 16 | if let Some(schema_ref) = schema["$ref"].as_str() { 17 | let ref_name = schema_ref.rsplit('/') 18 | .next().expect("Malformed $ref") 19 | .to_class_case(); 20 | let ident = Ident::new(ref_name); 21 | 22 | Ok((quote!{ schema_ref::#ident }, None)) 23 | } else { 24 | match schema["type"].as_str() { 25 | None => Err(From::from("Parameter schema type must be a string.")), 26 | 27 | Some("array") => { 28 | let (item_type, supp_types) = infer_v3(&schema["items"])?; 29 | Ok((quote!{ Vec<#item_type> }, supp_types)) 30 | }, 31 | 32 | Some("object") => { 33 | let mut fields: Vec = Vec::new(); 34 | let mut additional_types: Vec = Vec::new(); 35 | let required: Vec<&str> = match schema["required"].as_vec() { 36 | Some(v) => v.iter() 37 | .map(|e| e.as_str() 38 | .expect("Required field names must be strings.") 39 | ) 40 | .collect(), 41 | None => Vec::new(), 42 | }; 43 | 44 | for (name, schema) in schema["properties"].as_hash() 45 | .expect("Properties must be a map.") 46 | { 47 | let name = name.as_str() 48 | .expect("Property keys must be strings."); 49 | 50 | let rusty_ident = Ident::new(name.to_snake_case()); 51 | let (field_type, supp_types) = infer_v3(&schema)?; 52 | 53 | if let Some(supp_types) = supp_types { 54 | additional_types.push(supp_types); 55 | } 56 | 57 | if required.contains(&name) { 58 | fields.push(quote!{ 59 | #[serde(rename=#name)] 60 | pub #rusty_ident: #field_type 61 | }); 62 | } else { 63 | fields.push(quote!{ 64 | #[serde(rename=#name)] 65 | pub #rusty_ident: Option<#field_type> 66 | }); 67 | } 68 | } 69 | 70 | let mut hasher = DefaultHasher::new(); 71 | let field_strs: Vec = fields.iter() 72 | .map(|f| f.to_string()) 73 | .collect(); 74 | field_strs.hash(&mut hasher); 75 | let ident = Ident::new(format!("Type{}", hasher.finish())); 76 | 77 | Ok(( 78 | quote!(#ident), 79 | Some(quote!{ 80 | #(#additional_types)* 81 | 82 | #[derive(Debug, Deserialize, PartialEq, Serialize)] 83 | pub struct #ident { 84 | #(#fields),* 85 | } 86 | }) 87 | )) 88 | }, 89 | 90 | Some("integer") => { 91 | match schema["format"].as_str() { 92 | None 93 | | Some("int64") => Ok((quote!{i64}, None)), 94 | Some("int32") => Ok((quote!{i32}, None)), 95 | Some(_) => Err(From::from("Invalid format for `integer` type.")), 96 | } 97 | }, 98 | 99 | Some("number") => { 100 | match schema["format"].as_str() { 101 | None 102 | | Some("double") => Ok((quote!{f64}, None)), 103 | Some("float") => Ok((quote!{f32}, None)), 104 | Some(_) => Err(From::from("Invalid format for `number` type.")), 105 | } 106 | }, 107 | 108 | Some("string") => { 109 | match schema["format"].as_str() { 110 | None => Ok((quote!{String}, None)), 111 | Some("byte") => Ok((quote!{::tapioca::Base64}, None)), 112 | Some("binary") => Ok((quote!{&'static [u8]}, None)), 113 | Some("date") => Ok((quote!{::tapioca::Date}, None)), 114 | Some("date-time") => Ok((quote!{::tapioca::DateTime}, None)), 115 | Some("password") => Ok((quote!{String}, None)), 116 | Some(_) => Ok((quote!{String}, None)), 117 | } 118 | }, 119 | 120 | Some("boolean") => { 121 | match schema["format"].as_str() { 122 | None => Ok((quote!{bool}, None)), 123 | Some(_) => Err(From::from("Unexpected format for `boolean` type.")), 124 | } 125 | }, 126 | 127 | Some(ptype) => Err(From::from(format!("Parameter type `{}` invalid", ptype))), 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/method.rs: -------------------------------------------------------------------------------- 1 | use ::inflector::Inflector; 2 | use ::quote::Tokens; 3 | use ::syn::Ident; 4 | use ::yaml_rust::Yaml; 5 | 6 | use infer::auth; 7 | use infer::body; 8 | use infer::params; 9 | use infer::query; 10 | use infer::response; 11 | use infer::InferResult; 12 | 13 | const METHODS: &'static [&'static str] = &[ 14 | "DELETE", 15 | "GET", 16 | "HEAD", 17 | "PATCH", 18 | "POST", 19 | "PUT", 20 | ]; 21 | 22 | pub(super) fn valid(method: &str) -> bool { 23 | METHODS.contains(&method.to_uppercase().as_str()) 24 | } 25 | 26 | fn fn_ident(method: &str) -> Ident { 27 | assert!(valid(method), "Invalid method: {}", method); 28 | Ident::new(method.to_snake_case()) 29 | } 30 | 31 | fn mod_ident(method: &str) -> Ident { 32 | assert!(valid(method), "Invalid method: {}", method); 33 | Ident::new(method.to_snake_case()) 34 | } 35 | 36 | pub(super) fn infer_v3(method: &str, schema: &Yaml) -> InferResult<(Tokens, Option)> { 37 | let method_fn = fn_ident(&method); 38 | let method_mod = mod_ident(&method); 39 | 40 | let mut method_level_structs: Vec = Vec::new(); 41 | let mut path_level_structs: Option = None; 42 | let mut bounds: Vec = Vec::new(); 43 | let mut args: Vec = Vec::new(); 44 | let mut url_transforms: Vec = Vec::new(); 45 | let mut req_transforms: Vec = Vec::new(); 46 | 47 | if let Some(parameters) = schema["parameters"].as_vec() { 48 | let query_parameters = parameters.iter().cloned() 49 | .filter(|p| p["in"] == Yaml::from_str("query")) 50 | .collect::>(); 51 | 52 | let path_parameters = parameters.iter().cloned() 53 | .filter(|p| p["in"] == Yaml::from_str("path")) 54 | .collect::>(); 55 | 56 | if !query_parameters.is_empty() { 57 | let (s, b, a, t) = query::infer_v3(&method_mod, &Yaml::Array(query_parameters))?; 58 | if let Some(method_struct) = s { 59 | method_level_structs.push(method_struct); 60 | } 61 | if let Some(bound) = b { 62 | bounds.push(bound); 63 | } 64 | if let Some(arg) = a { 65 | args.push(arg); 66 | } 67 | if let Some(transformation) = t { 68 | url_transforms.push(transformation); 69 | } 70 | } 71 | 72 | if !path_parameters.is_empty() { 73 | let (s, b, a, t) = params::infer_v3(&method, &Yaml::Array(path_parameters))?; 74 | 75 | path_level_structs = path_level_structs.or(s); 76 | if let Some(bound) = b { 77 | bounds.push(bound); 78 | } 79 | if let Some(arg) = a { 80 | args.push(arg); 81 | } 82 | if let Some(transformation) = t { 83 | url_transforms.push(transformation); 84 | } 85 | } 86 | } 87 | 88 | match schema["requestBody"] { 89 | Yaml::BadValue => (), 90 | ref schema => { 91 | let (s, b, a, t) = body::infer_v3(&method_mod, &schema)?; 92 | 93 | if let Some(method_struct) = s { 94 | method_level_structs.push(method_struct); 95 | } 96 | if let Some(bound) = b { 97 | bounds.push(bound); 98 | } 99 | if let Some(arg) = a { 100 | args.push(arg); 101 | } 102 | if let Some(transformation) = t { 103 | req_transforms.push(transformation); 104 | } 105 | } 106 | } 107 | 108 | match schema["security"] { 109 | Yaml::BadValue => { 110 | args.push(quote!{ authentication: ServerAuth }); 111 | req_transforms.push(quote! { 112 | .header(authentication) 113 | }); 114 | }, 115 | ref schema => { 116 | let struct_ident = Ident::new("OpAuth"); 117 | 118 | let (structs, variants) = auth::infer_v3(&struct_ident, &schema); 119 | let scheme_match_arms = variants.iter() 120 | .map(|v| quote!(#method_mod::#struct_ident::#v)) 121 | .collect::>(); 122 | 123 | method_level_structs.push(structs); 124 | args.push(quote!{ authentication: #method_mod::#struct_ident }); 125 | req_transforms.push(quote! { 126 | .header(match authentication { 127 | #(#scheme_match_arms(scheme) => scheme,)* 128 | }) 129 | }); 130 | }, 131 | } 132 | 133 | method_level_structs.push(response::infer_v3(&schema["responses"])?); 134 | 135 | Ok(( 136 | quote! { 137 | pub mod #method_mod { 138 | #[allow(unused_imports)] 139 | use ::tapioca::header; 140 | #[allow(unused_imports)] 141 | use ::tapioca::HeaderResult; 142 | #[allow(unused_imports)] 143 | use super::auth_scheme; 144 | #[allow(unused_imports)] 145 | use super::schema_ref; 146 | 147 | #(#method_level_structs)* 148 | } 149 | 150 | #[allow(dead_code)] 151 | #[allow(unused_mut)] 152 | pub fn #method_fn<#(#bounds),*>(#(#args),*) -> #method_mod::ResponseResult { 153 | let mut url = Url::parse( 154 | format!("{}{}", self::API_URL, self::API_PATH).as_str() 155 | ).expect("malformed server URL or path"); 156 | #(url#url_transforms;)* 157 | 158 | let client = Client::new().unwrap(); 159 | let request = client.#method_fn(url) 160 | .header(header::Accept::json()) 161 | #(#req_transforms)*; 162 | 163 | let mut response = request.send().ok(); 164 | <#method_mod::ResponseResult as Response>::from(&mut response.as_mut()) 165 | } 166 | }, 167 | path_level_structs, 168 | )) 169 | } 170 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/schema.rs: -------------------------------------------------------------------------------- 1 | use ::quote::Tokens; 2 | use ::syn::Ident; 3 | use ::yaml_rust::Yaml; 4 | 5 | use infer::auth; 6 | use infer::datatype; 7 | use infer::path; 8 | use infer::TokensResult; 9 | 10 | type FieldsSupport = (Vec, Vec); 11 | 12 | fn infer_ref_obj(schema: &Yaml, required: &Vec) -> FieldsSupport { 13 | let mut fields: Vec = Vec::new(); 14 | let mut additional_types: Vec = Vec::new(); 15 | 16 | for (field, schema) in schema["properties"].as_hash() 17 | .expect("Properties must be a map.") 18 | { 19 | let field_name = field.as_str() 20 | .expect("Property must be a string."); 21 | let field_ident = Ident::new(field_name); 22 | let (ty, maybe_at) = datatype::infer_v3(&schema).unwrap(); 23 | let mandate: Tokens; 24 | 25 | if let Some(true) = schema["required"].as_bool() { 26 | mandate = quote!(::tapioca::datatype::Required); 27 | } else if required.contains(field) { 28 | mandate = quote!(::tapioca::datatype::Required); 29 | } else { 30 | mandate = quote!(::tapioca::datatype::Optional); 31 | } 32 | 33 | fields.push(quote!{ #field_ident: #mandate<#ty> }); 34 | 35 | if let Some(additional_type) = maybe_at { 36 | additional_types.push(additional_type); 37 | } 38 | } 39 | 40 | (fields, additional_types) 41 | } 42 | 43 | fn infer_ref(ident: &Ident, schema: &Yaml, required: &Vec) -> TokensResult { 44 | match schema["properties"].as_hash() { 45 | Some(_) => { 46 | let (fields, additionals) = infer_ref_obj(&schema, &required); 47 | 48 | Ok(quote! { 49 | #(#additionals)* 50 | 51 | #[derive(Debug, Deserialize, PartialEq, Serialize)] 52 | pub struct #ident { 53 | #(pub #fields),* 54 | } 55 | }) 56 | }, 57 | None => { 58 | let (alias_to, maybe_at) = datatype::infer_v3(&schema)?; 59 | let additional_type = match maybe_at { 60 | Some(at) => at, 61 | None => quote!(), 62 | }; 63 | 64 | Ok(quote! { 65 | #additional_type 66 | 67 | #[allow(dead_code)] 68 | pub type #ident = #alias_to; 69 | }) 70 | }, 71 | } 72 | } 73 | 74 | pub(super) fn infer_v3(schema: &Yaml) -> TokensResult { 75 | let paths = schema["paths"].clone(); 76 | let path_impls: Vec = paths.as_hash() 77 | .expect("Paths must be a map.") 78 | .iter() 79 | .map(|(path, path_schema)| path::infer_v3( 80 | path.as_str().expect("Path must be a string."), &path_schema 81 | ).unwrap()) 82 | .collect(); 83 | 84 | let api_url = schema["servers"][0]["url"].as_str() 85 | .expect("Must have at least one server URL."); 86 | 87 | let mut schema_ref_defs: Vec = Vec::new(); 88 | let schema_refs = &schema["components"]["schemas"]; 89 | 90 | if !schema_refs.is_badvalue() { 91 | for (schema_ref, schema) in schema_refs.as_hash() 92 | .expect("#/components/schemas must be a map.") 93 | { 94 | let schema_ref_name = schema_ref.as_str() 95 | .expect("$ref name must be a string."); 96 | schema_ref_defs.push(infer_ref( 97 | &Ident::new(schema_ref_name), 98 | &schema, 99 | &schema["required"].as_vec().unwrap_or(&Vec::new()) 100 | )?); 101 | } 102 | } 103 | 104 | let mut auth_scheme_defs: Vec = Vec::new(); 105 | let auth_schemes = &schema["components"]["securitySchemes"]; 106 | 107 | if !auth_schemes.is_badvalue() { 108 | for (auth_scheme, schema) in auth_schemes.as_hash() 109 | .expect("#/components/securitySchemes must be a map.") 110 | { 111 | let auth_scheme_name = auth_scheme.as_str() 112 | .expect("security scheme name must be a string"); 113 | 114 | auth_scheme_defs.push(auth::infer_v3_component(auth_scheme_name, &schema)?); 115 | } 116 | } 117 | 118 | let server_auth_impl: Tokens; 119 | let auth_struct = Ident::new("ServerAuth"); 120 | let security_reqs = &schema["security"]; 121 | 122 | if !security_reqs.is_badvalue() { 123 | server_auth_impl = auth::infer_v3(&auth_struct, &security_reqs).0; 124 | } else { 125 | server_auth_impl = quote!{ 126 | #[derive(Clone, Copy, Debug, Default)] 127 | pub struct #auth_struct(()); 128 | 129 | impl #auth_struct { 130 | pub fn new() -> Self { 131 | Self { 0: () } 132 | } 133 | } 134 | 135 | impl header::Header for #auth_struct { 136 | fn header_name() -> &'static str { 137 | "X-No-Auth" 138 | } 139 | 140 | fn parse_header(_: &[Vec]) -> HeaderResult { 141 | Ok(Self::new()) 142 | } 143 | } 144 | 145 | impl header::HeaderFormat for #auth_struct { 146 | fn fmt_header(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { 147 | f.write_str("") 148 | } 149 | } 150 | } 151 | } 152 | 153 | Ok(quote! { 154 | pub mod schema_ref { 155 | #[allow(unused_imports)] 156 | use super::schema_ref; 157 | 158 | #(#schema_ref_defs)* 159 | } 160 | 161 | pub mod auth_scheme { 162 | #[allow(unused_imports)] 163 | use ::tapioca::header; 164 | #[allow(unused_imports)] 165 | use ::tapioca::HeaderResult; 166 | 167 | #(#auth_scheme_defs)* 168 | } 169 | 170 | use ::tapioca::header; 171 | use ::tapioca::HeaderResult; 172 | 173 | const API_URL: &'static str = #api_url; 174 | 175 | #[allow(dead_code)] 176 | #server_auth_impl 177 | 178 | #(#path_impls)* 179 | }) 180 | } 181 | -------------------------------------------------------------------------------- /tests/schemata/httpbin.yml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | servers: 3 | - url: https://httpbin.org 4 | paths: 5 | /anything: 6 | get: 7 | responses: 8 | 200: 9 | content: 10 | application/json: 11 | schema: 12 | type: object 13 | required: 14 | - headers 15 | properties: 16 | headers: 17 | type: object 18 | required: 19 | - Accept 20 | properties: 21 | Accept: 22 | type: string 23 | /anything/array: 24 | get: 25 | parameters: 26 | - name: array 27 | required: true 28 | in: query 29 | schema: 30 | type: array 31 | items: 32 | type: number 33 | format: float 34 | responses: 35 | 200: 36 | content: 37 | application/json: 38 | schema: 39 | type: object 40 | required: 41 | - args 42 | - method 43 | - origin 44 | - url 45 | properties: 46 | args: 47 | type: object 48 | required: 49 | - array 50 | properties: 51 | array: 52 | type: array 53 | items: 54 | type: string 55 | method: 56 | type: string 57 | origin: 58 | type: string 59 | url: 60 | type: string 61 | /anything/ref: 62 | get: 63 | parameters: 64 | - name: array 65 | required: true 66 | in: query 67 | schema: 68 | $ref: "#/components/schemas/AnArray" 69 | responses: 70 | 200: 71 | content: 72 | application/json: 73 | schema: 74 | type: object 75 | required: 76 | - args 77 | - method 78 | - origin 79 | - url 80 | properties: 81 | args: 82 | type: object 83 | required: 84 | - array 85 | properties: 86 | array: 87 | $ref: "#/components/schemas/AnArray" 88 | method: 89 | type: string 90 | origin: 91 | type: string 92 | url: 93 | type: string 94 | /basic-auth/{user}/hunter2: 95 | get: 96 | parameters: 97 | - name: user 98 | in: path 99 | schema: 100 | type: string 101 | security: 102 | - http_basic: [] 103 | responses: 104 | 200: 105 | content: 106 | application/json: 107 | schema: 108 | type: object 109 | required: 110 | - authenticated 111 | - user 112 | properties: 113 | authenticated: 114 | type: boolean 115 | user: 116 | type: string 117 | 401: 118 | description: "Not authorised" 119 | /ip: 120 | get: 121 | responses: 122 | 200: 123 | content: 124 | application/json: 125 | schema: 126 | type: object 127 | required: 128 | - origin 129 | properties: 130 | origin: 131 | type: string 132 | /patch: 133 | patch: 134 | requestBody: 135 | content: 136 | application/json: 137 | schema: 138 | $ref: "#/components/schemas/PatchBody" 139 | responses: 140 | 200: 141 | content: 142 | application/json: 143 | schema: 144 | type: object 145 | required: 146 | - headers 147 | - json 148 | properties: 149 | headers: 150 | type: object 151 | properties: 152 | Content-Length: 153 | type: string 154 | json: 155 | $ref: "#/components/schemas/PatchBody" 156 | /post: 157 | post: 158 | parameters: 159 | - name: echo 160 | required: false 161 | in: query 162 | schema: 163 | type: string 164 | responses: 165 | 200: 166 | content: 167 | application/json: 168 | schema: 169 | type: object 170 | required: 171 | - args 172 | - data 173 | - origin 174 | - url 175 | properties: 176 | args: 177 | type: object 178 | properties: 179 | echo: 180 | type: string 181 | data: 182 | type: string 183 | origin: 184 | type: string 185 | url: 186 | type: string 187 | /status/{code}: 188 | delete: 189 | parameters: 190 | - name: code 191 | in: path 192 | schema: 193 | type: integer 194 | format: int32 195 | responses: 196 | 200: 197 | description: "An Ok response" 198 | 201: 199 | description: "An Ok response" 200 | 400: 201 | description: "An Err response" 202 | 401: 203 | description: "An Err response" 204 | get: 205 | parameters: 206 | - name: code 207 | in: path 208 | schema: 209 | type: integer 210 | format: int32 211 | responses: 212 | 200: 213 | description: "An Ok response" 214 | 201: 215 | description: "An Ok response" 216 | 400: 217 | description: "An Err response" 218 | 401: 219 | description: "An Err response" 220 | /redirect-to: 221 | get: 222 | parameters: 223 | - name: url 224 | in: query 225 | required: true 226 | schema: 227 | type: string 228 | responses: 229 | 200: 230 | description: "An Ok response" 231 | 400: 232 | description: "An Err response" 233 | components: 234 | securitySchemes: 235 | http_basic: 236 | type: http 237 | scheme: basic 238 | schemas: 239 | AnArray: 240 | type: array 241 | items: 242 | type: string 243 | PatchBody: 244 | type: object 245 | required: 246 | - musthave 247 | properties: 248 | musthave: 249 | type: string 250 | ifyouwant: 251 | $ref: "#/components/schemas/AnArray" 252 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Tapioca 2 | ======= 3 | 4 | _**T**yped **API**s (that **O**llie **C**oshed into an **A**cronym)_ 5 | 6 | [![Crate](https://img.shields.io/crates/v/tapioca.svg)](https://crates.io/crates/tapioca) 7 | [![Build Status](https://travis-ci.org/OJFord/tapioca.svg?branch=master)](https://travis-ci.org/OJFord/tapioca) 8 | 9 | _tapioca_ is an HTTP client for _[rust](https://github.com/rust-lang/rust)_ that 10 | aims to help the compiler help _you_ to access REST+JSON APIs in a type-safer 11 | manner. 12 | 13 | It uses the [OpenAPI Initiative's schema specification](https://github.com/OAI/OpenAPI-Specification) 14 | to infer types for path and query parameters, request and response bodies, et 15 | al. and then [serde](serde-rs/json) to de/serialise them. 16 | 17 | ```rust 18 | infer_api!(service, "https://service.api/schema.yml") 19 | use service::path; 20 | 21 | fn main() { 22 | let auth = service::ServerAuth::new(); 23 | 24 | match path::get(&auth) { 25 | Ok(response) => match response.body() { 26 | path::OkBody::Status200(body) => println!("Thing is: {}", body.thing), 27 | path::OkBody::UnspecifiedCode(body) => { 28 | // We're forced to handle every status code in the schema; 29 | // including the possibility that the server replies off-script. 30 | println!("I don't know what thing is!") 31 | }, 32 | }, 33 | Err(response) => match response.body() { 34 | path::ErrBody::Status403(body) => println!("That's not my thing"), 35 | path::ErrBody::UnspecifiedCode(_) 36 | | path::ErrBody::MalformedJson(_) 37 | | path::ErrBody::NetworkFailure() => println!("Something went wrong"), 38 | }, 39 | } 40 | } 41 | ``` 42 | 43 | So, we can pattern-match responses by status code, and access the JSON response 44 | as a _rust_ type. 45 | 46 | _tapioca_ also aims to prevent you from shooting yourself in the foot with an 47 | invalid _sequence_ of requests, such as '`GET` after `DELETE`' on a particular 48 | resource: this is achieved by constructing resource IDs only from responses, 49 | and static values. `DELETE` functions cause the resource ID argument to be 50 | _moved_ (while other methods only _borrow_) preventing it from being further 51 | used. 52 | 53 | ## Getting started 54 | 55 | In order to start using tapioca in your project, the first step is to locate the OAS schema for the API you wish to use. Let's assume it's at `https://example.org/schema.yml`. Then, add the latest version to your `Cargo.toml` as usual, and import tapioca with macros: 56 | ```rust 57 | #[macro_use] 58 | extern crate tapioca; 59 | ``` 60 | and invoke the `infer_api` macro to build a client for the API: 61 | ```rust 62 | infer_api!(example, "https://example.org/schema.yml"); 63 | ``` 64 | 65 | The macro expands at compile-time, building a typed client in-place; (almost) all the code it generates will be located under a module named `example`, or whatever we specify in the first argument. The only exception is two crates (which must be loaded at the root level) which are needed to be externed inside your crate in order to use their macros - at least for now, the Rust's macro system is seeing a lot of change, and this may be improved. These are `serde_derive` and `tapicoa_codegen`; consequently, they also need to be in your `Cargo.toml`, but any other crates used (not for macros) by tapicoa will _not_ need this treatment, or pollute your project's namespace. 66 | 67 | ## Accessing the client 68 | 69 | The module built by `infer_api` contains modules with the names of each of the paths available on an API, and each of those contains a function for each of the HTTP methods valid for that resource. For example, to `GET /foobars`, the function ident is: 70 | ```rust 71 | example::foobars::get 72 | ``` 73 | 74 | In order to call this function, we might need to supply some arguments for [authentication](#authentication), [query parameters](#query-parameters), [request body](#request-bodies), et al. - the types for these are located inside a module of the same name as the function, for example: 75 | ```rust 76 | example::foobars::get::QueryParams 77 | ``` 78 | 79 | ## Authentication 80 | 81 | Before we make a request, we need to introduce authentication - currently, authentication must be specified for every request, even if null. 82 | 83 | Authentication requirements in an OAS schema are specified at two levels: server-wide, and operation specific - `GET /foobars` can have different requirements to other operations, which may just inherit from the server requirement. Thus we have two `enum`s of acceptable authentication schemes: 84 | ```rust 85 | example::ServerAuth 86 | example::foobars::get::OpAuth 87 | ``` 88 | which must be used depends on whether the operation `examples::foobars::get` overrides the server-wide authentication requirement - but the type-checker will tell us if we get it wrong. 89 | 90 | If there's no authentication required at all, we can just use: 91 | ```rust 92 | example::ServerAuth::new(); 93 | ``` 94 | 95 | If it's HTTP Basic, then (depending on whether it's a server or operation requirement): 96 | ```rust 97 | example::ServerAuth::Basic(username: String, password:String); 98 | example::foobars::get::OpAuth::Basic(username: String, password:String); 99 | ``` 100 | 101 | If it's a custom header: 102 | ```rust 103 | example::ServerAuth::ApiKey(api_key: String); 104 | example::foobars::get::OpAuth::ApiKey(api_key: String); 105 | ``` 106 | 107 | Though note that the variant identifier, e.g. `Basic` or `ApiKey`, depends on the name used in the OAS schema. This is because there may be multiple definitions of the same type. 108 | 109 | ## Making a request 110 | 111 | Now that we've seen how to construct an authentication argument, we can actually `GET` some `foobars`! 112 | 113 | ```rust 114 | let auth = examples::ServerAuth::new(); 115 | let response = examples::foobars::get(&auth); 116 | ``` 117 | 118 | `response` is actually a `Result`: if the response status code is an error, we get an `Err(response)`, otherwise it's an `Ok(response)`. This means we can use `response.is_ok`, `response.is_err`, and pattern matching: 119 | ```rust 120 | match examples::foobars::get(&auth) { 121 | Ok(response) => foobar_handler(response), 122 | Err(response) => err_handler(response), 123 | } 124 | ``` 125 | 126 | We can use further pattern matching in each of these handlers, in order to respond differently to different status codes: 127 | ```rust 128 | fn foobar_handler(response: Response) { 129 | match response.body { 130 | OkBody::Status200(body) => { 131 | for foobar in body.the_foobars { 132 | println!("Foobar {} is named {}", foobar.id, foobar.name); 133 | } 134 | }, 135 | OkBody::UnspecifiedCode(body) 136 | | OkBody::MalformedJson(body) => something_else(), 137 | } 138 | } 139 | ``` 140 | where we always have `UnspecifiedCode` (one not in the schema) and `MalformedJson` (invalid JSON, or did not match schema) as well as a `StatusXXX` for each of the possibilities specified in the schema. `err_handler` would look similar, with `ErrBody::Status403`, etc. 141 | 142 | ## Request bodies 143 | 144 | Say this `example::foobars` collection also supports `POST`ing new `foobar`s, we can supply the request body to create one like this: 145 | ```rust 146 | let body = example::foobars::post::RequestBody { 147 | name: "Foobarry".into(), 148 | age: 12, 149 | email: None, 150 | }; 151 | ``` 152 | 153 | The structure and field types of the body is fully defined by the schema, and may include: 154 | - `i32`, `i64` 155 | - `bool` 156 | - `String` 157 | - `Option<_>` 158 | - `Vec<_>` 159 | - further `struct`s 160 | 161 | ## Query parameters 162 | 163 | Query parameters are supplied much like [request bodies](#request-bodies): 164 | ```rust 165 | let query = example::foobars::get::QueryParams { 166 | age: 34, 167 | }; 168 | ``` 169 | 170 | ## Path parameters 171 | 172 | Path parameters are slightly different. Because of the need to distinguish `example::foobars::get` from a `GET` on a single resource in that collection, the name of the path parameter is encoded in the path module name, for example: 173 | ```rust 174 | example::foobars__id_::get 175 | ``` 176 | 177 | If the API specifies two resource identifiers in a row, this would be `foobars__id1___id2_`. This gets ugly, and may be changed in a future version. 178 | 179 | Path parameters can be constructed from the response, for example when creating a new resource and the server generated its ID, or from a static reference: 180 | ```rust 181 | static provisioned_id = "fea3c8e91baa1"; 182 | 183 | fn main() { 184 | let auth = example::ServerAuth::new(); 185 | let resource = examples::foobars__id_::Resource_id::from_static(provisioned_id); 186 | 187 | example::foobars__id_::get(&resource, &auth); 188 | } 189 | ``` 190 | -------------------------------------------------------------------------------- /tapioca-codegen/src/infer/response.rs: -------------------------------------------------------------------------------- 1 | use ::quote::Tokens; 2 | use ::syn::Ident; 3 | use ::yaml_rust::Yaml; 4 | 5 | use infer::datatype; 6 | use infer::TokensResult; 7 | 8 | const UNSPECIFIED_CODE: u16 = 999; 9 | 10 | fn parse_response_key(key: &Yaml) -> (u16, Ident) { 11 | match key.as_i64() { 12 | Some(code) => (code as u16, Ident::new(format!("Status{}", code))), 13 | None => (UNSPECIFIED_CODE, Ident::new("UnspecifiedCode")), 14 | } 15 | } 16 | 17 | pub(super) fn infer_v3(schema: &Yaml) -> TokensResult { 18 | let mut err_codes: Vec = Vec::new(); 19 | let mut err_variants: Vec = Vec::new(); 20 | let mut err_models: Vec = Vec::new(); 21 | 22 | let mut ok_codes: Vec = Vec::new(); 23 | let mut ok_variants: Vec = Vec::new(); 24 | let mut ok_models: Vec = Vec::new(); 25 | 26 | let mut additional_types: Vec = Vec::new(); 27 | let mut unspecified_err = quote!(UnspecifiedCode(String),); 28 | let mut unspecified_err_deser = quote! { 29 | let mut buf = String::new(); 30 | response.read_to_string(&mut buf).ok(); 31 | ErrBody::UnspecifiedCode(buf) 32 | }; 33 | 34 | for (code, schema) in schema.as_hash().expect("Responses must be a map.") { 35 | let (status_code, variant_ident) = parse_response_key(&code); 36 | 37 | let inferred_type: Tokens; 38 | let additional_type: Option; 39 | 40 | let schema = &schema["content"]["application/json"]["schema"]; 41 | if let None = schema.as_hash() { 42 | inferred_type = quote!{ () }; 43 | additional_type = None; 44 | } else { 45 | let (ty, at) = datatype::infer_v3(&schema)?; 46 | inferred_type = ty; 47 | additional_type = at; 48 | } 49 | 50 | if let Some(t) = additional_type { 51 | additional_types.push(t); 52 | } 53 | 54 | if status_code < 400 { 55 | ok_codes.push(status_code); 56 | ok_variants.push(variant_ident); 57 | ok_models.push(inferred_type.clone()); 58 | } else { 59 | err_codes.push(status_code); 60 | err_variants.push(variant_ident); 61 | err_models.push(inferred_type.clone()); 62 | } 63 | 64 | if code == &Yaml::from_str("default") { 65 | // In this case we don't add a Bytes variant; instead use the given model. 66 | unspecified_err = quote!(); 67 | unspecified_err_deser = quote! { 68 | deser_into::<#inferred_type>(response) 69 | .map(ErrBody::UnspecifiedCode) 70 | .unwrap_or_else(ErrBody::MalformedJSON) 71 | }; 72 | } 73 | } 74 | 75 | let ok_models2 = ok_models.clone(); 76 | let err_models2 = err_models.clone(); 77 | let ok_variants2 = ok_variants.clone(); 78 | let err_variants2 = err_variants.clone(); 79 | Ok(quote! { 80 | use std::io::Read; 81 | use ::tapioca::response::ClientResponse; 82 | use ::tapioca::response::Response; 83 | use ::tapioca::response::ResponseBody; 84 | use ::tapioca::response::ResponseResult as _ResponseResult; 85 | use ::tapioca::response::Status; 86 | use ::tapioca::response::StatusCode; 87 | use ::tapioca::serde::de::DeserializeOwned; 88 | use ::tapioca::serde_json; 89 | 90 | #(#additional_types)* 91 | 92 | pub type ResponseResult = _ResponseResult; 93 | 94 | fn deser_into(response: &mut ClientResponse) -> Result { 95 | let mut buf = String::new(); 96 | match response.json::() { 97 | Ok(body) => Ok(body), 98 | Err(_) => match response.read_to_string(&mut buf) { 99 | Err(_) 100 | | Ok(0) => serde_json::from_str::("null").or_else(|_| Err(buf)), 101 | _ => Err(buf), 102 | }, 103 | } 104 | } 105 | 106 | #[derive(Debug)] 107 | #[allow(dead_code)] 108 | pub enum OkBody { 109 | #(#ok_variants(#ok_models),)* 110 | MalformedJSON(String), 111 | UnspecifiedCode(String), 112 | } 113 | 114 | #[derive(Debug)] 115 | #[allow(dead_code)] 116 | pub enum ErrBody { 117 | #(#err_variants(#err_models),)* 118 | #unspecified_err 119 | MalformedJSON(String), 120 | NetworkFailure(), 121 | } 122 | 123 | #[derive(Debug)] 124 | pub struct OkResult { 125 | body: OkBody, 126 | status_code: StatusCode, 127 | } 128 | 129 | #[derive(Debug)] 130 | pub struct ErrResult { 131 | body: ErrBody, 132 | status_code: StatusCode, 133 | } 134 | 135 | impl Response for OkResult { 136 | type BodyType = OkBody; 137 | 138 | fn from(maybe_response: &mut Option<&mut ClientResponse>) -> Self { 139 | let status_code = match *maybe_response { 140 | Some(ref response) => StatusCode::of(&Some(response)), 141 | None => panic!("OkResponse requires Some response."), 142 | }; 143 | 144 | assert!(status_code.is_ok()); 145 | 146 | Self { 147 | body: ::from(maybe_response), 148 | status_code, 149 | } 150 | } 151 | 152 | fn body(self) -> Self::BodyType { 153 | self.body 154 | } 155 | 156 | fn status_code(&self) -> StatusCode { 157 | self.status_code 158 | } 159 | } 160 | 161 | impl Response for ErrResult { 162 | type BodyType = ErrBody; 163 | 164 | fn from(maybe_response: &mut Option<&mut ClientResponse>) -> Self { 165 | let status_code = match *maybe_response { 166 | Some(ref response) => StatusCode::of(&Some(response)), 167 | None => StatusCode::of(&None), 168 | }; 169 | assert!(status_code.is_err()); 170 | 171 | Self { 172 | body: ::from(maybe_response), 173 | status_code, 174 | } 175 | } 176 | 177 | fn body(self) -> Self::BodyType { 178 | self.body 179 | } 180 | 181 | fn status_code(&self) -> StatusCode { 182 | self.status_code 183 | } 184 | } 185 | 186 | 187 | impl ResponseBody for OkBody { 188 | fn from(maybe_response: &mut Option<&mut ClientResponse>) -> Self { 189 | let status_code = match *maybe_response { 190 | Some(ref response) => StatusCode::of(&Some(response)), 191 | None => panic!("OkResponse requires Some response"), 192 | }.to_u16(); 193 | 194 | match (maybe_response, status_code) { 195 | #( 196 | (&mut Some(ref mut response), #ok_codes) => 197 | deser_into::<#ok_models2>(response) 198 | .map(OkBody::#ok_variants2) 199 | .unwrap_or_else(OkBody::MalformedJSON), 200 | )* 201 | (&mut Some(ref mut response), _) => { 202 | let mut buf = String::new(); 203 | response.read_to_string(&mut buf).ok(); 204 | OkBody::UnspecifiedCode(buf) 205 | }, 206 | (&mut None, _) => panic!("OkResponse requires Some response"), 207 | } 208 | } 209 | } 210 | 211 | impl ResponseBody for ErrBody { 212 | fn from(maybe_response: &mut Option<&mut ClientResponse>) -> Self { 213 | let status_code = match *maybe_response { 214 | Some(ref response) => StatusCode::of(&Some(response)), 215 | None => StatusCode::of(&None), 216 | }; 217 | assert!(status_code.is_err()); 218 | 219 | match (maybe_response, status_code.to_u16()) { 220 | #( 221 | (&mut Some(ref mut response), #err_codes) => 222 | deser_into::<#err_models2>(response) 223 | .map(ErrBody::#err_variants2) 224 | .unwrap_or_else(ErrBody::MalformedJSON), 225 | )* 226 | (&mut Some(ref mut response), _) => { #unspecified_err_deser }, 227 | (&mut None, _) => ErrBody::NetworkFailure(), 228 | } 229 | } 230 | } 231 | }) 232 | } 233 | --------------------------------------------------------------------------------