diff --git a/Cargo.toml b/Cargo.toml index cc833d78..cee85539 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,4 +3,5 @@ members = [ "juniper", "juniper_codegen", "juniper_tests", + "juniper_iron", ] diff --git a/juniper_iron/.gitignore b/juniper_iron/.gitignore new file mode 100644 index 00000000..a9d37c56 --- /dev/null +++ b/juniper_iron/.gitignore @@ -0,0 +1,2 @@ +target +Cargo.lock diff --git a/juniper_iron/CHANGELOG.md b/juniper_iron/CHANGELOG.md new file mode 100644 index 00000000..e69de29b diff --git a/juniper_iron/Cargo.toml b/juniper_iron/Cargo.toml new file mode 100644 index 00000000..464e1f7a --- /dev/null +++ b/juniper_iron/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "juniper_iron" +version = "0.1.0" +authors = ["Magnus Hallin "] +description = "Iron integration for juniper" +license = "BSD-2-Clause" +documentation = "https://docs.rs/juniper_iron" +repository = "https://github.com/graphql-rust/juniper_iron" + +[dependencies] +serde = { version = "1.0.2" } +serde_json = { version = "1.0.2" } +urlencoded = { version = "0.5.0" } +iron = "0.5.1" +juniper = { version = "0.8.1", git = "https://github.com/graphql-rust/juniper" } + +[dev-dependencies] +iron-test = "^0.5.0" +router = "^0.5.0" +mount = "^0.3.0" +logger = "^0.3.0" +juniper = { version = "0.8.1", features = ["expose-test-schema", "serde_json"], git = "https://github.com/graphql-rust/juniper" } + +[badges] +travis-ci = { repository = "graphql-rust/juniper_iron" } +appveyor = { repository = "graphql-rust/juniper_iron" } diff --git a/juniper_iron/LICENSE b/juniper_iron/LICENSE new file mode 100644 index 00000000..0ccd1e17 --- /dev/null +++ b/juniper_iron/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_iron/README.md b/juniper_iron/README.md new file mode 100644 index 00000000..592268bd --- /dev/null +++ b/juniper_iron/README.md @@ -0,0 +1,31 @@ +# juniper_iron + +[![Build Status](https://travis-ci.org/graphql-rust/juniper_iron.svg?branch=master)](https://travis-ci.org/graphql-rust/juniper_iron) +[![Build status](https://ci.appveyor.com/api/projects/status/rqguvfkl9m0g7hum?svg=true)](https://ci.appveyor.com/project/theduke/juniper-iron) +[![Crates.io](https://img.shields.io/crates/v/juniper_iron.svg?maxAge=2592000)](https://crates.io/crates/juniper_iron) +[![Gitter chat](https://badges.gitter.im/juniper-graphql/gitter.png)](https://gitter.im/juniper-graphql) + +This repository contains the [Iron][Iron] web framework integration for [Juniper][Juniper], a [GraphQL][GraphQL] +implementation for Rust. + +## Documentation + +Once the crate is published, documentation will be on [docs.rs][documentation]. + +For now, please consult the documentation comments [here](https://github.com/graphql-rust/juniper_iron/blob/master/src/lib.rs). + +## Examples + +Check [examples/iron_server.rs][example] for example code of a working Iron server with GraphQL handlers. + +## License + +This project is under the BSD-2 license. + +Check the LICENSE file for details. + +[Iron]: https://github.com/iron/iron +[Juniper]: https://github.com/graphql-rust/juniper +[GraphQL]: http://graphql.org +[documentation]: https://docs.rs/juniper_iron +[example]: https://github.com/graphql-rust/juniper_iron/blob/master/examples/iron_server.rs diff --git a/juniper_iron/examples/iron_server.rs b/juniper_iron/examples/iron_server.rs new file mode 100644 index 00000000..9fa4f0ab --- /dev/null +++ b/juniper_iron/examples/iron_server.rs @@ -0,0 +1,43 @@ +extern crate iron; +extern crate mount; +extern crate logger; +extern crate serde; +extern crate juniper; +extern crate juniper_iron; + +use std::env; + +use mount::Mount; +use logger::Logger; +use iron::prelude::*; +use juniper::EmptyMutation; +use juniper_iron::{GraphQLHandler, GraphiQLHandler}; +use juniper::tests::model::Database; + +fn context_factory(_: &mut Request) -> Database { + Database::new() +} + +fn main() { + let mut mount = Mount::new(); + + let graphql_endpoint = GraphQLHandler::new( + context_factory, + Database::new(), + EmptyMutation::::new(), + ); + let graphiql_endpoint = GraphiQLHandler::new("/graphql"); + + mount.mount("/", graphiql_endpoint); + mount.mount("/graphql", graphql_endpoint); + + let (logger_before, logger_after) = Logger::new(None); + + let mut chain = Chain::new(mount); + chain.link_before(logger_before); + chain.link_after(logger_after); + + let host = env::var("LISTEN").unwrap_or("0.0.0.0:8080".to_owned()); + println!("GraphQL server started on {}", host); + Iron::new(chain).http(host.as_str()).unwrap(); +} diff --git a/juniper_iron/src/lib.rs b/juniper_iron/src/lib.rs new file mode 100644 index 00000000..8fda09d5 --- /dev/null +++ b/juniper_iron/src/lib.rs @@ -0,0 +1,397 @@ +/*! + +[Juniper][1] handlers for the [Iron][2] framework. + +## Integrating with Iron + + + +For example, continuing from the schema created above and using Iron to expose +the schema on an HTTP endpoint supporting both GET and POST requests: + +```rust,no_run +extern crate iron; +# #[macro_use] extern crate juniper; +# extern crate juniper_iron; +# use std::collections::HashMap; + +use iron::prelude::*; +use juniper_iron::GraphQLHandler; +use juniper::{Context, EmptyMutation}; + +# use juniper::FieldResult; +# +# struct User { id: String, name: String, friend_ids: Vec } +# struct QueryRoot; +# struct Database { users: HashMap } +# +# graphql_object!(User: Database |&self| { +# field id() -> FieldResult<&String> { +# Ok(&self.id) +# } +# +# field name() -> FieldResult<&String> { +# Ok(&self.name) +# } +# +# field friends(&executor) -> FieldResult> { +# Ok(self.friend_ids.iter() +# .filter_map(|id| executor.context().users.get(id)) +# .collect()) +# } +# }); +# +# graphql_object!(QueryRoot: Database |&self| { +# field user(&executor, id: String) -> FieldResult> { +# Ok(executor.context().users.get(&id)) +# } +# }); + +// This function is executed for every request. Here, we would realistically +// provide a database connection or similar. For this example, we'll be +// creating the database from scratch. +fn context_factory(_: &mut Request) -> Database { + Database { + users: vec![ + ( "1000".to_owned(), User { + id: "1000".to_owned(), name: "Robin".to_owned(), + friend_ids: vec!["1001".to_owned()] } ), + ( "1001".to_owned(), User { + id: "1001".to_owned(), name: "Max".to_owned(), + friend_ids: vec!["1000".to_owned()] } ), + ].into_iter().collect() + } +} + +impl Context for Database {} + +fn main() { + // GraphQLHandler takes a context factory function, the root object, + // and the mutation object. If we don't have any mutations to expose, we + // can use the empty tuple () to indicate absence. + let graphql_endpoint = GraphQLHandler::new( + context_factory, QueryRoot, EmptyMutation::::new()); + + // Start serving the schema at the root on port 8080. + Iron::new(graphql_endpoint).http("localhost:8080").unwrap(); +} + +``` + +See the [iron_server.rs][5] +example for more information on how to use these handlers. + +See the the [`GraphQLHandler`][3] documentation for more information on what request methods are +supported. +There's also a built-in [GraphiQL][4] handler included. + +[1]: https://github.com/mhallin/Juniper +[2]: http://ironframework.io +[3]: ./struct.GraphQLHandler.html +[4]: https://github.com/graphql/graphiql +[5]: https://github.com/mhallin/juniper/blob/master/juniper_iron/examples/iron_server.rs + +*/ + +extern crate serde_json; +extern crate juniper; +extern crate urlencoded; +#[macro_use] +extern crate iron; +#[cfg(test)] +extern crate iron_test; + +use iron::prelude::*; +use iron::middleware::Handler; +use iron::mime::Mime; +use iron::status; +use iron::method; +use urlencoded::{UrlDecodingError, UrlEncodedQuery}; + +use std::io::Read; +use std::error::Error; +use std::fmt; + +use serde_json::error::Error as SerdeError; + +use juniper::{GraphQLType, InputValue, RootNode}; +use juniper::http; + +/// Handler that executes GraphQL queries in the given schema +/// +/// The handler responds to GET requests and POST requests only. In GET +/// requests, the query should be supplied in the `query` URL parameter, e.g. +/// `http://localhost:3000/graphql?query={hero{name}}`. +/// +/// POST requests support both queries and variables. POST a JSON document to +/// this endpoint containing the field `"query"` and optionally `"variables"`. +/// The variables should be a JSON object containing the variable to value +/// mapping. +pub struct GraphQLHandler<'a, CtxFactory, Query, Mutation, CtxT> +where + CtxFactory: Fn(&mut Request) -> CtxT + Send + Sync + 'static, + CtxT: 'static, + Query: GraphQLType + Send + Sync + 'static, + Mutation: GraphQLType + Send + Sync + 'static, +{ + context_factory: CtxFactory, + root_node: RootNode<'a, Query, Mutation>, +} + +/// Handler that renders GraphiQL - a graphical query editor interface +pub struct GraphiQLHandler { + graphql_url: String, +} + + +fn get_single_value(mut values: Vec) -> IronResult { + if values.len() == 1 { + Ok(values.remove(0)) + } else { + Err( + GraphQLIronError::InvalidData("Duplicate URL query parameter").into(), + ) + } +} + +fn parse_url_param(params: Option>) -> IronResult> { + if let Some(values) = params { + get_single_value(values).map(Some) + } else { + Ok(None) + } +} + +fn parse_variable_param(params: Option>) -> IronResult> { + if let Some(values) = params { + Ok(serde_json::from_str::( + get_single_value(values)?.as_ref(), + ).map(Some) + .map_err(GraphQLIronError::Serde)?) + } else { + Ok(None) + } +} + + +impl<'a, CtxFactory, Query, Mutation, CtxT> GraphQLHandler<'a, CtxFactory, Query, Mutation, CtxT> +where + CtxFactory: Fn(&mut Request) -> CtxT + Send + Sync + 'static, + CtxT: 'static, + Query: GraphQLType + Send + Sync + 'static, + Mutation: GraphQLType + Send + Sync + 'static, +{ + /// Build a new GraphQL handler + /// + /// The context factory will receive the Iron request object and is + /// expected to construct a context object for the given schema. This can + /// be used to construct e.g. database connections or similar data that + /// the schema needs to execute the query. + pub fn new(context_factory: CtxFactory, query: Query, mutation: Mutation) -> Self { + GraphQLHandler { + context_factory: context_factory, + root_node: RootNode::new(query, mutation), + } + } + + + fn handle_get(&self, req: &mut Request) -> IronResult { + let url_query_string = req.get_mut::() + .map_err(|e| GraphQLIronError::Url(e))?; + + let input_query = parse_url_param(url_query_string.remove("query"))? + .ok_or_else(|| GraphQLIronError::InvalidData("No query provided"))?; + let operation_name = parse_url_param(url_query_string.remove("operationName"))?; + let variables = parse_variable_param(url_query_string.remove("variables"))?; + + Ok(http::GraphQLRequest::new( + input_query, + operation_name, + variables, + )) + } + + fn handle_post(&self, req: &mut Request) -> IronResult { + let mut request_payload = String::new(); + itry!(req.body.read_to_string(&mut request_payload)); + + Ok(serde_json::from_str::( + request_payload.as_str(), + ).map_err(|err| GraphQLIronError::Serde(err))?) + } + + fn execute(&self, context: &CtxT, request: http::GraphQLRequest) -> IronResult { + let response = request.execute(&self.root_node, context); + let content_type = "application/json".parse::().unwrap(); + let json = serde_json::to_string_pretty(&response).unwrap(); + let status = if response.is_ok() { + status::Ok + } else { + status::BadRequest + }; + Ok(Response::with((content_type, status, json))) + } +} + +impl GraphiQLHandler { + /// Build a new GraphiQL handler targeting the specified URL. + /// + /// The provided URL should point to the URL of the attached `GraphQLHandler`. It can be + /// relative, so a common value could be `"/graphql"`. + pub fn new(graphql_url: &str) -> GraphiQLHandler { + GraphiQLHandler { + graphql_url: graphql_url.to_owned(), + } + } +} + +impl<'a, CtxFactory, Query, Mutation, CtxT> Handler + for GraphQLHandler<'a, CtxFactory, Query, Mutation, CtxT> +where + CtxFactory: Fn(&mut Request) -> CtxT + Send + Sync + 'static, + CtxT: 'static, + Query: GraphQLType + Send + Sync + 'static, + Mutation: GraphQLType + Send + Sync + 'static, + 'a: 'static, +{ + fn handle(&self, mut req: &mut Request) -> IronResult { + let context = (self.context_factory)(req); + + let graphql_request = match req.method { + method::Get => self.handle_get(&mut req)?, + method::Post => self.handle_post(&mut req)?, + _ => return Ok(Response::with((status::MethodNotAllowed))), + }; + + self.execute(&context, graphql_request) + } +} + +impl Handler for GraphiQLHandler { + fn handle(&self, _: &mut Request) -> IronResult { + let content_type = "text/html".parse::().unwrap(); + + Ok(Response::with(( + content_type, + status::Ok, + juniper::graphiql::graphiql_source(&self.graphql_url), + ))) + } +} + +#[derive(Debug)] +enum GraphQLIronError { + Serde(SerdeError), + Url(UrlDecodingError), + InvalidData(&'static str), +} + +impl fmt::Display for GraphQLIronError { + fn fmt(&self, mut f: &mut fmt::Formatter) -> fmt::Result { + match *self { + GraphQLIronError::Serde(ref err) => fmt::Display::fmt(err, &mut f), + GraphQLIronError::Url(ref err) => fmt::Display::fmt(err, &mut f), + GraphQLIronError::InvalidData(ref err) => fmt::Display::fmt(err, &mut f), + } + } +} + +impl Error for GraphQLIronError { + fn description(&self) -> &str { + match *self { + GraphQLIronError::Serde(ref err) => err.description(), + GraphQLIronError::Url(ref err) => err.description(), + GraphQLIronError::InvalidData(ref err) => err, + } + } + + fn cause(&self) -> Option<&Error> { + match *self { + GraphQLIronError::Serde(ref err) => Some(err), + GraphQLIronError::Url(ref err) => Some(err), + GraphQLIronError::InvalidData(_) => None, + } + } +} + +impl From for IronError { + fn from(err: GraphQLIronError) -> IronError { + let message = format!("{}", err); + IronError::new(err, (status::BadRequest, message)) + } +} + +#[cfg(test)] +mod tests { + use iron::prelude::*; + use iron_test::{request, response}; + use iron::{Handler, Headers}; + + use juniper::tests::model::Database; + use juniper::http::tests as http_tests; + use juniper::EmptyMutation; + + use super::GraphQLHandler; + + struct TestIronIntegration; + + impl http_tests::HTTPIntegration for TestIronIntegration { + fn get(&self, url: &str) -> http_tests::TestResponse { + make_test_response(request::get( + &("http://localhost:3000".to_owned() + url), + Headers::new(), + &make_handler(), + )) + } + + fn post(&self, url: &str, body: &str) -> http_tests::TestResponse { + make_test_response(request::post( + &("http://localhost:3000".to_owned() + url), + Headers::new(), + body, + &make_handler(), + )) + } + } + + #[test] + fn test_iron_integration() { + let integration = TestIronIntegration; + + http_tests::run_http_test_suite(&integration); + } + + fn context_factory(_: &mut Request) -> Database { + Database::new() + } + + fn make_test_response(response: IronResult) -> http_tests::TestResponse { + let response = response.expect("Error response from GraphQL handler"); + let status_code = response + .status + .expect("No status code returned from handler") + .to_u16() as i32; + let content_type = String::from_utf8( + response + .headers + .get_raw("content-type") + .expect("No content type header from handler")[0] + .clone(), + ).expect("Content-type header invalid UTF-8"); + let body = response::extract_body_to_string(response); + + http_tests::TestResponse { + status_code: status_code, + body: Some(body), + content_type: content_type, + } + } + + fn make_handler() -> Box { + Box::new(GraphQLHandler::new( + context_factory, + Database::new(), + EmptyMutation::::new(), + )) + } +}