940 lines
33 KiB
Rust
940 lines
33 KiB
Rust
/*!
|
|
|
|
# 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/blob/master/juniper_warp/examples/warp_server
|
|
|
|
*/
|
|
|
|
#![deny(missing_docs)]
|
|
#![deny(warnings)]
|
|
#![doc(html_root_url = "https://docs.rs/juniper_warp/0.2.0")]
|
|
|
|
use anyhow::anyhow;
|
|
use bytes::Bytes;
|
|
use futures::{FutureExt as _, TryFutureExt};
|
|
use juniper::{
|
|
http::{GraphQLBatchRequest, GraphQLRequest},
|
|
ScalarValue,
|
|
};
|
|
use std::{collections::HashMap, str, sync::Arc};
|
|
use tokio::task;
|
|
use warp::{body, filters::BoxedFilter, http, query, Filter};
|
|
|
|
/// Make a filter for graphql queries/mutations.
|
|
///
|
|
/// 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 use the `tokio_threadpool` threadpool created by hyper to resolve GraphQL requests.
|
|
///
|
|
/// Example:
|
|
///
|
|
/// ```
|
|
/// # extern crate juniper_warp;
|
|
/// # extern crate juniper;
|
|
/// # extern crate warp;
|
|
/// #
|
|
/// # use std::sync::Arc;
|
|
/// # use warp::Filter;
|
|
/// # use juniper::{EmptyMutation, EmptySubscription, RootNode};
|
|
/// # use juniper_warp::make_graphql_filter;
|
|
/// #
|
|
/// type UserId = String;
|
|
/// # #[derive(Debug)]
|
|
/// struct AppState(Vec<i64>);
|
|
/// struct ExampleContext(Arc<AppState>, UserId);
|
|
///
|
|
/// struct QueryRoot;
|
|
///
|
|
/// #[juniper::graphql_object(
|
|
/// Context = ExampleContext
|
|
/// )]
|
|
/// impl QueryRoot {
|
|
/// fn say_hello(context: &ExampleContext) -> String {
|
|
/// format!(
|
|
/// "good morning {}, the app state is {:?}",
|
|
/// context.1,
|
|
/// context.0
|
|
/// )
|
|
/// }
|
|
/// }
|
|
///
|
|
/// let schema = RootNode::new(QueryRoot, EmptyMutation::new(), EmptySubscription::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::post())
|
|
/// .and(graphql_filter);
|
|
/// ```
|
|
pub fn make_graphql_filter<Query, Mutation, Subscription, CtxT, S>(
|
|
schema: juniper::RootNode<'static, Query, Mutation, Subscription, S>,
|
|
context_extractor: BoxedFilter<(CtxT,)>,
|
|
) -> BoxedFilter<(http::Response<Vec<u8>>,)>
|
|
where
|
|
Query: juniper::GraphQLTypeAsync<S, Context = CtxT> + Send + 'static,
|
|
Query::TypeInfo: Send + Sync,
|
|
Mutation: juniper::GraphQLTypeAsync<S, Context = CtxT> + Send + 'static,
|
|
Mutation::TypeInfo: Send + Sync,
|
|
Subscription: juniper::GraphQLSubscriptionType<S, Context = CtxT> + Send + 'static,
|
|
Subscription::TypeInfo: Send + Sync,
|
|
CtxT: Send + Sync + 'static,
|
|
S: ScalarValue + Send + Sync + 'static,
|
|
{
|
|
let schema = Arc::new(schema);
|
|
let post_json_schema = schema.clone();
|
|
let post_graphql_schema = schema.clone();
|
|
|
|
let handle_post_json_request = move |context: CtxT, req: GraphQLBatchRequest<S>| {
|
|
let schema = post_json_schema.clone();
|
|
async move {
|
|
let resp = req.execute(&schema, &context).await;
|
|
|
|
Ok::<_, warp::Rejection>(build_response(
|
|
serde_json::to_vec(&resp)
|
|
.map(|json| (json, resp.is_ok()))
|
|
.map_err(Into::into),
|
|
))
|
|
}
|
|
};
|
|
let post_json_filter = warp::post()
|
|
.and(context_extractor.clone())
|
|
.and(body::json())
|
|
.and_then(handle_post_json_request);
|
|
|
|
let handle_post_graphql_request = move |context: CtxT, body: Bytes| {
|
|
let schema = post_graphql_schema.clone();
|
|
async move {
|
|
let query = str::from_utf8(body.as_ref())
|
|
.map_err(|e| anyhow!("Request body query is not a valid UTF-8 string: {}", e))?;
|
|
let req = GraphQLRequest::new(query.into(), None, None);
|
|
|
|
let resp = req.execute(&schema, &context).await;
|
|
|
|
Ok((serde_json::to_vec(&resp)?, resp.is_ok()))
|
|
}
|
|
.then(|res| async { Ok::<_, warp::Rejection>(build_response(res)) })
|
|
};
|
|
let post_graphql_filter = warp::post()
|
|
.and(context_extractor.clone())
|
|
.and(body::bytes())
|
|
.and_then(handle_post_graphql_request);
|
|
|
|
let handle_get_request = move |context: CtxT, mut qry: HashMap<String, String>| {
|
|
let schema = schema.clone();
|
|
async move {
|
|
let req = GraphQLRequest::new(
|
|
qry.remove("query")
|
|
.ok_or_else(|| anyhow!("Missing GraphQL query string in query parameters"))?,
|
|
qry.remove("operation_name"),
|
|
qry.remove("variables")
|
|
.map(|vs| serde_json::from_str(&vs))
|
|
.transpose()?,
|
|
);
|
|
|
|
let resp = req.execute(&schema, &context).await;
|
|
|
|
Ok((serde_json::to_vec(&resp)?, resp.is_ok()))
|
|
}
|
|
.then(|res| async move { Ok::<_, warp::Rejection>(build_response(res)) })
|
|
};
|
|
let get_filter = warp::get()
|
|
.and(context_extractor)
|
|
.and(query::query())
|
|
.and_then(handle_get_request);
|
|
|
|
get_filter
|
|
.or(post_json_filter)
|
|
.unify()
|
|
.or(post_graphql_filter)
|
|
.unify()
|
|
.boxed()
|
|
}
|
|
|
|
/// Make a synchronous filter for graphql endpoint.
|
|
pub fn make_graphql_filter_sync<Query, Mutation, Subscription, CtxT, S>(
|
|
schema: juniper::RootNode<'static, Query, Mutation, Subscription, S>,
|
|
context_extractor: BoxedFilter<(CtxT,)>,
|
|
) -> BoxedFilter<(http::Response<Vec<u8>>,)>
|
|
where
|
|
Query: juniper::GraphQLType<S, Context = CtxT, TypeInfo = ()> + Send + Sync + 'static,
|
|
Mutation: juniper::GraphQLType<S, Context = CtxT, TypeInfo = ()> + Send + Sync + 'static,
|
|
Subscription: juniper::GraphQLType<S, Context = CtxT, TypeInfo = ()> + Send + Sync + 'static,
|
|
CtxT: Send + Sync + 'static,
|
|
S: ScalarValue + Send + Sync + 'static,
|
|
{
|
|
let schema = Arc::new(schema);
|
|
let post_json_schema = schema.clone();
|
|
let post_graphql_schema = schema.clone();
|
|
|
|
let handle_post_json_request = move |context: CtxT, req: GraphQLBatchRequest<S>| {
|
|
let schema = post_json_schema.clone();
|
|
async move {
|
|
let res = task::spawn_blocking(move || {
|
|
let resp = req.execute_sync(&schema, &context);
|
|
Ok((serde_json::to_vec(&resp)?, resp.is_ok()))
|
|
})
|
|
.await?;
|
|
|
|
Ok(build_response(res))
|
|
}
|
|
.map_err(|e: task::JoinError| warp::reject::custom(JoinError(e)))
|
|
};
|
|
let post_json_filter = warp::post()
|
|
.and(context_extractor.clone())
|
|
.and(body::json())
|
|
.and_then(handle_post_json_request);
|
|
|
|
let handle_post_graphql_request = move |context: CtxT, body: Bytes| {
|
|
let schema = post_graphql_schema.clone();
|
|
async move {
|
|
let res = task::spawn_blocking(move || {
|
|
let query = str::from_utf8(body.as_ref())
|
|
.map_err(|e| anyhow!("Request body is not a valid UTF-8 string: {}", e))?;
|
|
let req = GraphQLRequest::new(query.into(), None, None);
|
|
|
|
let resp = req.execute_sync(&schema, &context);
|
|
Ok((serde_json::to_vec(&resp)?, resp.is_ok()))
|
|
})
|
|
.await?;
|
|
|
|
Ok(build_response(res))
|
|
}
|
|
.map_err(|e: task::JoinError| warp::reject::custom(JoinError(e)))
|
|
};
|
|
let post_graphql_filter = warp::post()
|
|
.and(context_extractor.clone())
|
|
.and(body::bytes())
|
|
.and_then(handle_post_graphql_request);
|
|
|
|
let handle_get_request = move |context: CtxT, mut qry: HashMap<String, String>| {
|
|
let schema = schema.clone();
|
|
async move {
|
|
let res = task::spawn_blocking(move || {
|
|
let req = GraphQLRequest::new(
|
|
qry.remove("query").ok_or_else(|| {
|
|
anyhow!("Missing GraphQL query string in query parameters")
|
|
})?,
|
|
qry.remove("operation_name"),
|
|
qry.remove("variables")
|
|
.map(|vs| serde_json::from_str(&vs))
|
|
.transpose()?,
|
|
);
|
|
|
|
let resp = req.execute_sync(&schema, &context);
|
|
Ok((serde_json::to_vec(&resp)?, resp.is_ok()))
|
|
})
|
|
.await?;
|
|
|
|
Ok(build_response(res))
|
|
}
|
|
.map_err(|e: task::JoinError| warp::reject::custom(JoinError(e)))
|
|
};
|
|
let get_filter = warp::get()
|
|
.and(context_extractor)
|
|
.and(query::query())
|
|
.and_then(handle_get_request);
|
|
|
|
get_filter
|
|
.or(post_json_filter)
|
|
.unify()
|
|
.or(post_graphql_filter)
|
|
.unify()
|
|
.boxed()
|
|
}
|
|
|
|
/// Error raised by `tokio_threadpool` if the thread pool has been shutdown.
|
|
///
|
|
/// Wrapper type is needed as inner type does not implement `warp::reject::Reject`.
|
|
#[derive(Debug)]
|
|
pub struct JoinError(task::JoinError);
|
|
|
|
impl warp::reject::Reject for JoinError {}
|
|
|
|
fn build_response(response: Result<(Vec<u8>, bool), anyhow::Error>) -> http::Response<Vec<u8>> {
|
|
match response {
|
|
Ok((body, is_ok)) => http::Response::builder()
|
|
.status(if is_ok { 200 } else { 400 })
|
|
.header("content-type", "application/json")
|
|
.body(body)
|
|
.expect("response is valid"),
|
|
Err(_) => http::Response::builder()
|
|
.status(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_filter;
|
|
/// #
|
|
/// let graphiql_route = warp::path("graphiql").and(graphiql_filter("/graphql",
|
|
/// None));
|
|
/// ```
|
|
///
|
|
/// Or with subscriptions support, provide the subscriptions endpoint URL:
|
|
///
|
|
/// ```
|
|
/// # extern crate warp;
|
|
/// # extern crate juniper_warp;
|
|
/// #
|
|
/// # use warp::Filter;
|
|
/// # use juniper_warp::graphiql_filter;
|
|
/// #
|
|
/// let graphiql_route = warp::path("graphiql").and(graphiql_filter("/graphql",
|
|
/// Some("ws://localhost:8080/subscriptions")));
|
|
/// ```
|
|
pub fn graphiql_filter(
|
|
graphql_endpoint_url: &'static str,
|
|
subscriptions_endpoint: Option<&'static str>,
|
|
) -> warp::filters::BoxedFilter<(http::Response<Vec<u8>>,)> {
|
|
warp::any()
|
|
.map(move || graphiql_response(graphql_endpoint_url, subscriptions_endpoint))
|
|
.boxed()
|
|
}
|
|
|
|
fn graphiql_response(
|
|
graphql_endpoint_url: &'static str,
|
|
subscriptions_endpoint: Option<&'static str>,
|
|
) -> http::Response<Vec<u8>> {
|
|
http::Response::builder()
|
|
.header("content-type", "text/html;charset=utf-8")
|
|
.body(
|
|
juniper::http::graphiql::graphiql_source(graphql_endpoint_url, subscriptions_endpoint)
|
|
.into_bytes(),
|
|
)
|
|
.expect("response is valid")
|
|
}
|
|
|
|
/// Create a filter that replies with an HTML page containing GraphQL Playground. This does not handle routing, so you can mount it on any endpoint.
|
|
pub fn playground_filter(
|
|
graphql_endpoint_url: &'static str,
|
|
subscriptions_endpoint_url: Option<&'static str>,
|
|
) -> warp::filters::BoxedFilter<(http::Response<Vec<u8>>,)> {
|
|
warp::any()
|
|
.map(move || playground_response(graphql_endpoint_url, subscriptions_endpoint_url))
|
|
.boxed()
|
|
}
|
|
|
|
fn playground_response(
|
|
graphql_endpoint_url: &'static str,
|
|
subscriptions_endpoint_url: Option<&'static str>,
|
|
) -> http::Response<Vec<u8>> {
|
|
http::Response::builder()
|
|
.header("content-type", "text/html;charset=utf-8")
|
|
.body(
|
|
juniper::http::playground::playground_source(
|
|
graphql_endpoint_url,
|
|
subscriptions_endpoint_url,
|
|
)
|
|
.into_bytes(),
|
|
)
|
|
.expect("response is valid")
|
|
}
|
|
|
|
/// `juniper_warp` subscriptions handler implementation.
|
|
/// Cannot be merged to `juniper_warp` yet as GraphQL over WS[1]
|
|
/// is not fully supported in current implementation.
|
|
///
|
|
/// *Note: this implementation is in an alpha state.*
|
|
///
|
|
/// [1]: https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md
|
|
#[cfg(feature = "subscriptions")]
|
|
pub mod subscriptions {
|
|
use std::{
|
|
collections::HashMap,
|
|
sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc,
|
|
},
|
|
};
|
|
|
|
use anyhow::anyhow;
|
|
use futures::{channel::mpsc, Future, StreamExt as _, TryFutureExt as _, TryStreamExt as _};
|
|
use juniper::{http::GraphQLRequest, InputValue, ScalarValue, SubscriptionCoordinator as _};
|
|
use juniper_subscriptions::Coordinator;
|
|
use serde::{Deserialize, Serialize};
|
|
use warp::ws::Message;
|
|
|
|
/// Listen to incoming messages and do one of the following:
|
|
/// - execute subscription and return values from stream
|
|
/// - stop stream and close ws connection
|
|
#[allow(dead_code)]
|
|
pub fn graphql_subscriptions<Query, Mutation, Subscription, CtxT, S>(
|
|
websocket: warp::ws::WebSocket,
|
|
coordinator: Arc<Coordinator<'static, Query, Mutation, Subscription, CtxT, S>>,
|
|
context: CtxT,
|
|
) -> impl Future<Output = Result<(), anyhow::Error>> + Send
|
|
where
|
|
Query: juniper::GraphQLTypeAsync<S, Context = CtxT> + Send + 'static,
|
|
Query::TypeInfo: Send + Sync,
|
|
Mutation: juniper::GraphQLTypeAsync<S, Context = CtxT> + Send + 'static,
|
|
Mutation::TypeInfo: Send + Sync,
|
|
Subscription: juniper::GraphQLSubscriptionType<S, Context = CtxT> + Send + 'static,
|
|
Subscription::TypeInfo: Send + Sync,
|
|
CtxT: Send + Sync + 'static,
|
|
S: ScalarValue + Send + Sync + 'static,
|
|
{
|
|
let (sink_tx, sink_rx) = websocket.split();
|
|
let (ws_tx, ws_rx) = mpsc::unbounded();
|
|
tokio::task::spawn(
|
|
ws_rx
|
|
.take_while(|v: &Option<_>| futures::future::ready(v.is_some()))
|
|
.map(|x| x.unwrap())
|
|
.forward(sink_tx),
|
|
);
|
|
|
|
let context = Arc::new(context);
|
|
let got_close_signal = Arc::new(AtomicBool::new(false));
|
|
let got_close_signal2 = got_close_signal.clone();
|
|
|
|
struct SubscriptionState {
|
|
should_stop: AtomicBool,
|
|
}
|
|
let subscription_states = HashMap::<String, Arc<SubscriptionState>>::new();
|
|
|
|
sink_rx
|
|
.map_err(move |e| {
|
|
got_close_signal2.store(true, Ordering::Relaxed);
|
|
anyhow!("Websocket error: {}", e)
|
|
})
|
|
.try_fold(subscription_states, move |mut subscription_states, msg| {
|
|
let coordinator = coordinator.clone();
|
|
let context = context.clone();
|
|
let got_close_signal = got_close_signal.clone();
|
|
let ws_tx = ws_tx.clone();
|
|
|
|
async move {
|
|
if msg.is_close() {
|
|
return Ok(subscription_states);
|
|
}
|
|
|
|
let msg = msg
|
|
.to_str()
|
|
.map_err(|_| anyhow!("Non-text messages are not accepted"))?;
|
|
let request: WsPayload<S> = serde_json::from_str(msg)
|
|
.map_err(|e| anyhow!("Invalid WsPayload: {}", e))?;
|
|
|
|
match request.type_name.as_str() {
|
|
"connection_init" => {}
|
|
"start" => {
|
|
if got_close_signal.load(Ordering::Relaxed) {
|
|
return Ok(subscription_states);
|
|
}
|
|
|
|
let request_id = request.id.clone().unwrap_or("1".to_owned());
|
|
|
|
if let Some(existing) = subscription_states.get(&request_id) {
|
|
existing.should_stop.store(true, Ordering::Relaxed);
|
|
}
|
|
let state = Arc::new(SubscriptionState {
|
|
should_stop: AtomicBool::new(false),
|
|
});
|
|
subscription_states.insert(request_id.clone(), state.clone());
|
|
|
|
let ws_tx = ws_tx.clone();
|
|
|
|
if let Some(ref payload) = request.payload {
|
|
if payload.query.is_none() {
|
|
return Err(anyhow!("Query not found"));
|
|
}
|
|
} else {
|
|
return Err(anyhow!("Payload not found"));
|
|
}
|
|
|
|
tokio::task::spawn(async move {
|
|
let payload = request.payload.unwrap();
|
|
|
|
let graphql_request = GraphQLRequest::<S>::new(
|
|
payload.query.unwrap(),
|
|
None,
|
|
payload.variables,
|
|
);
|
|
|
|
let values_stream = match coordinator
|
|
.subscribe(&graphql_request, &context)
|
|
.await
|
|
{
|
|
Ok(s) => s,
|
|
Err(err) => {
|
|
let _ =
|
|
ws_tx.unbounded_send(Some(Ok(Message::text(format!(
|
|
r#"{{"type":"error","id":"{}","payload":{}}}"#,
|
|
request_id,
|
|
serde_json::ser::to_string(&err).unwrap_or(
|
|
"Error deserializing GraphQLError".to_owned()
|
|
)
|
|
)))));
|
|
|
|
let close_message = format!(
|
|
r#"{{"type":"complete","id":"{}","payload":null}}"#,
|
|
request_id
|
|
);
|
|
let _ = ws_tx
|
|
.unbounded_send(Some(Ok(Message::text(close_message))));
|
|
// close channel
|
|
let _ = ws_tx.unbounded_send(None);
|
|
return;
|
|
}
|
|
};
|
|
|
|
values_stream
|
|
.take_while(move |response| {
|
|
let request_id = request_id.clone();
|
|
let should_stop = state.should_stop.load(Ordering::Relaxed)
|
|
|| got_close_signal.load(Ordering::Relaxed);
|
|
if !should_stop {
|
|
let mut response_text = serde_json::to_string(
|
|
&response,
|
|
)
|
|
.unwrap_or("Error deserializing response".to_owned());
|
|
|
|
response_text = format!(
|
|
r#"{{"type":"data","id":"{}","payload":{} }}"#,
|
|
request_id, response_text
|
|
);
|
|
|
|
let _ = ws_tx.unbounded_send(Some(Ok(Message::text(
|
|
response_text,
|
|
))));
|
|
}
|
|
|
|
async move { !should_stop }
|
|
})
|
|
.for_each(|_| async {})
|
|
.await;
|
|
});
|
|
}
|
|
"stop" => {
|
|
let request_id = request.id.unwrap_or("1".to_owned());
|
|
if let Some(existing) = subscription_states.get(&request_id) {
|
|
existing.should_stop.store(true, Ordering::Relaxed);
|
|
subscription_states.remove(&request_id);
|
|
}
|
|
|
|
let close_message = format!(
|
|
r#"{{"type":"complete","id":"{}","payload":null}}"#,
|
|
request_id
|
|
);
|
|
let _ = ws_tx.unbounded_send(Some(Ok(Message::text(close_message))));
|
|
|
|
// close channel
|
|
let _ = ws_tx.unbounded_send(None);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
Ok(subscription_states)
|
|
}
|
|
})
|
|
.map_ok(|_| ())
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(bound = "GraphQLPayload<S>: Deserialize<'de>")]
|
|
struct WsPayload<S>
|
|
where
|
|
S: ScalarValue + Send + Sync,
|
|
{
|
|
id: Option<String>,
|
|
#[serde(rename(deserialize = "type"))]
|
|
type_name: String,
|
|
payload: Option<GraphQLPayload<S>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(bound = "InputValue<S>: Deserialize<'de>")]
|
|
struct GraphQLPayload<S>
|
|
where
|
|
S: ScalarValue + Send + Sync,
|
|
{
|
|
variables: Option<InputValue<S>>,
|
|
extensions: Option<HashMap<String, String>>,
|
|
#[serde(rename(deserialize = "operationName"))]
|
|
operaton_name: Option<String>,
|
|
query: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct Output {
|
|
data: String,
|
|
variables: String,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use warp::{http, test::request};
|
|
|
|
#[test]
|
|
fn graphiql_response_does_not_panic() {
|
|
graphiql_response("/abcd", None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn graphiql_endpoint_matches() {
|
|
let filter = warp::get()
|
|
.and(warp::path("graphiql"))
|
|
.and(graphiql_filter("/graphql", None));
|
|
let result = request()
|
|
.method("GET")
|
|
.path("/graphiql")
|
|
.header("accept", "text/html")
|
|
.filter(&filter)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn graphiql_endpoint_returns_graphiql_source() {
|
|
let filter = warp::get()
|
|
.and(warp::path("dogs-api"))
|
|
.and(warp::path("graphiql"))
|
|
.and(graphiql_filter("/dogs-api/graphql", None));
|
|
let response = request()
|
|
.method("GET")
|
|
.path("/dogs-api/graphiql")
|
|
.header("accept", "text/html")
|
|
.reply(&filter)
|
|
.await;
|
|
|
|
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>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn graphiql_endpoint_with_subscription_matches() {
|
|
let filter = warp::get().and(warp::path("graphiql")).and(graphiql_filter(
|
|
"/graphql",
|
|
Some("ws:://localhost:8080/subscriptions"),
|
|
));
|
|
let result = request()
|
|
.method("GET")
|
|
.path("/graphiql")
|
|
.header("accept", "text/html")
|
|
.filter(&filter)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn playground_endpoint_matches() {
|
|
let filter = warp::get()
|
|
.and(warp::path("playground"))
|
|
.and(playground_filter("/graphql", Some("/subscripitons")));
|
|
|
|
let result = request()
|
|
.method("GET")
|
|
.path("/playground")
|
|
.header("accept", "text/html")
|
|
.filter(&filter)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn playground_endpoint_returns_playground_source() {
|
|
let filter = warp::get()
|
|
.and(warp::path("dogs-api"))
|
|
.and(warp::path("playground"))
|
|
.and(playground_filter(
|
|
"/dogs-api/graphql",
|
|
Some("/dogs-api/subscriptions"),
|
|
));
|
|
let response = request()
|
|
.method("GET")
|
|
.path("/dogs-api/playground")
|
|
.header("accept", "text/html")
|
|
.reply(&filter)
|
|
.await;
|
|
|
|
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("GraphQLPlayground.init(root, { endpoint: '/dogs-api/graphql', subscriptionEndpoint: '/dogs-api/subscriptions' })"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn graphql_handler_works_json_post() {
|
|
use juniper::{
|
|
tests::fixtures::starwars::{model::Database, schema::Query},
|
|
EmptyMutation, EmptySubscription, RootNode,
|
|
};
|
|
|
|
type Schema =
|
|
juniper::RootNode<'static, Query, EmptyMutation<Database>, EmptySubscription<Database>>;
|
|
|
|
let schema: Schema = RootNode::new(
|
|
Query,
|
|
EmptyMutation::<Database>::new(),
|
|
EmptySubscription::<Database>::new(),
|
|
);
|
|
|
|
let state = warp::any().map(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)
|
|
.await;
|
|
|
|
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"}}}"#
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn batch_requests_work() {
|
|
use juniper::{
|
|
tests::fixtures::starwars::{model::Database, schema::Query},
|
|
EmptyMutation, EmptySubscription, RootNode,
|
|
};
|
|
|
|
type Schema =
|
|
juniper::RootNode<'static, Query, EmptyMutation<Database>, EmptySubscription<Database>>;
|
|
|
|
let schema: Schema = RootNode::new(
|
|
Query,
|
|
EmptyMutation::<Database>::new(),
|
|
EmptySubscription::<Database>::new(),
|
|
);
|
|
|
|
let state = warp::any().map(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)
|
|
.await;
|
|
|
|
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},
|
|
tests::fixtures::starwars::{model::Database, schema::Query},
|
|
EmptyMutation, EmptySubscription, RootNode,
|
|
};
|
|
use warp::{
|
|
self,
|
|
filters::{path, BoxedFilter},
|
|
Filter,
|
|
};
|
|
|
|
struct TestWarpIntegration {
|
|
filter: BoxedFilter<(http::Response<Vec<u8>>,)>,
|
|
}
|
|
|
|
impl TestWarpIntegration {
|
|
fn new(is_sync: bool) -> Self {
|
|
let schema = RootNode::new(
|
|
Query,
|
|
EmptyMutation::<Database>::new(),
|
|
EmptySubscription::<Database>::new(),
|
|
);
|
|
let state = warp::any().map(move || Database::new());
|
|
|
|
let filter = path::end().and(if is_sync {
|
|
make_graphql_filter_sync(schema, state.boxed())
|
|
} else {
|
|
make_graphql_filter(schema, state.boxed())
|
|
});
|
|
Self {
|
|
filter: filter.boxed(),
|
|
}
|
|
}
|
|
|
|
fn make_request(&self, req: warp::test::RequestBuilder) -> TestResponse {
|
|
let mut rt = tokio::runtime::Runtime::new().expect("Failed to create tokio::Runtime");
|
|
make_test_response(rt.block_on(async move {
|
|
req.filter(&self.filter).await.unwrap_or_else(|rejection| {
|
|
let code = if rejection.is_not_found() {
|
|
http::StatusCode::NOT_FOUND
|
|
} else if let Some(body::BodyDeserializeError { .. }) = rejection.find() {
|
|
http::StatusCode::BAD_REQUEST
|
|
} else {
|
|
http::StatusCode::INTERNAL_SERVER_ERROR
|
|
};
|
|
http::Response::builder()
|
|
.status(code)
|
|
.header("content-type", "application/json")
|
|
.body(Vec::new())
|
|
.unwrap()
|
|
})
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl HttpIntegration for TestWarpIntegration {
|
|
fn get(&self, url: &str) -> TestResponse {
|
|
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
|
use url::Url;
|
|
|
|
/// https://url.spec.whatwg.org/#query-state
|
|
const QUERY_ENCODE_SET: &AsciiSet =
|
|
&CONTROLS.add(b' ').add(b'"').add(b'#').add(b'<').add(b'>');
|
|
|
|
let url = Url::parse(&format!("http://localhost:3000{}", url)).expect("url to parse");
|
|
|
|
let url: String = utf8_percent_encode(url.query().unwrap_or(""), QUERY_ENCODE_SET)
|
|
.into_iter()
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
|
|
self.make_request(
|
|
warp::test::request()
|
|
.method("GET")
|
|
.path(&format!("/?{}", url)),
|
|
)
|
|
}
|
|
|
|
fn post_json(&self, url: &str, body: &str) -> TestResponse {
|
|
self.make_request(
|
|
warp::test::request()
|
|
.method("POST")
|
|
.header("content-type", "application/json; charset=utf-8")
|
|
.path(url)
|
|
.body(body),
|
|
)
|
|
}
|
|
|
|
fn post_graphql(&self, url: &str, body: &str) -> TestResponse {
|
|
self.make_request(
|
|
warp::test::request()
|
|
.method("POST")
|
|
.header("content-type", "application/graphql; charset=utf-8")
|
|
.path(url)
|
|
.body(body),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn make_test_response(resp: http::Response<Vec<u8>>) -> TestResponse {
|
|
TestResponse {
|
|
status_code: resp.status().as_u16() as i32,
|
|
body: Some(String::from_utf8(resp.body().to_owned()).unwrap()),
|
|
content_type: resp
|
|
.headers()
|
|
.get("content-type")
|
|
.expect("missing content-type header in warp response")
|
|
.to_str()
|
|
.expect("invalid content-type string")
|
|
.to_owned(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_warp_integration() {
|
|
run_http_test_suite(&TestWarpIntegration::new(false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sync_warp_integration() {
|
|
run_http_test_suite(&TestWarpIntegration::new(true));
|
|
}
|
|
}
|