diff --git a/Cargo.toml b/Cargo.toml index d6e64937..08120b82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "juniper", "juniper_codegen", "juniper_tests", + "juniper_hyper", "juniper_iron", "juniper_rocket", ] diff --git a/README.md b/README.md index bb90029a..ab14ac92 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ GraphQL schemas as convenient as possible as Rust will allow. Juniper does not include a web server - instead it provides building blocks to make integration with existing servers straightforward. It optionally provides a -pre-built integration for the [Iron][iron] and [Rocket] frameworks, including +pre-built integration for the [Hyper][hyper], [Iron][iron], and [Rocket] frameworks, including embedded [Graphiql][graphiql] for easy debugging. * [Cargo crate](https://crates.io/crates/juniper) @@ -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 [rocket][rocket_examples] and [iron][iron_examples] examples folders. +see the [hyper][hyper_examples], [rocket][rocket_examples], and [iron][iron_examples] examples folders. ## Features @@ -90,8 +90,10 @@ Juniper has not reached 1.0 yet, thus some API instability should be expected. [graphql_spec]: http://facebook.github.io/graphql [test_schema_rs]: https://github.com/graphql-rust/juniper/blob/master/juniper/src/tests/schema.rs [tokio]: https://github.com/tokio-rs/tokio +[hyper_examples]: https://github.com/graphql-rust/juniper/tree/master/hyper_rocket/examples [rocket_examples]: https://github.com/graphql-rust/juniper/tree/master/juniper_rocket/examples [iron_examples]: https://github.com/graphql-rust/juniper/tree/master/juniper_iron/examples +[Hyper]: https://hyper.rs [Rocket]: https://rocket.rs [book]: https://graphql-rust.github.io [book_quickstart]: https://graphql-rust.github.io/quickstart.html diff --git a/juniper/src/http/mod.rs b/juniper/src/http/mod.rs index 8319b83b..591d6264 100644 --- a/juniper/src/http/mod.rs +++ b/juniper/src/http/mod.rs @@ -180,7 +180,8 @@ pub mod tests { } fn test_simple_get(integration: &T) { - let response = integration.get("/?query={hero{name}}"); + // {hero{name}} + let response = integration.get("/?query=%7Bhero%7Bname%7D%7D"); assert_eq!(response.status_code, 200); assert_eq!(response.content_type.as_str(), "application/json"); @@ -193,8 +194,9 @@ pub mod tests { } fn test_encoded_get(integration: &T) { + // query { human(id: "1000") { id, name, appearsIn, homePlanet } } let response = integration.get( - "/?query=query%20{%20%20%20human(id:%20\"1000\")%20{%20%20%20%20%20id,%20%20%20%20%20name,%20%20%20%20%20appearsIn,%20%20%20%20%20homePlanet%20%20%20}%20}"); + "/?query=query%20%7B%20human(id%3A%20%221000%22)%20%7B%20id%2C%20name%2C%20appearsIn%2C%20homePlanet%20%7D%20%7D"); assert_eq!(response.status_code, 200); assert_eq!(response.content_type.as_str(), "application/json"); @@ -221,8 +223,10 @@ pub mod tests { } fn test_get_with_variables(integration: &T) { + // query($id: String!) { human(id: $id) { id, name, appearsIn, homePlanet } } + // with variables = { "id": "1000" } let response = integration.get( - "/?query=query($id:%20String!)%20{%20%20%20human(id:%20$id)%20{%20%20%20%20%20id,%20%20%20%20%20name,%20%20%20%20%20appearsIn,%20%20%20%20%20homePlanet%20%20%20}%20}&variables={%20%20%20\"id\":%20%20\"1000\"%20}"); + "/?query=query(%24id%3A%20String!)%20%7B%20human(id%3A%20%24id)%20%7B%20id%2C%20name%2C%20appearsIn%2C%20homePlanet%20%7D%20%7D&variables=%7B%20%22id%22%3A%20%221000%22%20%7D"); assert_eq!(response.status_code, 200); assert_eq!(response.content_type, "application/json"); diff --git a/juniper_hyper/.gitignore b/juniper_hyper/.gitignore new file mode 100644 index 00000000..a9d37c56 --- /dev/null +++ b/juniper_hyper/.gitignore @@ -0,0 +1,2 @@ +target +Cargo.lock diff --git a/juniper_hyper/Cargo.toml b/juniper_hyper/Cargo.toml new file mode 100644 index 00000000..10285d3d --- /dev/null +++ b/juniper_hyper/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "juniper_hyper" +version = "0.1.0" +authors = ["Damir Vandic "] +description = "Juniper GraphQL integration with Hyper" +license = "BSD-2-Clause" +documentation = "https://docs.rs/juniper_hyper" +repository = "https://github.com/graphql-rust/juniper" + +[dependencies] +serde = "1.0" +serde_json = "1.0" +serde_derive = "1.0" +url = "1.7" +juniper = { version = "0.9" , default-features = false, path = "../juniper"} + +futures = "0.1" +futures-cpupool = "0.1" +hyper = "0.12" + +[dev-dependencies] +pretty_env_logger = "0.2" +tokio = "0.1.8" +reqwest = { git = "https://github.com/seanmonstar/reqwest" } + +[dev-dependencies.juniper] +version = "0.9" +features = ["expose-test-schema", "serde_json"] +path = "../juniper" diff --git a/juniper_hyper/LICENSE b/juniper_hyper/LICENSE new file mode 100644 index 00000000..0ccd1e17 --- /dev/null +++ b/juniper_hyper/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2016, Magnus Hallin +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/juniper_hyper/Makefile.toml b/juniper_hyper/Makefile.toml new file mode 100644 index 00000000..fd86490c --- /dev/null +++ b/juniper_hyper/Makefile.toml @@ -0,0 +1,18 @@ + +[tasks.build-verbose] +condition = { channels = ["nightly"] } + +[tasks.build-verbose.windows] +condition = { channels = ["nightly"], env = { "TARGET" = "x86_64-pc-windows-msvc" } } + +[tasks.test-verbose] +condition = { channels = ["nightly"] } + +[tasks.test-verbose.windows] +condition = { channels = ["nightly"], env = { "TARGET" = "x86_64-pc-windows-msvc" } } + +[tasks.ci-coverage-flow] +condition = { channels = ["nightly"] } + +[tasks.ci-coverage-flow.windows] +disabled = true diff --git a/juniper_hyper/README.md b/juniper_hyper/README.md new file mode 100644 index 00000000..1c874f27 --- /dev/null +++ b/juniper_hyper/README.md @@ -0,0 +1,35 @@ +# juniper_hyper + +This repository contains the [Hyper][Hyper] 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/hyper_server.rs][example] for example code of a working Hyper +server with GraphQL handlers. + +## Links + +* [Juniper][Juniper] +* [API documentation][documentation] +* [Hyper][Hyper] + +## License + +This project is under the BSD-2 license. + +Check the LICENSE file for details. + +[Hyper]: https://hyper.rs +[Juniper]: https://github.com/graphql-rust/juniper +[GraphQL]: http://graphql.org +[documentation]: https://docs.rs/juniper_hyper +[example]: https://github.com/graphql-rust/juniper/blob/hyper-integration/juniper_hyper/examples/hyper_server.rs + + diff --git a/juniper_hyper/examples/hyper_server.rs b/juniper_hyper/examples/hyper_server.rs new file mode 100644 index 00000000..ab146271 --- /dev/null +++ b/juniper_hyper/examples/hyper_server.rs @@ -0,0 +1,54 @@ +extern crate futures; +extern crate futures_cpupool; +extern crate hyper; +extern crate juniper; +extern crate juniper_hyper; +extern crate pretty_env_logger; + +use futures::future; +use futures_cpupool::Builder as CpuPoolBuilder; +use hyper::rt::{self, Future}; +use hyper::service::service_fn; +use hyper::Method; +use hyper::{Body, Response, Server, StatusCode}; +use juniper::tests::model::Database; +use juniper::EmptyMutation; +use juniper::RootNode; +use std::sync::Arc; + +fn main() { + pretty_env_logger::init(); + + let addr = ([127, 0, 0, 1], 3000).into(); + + let pool = CpuPoolBuilder::new().create(); + let db = Arc::new(Database::new()); + let root_node = Arc::new(RootNode::new(db.clone(), EmptyMutation::::new())); + + let new_service = move || { + let pool = pool.clone(); + let root_node = root_node.clone(); + let ctx = db.clone(); + service_fn(move |req| { + let pool = pool.clone(); + let root_node = root_node.clone(); + let ctx = ctx.clone(); + match (req.method(), req.uri().path()) { + (&Method::GET, "/") => juniper_hyper::graphiql("/graphql"), + (&Method::GET, "/graphql") => juniper_hyper::graphql(pool, root_node, ctx, req), + (&Method::POST, "/graphql") => juniper_hyper::graphql(pool, root_node, ctx, req), + _ => { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::NOT_FOUND; + Box::new(future::ok(response)) + } + } + }) + }; + let server = Server::bind(&addr) + .serve(new_service) + .map_err(|e| eprintln!("server error: {}", e)); + println!("Listening on http://{}", addr); + + rt::run(server); +} diff --git a/juniper_hyper/src/lib.rs b/juniper_hyper/src/lib.rs new file mode 100644 index 00000000..a5224292 --- /dev/null +++ b/juniper_hyper/src/lib.rs @@ -0,0 +1,382 @@ +#![feature(extern_prelude)] + +extern crate futures; +extern crate futures_cpupool; +extern crate hyper; +extern crate juniper; +#[macro_use] +extern crate serde_derive; +#[cfg(test)] +extern crate reqwest; +extern crate serde_json; +#[cfg(test)] +extern crate tokio; +extern crate url; + +use futures::{future, Future}; +use futures_cpupool::CpuPool; +use hyper::header::HeaderValue; +use hyper::rt::Stream; +use hyper::{header, Body, Method, Request, Response, StatusCode}; +use juniper::http::{ + GraphQLRequest as JuniperGraphQLRequest, GraphQLResponse as JuniperGraphQLResponse, +}; +use juniper::{GraphQLType, InputValue, RootNode}; +use serde_json::error::Error as SerdeError; +use std::error::Error; +use std::fmt; +use std::string::FromUtf8Error; +use std::sync::Arc; +use url::form_urlencoded; + +pub fn graphql( + pool: CpuPool, + root_node: Arc>, + context: Arc, + request: Request, +) -> Box, Error = hyper::Error> + Send> +where + CtxT: Send + Sync + 'static, + QueryT: GraphQLType + Send + Sync + 'static, + MutationT: GraphQLType + Send + Sync + 'static, + QueryT::TypeInfo: Send + Sync, + MutationT::TypeInfo: Send + Sync, +{ + match request.method() { + &Method::GET => Box::new( + future::done( + request + .uri() + .query() + .map(|q| gql_request_from_get(q).map(GraphQLRequest::Single)) + .unwrap_or(Err(GraphQLRequestError::Invalid( + "'query' parameter is missing".to_string(), + ))), + ).and_then(move |gql_req| execute_request(pool, root_node, context, gql_req)) + .or_else(|err| future::ok(render_error(err))), + ), + &Method::POST => Box::new( + request + .into_body() + .concat2() + .or_else(|err| future::done(Err(GraphQLRequestError::BodyHyper(err)))) + .and_then(move |chunk| { + future::done({ + String::from_utf8(chunk.iter().cloned().collect::>()) + .map_err(GraphQLRequestError::BodyUtf8) + .and_then(|input| { + serde_json::from_str::(&input) + .map_err(GraphQLRequestError::BodyJSONError) + }) + }) + }).and_then(move |gql_req| execute_request(pool, root_node, context, gql_req)) + .or_else(|err| future::ok(render_error(err))), + ), + _ => return Box::new(future::ok(new_response(StatusCode::METHOD_NOT_ALLOWED))), + } +} + +pub fn graphiql( + graphql_endpoint: &str, +) -> Box, Error = hyper::Error> + Send> { + let mut resp = new_html_response(StatusCode::OK); + *resp.body_mut() = Body::from(juniper::graphiql::graphiql_source(graphql_endpoint)); + Box::new(future::ok(resp)) +} + +fn render_error(err: GraphQLRequestError) -> Response { + let message = format!("{}", err); + let mut resp = new_response(StatusCode::BAD_REQUEST); + *resp.body_mut() = Body::from(message); + resp +} + +fn execute_request( + pool: CpuPool, + root_node: Arc>, + context: Arc, + request: GraphQLRequest, +) -> impl Future, Error = Err> +where + CtxT: Send + Sync + 'static, + QueryT: GraphQLType + Send + Sync + 'static, + MutationT: GraphQLType + Send + Sync + 'static, + QueryT::TypeInfo: Send + Sync, + MutationT::TypeInfo: Send + Sync, + Err: Send + Sync + 'static, +{ + pool.spawn_fn(move || { + future::lazy(move || { + let res = request.execute(&root_node, &context); + let code = if res.is_ok() { + StatusCode::OK + } else { + StatusCode::BAD_REQUEST + }; + let mut resp = new_response(code); + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + *resp.body_mut() = Body::from(serde_json::to_string_pretty(&res).unwrap()); + future::ok(resp) + }) + }) +} + +fn gql_request_from_get(input: &str) -> Result { + let mut query = None; + let operation_name = None; + let mut variables = None; + for (key, value) in form_urlencoded::parse(input.as_bytes()).into_owned() { + match key.as_ref() { + "query" => { + if query.is_some() { + return Err(invalid_err("query")); + } + query = Some(value) + } + "operationName" => { + if operation_name.is_some() { + return Err(invalid_err("operationName")); + } + } + "variables" => { + if variables.is_some() { + return Err(invalid_err("variables")); + } + match serde_json::from_str::(&value) + .map_err(GraphQLRequestError::Variables) + { + Ok(parsed_variables) => variables = Some(parsed_variables), + Err(e) => return Err(e), + } + } + _ => continue, + } + } + match query { + Some(query) => Ok(JuniperGraphQLRequest::new(query, operation_name, variables)), + None => Err(GraphQLRequestError::Invalid( + "'query' parameter is missing".to_string(), + )), + } +} + +fn invalid_err(parameter_name: &str) -> GraphQLRequestError { + GraphQLRequestError::Invalid(format!( + "'{}' parameter is specified multiple times", + parameter_name + )) +} + +fn new_response(code: StatusCode) -> Response { + let mut r = Response::new(Body::empty()); + *r.status_mut() = code; + r +} + +fn new_html_response(code: StatusCode) -> Response { + let mut resp = new_response(code); + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + resp +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum GraphQLRequest { + Single(JuniperGraphQLRequest), + Batch(Vec), +} + +impl GraphQLRequest { + pub fn execute<'a, CtxT, QueryT, MutationT>( + &'a self, + root_node: &RootNode, + context: &CtxT, + ) -> GraphQLResponse<'a> + where + QueryT: GraphQLType, + MutationT: GraphQLType, + { + match self { + &GraphQLRequest::Single(ref request) => { + GraphQLResponse::Single(request.execute(root_node, context)) + } + &GraphQLRequest::Batch(ref requests) => GraphQLResponse::Batch( + requests + .iter() + .map(|request| request.execute(root_node, context)) + .collect(), + ), + } + } +} + +#[derive(Serialize)] +#[serde(untagged)] +enum GraphQLResponse<'a> { + Single(JuniperGraphQLResponse<'a>), + Batch(Vec>), +} + +impl<'a> GraphQLResponse<'a> { + fn is_ok(&self) -> bool { + match self { + &GraphQLResponse::Single(ref response) => response.is_ok(), + &GraphQLResponse::Batch(ref responses) => responses + .iter() + .fold(true, |ok, response| ok && response.is_ok()), + } + } +} + +#[derive(Debug)] +enum GraphQLRequestError { + BodyHyper(hyper::Error), + BodyUtf8(FromUtf8Error), + BodyJSONError(SerdeError), + Variables(SerdeError), + Invalid(String), +} + +impl fmt::Display for GraphQLRequestError { + fn fmt(&self, mut f: &mut fmt::Formatter) -> fmt::Result { + match *self { + GraphQLRequestError::BodyHyper(ref err) => fmt::Display::fmt(err, &mut f), + GraphQLRequestError::BodyUtf8(ref err) => fmt::Display::fmt(err, &mut f), + GraphQLRequestError::BodyJSONError(ref err) => fmt::Display::fmt(err, &mut f), + GraphQLRequestError::Variables(ref err) => fmt::Display::fmt(err, &mut f), + GraphQLRequestError::Invalid(ref err) => fmt::Display::fmt(err, &mut f), + } + } +} + +impl Error for GraphQLRequestError { + fn description(&self) -> &str { + match *self { + GraphQLRequestError::BodyHyper(ref err) => err.description(), + GraphQLRequestError::BodyUtf8(ref err) => err.description(), + GraphQLRequestError::BodyJSONError(ref err) => err.description(), + GraphQLRequestError::Variables(ref err) => err.description(), + GraphQLRequestError::Invalid(ref err) => err, + } + } + + fn cause(&self) -> Option<&Error> { + match *self { + GraphQLRequestError::BodyHyper(ref err) => Some(err), + GraphQLRequestError::BodyUtf8(ref err) => Some(err), + GraphQLRequestError::BodyJSONError(ref err) => Some(err), + GraphQLRequestError::Variables(ref err) => Some(err), + GraphQLRequestError::Invalid(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use futures::{future, Future}; + use futures_cpupool::Builder; + use hyper::service::service_fn; + use hyper::Method; + use hyper::{header, Body, Response, Server, StatusCode}; + use juniper::http::tests as http_tests; + use juniper::tests::model::Database; + use juniper::EmptyMutation; + use juniper::RootNode; + use reqwest; + use reqwest::Response as ReqwestResponse; + use std::sync::Arc; + use std::thread; + use std::time; + use tokio::runtime::Runtime; + + struct TestHyperIntegration; + + impl http_tests::HTTPIntegration for TestHyperIntegration { + fn get(&self, url: &str) -> http_tests::TestResponse { + let url = format!("http://127.0.0.1:3001/graphql{}", url); + make_test_response(reqwest::get(&url).expect(&format!("failed GET {}", url))) + } + + fn post(&self, url: &str, body: &str) -> http_tests::TestResponse { + let url = format!("http://127.0.0.1:3001/graphql{}", url); + let client = reqwest::Client::new(); + let res = client + .post(&url) + .body(body.to_string()) + .send() + .expect(&format!("failed POST {}", url)); + make_test_response(res) + } + } + + fn make_test_response(mut response: ReqwestResponse) -> http_tests::TestResponse { + let status_code = response.status().as_u16() as i32; + let content_type = String::from_utf8( + response + .headers() + .get(header::CONTENT_TYPE) + .map(|h| h.clone().as_ref().to_vec()) + .unwrap_or(vec![]), + ).expect("Content-type header invalid UTF-8"); + + let body = response.text().unwrap(); + + http_tests::TestResponse { + status_code, + body: Some(body), + content_type, + } + } + + #[test] + fn test_hyper_integration() { + let addr = ([127, 0, 0, 1], 3001).into(); + + let pool = Builder::new().create(); + let db = Arc::new(Database::new()); + let root_node = Arc::new(RootNode::new(db.clone(), EmptyMutation::::new())); + + let new_service = move || { + let pool = pool.clone(); + let root_node = root_node.clone(); + let ctx = db.clone(); + service_fn(move |req| { + let pool = pool.clone(); + let root_node = root_node.clone(); + let ctx = ctx.clone(); + let matches = { + let path = req.uri().path(); + match req.method() { + &Method::POST | &Method::GET => path == "/graphql" || path == "/graphql/", + _ => false, + } + }; + if matches { + super::graphql(pool, root_node, ctx, req) + } else { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::NOT_FOUND; + Box::new(future::ok(response)) + } + }) + }; + let server = Server::bind(&addr) + .serve(new_service) + .map_err(|e| eprintln!("server error: {}", e)); + + let mut runtime = Runtime::new().unwrap(); + runtime.spawn(server); + thread::sleep(time::Duration::from_millis(10)); // wait 10ms for server to bind + + let integration = TestHyperIntegration; + http_tests::run_http_test_suite(&integration); + + runtime.shutdown_now().wait().unwrap(); + } +}