From fd636e07df99744d9964570991bba3ff283f6642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= Date: Fri, 7 Sep 2018 23:28:56 +0200 Subject: [PATCH] Add integration crate for the warp framework (#216) --- .travis.yml | 8 + README.md | 6 +- juniper/src/http/mod.rs | 1 + juniper_warp/.gitignore | 4 + juniper_warp/Cargo.toml | 24 + juniper_warp/README.md | 33 ++ juniper_warp/examples/warp_server/Cargo.toml | 11 + juniper_warp/examples/warp_server/src/main.rs | 47 ++ juniper_warp/src/lib.rs | 509 ++++++++++++++++++ 9 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 juniper_warp/.gitignore create mode 100644 juniper_warp/Cargo.toml create mode 100644 juniper_warp/README.md create mode 100644 juniper_warp/examples/warp_server/Cargo.toml create mode 100644 juniper_warp/examples/warp_server/src/main.rs create mode 100644 juniper_warp/src/lib.rs diff --git a/.travis.yml b/.travis.yml index 99642157..a9b5ef93 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,6 +21,14 @@ script: - wget -O ~/.cargo/bin/cargo-make https://bintray.com/sagiegurari/cargo-make/download_file?file_path=cargo-make_v0.11.0u - chmod 777 ~/.cargo/bin/cargo-make - cargo make workspace-ci-flow --no-workspace + # The tests for juniper_warp need to run separately because warp has higher + # minimum rust version requirements than juniper. + # + # There is a cargo-make issue about this problem: https://github.com/sagiegurari/cargo-make/issues/110 + # + # We check for '1' because we the only channels we want to support are + # "stable", "beta" and "nightly" (that will be the values of $TRAVIS_RUST_VERSION) + - if ! [[ $TRAVIS_RUST_VERSION = *'1'* ]]; then cargo test --all --manifest-path=juniper_warp/Cargo.toml; fi before_deploy: - rm -rf target/package/ diff --git a/README.md b/README.md index eeed3235..a8b38783 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ For specific information about macros, types and the Juniper api, the You can also check out [src/tests/schema.rs][test_schema_rs] to see a complex schema including polymorphism with traits and interfaces. For an example of web framework integration, -see the [hyper][hyper_examples], [rocket][rocket_examples], and [iron][iron_examples] examples folders. +see the [hyper][hyper_examples], [rocket][rocket_examples], [iron][iron_examples], and [warp][warp_examples] examples folders. ## Features @@ -75,6 +75,7 @@ your Schemas automatically. * [hyper][hyper] * [rocket][rocket] * [iron][iron] +* [warp][warp] ## Guides & Examples @@ -99,8 +100,9 @@ Juniper has not reached 1.0 yet, thus some API instability should be expected. [book]: https://graphql-rust.github.io [book_quickstart]: https://graphql-rust.github.io/quickstart.html [docsrs]: https://docs.rs/juniper +[warp]: https://github.com/seanmonstar/warp +[warp_examples]: https://github.com/graphql-rust/juniper/tree/master/juniper_warp/examples [uuid]: https://crates.io/crates/uuid [url]: https://crates.io/crates/url [chrono]: https://crates.io/crates/chrono - diff --git a/juniper/src/http/mod.rs b/juniper/src/http/mod.rs index 4b72770f..9a114698 100644 --- a/juniper/src/http/mod.rs +++ b/juniper/src/http/mod.rs @@ -137,6 +137,7 @@ pub mod tests { /// Normalized response content we expect to get back from /// the http framework integration we are testing. + #[derive(Debug)] pub struct TestResponse { pub status_code: i32, pub body: Option, diff --git a/juniper_warp/.gitignore b/juniper_warp/.gitignore new file mode 100644 index 00000000..0d722487 --- /dev/null +++ b/juniper_warp/.gitignore @@ -0,0 +1,4 @@ +/target +/examples/**/target/**/* +**/*.rs.bk +Cargo.lock diff --git a/juniper_warp/Cargo.toml b/juniper_warp/Cargo.toml new file mode 100644 index 00000000..e04c3164 --- /dev/null +++ b/juniper_warp/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "juniper_warp" +version = "0.1.0" +authors = ["Tom Houlé "] +description = "Juniper GraphQL integration with Warp" +documentation = "https://docs.rs/juniper_warp" +repository = "https://github.com/graphql-rust/juniper" + +[dependencies] +warp = "0.1.2" +juniper = { path = "../juniper", version = "0.9.2", default-features = false } +serde_json = "1.0.24" +serde_derive = "1.0.75" +failure = "0.1.2" +futures-cpupool = "0.1.8" +futures = "0.1.23" +serde = "1.0.75" + +[dev-dependencies] +juniper = { path = "../juniper", version = "0.9.2", features = ["expose-test-schema", "serde_json"] } +percent-encoding = "1.0" + +[workspace] +members = [".", "examples/warp_server"] diff --git a/juniper_warp/README.md b/juniper_warp/README.md new file mode 100644 index 00000000..a0037386 --- /dev/null +++ b/juniper_warp/README.md @@ -0,0 +1,33 @@ +# juniper_warp + +This repository contains the [warp][warp] web server integration for +[Juniper][Juniper], a [GraphQL][GraphQL] implementation for Rust. + +## Documentation + +For documentation, including guides and examples, check out [Juniper][Juniper]. + +A basic usage example can also be found in the [API documentation][documentation]. + +## Examples + +Check [examples/warp_server][example] for example code of a working warp +server with GraphQL handlers. + +## Links + +* [Juniper][Juniper] +* [API Reference][documetation] +* [warp][warp] + +## License + +This project is under the BSD-2 license. + +Check the LICENSE file for details. + +[warp]: https://github.com/seanmonstar/warp +[Juniper]: https://github.com/graphql-rust/juniper +[GraphQL]: http://graphql.org +[documentation]: https://docs.rs/juniper_warp +[example]: https://github.com/graphql-rust/juniper_warp/blob/master/examples/warp_server diff --git a/juniper_warp/examples/warp_server/Cargo.toml b/juniper_warp/examples/warp_server/Cargo.toml new file mode 100644 index 00000000..8c295aa0 --- /dev/null +++ b/juniper_warp/examples/warp_server/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "warp_server" +version = "0.1.0" +authors = ["Tom Houlé "] + +[dependencies] +warp = "0.1.0" +juniper_warp = { path = "../.." } +env_logger = "0.5.11" +log = "0.4.3" +juniper = { path = "../../../juniper", version = "0.9.2", features = ["expose-test-schema", "serde_json"] } diff --git a/juniper_warp/examples/warp_server/src/main.rs b/juniper_warp/examples/warp_server/src/main.rs new file mode 100644 index 00000000..9a57ed69 --- /dev/null +++ b/juniper_warp/examples/warp_server/src/main.rs @@ -0,0 +1,47 @@ +#![deny(warnings)] + +extern crate env_logger; +#[macro_use] +extern crate log as irrelevant_log; +extern crate juniper; +extern crate juniper_warp; +extern crate warp; + +use juniper::tests::model::Database; +use juniper::{EmptyMutation, RootNode}; +use warp::{http::Response, log, Filter}; + +type Schema = RootNode<'static, Database, EmptyMutation>; + +fn schema() -> Schema { + Schema::new(Database::new(), EmptyMutation::::new()) +} + +fn main() { + ::std::env::set_var("RUST_LOG", "warp_server"); + env_logger::init(); + + let log = log("warp_server"); + + let homepage = warp::index().map(|| { + Response::builder() + .header("content-type", "text/html") + .body(format!( + "

juniper_warp

visit /graphiql" + )) + }); + + info!("Listening on 127.0.0.1:8080"); + + let state = warp::any().map(move || Database::new()); + let graphql_filter = juniper_warp::make_graphql_filter(schema(), state.boxed()); + + warp::serve( + warp::get2() + .and(warp::path("graphiql")) + .and(juniper_warp::graphiql_handler("/graphql")) + .or(homepage) + .or(warp::path("graphql").and(graphql_filter)) + .with(log), + ).run(([127, 0, 0, 1], 8080)); +} diff --git a/juniper_warp/src/lib.rs b/juniper_warp/src/lib.rs new file mode 100644 index 00000000..952674c9 --- /dev/null +++ b/juniper_warp/src/lib.rs @@ -0,0 +1,509 @@ +/*! + +# juniper_warp + +This repository contains the [warp][warp] web server integration for +[Juniper][Juniper], a [GraphQL][GraphQL] implementation for Rust. + +## Documentation + +For documentation, including guides and examples, check out [Juniper][Juniper]. + +A basic usage example can also be found in the [Api documentation][documentation]. + +## Examples + +Check [examples/warp_server][example] for example code of a working warp +server with GraphQL handlers. + +## Links + +* [Juniper][Juniper] +* [Api Reference][documentation] +* [warp][warp] + +## License + +This project is under the BSD-2 license. + +Check the LICENSE file for details. + +[warp]: https://github.com/seanmonstar/warp +[Juniper]: https://github.com/graphql-rust/juniper +[GraphQL]: http://graphql.org +[documentation]: https://docs.rs/juniper_warp +[example]: https://github.com/graphql-rust/juniper_warp/blob/master/examples/warp_server + +*/ + +#![deny(missing_docs)] +#![deny(warnings)] + +#[macro_use] +extern crate failure; +extern crate futures; +extern crate futures_cpupool; +extern crate juniper; +#[macro_use] +extern crate serde_derive; +extern crate serde; +extern crate serde_json; +extern crate warp; + +#[cfg(test)] +extern crate percent_encoding; + +use futures::Future; +use futures_cpupool::CpuPool; +use std::sync::Arc; +use warp::{filters::BoxedFilter, Filter}; + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(untagged)] +enum GraphQLBatchRequest { + Single(juniper::http::GraphQLRequest), + Batch(Vec), +} + +impl GraphQLBatchRequest { + pub fn execute<'a, CtxT, QueryT, MutationT>( + &'a self, + root_node: &juniper::RootNode, + context: &CtxT, + ) -> GraphQLBatchResponse<'a> + where + QueryT: juniper::GraphQLType, + MutationT: juniper::GraphQLType, + { + match self { + &GraphQLBatchRequest::Single(ref request) => { + GraphQLBatchResponse::Single(request.execute(root_node, context)) + } + &GraphQLBatchRequest::Batch(ref requests) => GraphQLBatchResponse::Batch( + requests + .iter() + .map(|request| request.execute(root_node, context)) + .collect(), + ), + } + } +} + +#[derive(Serialize)] +#[serde(untagged)] +enum GraphQLBatchResponse<'a> { + Single(juniper::http::GraphQLResponse<'a>), + Batch(Vec>), +} + +impl<'a> GraphQLBatchResponse<'a> { + fn is_ok(&self) -> bool { + match self { + GraphQLBatchResponse::Single(res) => res.is_ok(), + GraphQLBatchResponse::Batch(reses) => reses.iter().all(|res| res.is_ok()), + } + } +} + +/// Make a filter for graphql endpoint. +/// +/// The `schema` argument is your juniper schema. +/// +/// The `context_extractor` argument should be a filter that provides the GraphQL context required by the schema. +/// +/// In order to avoid blocking, this helper will create a [CpuPool](../futures_cpupool/struct.CpuPool.html) to resolve GraphQL requests. +/// +/// If you want to pass your own threadpool, use [make_graphql_filter_with_thread_pool](fn.make_graphql_filter_with_thread_pool.html) instead. +/// +/// Example: +/// +/// ``` +/// # extern crate juniper_warp; +/// # #[macro_use] +/// # extern crate juniper; +/// # extern crate warp; +/// # +/// # use std::sync::Arc; +/// # use warp::Filter; +/// # use juniper::{EmptyMutation, RootNode}; +/// # use juniper_warp::make_graphql_filter; +/// # +/// # fn main() { +/// type UserId = String; +/// # #[derive(Debug)] +/// struct AppState(Vec); +/// struct ExampleContext(Arc, UserId); +/// +/// struct QueryRoot; +/// +/// graphql_object! (QueryRoot: ExampleContext |&self| { +/// field say_hello(&executor) -> String { +/// let context = executor.context(); +/// +/// format!("good morning {}, the app state is {:?}", context.1, context.0) +/// } +/// }); +/// +/// let schema = RootNode::new(QueryRoot, EmptyMutation::new()); +/// +/// let app_state = Arc::new(AppState(vec![3, 4, 5])); +/// let app_state = warp::any().map(move || app_state.clone()); +/// +/// let context_extractor = warp::any() +/// .and(warp::header::("authorization")) +/// .and(app_state) +/// .map(|auth_header: String, app_state: Arc| { +/// let user_id = auth_header; // we believe them +/// ExampleContext(app_state, user_id) +/// }) +/// .boxed(); +/// +/// let graphql_filter = make_graphql_filter(schema, context_extractor); +/// +/// let graphql_endpoint = warp::path("graphql") +/// .and(warp::post2()) +/// .and(graphql_filter); +/// # } +/// ``` +pub fn make_graphql_filter( + schema: juniper::RootNode<'static, Query, Mutation>, + context_extractor: BoxedFilter<(Context,)>, +) -> BoxedFilter<(warp::http::Response>,)> +where + Context: Send + 'static, + Query: juniper::GraphQLType + Send + Sync + 'static, + Mutation: juniper::GraphQLType + Send + Sync + 'static, +{ + let pool = CpuPool::new_num_cpus(); + make_graphql_filter_with_thread_pool(schema, context_extractor, pool) +} + +type Response = + Box>, Error = warp::reject::Rejection> + Send>; + +/// Same as [make_graphql_filter](./fn.make_graphql_filter.html), but use the provided [CpuPool](../futures_cpupool/struct.CpuPool.html) instead. +pub fn make_graphql_filter_with_thread_pool( + schema: juniper::RootNode<'static, Query, Mutation>, + context_extractor: BoxedFilter<(Context,)>, + thread_pool: futures_cpupool::CpuPool, +) -> BoxedFilter<(warp::http::Response>,)> +where + Context: Send + 'static, + Query: juniper::GraphQLType + Send + Sync + 'static, + Mutation: juniper::GraphQLType + Send + Sync + 'static, +{ + let schema = Arc::new(schema); + let post_schema = schema.clone(); + let pool_filter = warp::any().map(move || thread_pool.clone()); + + let handle_post_request = + move |context: Context, request: GraphQLBatchRequest, pool: CpuPool| -> Response { + let schema = post_schema.clone(); + Box::new( + pool.spawn_fn(move || { + let response = request.execute(&schema, &context); + Ok((serde_json::to_vec(&response)?, response.is_ok())) + }).then(|result| ::futures::future::done(Ok(build_response(result)))) + .map_err(|_: failure::Error| warp::reject::server_error()), + ) + }; + + let post_filter = warp::post2() + .and(context_extractor.clone()) + .and(warp::body::json()) + .and(pool_filter.clone()) + .and_then(handle_post_request); + + let handle_get_request = move |context: Context, + mut request: std::collections::HashMap, + pool: CpuPool| + -> Response { + let schema = schema.clone(); + Box::new( + pool.spawn_fn(move || { + let variables = match request.remove("variables") { + None => None, + Some(vs) => serde_json::from_str(&vs)?, + }; + + let graphql_request = juniper::http::GraphQLRequest::new( + request.remove("query").ok_or_else(|| { + format_err!("Missing GraphQL query string in query parameters") + })?, + request.get("operation_name").map(|s| s.to_owned()), + variables, + ); + + let response = graphql_request.execute(&schema, &context); + Ok((serde_json::to_vec(&response)?, response.is_ok())) + }).then(|result| ::futures::future::done(Ok(build_response(result)))) + .map_err(|_: failure::Error| warp::reject::server_error()), + ) + }; + + let get_filter = warp::get2() + .and(context_extractor.clone()) + .and(warp::filters::query::query()) + .and(pool_filter) + .and_then(handle_get_request); + + get_filter.or(post_filter).unify().boxed() +} + +fn build_response( + response: Result<(Vec, bool), failure::Error>, +) -> warp::http::Response> { + match response { + Ok((body, is_ok)) => warp::http::Response::builder() + .status(if is_ok { 200 } else { 400 }) + .header("content-type", "application/json") + .body(body) + .expect("response is valid"), + Err(_) => warp::http::Response::builder() + .status(warp::http::StatusCode::INTERNAL_SERVER_ERROR) + .body(Vec::new()) + .expect("status code is valid"), + } +} + +/// Create a filter that replies with an HTML page containing GraphiQL. This does not handle routing, so you can mount it on any endpoint. +/// +/// For example: +/// +/// ``` +/// # extern crate warp; +/// # extern crate juniper_warp; +/// # +/// # use warp::Filter; +/// # use juniper_warp::graphiql_handler; +/// # +/// # fn main() { +/// let graphiql_route = warp::path("graphiql").and(graphiql_handler("/graphql")); +/// # } +/// ``` +pub fn graphiql_handler( + graphql_endpoint_url: &'static str, +) -> warp::filters::BoxedFilter<(warp::http::Response>,)> { + warp::any() + .map(move || graphiql_response(graphql_endpoint_url)) + .boxed() +} + +fn graphiql_response(graphql_endpoint_url: &'static str) -> warp::http::Response> { + warp::http::Response::builder() + .header("content-type", "text/html;charset=utf-8") + .body(juniper::graphiql::graphiql_source(graphql_endpoint_url).into_bytes()) + .expect("response is valid") +} + +#[cfg(test)] +mod tests { + use super::*; + use warp::http; + use warp::test::request; + + #[test] + fn graphiql_response_does_not_panic() { + graphiql_response("/abcd"); + } + + #[test] + fn graphiql_endpoint_matches() { + let filter = warp::get2() + .and(warp::path("graphiql")) + .and(graphiql_handler("/graphql")); + let result = request() + .method("GET") + .path("/graphiql") + .header("accept", "text/html") + .filter(&filter); + + assert!(result.is_ok()); + } + + #[test] + fn graphiql_endpoint_returns_graphiql_source() { + let filter = warp::get2() + .and(warp::path("dogs-api")) + .and(warp::path("graphiql")) + .and(graphiql_handler("/dogs-api/graphql")); + let response = request() + .method("GET") + .path("/dogs-api/graphiql") + .header("accept", "text/html") + .reply(&filter); + + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "text/html;charset=utf-8" + ); + let body = String::from_utf8(response.body().to_vec()).unwrap(); + + assert!(body.contains("")); + } + + #[test] + fn graphql_handler_works_json_post() { + use juniper::tests::model::Database; + use juniper::{EmptyMutation, RootNode}; + + type Schema = juniper::RootNode<'static, Database, EmptyMutation>; + + let schema: Schema = RootNode::new(Database::new(), EmptyMutation::::new()); + + let state = warp::any().map(move || Database::new()); + let filter = warp::path("graphql2").and(make_graphql_filter(schema, state.boxed())); + + let response = request() + .method("POST") + .path("/graphql2") + .header("accept", "application/json") + .header("content-type", "application/json") + .body(r##"{ "variables": null, "query": "{ hero(episode: NEW_HOPE) { name } }" }"##) + .reply(&filter); + + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/json", + ); + assert_eq!( + String::from_utf8(response.body().to_vec()).unwrap(), + r#"{"data":{"hero":{"name":"R2-D2"}}}"# + ); + } + + #[test] + fn batch_requests_work() { + use juniper::tests::model::Database; + use juniper::{EmptyMutation, RootNode}; + + type Schema = juniper::RootNode<'static, Database, EmptyMutation>; + + let schema: Schema = RootNode::new(Database::new(), EmptyMutation::::new()); + + let state = warp::any().map(move || Database::new()); + let filter = warp::path("graphql2").and(make_graphql_filter(schema, state.boxed())); + + let response = request() + .method("POST") + .path("/graphql2") + .header("accept", "application/json") + .header("content-type", "application/json") + .body( + r##"[ + { "variables": null, "query": "{ hero(episode: NEW_HOPE) { name } }" }, + { "variables": null, "query": "{ hero(episode: EMPIRE) { id name } }" } + ]"##, + ).reply(&filter); + + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!( + String::from_utf8(response.body().to_vec()).unwrap(), + r#"[{"data":{"hero":{"name":"R2-D2"}}},{"data":{"hero":{"id":"1000","name":"Luke Skywalker"}}}]"# + ); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/json", + ); + } + + #[test] + fn batch_request_deserialization_can_fail() { + let json = r#"blah"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + } +} + +#[cfg(test)] +mod tests_http_harness { + use super::*; + use juniper::http::tests::{run_http_test_suite, HTTPIntegration, TestResponse}; + use juniper::tests::model::Database; + use juniper::EmptyMutation; + use juniper::RootNode; + use warp; + use warp::Filter; + + type Schema = juniper::RootNode<'static, Database, EmptyMutation>; + + fn warp_server() -> warp::filters::BoxedFilter<(warp::http::Response>,)> { + let schema: Schema = RootNode::new(Database::new(), EmptyMutation::::new()); + + let state = warp::any().map(move || Database::new()); + let filter = warp::filters::path::index().and(make_graphql_filter(schema, state.boxed())); + + filter.boxed() + } + + struct TestWarpIntegration { + filter: warp::filters::BoxedFilter<(warp::http::Response>,)>, + } + + // This can't be implemented with the From trait since TestResponse is not defined in this crate. + fn test_response_from_http_response(response: warp::http::Response>) -> TestResponse { + TestResponse { + status_code: response.status().as_u16() as i32, + body: Some(String::from_utf8(response.body().to_owned()).unwrap()), + content_type: response + .headers() + .get("content-type") + .expect("missing content-type header in warp response") + .to_str() + .expect("invalid content-type string") + .to_owned(), + } + } + + impl HTTPIntegration for TestWarpIntegration { + fn get(&self, url: &str) -> TestResponse { + use percent_encoding::{percent_encode, DEFAULT_ENCODE_SET}; + let url: String = percent_encode(url.replace("/?", "").as_bytes(), DEFAULT_ENCODE_SET) + .into_iter() + .collect::>() + .join(""); + + let response = warp::test::request() + .method("GET") + .path(&format!("/?{}", url)) + .filter(&self.filter) + .unwrap_or_else(|rejection| { + warp::http::Response::builder() + .status(rejection.status()) + .header("content-type", "application/json") + .body(Vec::new()) + .unwrap() + }); + test_response_from_http_response(response) + } + + fn post(&self, url: &str, body: &str) -> TestResponse { + let response = warp::test::request() + .method("POST") + .header("content-type", "application/json") + .path(url) + .body(body) + .filter(&self.filter) + .unwrap_or_else(|rejection| { + warp::http::Response::builder() + .status(rejection.status()) + .header("content-type", "application/json") + .body(Vec::new()) + .unwrap() + }); + test_response_from_http_response(response) + } + } + + #[test] + fn test_warp_integration() { + let integration = TestWarpIntegration { + filter: warp_server(), + }; + + run_http_test_suite(&integration); + } +}