Add actix-web integration (#603)

This commit is contained in:
Jordão Rodrigues Oliveira Rosario 2020-04-21 03:21:02 -03:00 committed by GitHub
parent 2ba9f71b0c
commit a47d1c5430
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 684 additions and 0 deletions

View file

@ -12,6 +12,7 @@ members = [
"juniper_rocket_async",
"juniper_subscriptions",
"juniper_warp",
"juniper_actix",
]
exclude = [
"docs/book/tests",

View file

@ -30,4 +30,7 @@ pre-release-replacements = [
{file="../juniper_warp/Cargo.toml", search="\\[dev-dependencies\\.juniper\\]\nversion = \"[^\"]+\"", replace="[dev-dependencies.juniper]\nversion = \"{{version}}\""},
# Subscriptions
{file="../juniper_subscriptions/Cargo.toml", search="juniper = \\{ version = \"[^\"]+\"", replace="juniper = { version = \"{{version}}\""},
# Actix-Web
{file="../juniper_actix/Cargo.toml", search="juniper = \\{ version = \"[^\"]+\"", replace="juniper = { version = \"{{version}}\""},
{file="../juniper_actix/Cargo.toml", search="\\[dev-dependencies\\.juniper\\]\nversion = \"[^\"]+\"", replace="[dev-dependencies.juniper]\nversion = \"{{version}}\""},
]

4
juniper_actix/.gitignore vendored Normal file
View file

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

View file

@ -0,0 +1,3 @@
# master
- Initial Release

33
juniper_actix/Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "juniper_actix"
version = "0.1.0"
authors = ["Jordao Rosario <jordao.rosario01@gmail.com>"]
description = "Juniper GraphQL integration with Actix"
license = "BSD-2-Clause"
documentation = "https://docs.rs/juniper_actix"
repository = "https://github.com/graphql-rust/juniper"
edition = "2018"
[dependencies]
actix = "0.9.0"
actix-rt = "1.0.0"
actix-web = { version = "2.0.0", features = ["rustls"] }
actix-web-actors = "2.0.0"
futures = { version = "0.3.1", features = ["compat"] }
juniper = { version = "0.14.2", path = "../juniper", default-features = false }
tokio = { version = "0.2", features = ["time"] }
serde_json = "1.0.24"
serde_derive = "1.0.75"
failure = "0.1.7"
serde = "1.0.75"
[dev-dependencies]
juniper = { version = "0.14.2", path = "../juniper", features = ["expose-test-schema", "serde_json"] }
env_logger = "0.5.11"
log = "0.4.3"
percent-encoding = "1.0"
tokio = { version = "0.2", features = ["rt-core", "macros", "blocking"] }
actix-cors = "0.2.0"
actix-identity = "0.2.0"
bytes = "0.5.4"

25
juniper_actix/LICENSE Normal file
View file

@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2018, Jordao Rosario
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.

34
juniper_actix/README.md Normal file
View file

@ -0,0 +1,34 @@
# juniper_actix
This repository contains the [actix][actix] web server integration for
[Juniper][Juniper], a [GraphQL][GraphQL] implementation for Rust, its inspired and some parts are copied from [juniper_warp][juniper_warp].
## 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/actix_server][example] for example code of a working actix
server with GraphQL handlers.
## Links
* [Juniper][Juniper]
* [API Reference][documentation]
* [actix][actix]
## License
This project is under the BSD-2 license.
Check the LICENSE file for details.
[actix]: https://github.com/actix/actix-web
[Juniper]: https://github.com/graphql-rust/juniper
[GraphQL]: http://graphql.org
[documentation]: https://docs.rs/juniper_actix
[example]: https://github.com/graphql-rust/juniper/blob/master/juniper_actix/examples/actix_server.rs
[juniper_warp]:https://docs.rs/juniper_warp

View file

@ -0,0 +1,64 @@
#![deny(warnings)]
extern crate log;
use actix_cors::Cors;
use actix_web::{middleware, web, App, Error, HttpResponse, HttpServer};
use juniper::{
tests::{model::Database, schema::Query},
EmptyMutation, EmptySubscription, RootNode,
};
use juniper_actix::{
graphiql_handler as gqli_handler, graphql_handler, playground_handler as play_handler,
};
type Schema = RootNode<'static, Query, EmptyMutation<Database>, EmptySubscription<Database>>;
fn schema() -> Schema {
Schema::new(
Query,
EmptyMutation::<Database>::new(),
EmptySubscription::<Database>::new(),
)
}
async fn graphiql_handler() -> Result<HttpResponse, Error> {
gqli_handler("/", None).await
}
async fn playground_handler() -> Result<HttpResponse, Error> {
play_handler("/", None).await
}
async fn graphql(
req: actix_web::HttpRequest,
payload: actix_web::web::Payload,
schema: web::Data<Schema>,
) -> Result<HttpResponse, Error> {
let context = Database::new();
graphql_handler(&schema, &context, req, payload).await
}
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
::std::env::set_var("RUST_LOG", "actix_web=info");
env_logger::init();
let server = HttpServer::new(move || {
App::new()
.data(schema())
.wrap(middleware::Compress::default())
.wrap(middleware::Logger::default())
.wrap(
Cors::new()
.allowed_methods(vec!["POST", "GET"])
.supports_credentials()
.max_age(3600)
.finish(),
)
.service(
web::resource("/")
.route(web::post().to(graphql))
.route(web::get().to(graphql)),
)
.service(web::resource("/playground").route(web::get().to(playground_handler)))
.service(web::resource("/graphiql").route(web::get().to(graphiql_handler)))
});
server.bind("127.0.0.1:8080").unwrap().run().await
}

517
juniper_actix/src/lib.rs Normal file
View file

@ -0,0 +1,517 @@
/*!
# juniper_actix
This repository contains the [actix][actix] web server integration for
[Juniper][Juniper], a [GraphQL][GraphQL] implementation for Rust, its inspired and some parts are copied from [juniper_warp][juniper_warp]
## 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/actix_server][example] for example code of a working actix
server with GraphQL handlers.
## Links
* [Juniper][Juniper]
* [API Reference][documentation]
* [actix][actix]
## License
This project is under the BSD-2 license.
Check the LICENSE file for details.
[actix]: https://github.com/actix/actix-web
[Juniper]: https://github.com/graphql-rust/juniper
[GraphQL]: http://graphql.org
[documentation]: https://docs.rs/juniper_actix
[example]: https://github.com/graphql-rust/juniper/blob/master/juniper_actix/examples/actix_server.rs
[juniper_warp]: https://github.com/graphql-rust/juniper/juniper_warp
*/
#![deny(missing_docs)]
#![deny(warnings)]
#![doc(html_root_url = "https://docs.rs/juniper_actix/0.1.0")]
// use futures::{FutureExt as _};
use actix_web::{
error::{ErrorBadRequest, ErrorMethodNotAllowed, ErrorUnsupportedMediaType},
http::{header::CONTENT_TYPE, Method},
web, Error, FromRequest, HttpRequest, HttpResponse,
};
use juniper::{
http::{
graphiql::graphiql_source, playground::playground_source, GraphQLBatchRequest,
GraphQLRequest,
},
ScalarValue,
};
use serde::Deserialize;
#[serde(deny_unknown_fields)]
#[derive(Deserialize, Clone, PartialEq, Debug)]
struct GetGraphQLRequest {
query: String,
#[serde(rename = "operationName")]
operation_name: Option<String>,
variables: Option<String>,
}
impl<S> From<GetGraphQLRequest> for GraphQLRequest<S>
where
S: ScalarValue,
{
fn from(get_req: GetGraphQLRequest) -> Self {
let GetGraphQLRequest {
query,
operation_name,
variables,
} = get_req;
let variables = match variables {
Some(variables) => Some(serde_json::from_str(&variables).unwrap()),
None => None,
};
Self::new(query, operation_name, variables)
}
}
/// Actix Web GraphQL Handler for GET and POST requests
pub async fn graphql_handler<Query, Mutation, Subscription, Context, S>(
schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
context: &Context,
req: HttpRequest,
payload: actix_web::web::Payload,
) -> Result<HttpResponse, Error>
where
S: ScalarValue + Send + Sync + 'static,
Context: Send + Sync + 'static,
Query: juniper::GraphQLTypeAsync<S, Context = Context> + Send + Sync + 'static,
Query::TypeInfo: Send + Sync,
Mutation: juniper::GraphQLTypeAsync<S, Context = Context> + Send + Sync + 'static,
Mutation::TypeInfo: Send + Sync,
Subscription: juniper::GraphQLSubscriptionType<S, Context = Context> + Send + Sync + 'static,
Subscription::TypeInfo: Send + Sync,
{
match *req.method() {
Method::POST => post_graphql_handler(schema, context, req, payload).await,
Method::GET => get_graphql_handler(schema, context, req).await,
_ => Err(ErrorMethodNotAllowed(
"GraphQL requests can only be sent with GET or POST",
)),
}
}
/// Actix GraphQL Handler for GET requests
pub async fn get_graphql_handler<Query, Mutation, Subscription, Context, S>(
schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
context: &Context,
req: HttpRequest,
) -> Result<HttpResponse, Error>
where
S: ScalarValue + Send + Sync + 'static,
Context: Send + Sync + 'static,
Query: juniper::GraphQLTypeAsync<S, Context = Context> + Send + Sync + 'static,
Query::TypeInfo: Send + Sync,
Mutation: juniper::GraphQLTypeAsync<S, Context = Context> + Send + Sync + 'static,
Mutation::TypeInfo: Send + Sync,
Subscription: juniper::GraphQLSubscriptionType<S, Context = Context> + Send + Sync + 'static,
Subscription::TypeInfo: Send + Sync,
{
let get_req = web::Query::<GetGraphQLRequest>::from_query(req.query_string())?;
let req = GraphQLRequest::from(get_req.into_inner());
let gql_response = req.execute(schema, context).await;
let body_response = serde_json::to_string(&gql_response)?;
let response = match gql_response.is_ok() {
true => HttpResponse::Ok()
.content_type("application/json")
.body(body_response),
false => HttpResponse::BadRequest()
.content_type("application/json")
.body(body_response),
};
Ok(response)
}
/// Actix GraphQL Handler for POST requests
pub async fn post_graphql_handler<Query, Mutation, Subscription, Context, S>(
schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
context: &Context,
req: HttpRequest,
payload: actix_web::web::Payload,
) -> Result<HttpResponse, Error>
where
S: ScalarValue + Send + Sync + 'static,
Context: Send + Sync + 'static,
Query: juniper::GraphQLTypeAsync<S, Context = Context> + Send + Sync + 'static,
Query::TypeInfo: Send + Sync,
Mutation: juniper::GraphQLTypeAsync<S, Context = Context> + Send + Sync + 'static,
Mutation::TypeInfo: Send + Sync,
Subscription: juniper::GraphQLSubscriptionType<S, Context = Context> + Send + Sync + 'static,
Subscription::TypeInfo: Send + Sync,
{
let content_type_header = req
.headers()
.get(CONTENT_TYPE)
.and_then(|hv| hv.to_str().ok());
let req = match content_type_header {
Some("application/json") | Some("application/graphql") => {
let body_string = String::from_request(&req, &mut payload.into_inner()).await;
let body_string = body_string?;
match serde_json::from_str::<GraphQLBatchRequest<S>>(&body_string) {
Ok(req) => Ok(req),
Err(err) => Err(ErrorBadRequest(err)),
}
}
_ => Err(ErrorUnsupportedMediaType(
"GraphQL requests should have content type `application/json` or `application/graphql`",
)),
}?;
let gql_batch_response = req.execute(schema, context).await;
let gql_response = serde_json::to_string(&gql_batch_response)?;
let mut response = match gql_batch_response.is_ok() {
true => HttpResponse::Ok(),
false => HttpResponse::BadRequest(),
};
Ok(response.content_type("application/json").body(gql_response))
}
/// Create a handler 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 actix;
/// # extern crate juniper_actix;
/// #
/// # use juniper_actix::graphiql_handler;
/// # use actix_web::{web, App};
///
/// let app = App::new()
/// .route("/", web::get().to(|| graphiql_handler("/graphql", Some("/graphql/subscriptions"))));
/// ```
#[allow(dead_code)]
pub async fn graphiql_handler(
graphql_endpoint_url: &str,
subscriptions_endpoint_url: Option<&'static str>,
) -> Result<HttpResponse, Error> {
let html = graphiql_source(graphql_endpoint_url, subscriptions_endpoint_url);
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html))
}
/// Create a handler that replies with an HTML page containing GraphQL Playground. This does not handle routing, so you cant mount it on any endpoint.
pub async fn playground_handler(
graphql_endpoint_url: &str,
subscriptions_endpoint_url: Option<&'static str>,
) -> Result<HttpResponse, Error> {
let html = playground_source(graphql_endpoint_url, subscriptions_endpoint_url);
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html))
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{dev::ServiceResponse, http, http::header::CONTENT_TYPE, test, App};
use futures::StreamExt;
use juniper::{
http::tests::{run_http_test_suite, HTTPIntegration, TestResponse},
tests::{model::Database, schema::Query},
EmptyMutation, EmptySubscription, RootNode,
};
type Schema =
juniper::RootNode<'static, Query, EmptyMutation<Database>, EmptySubscription<Database>>;
async fn take_response_body_string(resp: &mut ServiceResponse) -> String {
let (response_body, ..) = resp
.take_body()
.map(|body_out| body_out.unwrap().to_vec())
.into_future()
.await;
match response_body {
Some(response_body) => String::from_utf8(response_body).unwrap(),
None => String::from(""),
}
}
async fn index(
req: HttpRequest,
payload: actix_web::web::Payload,
schema: web::Data<Schema>,
) -> Result<HttpResponse, Error> {
let context = Database::new();
graphql_handler(&schema, &context, req, payload).await
}
#[actix_rt::test]
async fn graphiql_response_does_not_panic() {
let result = graphiql_handler("/abcd", None).await;
assert!(result.is_ok())
}
#[actix_rt::test]
async fn graphiql_endpoint_matches() {
async fn graphql_handler() -> Result<HttpResponse, Error> {
graphiql_handler("/abcd", None).await
}
let mut app =
test::init_service(App::new().route("/", web::get().to(graphql_handler))).await;
let req = test::TestRequest::get()
.uri("/")
.header("accept", "text/html")
.to_request();
let resp = test::call_service(&mut app, req).await;
assert_eq!(resp.status(), http::StatusCode::OK);
}
#[actix_rt::test]
async fn graphiql_endpoint_returns_graphiql_source() {
async fn graphql_handler() -> Result<HttpResponse, Error> {
graphiql_handler("/dogs-api/graphql", Some("/dogs-api/subscriptions")).await
}
let mut app =
test::init_service(App::new().route("/", web::get().to(graphql_handler))).await;
let req = test::TestRequest::get()
.uri("/")
.header("accept", "text/html")
.to_request();
let mut resp = test::call_service(&mut app, req).await;
let body = take_response_body_string(&mut resp).await;
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap(),
"text/html; charset=utf-8"
);
assert!(body.contains("<script>var GRAPHQL_URL = '/dogs-api/graphql';</script>"));
assert!(body.contains(
"<script>var GRAPHQL_SUBSCRIPTIONS_URL = '/dogs-api/subscriptions';</script>"
))
}
#[actix_rt::test]
async fn playground_endpoint_matches() {
async fn graphql_handler() -> Result<HttpResponse, Error> {
playground_handler("/abcd", None).await
}
let mut app =
test::init_service(App::new().route("/", web::get().to(graphql_handler))).await;
let req = test::TestRequest::get()
.uri("/")
.header("accept", "text/html")
.to_request();
let resp = test::call_service(&mut app, req).await;
assert_eq!(resp.status(), http::StatusCode::OK);
}
#[actix_rt::test]
async fn playground_endpoint_returns_playground_source() {
async fn graphql_handler() -> Result<HttpResponse, Error> {
playground_handler("/dogs-api/graphql", Some("/dogs-api/subscriptions")).await
}
let mut app =
test::init_service(App::new().route("/", web::get().to(graphql_handler))).await;
let req = test::TestRequest::get()
.uri("/")
.header("accept", "text/html")
.to_request();
let mut resp = test::call_service(&mut app, req).await;
let body = take_response_body_string(&mut resp).await;
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap(),
"text/html; charset=utf-8"
);
assert!(body.contains("GraphQLPlayground.init(root, { endpoint: '/dogs-api/graphql', subscriptionEndpoint: '/dogs-api/subscriptions' })"));
}
#[actix_rt::test]
async fn graphql_post_works_json_post() {
let schema: Schema = RootNode::new(
Query,
EmptyMutation::<Database>::new(),
EmptySubscription::<Database>::new(),
);
let req = test::TestRequest::post()
.header("content-type", "application/json")
.set_payload(
r##"{ "variables": null, "query": "{ hero(episode: NEW_HOPE) { name } }" }"##,
)
.uri("/")
.to_request();
let mut app =
test::init_service(App::new().data(schema).route("/", web::post().to(index))).await;
let mut resp = test::call_service(&mut app, req).await;
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
take_response_body_string(&mut resp).await,
r#"{"data":{"hero":{"name":"R2-D2"}}}"#
);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/json",
);
}
#[actix_rt::test]
async fn graphql_get_works() {
let schema: Schema = RootNode::new(
Query,
EmptyMutation::<Database>::new(),
EmptySubscription::<Database>::new(),
);
let req = test::TestRequest::get()
.header("content-type", "application/json")
.uri("/?query=%7B%20hero%28episode%3A%20NEW_HOPE%29%20%7B%20name%20%7D%20%7D&variables=null")
.to_request();
let mut app =
test::init_service(App::new().data(schema).route("/", web::get().to(index))).await;
let mut resp = test::call_service(&mut app, req).await;
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
take_response_body_string(&mut resp).await,
r#"{"data":{"hero":{"name":"R2-D2"}}}"#
);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/json",
);
}
#[actix_rt::test]
async fn batch_request_works() {
use juniper::{
tests::{model::Database, schema::Query},
EmptyMutation, EmptySubscription, RootNode,
};
let schema: Schema = RootNode::new(
Query,
EmptyMutation::<Database>::new(),
EmptySubscription::<Database>::new(),
);
let req = test::TestRequest::post()
.header("content-type", "application/json")
.set_payload(
r##"[
{ "variables": null, "query": "{ hero(episode: NEW_HOPE) { name } }" },
{ "variables": null, "query": "{ hero(episode: EMPIRE) { id name } }" }
]"##,
)
.uri("/")
.to_request();
let mut app =
test::init_service(App::new().data(schema).route("/", web::post().to(index))).await;
let mut resp = test::call_service(&mut app, req).await;
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
take_response_body_string(&mut resp).await,
r#"[{"data":{"hero":{"name":"R2-D2"}}},{"data":{"hero":{"id":"1000","name":"Luke Skywalker"}}}]"#
);
assert_eq!(
resp.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());
}
pub struct TestActixWebIntegration {}
impl HTTPIntegration for TestActixWebIntegration {
fn get(&self, url: &str) -> TestResponse {
let url = url.to_string();
actix_rt::System::new("get_request").block_on(async move {
let schema: Schema = RootNode::new(
Query,
EmptyMutation::<Database>::new(),
EmptySubscription::<Database>::new(),
);
let req = test::TestRequest::get()
.header("content-type", "application/json")
.uri(&url.clone())
.to_request();
let mut app =
test::init_service(App::new().data(schema).route("/", web::get().to(index)))
.await;
let resp = test::call_service(&mut app, req).await;
let test_response = make_test_response(resp).await;
test_response
})
}
fn post(&self, url: &str, body: &str) -> TestResponse {
let url = url.to_string();
let body = body.to_string();
actix_rt::System::new("post_request").block_on(async move {
let schema: Schema = RootNode::new(
Query,
EmptyMutation::<Database>::new(),
EmptySubscription::<Database>::new(),
);
let req = test::TestRequest::post()
.header("content-type", "application/json")
.set_payload(body)
.uri(&url.clone())
.to_request();
let mut app =
test::init_service(App::new().data(schema).route("/", web::post().to(index)))
.await;
let resp = test::call_service(&mut app, req).await;
let test_response = make_test_response(resp).await;
test_response
})
}
}
async fn make_test_response(mut response: ServiceResponse) -> TestResponse {
let body = take_response_body_string(&mut response).await;
let status_code = response.status().as_u16();
let content_type = response.headers().get(CONTENT_TYPE).unwrap();
TestResponse {
status_code: status_code as i32,
body: Some(body),
content_type: content_type.to_str().unwrap().to_string(),
}
}
#[test]
fn test_actix_web_integration() {
run_http_test_suite(&TestActixWebIntegration {});
}
}