Add integration crate for the warp framework (#216)

This commit is contained in:
Tom Houlé 2018-09-07 23:28:56 +02:00 committed by Christian Legnitto
parent 1ee3dab3b7
commit fd636e07df
9 changed files with 641 additions and 2 deletions

View file

@ -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 - 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 - chmod 777 ~/.cargo/bin/cargo-make
- cargo make workspace-ci-flow --no-workspace - 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: before_deploy:
- rm -rf target/package/ - rm -rf target/package/

View file

@ -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 You can also check out [src/tests/schema.rs][test_schema_rs] to see a complex
schema including polymorphism with traits and interfaces. schema including polymorphism with traits and interfaces.
For an example of web framework integration, 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 ## Features
@ -75,6 +75,7 @@ your Schemas automatically.
* [hyper][hyper] * [hyper][hyper]
* [rocket][rocket] * [rocket][rocket]
* [iron][iron] * [iron][iron]
* [warp][warp]
## Guides & Examples ## 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]: https://graphql-rust.github.io
[book_quickstart]: https://graphql-rust.github.io/quickstart.html [book_quickstart]: https://graphql-rust.github.io/quickstart.html
[docsrs]: https://docs.rs/juniper [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 [uuid]: https://crates.io/crates/uuid
[url]: https://crates.io/crates/url [url]: https://crates.io/crates/url
[chrono]: https://crates.io/crates/chrono [chrono]: https://crates.io/crates/chrono

View file

@ -137,6 +137,7 @@ pub mod tests {
/// Normalized response content we expect to get back from /// Normalized response content we expect to get back from
/// the http framework integration we are testing. /// the http framework integration we are testing.
#[derive(Debug)]
pub struct TestResponse { pub struct TestResponse {
pub status_code: i32, pub status_code: i32,
pub body: Option<String>, pub body: Option<String>,

4
juniper_warp/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
/examples/**/target/**/*
**/*.rs.bk
Cargo.lock

24
juniper_warp/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "juniper_warp"
version = "0.1.0"
authors = ["Tom Houlé <tom@tomhoule.com>"]
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"]

33
juniper_warp/README.md Normal file
View file

@ -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

View file

@ -0,0 +1,11 @@
[package]
name = "warp_server"
version = "0.1.0"
authors = ["Tom Houlé <tom@tomhoule.com>"]
[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"] }

View file

@ -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<Database>>;
fn schema() -> Schema {
Schema::new(Database::new(), EmptyMutation::<Database>::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!(
"<html><h1>juniper_warp</h1><div>visit <a href=\"/graphiql\">/graphiql</a></html>"
))
});
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));
}

509
juniper_warp/src/lib.rs Normal file
View file

@ -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<juniper::http::GraphQLRequest>),
}
impl GraphQLBatchRequest {
pub fn execute<'a, CtxT, QueryT, MutationT>(
&'a self,
root_node: &juniper::RootNode<QueryT, MutationT>,
context: &CtxT,
) -> GraphQLBatchResponse<'a>
where
QueryT: juniper::GraphQLType<Context = CtxT>,
MutationT: juniper::GraphQLType<Context = CtxT>,
{
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<juniper::http::GraphQLResponse<'a>>),
}
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<i64>);
/// struct ExampleContext(Arc<AppState>, 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::<String>("authorization"))
/// .and(app_state)
/// .map(|auth_header: String, app_state: Arc<AppState>| {
/// 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<Query, Mutation, Context>(
schema: juniper::RootNode<'static, Query, Mutation>,
context_extractor: BoxedFilter<(Context,)>,
) -> BoxedFilter<(warp::http::Response<Vec<u8>>,)>
where
Context: Send + 'static,
Query: juniper::GraphQLType<Context = Context, TypeInfo = ()> + Send + Sync + 'static,
Mutation: juniper::GraphQLType<Context = Context, TypeInfo = ()> + Send + Sync + 'static,
{
let pool = CpuPool::new_num_cpus();
make_graphql_filter_with_thread_pool(schema, context_extractor, pool)
}
type Response =
Box<Future<Item = warp::http::Response<Vec<u8>>, 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<Query, Mutation, Context>(
schema: juniper::RootNode<'static, Query, Mutation>,
context_extractor: BoxedFilter<(Context,)>,
thread_pool: futures_cpupool::CpuPool,
) -> BoxedFilter<(warp::http::Response<Vec<u8>>,)>
where
Context: Send + 'static,
Query: juniper::GraphQLType<Context = Context, TypeInfo = ()> + Send + Sync + 'static,
Mutation: juniper::GraphQLType<Context = Context, TypeInfo = ()> + 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<String, String>,
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<u8>, bool), failure::Error>,
) -> warp::http::Response<Vec<u8>> {
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<Vec<u8>>,)> {
warp::any()
.map(move || graphiql_response(graphql_endpoint_url))
.boxed()
}
fn graphiql_response(graphql_endpoint_url: &'static str) -> warp::http::Response<Vec<u8>> {
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("<script>var GRAPHQL_URL = '/dogs-api/graphql';</script>"));
}
#[test]
fn graphql_handler_works_json_post() {
use juniper::tests::model::Database;
use juniper::{EmptyMutation, RootNode};
type Schema = juniper::RootNode<'static, Database, EmptyMutation<Database>>;
let schema: Schema = RootNode::new(Database::new(), EmptyMutation::<Database>::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<Database>>;
let schema: Schema = RootNode::new(Database::new(), EmptyMutation::<Database>::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<GraphQLBatchRequest, _> = 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<Database>>;
fn warp_server() -> warp::filters::BoxedFilter<(warp::http::Response<Vec<u8>>,)> {
let schema: Schema = RootNode::new(Database::new(), EmptyMutation::<Database>::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<Vec<u8>>,)>,
}
// 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<Vec<u8>>) -> 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::<Vec<_>>()
.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);
}
}