From bc7f4a8f9aa5dcc1e23f3caaae1a209e21fa3a56 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Fri, 15 Jul 2022 20:20:56 +0200 Subject: [PATCH 01/33] Add Error to Connection Stream --- juniper_graphql_ws/src/lib.rs | 16 +++++++++++++--- juniper_warp/src/lib.rs | 23 +++++++++++++---------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/juniper_graphql_ws/src/lib.rs b/juniper_graphql_ws/src/lib.rs index d1621a615..6ae3434b1 100644 --- a/juniper_graphql_ws/src/lib.rs +++ b/juniper_graphql_ws/src/lib.rs @@ -29,6 +29,16 @@ use juniper::{ GraphQLError, RuleError, ScalarValue, Variables, }; +/// Errors +#[derive(Debug)] +pub enum WebsocketError { + /// The connection was already closed + ConnectionAlreadyClosed, + + /// The connection is not ready yet to accept messages + ConnectionNotReady +} + struct ExecutionParams { start_payload: StartPayload, config: Arc>, @@ -520,7 +530,7 @@ where S: Schema, I: Init + Send, { - type Error = Infallible; + type Error = WebsocketError; fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> { match &mut self.sink_state { @@ -535,7 +545,7 @@ where Poll::Pending => Poll::Pending, } } - ConnectionSinkState::Closed => panic!("poll_ready called after close"), + ConnectionSinkState::Closed => Poll::Ready(Err(WebsocketError::ConnectionAlreadyClosed)), } } @@ -562,7 +572,7 @@ where } } } - _ => panic!("start_send called when not ready"), + _ => return Err(WebsocketError::ConnectionNotReady), }; Ok(()) } diff --git a/juniper_warp/src/lib.rs b/juniper_warp/src/lib.rs index b30b007b1..574f91e11 100644 --- a/juniper_warp/src/lib.rs +++ b/juniper_warp/src/lib.rs @@ -345,7 +345,8 @@ fn playground_response( /// [1]: https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md #[cfg(feature = "subscriptions")] pub mod subscriptions { - use std::{convert::Infallible, fmt, sync::Arc}; + use std::{fmt, sync::Arc}; + use futures::TryStreamExt; use juniper::{ futures::{ @@ -355,7 +356,7 @@ pub mod subscriptions { }, GraphQLSubscriptionType, GraphQLTypeAsync, RootNode, ScalarValue, }; - use juniper_graphql_ws::{ArcSchema, ClientMessage, Connection, Init}; + use juniper_graphql_ws::{ArcSchema, ClientMessage, Connection, Init, WebsocketError}; struct Message(warp::ws::Message); @@ -376,14 +377,14 @@ pub mod subscriptions { /// Errors that can happen while serializing outgoing messages. Note that errors that occur /// while deserializing incoming messages are handled internally by the protocol. Serde(serde_json::Error), + + /// Errors that can happen while communication with Juniper + Juniper(juniper_graphql_ws::WebsocketError) } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Warp(e) => write!(f, "warp error: {e}"), - Self::Serde(e) => write!(f, "serde error: {e}"), - } + write!(f, "{:?}", self) } } @@ -395,9 +396,9 @@ pub mod subscriptions { } } - impl From for Error { - fn from(_err: Infallible) -> Self { - unreachable!() + impl From for Error { + fn from(err: WebsocketError) -> Self { + Self::Juniper(err) } } @@ -427,7 +428,9 @@ pub mod subscriptions { let (ws_tx, ws_rx) = websocket.split(); let (s_tx, s_rx) = Connection::new(ArcSchema(root_node), init).split(); - let ws_rx = ws_rx.map(|r| r.map(Message)); + let ws_rx = ws_rx + .map(|r| r.map(Message)) + .map_err(Error::Warp); let s_rx = s_rx.map(|msg| { serde_json::to_string(&msg) .map(warp::ws::Message::text) From 11ecc2586c7e92ea7d9d3897b20eb27110b9b233 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Fri, 22 Jul 2022 14:02:41 +0200 Subject: [PATCH 02/33] Add post_with_variables test --- juniper/src/http/mod.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/juniper/src/http/mod.rs b/juniper/src/http/mod.rs index 45e310be7..f7ccdfd88 100644 --- a/juniper/src/http/mod.rs +++ b/juniper/src/http/mod.rs @@ -400,6 +400,9 @@ pub mod tests { println!(" - test_get_with_variables"); test_get_with_variables(integration); + println!(" - test_post_with_variables"); + test_post_with_variables(integration); + println!(" - test_simple_post"); test_simple_post(integration); @@ -510,6 +513,37 @@ pub mod tests { ); } + fn test_post_with_variables(integration: &T) { + let response = integration.post_json("/", r#"{ + "query": "query($id: String!) { human(id: $id) { id, name, appearsIn, homePlanet } }", + "variables": { "id": "1000" } + }"#); + + assert_eq!(response.status_code, 200); + assert_eq!(response.content_type, "application/json"); + + assert_eq!( + unwrap_json_response(&response), + serde_json::from_str::( + r#"{ + "data": { + "human": { + "appearsIn": [ + "NEW_HOPE", + "EMPIRE", + "JEDI" + ], + "homePlanet": "Tatooine", + "name": "Luke Skywalker", + "id": "1000" + } + } + }"# + ) + .expect("Invalid JSON constant in test") + ); + } + fn test_simple_post(integration: &T) { let response = integration.post_json("/", r#"{"query": "{hero{name}}"}"#); From 2932e6e64709e027173a4cd493f888e674749008 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Sat, 23 Jul 2022 20:58:01 +0200 Subject: [PATCH 03/33] Decouple test and implementation --- juniper/src/http/mod.rs | 2 +- juniper_actix/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/juniper/src/http/mod.rs b/juniper/src/http/mod.rs index f7ccdfd88..2008f4dd5 100644 --- a/juniper/src/http/mod.rs +++ b/juniper/src/http/mod.rs @@ -724,7 +724,7 @@ pub mod tests { r#"{ "type":"connection_error", "payload":{ - "message":"serde error: expected value at line 1 column 1" + "message":"expected value at line 1 column 1" } }"# .into(), diff --git a/juniper_actix/src/lib.rs b/juniper_actix/src/lib.rs index 3c6192e6c..519ff8c97 100644 --- a/juniper_actix/src/lib.rs +++ b/juniper_actix/src/lib.rs @@ -416,7 +416,7 @@ pub mod subscriptions { impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Serde(e) => write!(f, "serde error: {e}"), + Self::Serde(e) => write!(f, "{e}"), Self::UnexpectedClientMessage => { write!(f, "unexpected message received from client") } From 719eb9728deb5605d0f69ac94894d2a3f5fdcc01 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Sun, 24 Jul 2022 18:12:55 +0200 Subject: [PATCH 04/33] Add juniper_axum --- Cargo.toml | 1 + juniper_axum/CHANGELOG.md | 26 ++ juniper_axum/Cargo.toml | 32 ++ juniper_axum/LICENCE | 25 ++ juniper_axum/README.md | 93 +++++ juniper_axum/examples/simple.rs | 92 +++++ juniper_axum/examples/starwars.rs | 59 +++ juniper_axum/src/extract.rs | 346 ++++++++++++++++++ juniper_axum/src/lib.rs | 34 ++ juniper_axum/src/response.rs | 20 + juniper_axum/src/subscriptions.rs | 60 +++ juniper_axum/tests/juniper_http_test_suite.rs | 117 ++++++ juniper_axum/tests/juniper_ws_test_suite.rs | 171 +++++++++ juniper_axum/tests/simple_schema.rs | 79 ++++ 14 files changed, 1155 insertions(+) create mode 100644 juniper_axum/CHANGELOG.md create mode 100644 juniper_axum/Cargo.toml create mode 100644 juniper_axum/LICENCE create mode 100644 juniper_axum/README.md create mode 100644 juniper_axum/examples/simple.rs create mode 100644 juniper_axum/examples/starwars.rs create mode 100644 juniper_axum/src/extract.rs create mode 100644 juniper_axum/src/lib.rs create mode 100644 juniper_axum/src/response.rs create mode 100644 juniper_axum/src/subscriptions.rs create mode 100644 juniper_axum/tests/juniper_http_test_suite.rs create mode 100644 juniper_axum/tests/juniper_ws_test_suite.rs create mode 100644 juniper_axum/tests/simple_schema.rs diff --git a/Cargo.toml b/Cargo.toml index 9b94e2fa5..39231f8b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "juniper_graphql_ws", "juniper_warp", "juniper_actix", + "juniper_axum", "tests/codegen", "tests/integration", ] diff --git a/juniper_axum/CHANGELOG.md b/juniper_axum/CHANGELOG.md new file mode 100644 index 000000000..68a04ef70 --- /dev/null +++ b/juniper_axum/CHANGELOG.md @@ -0,0 +1,26 @@ +`juniper_axum` changelog +======================== + +All user visible changes to `juniper_axum` crate will be documented in this file. This project uses [Semantic Versioning 2.0.0]. + + + + +## master + +### BC Breaks + +- Switched to 0.16 version of [`juniper` crate]. + + + + +## Previous releases + +See [old CHANGELOG](/../../blob/juniper_warp-v0.0.0/juniper_axum/CHANGELOG.md). + + + + +[`juniper` crate]: https://docs.rs/juniper +[Semantic Versioning 2.0.0]: https://semver.org diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml new file mode 100644 index 000000000..3d01f4636 --- /dev/null +++ b/juniper_axum/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "juniper_axum" +version = "0.1.0" +edition = "2021" +rust-version = "1.62" +description = "`juniper` GraphQL integration with `axum`." +license = "BSD-2-Clause" +authors = ["Benno Tielen "] +documentation = "https://docs.rs/juniper_axum" +homepage = "https://github.com/graphql-rust/juniper/tree/master/juniper_axum" +repository = "https://github.com/graphql-rust/juniper" +readme = "README.md" +categories = ["asynchronous", "web-programming", "web-programming::http-server"] +keywords = ["graphql", "juniper", "axum", "websocket"] +exclude = ["/release.toml"] + +[dependencies] +axum = { version = "0.5.11", features = ["ws"]} +juniper = { path = "../juniper" } +serde = "1.0" +serde_json = "1.0" +juniper_graphql_ws = { path="../juniper_graphql_ws" } +futures = "0.3" + +[dev-dependencies] +tokio = { version = "1.20", features = ["full"] } +tokio-tungstenite = "0.17.2" +tokio-stream = "0.1.9" +tower = "0.4.13" +hyper = "0.14.20" +juniper = { path = "../juniper", features = ["expose-test-schema"]} +anyhow = "1.0" \ No newline at end of file diff --git a/juniper_axum/LICENCE b/juniper_axum/LICENCE new file mode 100644 index 000000000..f060a794d --- /dev/null +++ b/juniper_axum/LICENCE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2022, Benno Tielen +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. diff --git a/juniper_axum/README.md b/juniper_axum/README.md new file mode 100644 index 000000000..90db74fc5 --- /dev/null +++ b/juniper_axum/README.md @@ -0,0 +1,93 @@ +`juniper_axum` crate +==================== + +[![Crates.io](https://img.shields.io/crates/v/juniper_axum.svg?maxAge=2592000)](https://crates.io/crates/juniper_warp) +[![Documentation](https://docs.rs/juniper_warp/badge.svg)](https://docs.rs/juniper_warp) +[![CI](https://github.com/graphql-rust/juniper/workflows/CI/badge.svg?branch=master "CI")](https://github.com/graphql-rust/juniper/actions?query=workflow%3ACI+branch%3Amaster) + +- [Changelog](https://github.com/graphql-rust/juniper/blob/master/juniper_axum/CHANGELOG.md) + +[`axum`] web server integration for [`juniper`] ([GraphQL] implementation for [Rust]). + +## Getting started + +The best way to get started is to examine the `simple` example in the `examples` directory. To execute +this example run + +`cargo run --example simple` + +Open your browser and navigate to `127.0.0.1:3000`. A GraphQL Playground opens. The +following commands are available in the playground. + +```graphql +{ + add(a: 2, b: 40) +} +``` + +```graphql +subscription { + count +} +``` + +## Queries and mutations +This crate provides an extractor and response for axum to work with juniper. + +```rust,ignore +use juniper_axum::response::JuniperResponse; + +let app: Router = Router::new() + .route("/graphql", post(graphql)) + .layer(Extension(schema)) + .layer(Extension(context)); + +async fn graphql( + JuniperRequest(request): JuniperRequest, + Extension(schema): Extension>, + Extension(context): Extension> +) -> JuniperResponse { + JuniperResponse(request.execute(&schema, &context).await) +} +``` + +## Subscriptions +This crate provides a helper function to easily work with graphql subscriptions over a websocket. +```rust,ignore +use juniper_axum::subscription::handle_graphql_socket; + +let app: Router = Router::new() + .route("/subscriptions", get(juniper_subscriptions)) + .layer(Extension(schema)) + .layer(Extension(context)); + +pub async fn juniper_subscriptions( + Extension(schema): Extension>, + Extension(context): Extension, + ws: WebSocketUpgrade, +) -> Response { + ws.protocols(["graphql-ws", "graphql-transport-ws"]) + .max_frame_size(1024) + .max_message_size(1024) + .max_send_queue(100) + .on_upgrade(|socket| handle_graphql_socket(socket, schema, context)) +} +``` + + + +## License + +This project is licensed under [BSD 2-Clause License](https://github.com/graphql-rust/juniper/blob/master/juniper_axum/LICENSE). + + + + +[`juniper`]: https://docs.rs/juniper +[`juniper_axum`]: https://docs.rs/juniper_axum +[`axum`]: https://docs.rs/axum +[GraphQL]: http://graphql.org +[Juniper Book]: https://graphql-rust.github.io +[Rust]: https://www.rust-lang.org + +[1]: https://github.com/graphql-rust/juniper/blob/master/juniper_warp/examples/warp_server.rs diff --git a/juniper_axum/examples/simple.rs b/juniper_axum/examples/simple.rs new file mode 100644 index 000000000..c0440af23 --- /dev/null +++ b/juniper_axum/examples/simple.rs @@ -0,0 +1,92 @@ +use std::{net::SocketAddr, pin::Pin, sync::Arc, time::Duration}; + +use axum::{ + extract::WebSocketUpgrade, + response::Response, + routing::{get, post}, + Extension, Router, +}; +use futures::{Stream, StreamExt}; + +use juniper::{graphql_object, graphql_subscription, EmptyMutation, FieldError, RootNode}; +use juniper_axum::{ + extract::JuniperRequest, playground, response::JuniperResponse, + subscriptions::handle_graphql_socket, +}; + +#[derive(Clone)] +pub struct Context; +impl juniper::Context for Context {} + +#[derive(Clone, Copy, Debug)] +pub struct Query; + +#[graphql_object(context = Context)] +impl Query { + /// Add two numbers a and b + fn add(a: i32, b: i32) -> i32 { + a + b + } +} + +pub struct Subscription; +type NumberStream = Pin> + Send>>; +type AppSchema = RootNode<'static, Query, EmptyMutation, Subscription>; + +#[graphql_subscription(context = Context)] +impl Subscription { + /// Count seconds + async fn count() -> NumberStream { + let mut value = 0; + let stream = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval( + Duration::from_secs(1), + )) + .map(move |_| { + value += 1; + Ok(value) + }); + Box::pin(stream) + } +} + +#[tokio::main] +async fn main() { + let schema = Arc::new(AppSchema::new(Query, EmptyMutation::new(), Subscription)); + + let context = Context; + + let app = Router::new() + .route("/", get(|| playground("/graphql", Some("/subscriptions")))) + .route("/graphql", post(graphql)) + .route("/subscriptions", get(juniper_subscriptions)) + .layer(Extension(schema)) + .layer(Extension(context)); + + // run it + let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); + println!("listening on {}", addr); + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); +} + +pub async fn juniper_subscriptions( + Extension(schema): Extension>, + Extension(context): Extension, + ws: WebSocketUpgrade, +) -> Response { + ws.protocols(["graphql-ws"]) + .max_frame_size(1024) + .max_message_size(1024) + .max_send_queue(100) + .on_upgrade(|socket| handle_graphql_socket(socket, schema, context)) +} + +async fn graphql( + JuniperRequest(request): JuniperRequest, + Extension(schema): Extension>, + Extension(context): Extension, +) -> JuniperResponse { + JuniperResponse(request.execute(&schema, &context).await) +} diff --git a/juniper_axum/examples/starwars.rs b/juniper_axum/examples/starwars.rs new file mode 100644 index 000000000..aa8fff771 --- /dev/null +++ b/juniper_axum/examples/starwars.rs @@ -0,0 +1,59 @@ +use axum::{ + extract::WebSocketUpgrade, + response::Response, + routing::{get, post}, + Extension, Router, +}; +use std::{net::SocketAddr, sync::Arc}; + +use juniper::{ + tests::fixtures::starwars::schema::{Database, Query, Subscription}, + EmptyMutation, RootNode, +}; +use juniper_axum::{ + extract::JuniperRequest, playground, response::JuniperResponse, + subscriptions::handle_graphql_socket, +}; + +type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; + +#[tokio::main] +async fn main() { + let schema = Arc::new(Schema::new(Query, EmptyMutation::new(), Subscription)); + + let context = Database::new(); + + let app = Router::new() + .route("/", get(|| playground("/graphql", Some("/subscriptions")))) + .route("/graphql", post(graphql)) + .route("/subscriptions", get(juniper_subscriptions)) + .layer(Extension(schema)) + .layer(Extension(context)); + + let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); + println!("listening on {}", addr); + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); +} + +pub async fn juniper_subscriptions( + Extension(schema): Extension>, + Extension(context): Extension, + ws: WebSocketUpgrade, +) -> Response { + ws.protocols(["graphql-ws"]) + .max_frame_size(1024) + .max_message_size(1024) + .max_send_queue(100) + .on_upgrade(|socket| handle_graphql_socket(socket, schema, context)) +} + +async fn graphql( + JuniperRequest(request): JuniperRequest, + Extension(schema): Extension>, + Extension(context): Extension, +) -> JuniperResponse { + JuniperResponse(request.execute(&schema, &context).await) +} diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs new file mode 100644 index 000000000..1f3c030c9 --- /dev/null +++ b/juniper_axum/src/extract.rs @@ -0,0 +1,346 @@ +use axum::{ + async_trait, + body::Body, + extract::{FromRequest, Query, RequestParts}, + http::{Method, StatusCode}, + Json, +}; +use juniper::{ + http::{GraphQLBatchRequest, GraphQLRequest}, + InputValue, +}; +use serde::Deserialize; +use serde_json::{Map, Value}; + +/// The query variables for a GET request +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct GetQueryVariables { + query: String, + operation_name: Option, + variables: Option, +} + +/// The request body for JSON POST +#[derive(Deserialize, Debug)] +#[serde(untagged)] +enum JsonRequestBody { + Single(SingleRequestBody), + Batch(Vec), +} + +/// The request body for a single JSON POST request +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct SingleRequestBody { + query: String, + operation_name: Option, + variables: Option>, +} + +impl JsonRequestBody { + /// Returns true if the request body is an empty array + fn is_empty_batch(&self) -> bool { + match self { + JsonRequestBody::Batch(r) => r.is_empty(), + JsonRequestBody::Single(_) => false, + } + } +} + +/// An extractor for Axum to Extract a JuniperRequest +/// +/// # Example +/// +/// ```rust +/// use axum::{ +/// Json, +/// routing::post, +/// Router, +/// Extension +/// }; +/// use std::sync::Arc; +/// use axum::body::Body; +/// use juniper::{RootNode, EmptySubscription, EmptyMutation, graphql_object}; +/// use juniper::http::GraphQLBatchResponse; +/// use juniper_axum::extract::JuniperRequest; +/// use juniper_axum::response::JuniperResponse; +/// +/// pub struct Context(); +/// +/// impl juniper::Context for Context {} +/// pub struct Query; +/// +/// #[graphql_object(context = Context)] +/// impl Query { +/// fn add(a: i32, b: i32) -> i32 { +/// a + b +/// } +/// } +/// +/// type Schema = RootNode<'static, Query, EmptyMutation, EmptySubscription>; +/// +/// let schema = Arc::from(Schema::new( +/// Query, +/// EmptyMutation::::new(), +/// EmptySubscription::::new() +/// )); +/// +/// let context = Arc::new(Context()); +/// +/// let app: Router = Router::new() +/// .route("/graphql", post(graphql)) +/// .layer(Extension(schema)) +/// .layer(Extension(context)); +/// +/// async fn graphql( +/// JuniperRequest(request): JuniperRequest, +/// Extension(schema): Extension>, +/// Extension(context): Extension> +/// ) -> JuniperResponse { +/// JuniperResponse(request.execute(&schema, &context).await) +/// } +#[derive(Debug, PartialEq)] +pub struct JuniperRequest(pub GraphQLBatchRequest); + +impl TryFrom for JuniperRequest { + type Error = serde_json::Error; + + fn try_from(value: SingleRequestBody) -> Result { + Ok(JuniperRequest(GraphQLBatchRequest::Single( + GraphQLRequest::try_from(value)?, + ))) + } +} + +impl TryFrom for GraphQLRequest { + type Error = serde_json::Error; + + fn try_from(value: SingleRequestBody) -> Result { + // Convert Map to InputValue with the help of serde_json + let variables: Option = value + .variables + .map(|vars| serde_json::to_string(&vars)) + .transpose()? + .map(|s| serde_json::from_str(&s)) + .transpose()?; + + Ok(GraphQLRequest::new( + value.query, + value.operation_name, + variables, + )) + } +} + +impl TryFrom for JuniperRequest { + type Error = serde_json::Error; + + fn try_from(value: JsonRequestBody) -> Result { + match value { + JsonRequestBody::Single(r) => JuniperRequest::try_from(r), + JsonRequestBody::Batch(requests) => { + let mut graphql_requests: Vec = Vec::new(); + + for request in requests { + graphql_requests.push(GraphQLRequest::try_from(request)?); + } + + Ok(JuniperRequest(GraphQLBatchRequest::Batch(graphql_requests))) + } + } + } +} + +impl From for JuniperRequest { + fn from(query: String) -> Self { + JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( + query, None, None, + ))) + } +} + +impl TryFrom for JuniperRequest { + type Error = serde_json::Error; + + fn try_from(value: GetQueryVariables) -> Result { + let variables: Option = value + .variables + .map(|var| serde_json::from_str(&var)) + .transpose()?; + + Ok(JuniperRequest(GraphQLBatchRequest::Single( + GraphQLRequest::new(value.query, value.operation_name, variables), + ))) + } +} + +/// Helper trait to get some nice clean code +#[async_trait] +trait TryFromRequest { + type Rejection; + + /// Get `content-type` header from request + fn try_get_content_type_header(&self) -> Result, Self::Rejection>; + + /// Try to convert GET request to RequestBody + async fn try_from_get_request(&mut self) -> Result; + + /// Try to convert POST json request to RequestBody + async fn try_from_json_post_request(&mut self) -> Result; + + /// Try to convert POST graphql request to RequestBody + async fn try_from_graphql_post_request(&mut self) -> Result; +} + +#[async_trait] +impl TryFromRequest for RequestParts { + type Rejection = (StatusCode, &'static str); + + fn try_get_content_type_header(&self) -> Result, Self::Rejection> { + self.headers() + .get("content-Type") + .map(|header| header.to_str()) + .transpose() + .map_err(|_e| { + ( + StatusCode::BAD_REQUEST, + "content-type header not a valid string", + ) + }) + } + + async fn try_from_get_request(&mut self) -> Result { + let query_vars = Query::::from_request(self) + .await + .map(|result| result.0) + .map_err(|_err| (StatusCode::BAD_REQUEST, "Request not valid"))?; + + JuniperRequest::try_from(query_vars) + .map_err(|_err| (StatusCode::BAD_REQUEST, "Could not convert variables")) + } + + async fn try_from_json_post_request(&mut self) -> Result { + let json_body = Json::::from_request(self) + .await + .map_err(|_err| (StatusCode::BAD_REQUEST, "JSON invalid")) + .map(|result| result.0)?; + + if json_body.is_empty_batch() { + return Err((StatusCode::BAD_REQUEST, "Batch request can not be empty")); + } + + JuniperRequest::try_from(json_body) + .map_err(|_err| (StatusCode::BAD_REQUEST, "Could not convert variables")) + } + + async fn try_from_graphql_post_request(&mut self) -> Result { + String::from_request(self) + .await + .map(|s| s.into()) + .map_err(|_err| (StatusCode::BAD_REQUEST, "Not valid utf-8")) + } +} + +#[async_trait] +impl FromRequest for JuniperRequest { + type Rejection = (StatusCode, &'static str); + + async fn from_request(req: &mut RequestParts) -> Result { + let content_type = req.try_get_content_type_header()?; + + // Convert `req` to JuniperRequest based on request method and content-type header + match (req.method(), content_type) { + (&Method::GET, _) => req.try_from_get_request().await, + (&Method::POST, Some("application/json")) => req.try_from_json_post_request().await, + (&Method::POST, Some("application/graphql")) => { + req.try_from_graphql_post_request().await + } + (&Method::POST, _) => Err(( + StatusCode::BAD_REQUEST, + "Header content-type is not application/json or application/graphql", + )), + _ => Err((StatusCode::METHOD_NOT_ALLOWED, "Method not supported")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::Request; + use juniper::http::GraphQLRequest; + + #[test] + fn convert_simple_request_body_to_juniper_request() { + let request_body = SingleRequestBody { + query: "{ add(a: 2, b: 3) }".to_string(), + operation_name: None, + variables: None, + }; + + let expected = JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( + "{ add(a: 2, b: 3) }".to_string(), + None, + None, + ))); + + assert_eq!(JuniperRequest::try_from(request_body).unwrap(), expected); + } + + #[tokio::test] + async fn convert_get_request_to_juniper_request() { + // /?query={ add(a: 2, b: 3) } + let request = Request::get("/?query=%7B%20add%28a%3A%202%2C%20b%3A%203%29%20%7D") + .body(Body::empty()) + .unwrap(); + let mut parts = RequestParts::new(request); + + let expected = JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( + "{ add(a: 2, b: 3) }".to_string(), + None, + None, + ))); + + let result = JuniperRequest::from_request(&mut parts).await.unwrap(); + assert_eq!(result, expected) + } + + #[tokio::test] + async fn convert_simple_post_request_to_juniper_request() { + let json = String::from(r#"{ "query": "{ add(a: 2, b: 3) }"}"#); + let request = Request::post("/") + .header("content-type", "application/json") + .body(Body::from(json)) + .unwrap(); + let mut parts = RequestParts::new(request); + + let expected = JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( + "{ add(a: 2, b: 3) }".to_string(), + None, + None, + ))); + + let result = JuniperRequest::from_request(&mut parts).await.unwrap(); + assert_eq!(result, expected) + } + + #[tokio::test] + async fn convert_simple_post_request_to_juniper_request_2() { + let body = String::from(r#"{ add(a: 2, b: 3) }"#); + let request = Request::post("/") + .header("content-type", "application/graphql") + .body(Body::from(body)) + .unwrap(); + let mut parts = RequestParts::new(request); + + let expected = JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( + "{ add(a: 2, b: 3) }".to_string(), + None, + None, + ))); + + let result = JuniperRequest::from_request(&mut parts).await.unwrap(); + assert_eq!(result, expected) + } +} diff --git a/juniper_axum/src/lib.rs b/juniper_axum/src/lib.rs new file mode 100644 index 000000000..784524e3d --- /dev/null +++ b/juniper_axum/src/lib.rs @@ -0,0 +1,34 @@ +use axum::response::Html; + +pub mod extract; +pub mod response; +pub mod subscriptions; + +/// Add a GraphQL Playground +/// +/// # Arguments +/// +/// * `graphql_endpoint_url` - The graphql endpoint you configured +/// * `subscriptions_endpoint_url` - An optional subscription endpoint +/// +/// # Example +/// +/// ```rust +/// use axum::{ +/// routing::get, +/// Router +/// }; +/// use axum::body::Body; +/// use juniper_axum::playground; +/// +/// let app: Router = Router::new().route("/", get(|| playground("/graphql", Some("/subscriptions")))); +/// ``` +pub async fn playground( + graphql_endpoint_url: &str, + subscriptions_endpoint_url: Option<&str>, +) -> Html { + Html(juniper::http::playground::playground_source( + graphql_endpoint_url, + subscriptions_endpoint_url, + )) +} diff --git a/juniper_axum/src/response.rs b/juniper_axum/src/response.rs new file mode 100644 index 000000000..07b512040 --- /dev/null +++ b/juniper_axum/src/response.rs @@ -0,0 +1,20 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use juniper::http::GraphQLBatchResponse; + +/// A wrapper around [`GraphQLBatchResponse`] that implements [`IntoResponse`] +/// so it can be returned from axum handlers. +pub struct JuniperResponse(pub GraphQLBatchResponse); + +impl IntoResponse for JuniperResponse { + fn into_response(self) -> Response { + if !self.0.is_ok() { + return (StatusCode::BAD_REQUEST, Json(self.0)).into_response(); + } + + Json(self.0).into_response() + } +} diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs new file mode 100644 index 000000000..a1b6703ea --- /dev/null +++ b/juniper_axum/src/subscriptions.rs @@ -0,0 +1,60 @@ +use axum::extract::ws::{Message, WebSocket}; +use juniper::{ + futures::{SinkExt, StreamExt, TryStreamExt}, + ScalarValue, +}; +use juniper_graphql_ws::{ClientMessage, Connection, ConnectionConfig, Schema, WebsocketError}; + +#[derive(Debug)] +struct AxumMessage(Message); + +#[derive(Debug)] +enum SubscriptionError { + Juniper(WebsocketError), + Axum(axum::Error), + Serde(serde_json::Error), +} + +impl TryFrom for ClientMessage { + type Error = serde_json::Error; + + fn try_from(msg: AxumMessage) -> serde_json::Result { + serde_json::from_slice(&msg.0.into_data()) + } +} + +/// Redirect the axum [`Websocket`] to a juniper [`Connection`] and vice versa. +/// +/// # Example +/// +/// +pub async fn handle_graphql_socket(socket: WebSocket, schema: S, context: S::Context) { + let config = ConnectionConfig::new(context); + let (ws_tx, ws_rx) = socket.split(); + let (juniper_tx, juniper_rx) = Connection::new(schema, config).split(); + + // In the following section we make the streams and sinks from + // Axum and Juniper compatible with each other. This makes it + // possible to forward an incoming message from Axum to Juniper + // and vice versa. + let juniper_tx = juniper_tx.sink_map_err(SubscriptionError::Juniper); + + let send_websocket_message_to_juniper = ws_rx + .map_err(SubscriptionError::Axum) + .map(|result| result.map(AxumMessage)) + .forward(juniper_tx); + + let ws_tx = ws_tx.sink_map_err(SubscriptionError::Axum); + + let send_juniper_message_to_axum = juniper_rx + .map(|msg| serde_json::to_string(&msg).map(Message::Text)) + .map_err(SubscriptionError::Serde) + .forward(ws_tx); + + // Start listening for messages from axum, and redirect them to juniper + let _result = futures::future::select( + send_websocket_message_to_juniper, + send_juniper_message_to_axum, + ) + .await; +} diff --git a/juniper_axum/tests/juniper_http_test_suite.rs b/juniper_axum/tests/juniper_http_test_suite.rs new file mode 100644 index 000000000..8fb739573 --- /dev/null +++ b/juniper_axum/tests/juniper_http_test_suite.rs @@ -0,0 +1,117 @@ +use axum::{ + http::Request, + response::Response, + routing::{get, post}, + Extension, Router, +}; +use hyper::{service::Service, Body}; +use juniper::{ + http::tests::{run_http_test_suite, HttpIntegration, TestResponse}, + tests::fixtures::starwars::schema::{Database, Query}, + EmptyMutation, EmptySubscription, RootNode, +}; +use juniper_axum::{extract::JuniperRequest, response::JuniperResponse}; +use std::{str::from_utf8, sync::Arc}; + +/// The app we want to test +struct AxumApp(Router); + +type Schema = RootNode<'static, Query, EmptyMutation, EmptySubscription>; + +/// Create a new axum app to test +fn test_app() -> AxumApp { + let schema = Schema::new( + Query, + EmptyMutation::::new(), + EmptySubscription::::new(), + ); + + let context = Database::new(); + + let router = Router::new() + .route("/", get(graphql)) + .route("/", post(graphql)) + .layer(Extension(Arc::from(schema))) + .layer(Extension(Arc::from(context))); + + AxumApp(router) +} + +async fn graphql( + JuniperRequest(request): JuniperRequest, + Extension(schema): Extension>, + Extension(context): Extension>, +) -> JuniperResponse { + JuniperResponse(request.execute(&schema, &context).await) +} + +/// Implement HttpIntegration to enable standard tests +impl HttpIntegration for AxumApp { + fn get(&self, url: &str) -> TestResponse { + let request = Request::get(url).body(Body::empty()).unwrap(); + + self.make_request(request) + } + + fn post_json(&self, url: &str, body: &str) -> TestResponse { + let request = Request::post(url) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + + self.make_request(request) + } + + fn post_graphql(&self, url: &str, body: &str) -> TestResponse { + let request = Request::post(url) + .header("content-type", "application/graphql") + .body(Body::from(body.to_string())) + .unwrap(); + + self.make_request(request) + } +} + +impl AxumApp { + /// Make a request to the Axum app + fn make_request(&self, request: Request) -> TestResponse { + let mut app = self.0.clone(); + + let task = app.call(request); + + // Call async code with tokio runtime + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + let response = task.await.unwrap(); + create_test_response(response).await + }) + } +} + +/// Convert an Axum Response to a Juniper TestResponse +async fn create_test_response(response: Response) -> TestResponse { + let status_code: i32 = response.status().as_u16().into(); + let content_type: String = response + .headers() + .get("content-type") + .map(|header| from_utf8(header.as_bytes()).unwrap().to_string()) + .unwrap_or_default(); + + let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); + let body: Option = Some(from_utf8(&body).map(|s| s.to_string()).unwrap()); + + TestResponse { + status_code, + content_type, + body, + } +} + +#[test] +fn test_axum_integration() { + let test_app = test_app(); + run_http_test_suite(&test_app) +} diff --git a/juniper_axum/tests/juniper_ws_test_suite.rs b/juniper_axum/tests/juniper_ws_test_suite.rs new file mode 100644 index 000000000..9c8da095e --- /dev/null +++ b/juniper_axum/tests/juniper_ws_test_suite.rs @@ -0,0 +1,171 @@ +use anyhow::anyhow; +use axum::{extract::WebSocketUpgrade, response::Response, routing::get, Extension, Router}; +use futures::{SinkExt, StreamExt}; +use juniper::{ + http::tests::{run_ws_test_suite, WsIntegration, WsIntegrationMessage}, + tests::fixtures::starwars::schema::{Database, Query, Subscription}, + EmptyMutation, LocalBoxFuture, RootNode, +}; +use juniper_axum::subscriptions::handle_graphql_socket; +use serde_json::Value; +use std::{ + net::{SocketAddr, TcpListener}, + sync::Arc, + time::Duration, +}; +use tokio::net::TcpStream; +use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; + +/// The app we want to test +#[derive(Clone)] +struct AxumApp(Router); + +type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; + +/// Create a new axum app to test +fn test_app() -> AxumApp { + let schema = Schema::new(Query, EmptyMutation::::new(), Subscription); + + let context = Database::new(); + + let router = Router::new() + .route("/subscriptions", get(juniper_subscriptions)) + .layer(Extension(Arc::from(schema))) + .layer(Extension(context)); + + AxumApp(router) +} + +/// Axum handler for websockets +pub async fn juniper_subscriptions( + Extension(schema): Extension>, + Extension(context): Extension, + ws: WebSocketUpgrade, +) -> Response { + ws.on_upgrade(|socket| handle_graphql_socket(socket, schema, context)) +} + +/// Test a vector of WsIntegrationMessages by +/// - sending messages to server +/// - receiving messages from server +/// +/// This function will result in an error if +/// - Message couldn't be send +/// - receiving the message timed out +/// - an error happened during receiving +/// - the received message was not a text message +/// - if expected_message != received_message +async fn run_async_tests( + app: AxumApp, + messages: Vec, +) -> Result<(), anyhow::Error> { + // Spawn test server + let listener = TcpListener::bind("0.0.0.0:0".parse::().unwrap()).unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::Server::from_tcp(listener) + .unwrap() + .serve(app.0.into_make_service()) + .await + .unwrap(); + }); + + // Connect to server with tokio-tungstenite library + let (mut websocket, _) = connect_async(format!("ws://{}/subscriptions", addr)) + .await + .unwrap(); + + // Send and receive messages + for message in messages { + process_message(&mut websocket, message).await?; + } + + Ok(()) +} + +/// Send or receive an message to the server +async fn process_message( + mut websocket: &mut WebSocketStream>, + message: WsIntegrationMessage, +) -> Result<(), anyhow::Error> { + match message { + WsIntegrationMessage::Send(mes) => send_message(&mut websocket, mes).await, + WsIntegrationMessage::Expect(expected, timeout) => { + receive_message_from_socket_and_test(&mut websocket, &expected, timeout).await + } + } +} + +async fn send_message( + websocket: &mut &mut WebSocketStream>, + mes: String, +) -> Result<(), anyhow::Error> { + match websocket.send(Message::Text(mes)).await { + Ok(_) => Ok(()), + Err(err) => Err(anyhow!("Could not send message: {:?}", err)), + } +} + +async fn receive_message_from_socket_and_test( + websocket: &mut WebSocketStream>, + expected: &String, + timeout: u64, +) -> Result<(), anyhow::Error> { + let message = tokio::time::timeout(Duration::from_millis(timeout), websocket.next()) + .await + .map_err(|e| anyhow!("Timed out receiving message. Elapsed: {e}"))?; + + match message { + None => Err(anyhow!("No Message received")), + Some(Err(e)) => Err(anyhow!("Websocket error: {:?}", e)), + Some(Ok(message)) => equals_received_text_message(&expected, message), + } +} + +fn equals_received_text_message(expected: &String, message: Message) -> Result<(), anyhow::Error> { + match message { + Message::Text(received) => is_the_same(&expected, &received), + Message::Binary(_) => Err(anyhow!("Received binary message, but expected text")), + Message::Ping(_) => Err(anyhow!("Received ping message, but expected text")), + Message::Pong(_) => Err(anyhow!("Received pong message, but expected text")), + Message::Close(_) => Err(anyhow!("Received close message, but expected text")), + Message::Frame(_) => Err(anyhow!("Received frame message, but expected text")), + } +} + +/// Check if expected == received by transforming both to a JSON value +fn is_the_same(expected: &String, received: &String) -> Result<(), anyhow::Error> { + let expected: Value = + serde_json::from_str(&expected).map_err(|e| anyhow::anyhow!("Serde error: {e:?}"))?; + + let received: Value = + serde_json::from_str(&received).map_err(|e| anyhow::anyhow!("Serde error: {e:?}"))?; + + if received != expected { + return Err(anyhow!( + "Expected: {:?}\nReceived: {:?}", + expected, + received + )); + } + + Ok(()) +} + +/// Implement WsIntegration trait so we can automize our tests +impl WsIntegration for AxumApp { + fn run( + &self, + messages: Vec, + ) -> LocalBoxFuture> { + let app = self.clone(); + Box::pin(run_async_tests(app, messages)) + } +} + +#[tokio::test] +async fn juniper_ws_test_suite() { + let app = test_app(); + run_ws_test_suite(&app).await; +} diff --git a/juniper_axum/tests/simple_schema.rs b/juniper_axum/tests/simple_schema.rs new file mode 100644 index 000000000..31b37e254 --- /dev/null +++ b/juniper_axum/tests/simple_schema.rs @@ -0,0 +1,79 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::{get, post}, + Extension, Router, +}; +use juniper::{graphql_object, EmptyMutation, EmptySubscription, RootNode}; +use juniper_axum::{extract::JuniperRequest, playground, response::JuniperResponse}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tower::util::ServiceExt; + +const GRAPHQL_ENDPOINT: &str = "/graphql"; + +pub struct Context(); + +impl juniper::Context for Context {} +pub struct Query; + +#[graphql_object(context = Context)] +impl Query { + fn add(a: i32, b: i32) -> i32 { + a + b + } +} + +type Schema = RootNode<'static, Query, EmptyMutation, EmptySubscription>; + +fn app() -> Router { + let schema = Arc::from(Schema::new( + Query, + EmptyMutation::::new(), + EmptySubscription::::new(), + )); + + let context = Arc::new(Context()); + + Router::new() + .route("/", get(|| playground(GRAPHQL_ENDPOINT, None))) + .route(GRAPHQL_ENDPOINT, post(graphql)) + .layer(Extension(schema)) + .layer(Extension(context)) +} + +async fn graphql( + JuniperRequest(request): JuniperRequest, + Extension(schema): Extension>, + Extension(context): Extension>, +) -> JuniperResponse { + JuniperResponse(request.execute(&schema, &context).await) +} + +#[tokio::test] +async fn add_two_and_three() { + let app = app(); + + let request_json = Body::from(r#"{ "query": "{ add(a: 2, b: 3) }"}"#); + let request = Request::post(GRAPHQL_ENDPOINT) + .header("content-type", "application/json") + .body(request_json) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body, json!({ "data": { "add": 5 } })); +} + +#[tokio::test] +async fn playground_is_ok() { + let app = app(); + + let request = Request::get("/").body(Body::empty()).unwrap(); + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} From 183302c0e83cdaa6a0da9d6776910567da9fbe5a Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Sun, 24 Jul 2022 18:13:13 +0200 Subject: [PATCH 05/33] Clippy and format --- juniper/src/http/mod.rs | 9 ++++++--- juniper_graphql_ws/src/lib.rs | 6 ++++-- juniper_warp/src/lib.rs | 10 ++++------ 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/juniper/src/http/mod.rs b/juniper/src/http/mod.rs index 2008f4dd5..8181eb2ce 100644 --- a/juniper/src/http/mod.rs +++ b/juniper/src/http/mod.rs @@ -514,10 +514,13 @@ pub mod tests { } fn test_post_with_variables(integration: &T) { - let response = integration.post_json("/", r#"{ + let response = integration.post_json( + "/", + r#"{ "query": "query($id: String!) { human(id: $id) { id, name, appearsIn, homePlanet } }", "variables": { "id": "1000" } - }"#); + }"#, + ); assert_eq!(response.status_code, 200); assert_eq!(response.content_type, "application/json"); @@ -540,7 +543,7 @@ pub mod tests { } }"# ) - .expect("Invalid JSON constant in test") + .expect("Invalid JSON constant in test") ); } diff --git a/juniper_graphql_ws/src/lib.rs b/juniper_graphql_ws/src/lib.rs index 6ae3434b1..4c19d76c8 100644 --- a/juniper_graphql_ws/src/lib.rs +++ b/juniper_graphql_ws/src/lib.rs @@ -36,7 +36,7 @@ pub enum WebsocketError { ConnectionAlreadyClosed, /// The connection is not ready yet to accept messages - ConnectionNotReady + ConnectionNotReady, } struct ExecutionParams { @@ -545,7 +545,9 @@ where Poll::Pending => Poll::Pending, } } - ConnectionSinkState::Closed => Poll::Ready(Err(WebsocketError::ConnectionAlreadyClosed)), + ConnectionSinkState::Closed => { + Poll::Ready(Err(WebsocketError::ConnectionAlreadyClosed)) + } } } diff --git a/juniper_warp/src/lib.rs b/juniper_warp/src/lib.rs index 574f91e11..631834291 100644 --- a/juniper_warp/src/lib.rs +++ b/juniper_warp/src/lib.rs @@ -345,8 +345,8 @@ fn playground_response( /// [1]: https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md #[cfg(feature = "subscriptions")] pub mod subscriptions { - use std::{fmt, sync::Arc}; use futures::TryStreamExt; + use std::{fmt, sync::Arc}; use juniper::{ futures::{ @@ -379,7 +379,7 @@ pub mod subscriptions { Serde(serde_json::Error), /// Errors that can happen while communication with Juniper - Juniper(juniper_graphql_ws::WebsocketError) + Juniper(juniper_graphql_ws::WebsocketError), } impl fmt::Display for Error { @@ -428,9 +428,7 @@ pub mod subscriptions { let (ws_tx, ws_rx) = websocket.split(); let (s_tx, s_rx) = Connection::new(ArcSchema(root_node), init).split(); - let ws_rx = ws_rx - .map(|r| r.map(Message)) - .map_err(Error::Warp); + let ws_rx = ws_rx.map(|r| r.map(Message)).map_err(Error::Warp); let s_rx = s_rx.map(|msg| { serde_json::to_string(&msg) .map(warp::ws::Message::text) @@ -443,7 +441,7 @@ pub mod subscriptions { ) .await { - Either::Left((r, _)) => r.map_err(|e| e.into()), + Either::Left((r, _)) => r, Either::Right((r, _)) => r, } } From 59ab64a1529e98ec367c713471f2cf6737f74a30 Mon Sep 17 00:00:00 2001 From: ilslv Date: Wed, 3 Aug 2022 13:59:00 +0200 Subject: [PATCH 06/33] Corrections --- juniper/src/schema/meta.rs | 17 +++-- juniper/src/schema/model.rs | 6 +- juniper/src/tests/fixtures/starwars/schema.rs | 2 + juniper/src/types/scalars.rs | 17 +++++ juniper_axum/CHANGELOG.md | 16 ----- juniper_axum/Cargo.toml | 6 +- juniper_axum/examples/simple.rs | 28 ++++---- juniper_axum/examples/starwars.rs | 12 ++-- juniper_axum/src/extract.rs | 42 +++++++----- juniper_axum/src/lib.rs | 24 ++++--- juniper_axum/src/response.rs | 2 + juniper_axum/src/subscriptions.rs | 68 +++++++++++++++++++ juniper_axum/tests/juniper_http_test_suite.rs | 11 +-- juniper_axum/tests/juniper_ws_test_suite.rs | 14 ++-- juniper_axum/tests/simple_schema.rs | 18 ++--- juniper_graphql_ws/Cargo.toml | 1 + juniper_graphql_ws/src/lib.rs | 9 ++- juniper_graphql_ws/src/schema.rs | 26 +++++++ juniper_warp/src/lib.rs | 4 +- 19 files changed, 225 insertions(+), 98 deletions(-) diff --git a/juniper/src/schema/meta.rs b/juniper/src/schema/meta.rs index 1c11d853c..c04973237 100644 --- a/juniper/src/schema/meta.rs +++ b/juniper/src/schema/meta.rs @@ -43,6 +43,7 @@ impl DeprecationStatus { } /// Scalar type metadata +#[derive(Clone)] pub struct ScalarMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -61,7 +62,7 @@ pub type InputValueParseFn = for<'b> fn(&'b InputValue) -> Result<(), Fiel pub type ScalarTokenParseFn = for<'b> fn(ScalarToken<'b>) -> Result; /// List type metadata -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ListMeta<'a> { #[doc(hidden)] pub of_type: Type<'a>, @@ -71,14 +72,14 @@ pub struct ListMeta<'a> { } /// Nullable type metadata -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct NullableMeta<'a> { #[doc(hidden)] pub of_type: Type<'a>, } /// Object type metadata -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ObjectMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -91,6 +92,7 @@ pub struct ObjectMeta<'a, S> { } /// Enum type metadata +#[derive(Clone)] pub struct EnumMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -102,7 +104,7 @@ pub struct EnumMeta<'a, S> { } /// Interface type metadata -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct InterfaceMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -115,7 +117,7 @@ pub struct InterfaceMeta<'a, S> { } /// Union type metadata -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct UnionMeta<'a> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -126,6 +128,7 @@ pub struct UnionMeta<'a> { } /// Input object metadata +#[derive(Clone)] pub struct InputObjectMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -140,14 +143,14 @@ pub struct InputObjectMeta<'a, S> { /// /// After a type's `meta` method has been called but before it has returned, a placeholder type /// is inserted into a registry to indicate existence. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct PlaceholderMeta<'a> { #[doc(hidden)] pub of_type: Type<'a>, } /// Generic type metadata -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum MetaType<'a, S = DefaultScalarValue> { #[doc(hidden)] Scalar(ScalarMeta<'a, S>), diff --git a/juniper/src/schema/model.rs b/juniper/src/schema/model.rs index e7cba411d..419d024ce 100644 --- a/juniper/src/schema/model.rs +++ b/juniper/src/schema/model.rs @@ -20,7 +20,7 @@ use crate::schema::translate::{graphql_parser::GraphQLParserTranslator, SchemaTr /// /// This brings the mutation, subscription and query types together, /// and provides the predefined metadata fields. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct RootNode< 'a, QueryT: GraphQLType, @@ -47,7 +47,7 @@ pub struct RootNode< } /// Metadata for a schema -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct SchemaType<'a, S> { pub(crate) description: Option>, pub(crate) types: FnvHashMap>, @@ -66,7 +66,7 @@ pub enum TypeType<'a, S: 'a> { List(Box>, Option), } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct DirectiveType<'a, S> { pub name: String, pub description: Option, diff --git a/juniper/src/tests/fixtures/starwars/schema.rs b/juniper/src/tests/fixtures/starwars/schema.rs index d89af6bc7..c2b094e6b 100644 --- a/juniper/src/tests/fixtures/starwars/schema.rs +++ b/juniper/src/tests/fixtures/starwars/schema.rs @@ -4,6 +4,7 @@ use std::{collections::HashMap, pin::Pin}; use crate::{graphql_interface, graphql_object, graphql_subscription, Context, GraphQLEnum}; +#[derive(Clone, Copy, Debug)] pub struct Query; #[graphql_object(context = Database)] @@ -33,6 +34,7 @@ impl Query { } } +#[derive(Clone, Copy, Debug)] pub struct Subscription; type HumanStream = Pin + Send>>; diff --git a/juniper/src/types/scalars.rs b/juniper/src/types/scalars.rs index 54efa392e..8ee7eb7fc 100644 --- a/juniper/src/types/scalars.rs +++ b/juniper/src/types/scalars.rs @@ -346,6 +346,14 @@ mod impl_float_scalar { #[derive(Debug)] pub struct EmptyMutation(PhantomData>>); +impl Clone for EmptyMutation { + fn clone(&self) -> Self { + Self(PhantomData) + } +} + +impl Copy for EmptyMutation {} + // `EmptyMutation` doesn't use `T`, so should be `Send` and `Sync` even when `T` is not. crate::sa::assert_impl_all!(EmptyMutation>: Send, Sync); @@ -405,8 +413,17 @@ impl Default for EmptyMutation { /// /// If you instantiate `RootNode` with this as the subscription, /// no subscriptions will be generated for the schema. +#[derive(Debug)] pub struct EmptySubscription(PhantomData>>); +impl Clone for EmptySubscription { + fn clone(&self) -> Self { + Self(PhantomData) + } +} + +impl Copy for EmptySubscription {} + // `EmptySubscription` doesn't use `T`, so should be `Send` and `Sync` even when `T` is not. crate::sa::assert_impl_all!(EmptySubscription>: Send, Sync); diff --git a/juniper_axum/CHANGELOG.md b/juniper_axum/CHANGELOG.md index 68a04ef70..f01ff17a0 100644 --- a/juniper_axum/CHANGELOG.md +++ b/juniper_axum/CHANGELOG.md @@ -6,21 +6,5 @@ All user visible changes to `juniper_axum` crate will be documented in this file -## master - -### BC Breaks - -- Switched to 0.16 version of [`juniper` crate]. - - - - -## Previous releases - -See [old CHANGELOG](/../../blob/juniper_warp-v0.0.0/juniper_axum/CHANGELOG.md). - - - - [`juniper` crate]: https://docs.rs/juniper [Semantic Versioning 2.0.0]: https://semver.org diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index 3d01f4636..d0696794f 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -15,11 +15,11 @@ keywords = ["graphql", "juniper", "axum", "websocket"] exclude = ["/release.toml"] [dependencies] -axum = { version = "0.5.11", features = ["ws"]} +axum = { version = "0.5.11", features = ["ws"] } juniper = { path = "../juniper" } serde = "1.0" serde_json = "1.0" -juniper_graphql_ws = { path="../juniper_graphql_ws" } +juniper_graphql_ws = { path = "../juniper_graphql_ws" } futures = "0.3" [dev-dependencies] @@ -28,5 +28,5 @@ tokio-tungstenite = "0.17.2" tokio-stream = "0.1.9" tower = "0.4.13" hyper = "0.14.20" -juniper = { path = "../juniper", features = ["expose-test-schema"]} +juniper = { path = "../juniper", features = ["expose-test-schema"] } anyhow = "1.0" \ No newline at end of file diff --git a/juniper_axum/examples/simple.rs b/juniper_axum/examples/simple.rs index c0440af23..b3af13f9b 100644 --- a/juniper_axum/examples/simple.rs +++ b/juniper_axum/examples/simple.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, pin::Pin, sync::Arc, time::Duration}; +use std::{net::SocketAddr, pin::Pin, time::Duration}; use axum::{ extract::WebSocketUpgrade, @@ -7,15 +7,17 @@ use axum::{ Extension, Router, }; use futures::{Stream, StreamExt}; - use juniper::{graphql_object, graphql_subscription, EmptyMutation, FieldError, RootNode}; use juniper_axum::{ extract::JuniperRequest, playground, response::JuniperResponse, subscriptions::handle_graphql_socket, }; +use tokio::time::interval; +use tokio_stream::wrappers::IntervalStream; -#[derive(Clone)] +#[derive(Clone, Copy, Debug)] pub struct Context; + impl juniper::Context for Context {} #[derive(Clone, Copy, Debug)] @@ -29,19 +31,17 @@ impl Query { } } +#[derive(Clone, Copy, Debug)] pub struct Subscription; + type NumberStream = Pin> + Send>>; -type AppSchema = RootNode<'static, Query, EmptyMutation, Subscription>; #[graphql_subscription(context = Context)] impl Subscription { /// Count seconds async fn count() -> NumberStream { let mut value = 0; - let stream = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval( - Duration::from_secs(1), - )) - .map(move |_| { + let stream = IntervalStream::new(interval(Duration::from_secs(1))).map(move |_| { value += 1; Ok(value) }); @@ -49,14 +49,16 @@ impl Subscription { } } +type AppSchema = RootNode<'static, Query, EmptyMutation, Subscription>; + #[tokio::main] async fn main() { - let schema = Arc::new(AppSchema::new(Query, EmptyMutation::new(), Subscription)); + let schema = AppSchema::new(Query, EmptyMutation::new(), Subscription); let context = Context; let app = Router::new() - .route("/", get(|| playground("/graphql", Some("/subscriptions")))) + .route("/", get(playground("/graphql", "/subscriptions"))) .route("/graphql", post(graphql)) .route("/subscriptions", get(juniper_subscriptions)) .layer(Extension(schema)) @@ -72,7 +74,7 @@ async fn main() { } pub async fn juniper_subscriptions( - Extension(schema): Extension>, + Extension(schema): Extension, Extension(context): Extension, ws: WebSocketUpgrade, ) -> Response { @@ -80,12 +82,12 @@ pub async fn juniper_subscriptions( .max_frame_size(1024) .max_message_size(1024) .max_send_queue(100) - .on_upgrade(|socket| handle_graphql_socket(socket, schema, context)) + .on_upgrade(move |socket| handle_graphql_socket(socket, schema, context)) } async fn graphql( JuniperRequest(request): JuniperRequest, - Extension(schema): Extension>, + Extension(schema): Extension, Extension(context): Extension, ) -> JuniperResponse { JuniperResponse(request.execute(&schema, &context).await) diff --git a/juniper_axum/examples/starwars.rs b/juniper_axum/examples/starwars.rs index aa8fff771..4f1667418 100644 --- a/juniper_axum/examples/starwars.rs +++ b/juniper_axum/examples/starwars.rs @@ -1,11 +1,11 @@ +use std::net::SocketAddr; + use axum::{ extract::WebSocketUpgrade, response::Response, routing::{get, post}, Extension, Router, }; -use std::{net::SocketAddr, sync::Arc}; - use juniper::{ tests::fixtures::starwars::schema::{Database, Query, Subscription}, EmptyMutation, RootNode, @@ -19,12 +19,12 @@ type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; #[tokio::main] async fn main() { - let schema = Arc::new(Schema::new(Query, EmptyMutation::new(), Subscription)); + let schema = Schema::new(Query, EmptyMutation::new(), Subscription); let context = Database::new(); let app = Router::new() - .route("/", get(|| playground("/graphql", Some("/subscriptions")))) + .route("/", get(playground("/graphql", "/subscriptions"))) .route("/graphql", post(graphql)) .route("/subscriptions", get(juniper_subscriptions)) .layer(Extension(schema)) @@ -39,7 +39,7 @@ async fn main() { } pub async fn juniper_subscriptions( - Extension(schema): Extension>, + Extension(schema): Extension, Extension(context): Extension, ws: WebSocketUpgrade, ) -> Response { @@ -52,7 +52,7 @@ pub async fn juniper_subscriptions( async fn graphql( JuniperRequest(request): JuniperRequest, - Extension(schema): Extension>, + Extension(schema): Extension, Extension(context): Extension, ) -> JuniperResponse { JuniperResponse(request.execute(&schema, &context).await) diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs index 1f3c030c9..0b66c5c0d 100644 --- a/juniper_axum/src/extract.rs +++ b/juniper_axum/src/extract.rs @@ -1,3 +1,5 @@ +//! Types and traits for extracting data from requests. + use axum::{ async_trait, body::Body, @@ -53,22 +55,27 @@ impl JsonRequestBody { /// # Example /// /// ```rust +/// use std::sync::Arc; +/// /// use axum::{ -/// Json, -/// routing::post, -/// Router, -/// Extension +/// body::Body, +/// Json, +/// routing::post, +/// Router, +/// Extension, /// }; -/// use std::sync::Arc; -/// use axum::body::Body; -/// use juniper::{RootNode, EmptySubscription, EmptyMutation, graphql_object}; -/// use juniper::http::GraphQLBatchResponse; -/// use juniper_axum::extract::JuniperRequest; -/// use juniper_axum::response::JuniperResponse; +/// use juniper::{ +/// http::GraphQLBatchResponse, +/// RootNode, EmptySubscription, EmptyMutation, graphql_object, +/// }; +/// use juniper_axum::{extract::JuniperRequest, response::JuniperResponse}; /// -/// pub struct Context(); +/// #[derive(Clone, Copy, Debug)] +/// pub struct Context; /// /// impl juniper::Context for Context {} +/// +/// #[derive(Clone, Copy, Debug)] /// pub struct Query; /// /// #[graphql_object(context = Context)] @@ -80,13 +87,13 @@ impl JsonRequestBody { /// /// type Schema = RootNode<'static, Query, EmptyMutation, EmptySubscription>; /// -/// let schema = Arc::from(Schema::new( +/// let schema = Schema::new( /// Query, /// EmptyMutation::::new(), /// EmptySubscription::::new() -/// )); +/// ); /// -/// let context = Arc::new(Context()); +/// let context = Context; /// /// let app: Router = Router::new() /// .route("/graphql", post(graphql)) @@ -95,8 +102,8 @@ impl JsonRequestBody { /// /// async fn graphql( /// JuniperRequest(request): JuniperRequest, -/// Extension(schema): Extension>, -/// Extension(context): Extension> +/// Extension(schema): Extension, +/// Extension(context): Extension /// ) -> JuniperResponse { /// JuniperResponse(request.execute(&schema, &context).await) /// } @@ -267,10 +274,11 @@ impl FromRequest for JuniperRequest { #[cfg(test)] mod tests { - use super::*; use axum::http::Request; use juniper::http::GraphQLRequest; + use super::*; + #[test] fn convert_simple_request_body_to_juniper_request() { let request_body = SingleRequestBody { diff --git a/juniper_axum/src/lib.rs b/juniper_axum/src/lib.rs index 784524e3d..ca1f1b083 100644 --- a/juniper_axum/src/lib.rs +++ b/juniper_axum/src/lib.rs @@ -1,9 +1,15 @@ -use axum::response::Html; +#![doc = include_str!("../README.md")] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![deny(missing_docs)] +#![deny(warnings)] pub mod extract; pub mod response; pub mod subscriptions; +use axum::response::Html; +use futures::future; + /// Add a GraphQL Playground /// /// # Arguments @@ -21,14 +27,16 @@ pub mod subscriptions; /// use axum::body::Body; /// use juniper_axum::playground; /// -/// let app: Router = Router::new().route("/", get(|| playground("/graphql", Some("/subscriptions")))); +/// let app: Router = Router::new().route("/", get(playground("/graphql", "/subscriptions"))); /// ``` -pub async fn playground( +pub fn playground<'a>( graphql_endpoint_url: &str, - subscriptions_endpoint_url: Option<&str>, -) -> Html { - Html(juniper::http::playground::playground_source( + subscriptions_endpoint_url: impl Into>, +) -> impl FnOnce() -> future::Ready> + Clone + Send { + let html = Html(juniper::http::playground::playground_source( graphql_endpoint_url, - subscriptions_endpoint_url, - )) + subscriptions_endpoint_url.into(), + )); + + || future::ready(html) } diff --git a/juniper_axum/src/response.rs b/juniper_axum/src/response.rs index 07b512040..8803e9af7 100644 --- a/juniper_axum/src/response.rs +++ b/juniper_axum/src/response.rs @@ -1,3 +1,5 @@ +//! [`JuniperResponse`] definition. + use axum::{ http::StatusCode, response::{IntoResponse, Response}, diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs index a1b6703ea..196bb5865 100644 --- a/juniper_axum/src/subscriptions.rs +++ b/juniper_axum/src/subscriptions.rs @@ -1,3 +1,5 @@ +//! Definitions for handling GraphQL subscriptions. + use axum::extract::ws::{Message, WebSocket}; use juniper::{ futures::{SinkExt, StreamExt, TryStreamExt}, @@ -27,7 +29,73 @@ impl TryFrom for ClientMessage { /// /// # Example /// +/// ```rust +/// use std::{pin::Pin, time::Duration}; +/// +/// use axum::{ +/// extract::WebSocketUpgrade, +/// body::Body, +/// response::Response, +/// routing::get, +/// Extension, Router +/// }; +/// use futures::{Stream, StreamExt as _}; +/// use juniper::{ +/// graphql_object, graphql_subscription, EmptyMutation, FieldError, +/// RootNode, +/// }; +/// use juniper_axum::{playground, subscriptions::handle_graphql_socket}; +/// use tokio::time::interval; +/// use tokio_stream::wrappers::IntervalStream; +/// +/// type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Query; +/// +/// #[graphql_object] +/// impl Query { +/// /// Add two numbers a and b +/// fn add(a: i32, b: i32) -> i32 { +/// a + b +/// } +/// } +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Subscription; +/// +/// type NumberStream = Pin> + Send>>; +/// +/// #[graphql_subscription] +/// impl Subscription { +/// /// Count seconds +/// async fn count() -> NumberStream { +/// let mut value = 0; +/// let stream = IntervalStream::new(interval(Duration::from_secs(1))).map(move |_| { +/// value += 1; +/// Ok(value) +/// }); +/// Box::pin(stream) +/// } +/// } +/// +/// async fn juniper_subscriptions( +/// Extension(schema): Extension, +/// ws: WebSocketUpgrade, +/// ) -> Response { +/// ws.protocols(["graphql-ws"]) +/// .max_frame_size(1024) +/// .max_message_size(1024) +/// .max_send_queue(100) +/// .on_upgrade(move |socket| handle_graphql_socket(socket, schema, ())) +/// } +/// +/// let schema = Schema::new(Query, EmptyMutation::new(), Subscription); /// +/// let app: Router = Router::new() +/// .route("/subscriptions", get(juniper_subscriptions)) +/// .layer(Extension(schema)); +/// ``` pub async fn handle_graphql_socket(socket: WebSocket, schema: S, context: S::Context) { let config = ConnectionConfig::new(context); let (ws_tx, ws_rx) = socket.split(); diff --git a/juniper_axum/tests/juniper_http_test_suite.rs b/juniper_axum/tests/juniper_http_test_suite.rs index 8fb739573..b17d98621 100644 --- a/juniper_axum/tests/juniper_http_test_suite.rs +++ b/juniper_axum/tests/juniper_http_test_suite.rs @@ -1,3 +1,5 @@ +use std::str::from_utf8; + use axum::{ http::Request, response::Response, @@ -11,7 +13,6 @@ use juniper::{ EmptyMutation, EmptySubscription, RootNode, }; use juniper_axum::{extract::JuniperRequest, response::JuniperResponse}; -use std::{str::from_utf8, sync::Arc}; /// The app we want to test struct AxumApp(Router); @@ -31,16 +32,16 @@ fn test_app() -> AxumApp { let router = Router::new() .route("/", get(graphql)) .route("/", post(graphql)) - .layer(Extension(Arc::from(schema))) - .layer(Extension(Arc::from(context))); + .layer(Extension(schema)) + .layer(Extension(context)); AxumApp(router) } async fn graphql( JuniperRequest(request): JuniperRequest, - Extension(schema): Extension>, - Extension(context): Extension>, + Extension(schema): Extension, + Extension(context): Extension, ) -> JuniperResponse { JuniperResponse(request.execute(&schema, &context).await) } diff --git a/juniper_axum/tests/juniper_ws_test_suite.rs b/juniper_axum/tests/juniper_ws_test_suite.rs index 9c8da095e..aeb9f6d37 100644 --- a/juniper_axum/tests/juniper_ws_test_suite.rs +++ b/juniper_axum/tests/juniper_ws_test_suite.rs @@ -1,3 +1,8 @@ +use std::{ + net::{SocketAddr, TcpListener}, + time::Duration, +}; + use anyhow::anyhow; use axum::{extract::WebSocketUpgrade, response::Response, routing::get, Extension, Router}; use futures::{SinkExt, StreamExt}; @@ -8,11 +13,6 @@ use juniper::{ }; use juniper_axum::subscriptions::handle_graphql_socket; use serde_json::Value; -use std::{ - net::{SocketAddr, TcpListener}, - sync::Arc, - time::Duration, -}; use tokio::net::TcpStream; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; @@ -30,7 +30,7 @@ fn test_app() -> AxumApp { let router = Router::new() .route("/subscriptions", get(juniper_subscriptions)) - .layer(Extension(Arc::from(schema))) + .layer(Extension(schema)) .layer(Extension(context)); AxumApp(router) @@ -38,7 +38,7 @@ fn test_app() -> AxumApp { /// Axum handler for websockets pub async fn juniper_subscriptions( - Extension(schema): Extension>, + Extension(schema): Extension, Extension(context): Extension, ws: WebSocketUpgrade, ) -> Response { diff --git a/juniper_axum/tests/simple_schema.rs b/juniper_axum/tests/simple_schema.rs index 31b37e254..8c2b1ef0b 100644 --- a/juniper_axum/tests/simple_schema.rs +++ b/juniper_axum/tests/simple_schema.rs @@ -7,14 +7,16 @@ use axum::{ use juniper::{graphql_object, EmptyMutation, EmptySubscription, RootNode}; use juniper_axum::{extract::JuniperRequest, playground, response::JuniperResponse}; use serde_json::{json, Value}; -use std::sync::Arc; use tower::util::ServiceExt; const GRAPHQL_ENDPOINT: &str = "/graphql"; -pub struct Context(); +#[derive(Clone, Copy, Debug)] +pub struct Context; impl juniper::Context for Context {} + +#[derive(Clone, Copy, Debug)] pub struct Query; #[graphql_object(context = Context)] @@ -27,16 +29,16 @@ impl Query { type Schema = RootNode<'static, Query, EmptyMutation, EmptySubscription>; fn app() -> Router { - let schema = Arc::from(Schema::new( + let schema = Schema::new( Query, EmptyMutation::::new(), EmptySubscription::::new(), - )); + ); - let context = Arc::new(Context()); + let context = Context; Router::new() - .route("/", get(|| playground(GRAPHQL_ENDPOINT, None))) + .route("/", get(playground(GRAPHQL_ENDPOINT, None))) .route(GRAPHQL_ENDPOINT, post(graphql)) .layer(Extension(schema)) .layer(Extension(context)) @@ -44,8 +46,8 @@ fn app() -> Router { async fn graphql( JuniperRequest(request): JuniperRequest, - Extension(schema): Extension>, - Extension(context): Extension>, + Extension(schema): Extension, + Extension(context): Extension, ) -> JuniperResponse { JuniperResponse(request.execute(&schema, &context).await) } diff --git a/juniper_graphql_ws/Cargo.toml b/juniper_graphql_ws/Cargo.toml index c8b68806c..25733c91b 100644 --- a/juniper_graphql_ws/Cargo.toml +++ b/juniper_graphql_ws/Cargo.toml @@ -15,6 +15,7 @@ keywords = ["apollo", "graphql", "graphql-ws", "subscription", "websocket"] exclude = ["/release.toml"] [dependencies] +derive_more = { version = "0.99.17", default-features = false, features = ["display"] } juniper = { version = "0.16.0-dev", path = "../juniper", default-features = false } juniper_subscriptions = { version = "0.17.0-dev", path = "../juniper_subscriptions" } serde = { version = "1.0.8", features = ["derive"], default-features = false } diff --git a/juniper_graphql_ws/src/lib.rs b/juniper_graphql_ws/src/lib.rs index 4c19d76c8..71ebfbca4 100644 --- a/juniper_graphql_ws/src/lib.rs +++ b/juniper_graphql_ws/src/lib.rs @@ -18,6 +18,7 @@ use std::{ sync::Arc, time::Duration, }; +use derive_more::Display; use juniper::{ futures::{ channel::oneshot, @@ -30,12 +31,14 @@ use juniper::{ }; /// Errors -#[derive(Debug)] +#[derive(Clone, Copy, Debug, Display)] pub enum WebsocketError { - /// The connection was already closed + /// The connection was already closed. + #[display(fmt = "Websocket connection was already closed.")] ConnectionAlreadyClosed, - /// The connection is not ready yet to accept messages + /// The connection is not ready to accept messages yet. + #[display(fmt = "The Websocket connection is not ready to accept messages yet.")] ConnectionNotReady, } diff --git a/juniper_graphql_ws/src/schema.rs b/juniper_graphql_ws/src/schema.rs index 68d282f0b..f4b72c073 100644 --- a/juniper_graphql_ws/src/schema.rs +++ b/juniper_graphql_ws/src/schema.rs @@ -129,3 +129,29 @@ where self } } + +impl Schema + for RootNode<'static, QueryT, MutationT, SubscriptionT, S> +where + QueryT: Clone + GraphQLTypeAsync + Send + Unpin + 'static, + QueryT::TypeInfo: Clone + Send + Sync + Unpin, + MutationT: Clone + GraphQLTypeAsync + Send + Unpin + 'static, + MutationT::TypeInfo: Clone + Send + Sync + Unpin, + SubscriptionT: Clone + GraphQLSubscriptionType + Send + Unpin + 'static, + SubscriptionT::TypeInfo: Clone + Send + Sync + Unpin, + CtxT: Unpin + Send + Sync, + S: ScalarValue + Send + Sync + Unpin + 'static, +{ + type Context = CtxT; + type ScalarValue = S; + type QueryTypeInfo = QueryT::TypeInfo; + type Query = QueryT; + type MutationTypeInfo = MutationT::TypeInfo; + type Mutation = MutationT; + type SubscriptionTypeInfo = SubscriptionT::TypeInfo; + type Subscription = SubscriptionT; + + fn root_node(&self) -> &RootNode<'static, QueryT, MutationT, SubscriptionT, S> { + self + } +} diff --git a/juniper_warp/src/lib.rs b/juniper_warp/src/lib.rs index 631834291..257df4ba1 100644 --- a/juniper_warp/src/lib.rs +++ b/juniper_warp/src/lib.rs @@ -345,9 +345,9 @@ fn playground_response( /// [1]: https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md #[cfg(feature = "subscriptions")] pub mod subscriptions { - use futures::TryStreamExt; use std::{fmt, sync::Arc}; + use futures::TryStreamExt as _; use juniper::{ futures::{ future::{self, Either}, @@ -379,7 +379,7 @@ pub mod subscriptions { Serde(serde_json::Error), /// Errors that can happen while communication with Juniper - Juniper(juniper_graphql_ws::WebsocketError), + Juniper(WebsocketError), } impl fmt::Display for Error { From 7414b4cf076677a6ead288b5c940372235e4ada3 Mon Sep 17 00:00:00 2001 From: Christian Legnitto Date: Mon, 28 Aug 2023 17:22:08 -0400 Subject: [PATCH 07/33] Update juniper_axum/README.md --- juniper_axum/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/juniper_axum/README.md b/juniper_axum/README.md index 90db74fc5..128f00462 100644 --- a/juniper_axum/README.md +++ b/juniper_axum/README.md @@ -2,7 +2,7 @@ ==================== [![Crates.io](https://img.shields.io/crates/v/juniper_axum.svg?maxAge=2592000)](https://crates.io/crates/juniper_warp) -[![Documentation](https://docs.rs/juniper_warp/badge.svg)](https://docs.rs/juniper_warp) +[![Documentation](https://docs.rs/juniper_axum/badge.svg)](https://docs.rs/juniper_axum) [![CI](https://github.com/graphql-rust/juniper/workflows/CI/badge.svg?branch=master "CI")](https://github.com/graphql-rust/juniper/actions?query=workflow%3ACI+branch%3Amaster) - [Changelog](https://github.com/graphql-rust/juniper/blob/master/juniper_axum/CHANGELOG.md) From e1a1104c83bd82c81395b8c0349c716c8145040d Mon Sep 17 00:00:00 2001 From: Christian Legnitto Date: Mon, 28 Aug 2023 17:22:38 -0400 Subject: [PATCH 08/33] Update juniper_warp/src/lib.rs --- juniper_warp/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/juniper_warp/src/lib.rs b/juniper_warp/src/lib.rs index 257df4ba1..1d26eddc1 100644 --- a/juniper_warp/src/lib.rs +++ b/juniper_warp/src/lib.rs @@ -378,7 +378,7 @@ pub mod subscriptions { /// while deserializing incoming messages are handled internally by the protocol. Serde(serde_json::Error), - /// Errors that can happen while communication with Juniper + /// Errors that can happen while communicating with Juniper Juniper(WebsocketError), } From b5d5be81d700eda423464625ce75da43bda51381 Mon Sep 17 00:00:00 2001 From: tyranron Date: Tue, 7 Nov 2023 16:44:00 +0200 Subject: [PATCH 09/33] Revert unrelevant changes --- juniper/src/http/mod.rs | 31 ++++++++++++++++--------------- juniper/src/schema/meta.rs | 17 +++++++---------- juniper/src/schema/model.rs | 6 +++--- juniper/src/types/scalars.rs | 17 ----------------- juniper_graphql_ws/Cargo.toml | 1 - juniper_graphql_ws/src/lib.rs | 12 ------------ juniper_graphql_ws/src/schema.rs | 26 -------------------------- juniper_warp/src/lib.rs | 30 ++++++++++++------------------ 8 files changed, 38 insertions(+), 102 deletions(-) diff --git a/juniper/src/http/mod.rs b/juniper/src/http/mod.rs index e3fb9b393..227ddf274 100644 --- a/juniper/src/http/mod.rs +++ b/juniper/src/http/mod.rs @@ -504,13 +504,13 @@ pub mod tests { "NEW_HOPE", "EMPIRE", "JEDI" - ], - "homePlanet": "Tatooine", - "name": "Luke Skywalker", - "id": "1000" - } + ], + "homePlanet": "Tatooine", + "name": "Luke Skywalker", + "id": "1000" } - }"# + } + }"# ) .expect("Invalid JSON constant in test") ); @@ -520,9 +520,10 @@ pub mod tests { let response = integration.post_json( "/", r#"{ - "query": "query($id: String!) { human(id: $id) { id, name, appearsIn, homePlanet } }", - "variables": { "id": "1000" } - }"#, + "query": + "query($id: String!) { human(id: $id) { id, name, appearsIn, homePlanet } }", + "variables": {"id": "1000"} + }"#, ); assert_eq!(response.status_code, 200); @@ -538,13 +539,13 @@ pub mod tests { "NEW_HOPE", "EMPIRE", "JEDI" - ], - "homePlanet": "Tatooine", - "name": "Luke Skywalker", - "id": "1000" - } + ], + "homePlanet": "Tatooine", + "name": "Luke Skywalker", + "id": "1000" } - }"# + } + }"# ) .expect("Invalid JSON constant in test") ); diff --git a/juniper/src/schema/meta.rs b/juniper/src/schema/meta.rs index 68d465a87..04f5eda96 100644 --- a/juniper/src/schema/meta.rs +++ b/juniper/src/schema/meta.rs @@ -43,7 +43,6 @@ impl DeprecationStatus { } /// Scalar type metadata -#[derive(Clone)] pub struct ScalarMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -62,7 +61,7 @@ pub type InputValueParseFn = for<'b> fn(&'b InputValue) -> Result<(), Fiel pub type ScalarTokenParseFn = for<'b> fn(ScalarToken<'b>) -> Result; /// List type metadata -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct ListMeta<'a> { #[doc(hidden)] pub of_type: Type<'a>, @@ -72,14 +71,14 @@ pub struct ListMeta<'a> { } /// Nullable type metadata -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct NullableMeta<'a> { #[doc(hidden)] pub of_type: Type<'a>, } /// Object type metadata -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct ObjectMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -92,7 +91,6 @@ pub struct ObjectMeta<'a, S> { } /// Enum type metadata -#[derive(Clone)] pub struct EnumMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -104,7 +102,7 @@ pub struct EnumMeta<'a, S> { } /// Interface type metadata -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct InterfaceMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -117,7 +115,7 @@ pub struct InterfaceMeta<'a, S> { } /// Union type metadata -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct UnionMeta<'a> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -128,7 +126,6 @@ pub struct UnionMeta<'a> { } /// Input object metadata -#[derive(Clone)] pub struct InputObjectMeta<'a, S> { #[doc(hidden)] pub name: Cow<'a, str>, @@ -143,14 +140,14 @@ pub struct InputObjectMeta<'a, S> { /// /// After a type's `meta` method has been called but before it has returned, a placeholder type /// is inserted into a registry to indicate existence. -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct PlaceholderMeta<'a> { #[doc(hidden)] pub of_type: Type<'a>, } /// Generic type metadata -#[derive(Clone, Debug)] +#[derive(Debug)] pub enum MetaType<'a, S = DefaultScalarValue> { #[doc(hidden)] Scalar(ScalarMeta<'a, S>), diff --git a/juniper/src/schema/model.rs b/juniper/src/schema/model.rs index 419d024ce..e7cba411d 100644 --- a/juniper/src/schema/model.rs +++ b/juniper/src/schema/model.rs @@ -20,7 +20,7 @@ use crate::schema::translate::{graphql_parser::GraphQLParserTranslator, SchemaTr /// /// This brings the mutation, subscription and query types together, /// and provides the predefined metadata fields. -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct RootNode< 'a, QueryT: GraphQLType, @@ -47,7 +47,7 @@ pub struct RootNode< } /// Metadata for a schema -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct SchemaType<'a, S> { pub(crate) description: Option>, pub(crate) types: FnvHashMap>, @@ -66,7 +66,7 @@ pub enum TypeType<'a, S: 'a> { List(Box>, Option), } -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct DirectiveType<'a, S> { pub name: String, pub description: Option, diff --git a/juniper/src/types/scalars.rs b/juniper/src/types/scalars.rs index 2d4d1e91d..c1002ec27 100644 --- a/juniper/src/types/scalars.rs +++ b/juniper/src/types/scalars.rs @@ -346,14 +346,6 @@ mod impl_float_scalar { #[derive(Debug)] pub struct EmptyMutation(PhantomData>>); -impl Clone for EmptyMutation { - fn clone(&self) -> Self { - Self(PhantomData) - } -} - -impl Copy for EmptyMutation {} - // `EmptyMutation` doesn't use `T`, so should be `Send` and `Sync` even when `T` is not. crate::sa::assert_impl_all!(EmptyMutation>: Send, Sync); @@ -413,17 +405,8 @@ impl Default for EmptyMutation { /// /// If you instantiate `RootNode` with this as the subscription, /// no subscriptions will be generated for the schema. -#[derive(Debug)] pub struct EmptySubscription(PhantomData>>); -impl Clone for EmptySubscription { - fn clone(&self) -> Self { - Self(PhantomData) - } -} - -impl Copy for EmptySubscription {} - // `EmptySubscription` doesn't use `T`, so should be `Send` and `Sync` even when `T` is not. crate::sa::assert_impl_all!(EmptySubscription>: Send, Sync); diff --git a/juniper_graphql_ws/Cargo.toml b/juniper_graphql_ws/Cargo.toml index 25dbe8182..ee5cabbaa 100644 --- a/juniper_graphql_ws/Cargo.toml +++ b/juniper_graphql_ws/Cargo.toml @@ -26,7 +26,6 @@ graphql-transport-ws = [] graphql-ws = [] [dependencies] -derive_more = { version = "0.99.17", default-features = false, features = ["display"] } juniper = { version = "0.16.0-dev", path = "../juniper", default-features = false } juniper_subscriptions = { version = "0.17.0-dev", path = "../juniper_subscriptions" } serde = { version = "1.0.122", features = ["derive"], default-features = false } diff --git a/juniper_graphql_ws/src/lib.rs b/juniper_graphql_ws/src/lib.rs index 526485da1..b8aceed4b 100644 --- a/juniper_graphql_ws/src/lib.rs +++ b/juniper_graphql_ws/src/lib.rs @@ -26,18 +26,6 @@ use juniper::{ScalarValue, Variables}; pub use self::schema::{ArcSchema, Schema}; -/// Errors -#[derive(Clone, Copy, Debug, Display)] -pub enum WebsocketError { - /// The connection was already closed. - #[display(fmt = "Websocket connection was already closed.")] - ConnectionAlreadyClosed, - - /// The connection is not ready to accept messages yet. - #[display(fmt = "The Websocket connection is not ready to accept messages yet.")] - ConnectionNotReady, -} - /// ConnectionConfig is used to configure the connection once the client sends the ConnectionInit /// message. #[derive(Clone, Copy, Debug)] diff --git a/juniper_graphql_ws/src/schema.rs b/juniper_graphql_ws/src/schema.rs index 89b9355e5..43b7f94b4 100644 --- a/juniper_graphql_ws/src/schema.rs +++ b/juniper_graphql_ws/src/schema.rs @@ -130,29 +130,3 @@ where self } } - -impl Schema - for RootNode<'static, QueryT, MutationT, SubscriptionT, S> -where - QueryT: Clone + GraphQLTypeAsync + Send + Unpin + 'static, - QueryT::TypeInfo: Clone + Send + Sync + Unpin, - MutationT: Clone + GraphQLTypeAsync + Send + Unpin + 'static, - MutationT::TypeInfo: Clone + Send + Sync + Unpin, - SubscriptionT: Clone + GraphQLSubscriptionType + Send + Unpin + 'static, - SubscriptionT::TypeInfo: Clone + Send + Sync + Unpin, - CtxT: Unpin + Send + Sync, - S: ScalarValue + Send + Sync + Unpin + 'static, -{ - type Context = CtxT; - type ScalarValue = S; - type QueryTypeInfo = QueryT::TypeInfo; - type Query = QueryT; - type MutationTypeInfo = MutationT::TypeInfo; - type Mutation = MutationT; - type SubscriptionTypeInfo = SubscriptionT::TypeInfo; - type Subscription = SubscriptionT; - - fn root_node(&self) -> &RootNode<'static, QueryT, MutationT, SubscriptionT, S> { - self - } -} diff --git a/juniper_warp/src/lib.rs b/juniper_warp/src/lib.rs index 9073e418d..676f2cd91 100644 --- a/juniper_warp/src/lib.rs +++ b/juniper_warp/src/lib.rs @@ -6,7 +6,7 @@ use std::{collections::HashMap, str, sync::Arc}; use anyhow::anyhow; -use futures::{FutureExt as _, TryFutureExt}; +use futures::{FutureExt as _, TryFutureExt as _}; use juniper::{ http::{GraphQLBatchRequest, GraphQLRequest}, ScalarValue, @@ -339,17 +339,14 @@ fn playground_response( #[cfg(feature = "subscriptions")] /// `juniper_warp` subscriptions handler implementation. pub mod subscriptions { - use std::{fmt, sync::Arc}; + use std::{convert::Infallible, fmt, sync::Arc}; - use futures::TryStreamExt as _; - use juniper::{ - futures::{ - future::{self, Either}, - sink::SinkExt, - stream::StreamExt, - }, - GraphQLSubscriptionType, GraphQLTypeAsync, RootNode, ScalarValue, + use futures::{ + future::{self, Either}, + sink::SinkExt as _, + stream::StreamExt as _, }; + use juniper::{GraphQLSubscriptionType, GraphQLTypeAsync, RootNode, ScalarValue}; use juniper_graphql_ws::{graphql_transport_ws, graphql_ws}; use warp::{filters::BoxedFilter, reply::Reply, Filter as _}; @@ -388,9 +385,6 @@ pub mod subscriptions { /// Errors that can happen while serializing outgoing messages. Note that errors that occur /// while deserializing incoming messages are handled internally by the protocol. Serde(serde_json::Error), - - /// Errors that can happen while communicating with Juniper - Juniper(WebsocketError), } impl fmt::Display for Error { @@ -410,9 +404,9 @@ pub mod subscriptions { } } - impl From for Error { - fn from(err: WebsocketError) -> Self { - Self::Juniper(err) + impl From for Error { + fn from(_err: Infallible) -> Self { + unreachable!() } } @@ -602,7 +596,7 @@ pub mod subscriptions { let (s_tx, s_rx) = graphql_ws::Connection::new(juniper_graphql_ws::ArcSchema(root_node), init).split(); - let ws_rx = ws_rx.map(|r| r.map(Message)).map_err(Error::Warp); + let ws_rx = ws_rx.map(|r| r.map(Message)); let s_rx = s_rx.map(|msg| { serde_json::to_string(&msg) .map(warp::ws::Message::text) @@ -666,7 +660,7 @@ pub mod subscriptions { ) .await { - Either::Left((r, _)) => r, + Either::Left((r, _)) => r.map_err(|e| e.into()), Either::Right((r, _)) => r, } } From f5b0d705c4cedcc221782662c48ada1bd5dae91a Mon Sep 17 00:00:00 2001 From: tyranron Date: Tue, 7 Nov 2023 16:46:36 +0200 Subject: [PATCH 10/33] Add to CI --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce5075b14..fda357262 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,8 @@ jobs: - { feature: graphql-ws, crate: juniper_graphql_ws } - { feature: , crate: juniper_actix } - { feature: subscriptions, crate: juniper_actix } + - { feature: , crate: juniper_axum } + - { feature: subscriptions, crate: juniper_axum } - { feature: , crate: juniper_warp } - { feature: subscriptions, crate: juniper_warp } runs-on: ubuntu-latest @@ -148,6 +150,7 @@ jobs: - juniper_subscriptions - juniper_graphql_ws - juniper_actix + - juniper_axum - juniper_hyper #- juniper_iron - juniper_rocket @@ -200,6 +203,7 @@ jobs: - juniper_integration_tests - juniper_codegen_tests - juniper_actix + - juniper_axum - juniper_hyper - juniper_iron - juniper_rocket @@ -326,6 +330,7 @@ jobs: - juniper_subscriptions - juniper_graphql_ws - juniper_actix + - juniper_axum - juniper_hyper - juniper_iron - juniper_rocket From 2691dc47f21bbc5fba428eb83365b6ef457225a2 Mon Sep 17 00:00:00 2001 From: tyranron Date: Tue, 7 Nov 2023 16:55:56 +0200 Subject: [PATCH 11/33] Tune up deps --- juniper_axum/Cargo.toml | 37 ++++++++++++++++++++++--------------- juniper_axum/LICENCE | 2 +- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index d0696794f..d5bc03f26 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -2,7 +2,7 @@ name = "juniper_axum" version = "0.1.0" edition = "2021" -rust-version = "1.62" +rust-version = "1.73" description = "`juniper` GraphQL integration with `axum`." license = "BSD-2-Clause" authors = ["Benno Tielen "] @@ -11,22 +11,29 @@ homepage = "https://github.com/graphql-rust/juniper/tree/master/juniper_axum" repository = "https://github.com/graphql-rust/juniper" readme = "README.md" categories = ["asynchronous", "web-programming", "web-programming::http-server"] -keywords = ["graphql", "juniper", "axum", "websocket"] -exclude = ["/release.toml"] +keywords = ["apollo", "axum", "graphql", "juniper", "websocket"] +exclude = ["/examples/", "/release.toml"] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[features] +subscriptions = ["axum/ws", "dep:juniper_graphql_ws"] [dependencies] -axum = { version = "0.5.11", features = ["ws"] } -juniper = { path = "../juniper" } -serde = "1.0" -serde_json = "1.0" -juniper_graphql_ws = { path = "../juniper_graphql_ws" } -futures = "0.3" +axum = "0.6" +futures = "0.3.22" +juniper = { version = "0.16.0-dev", path = "../juniper", default-features = false } +juniper_graphql_ws = { version = "0.4.0-dev", path = "../juniper_graphql_ws", features = ["graphql-transport-ws", "graphql-ws"], optional = true } +serde = { version = "1.0.122", features = ["derive"] } +serde_json = "1.0.18" [dev-dependencies] +anyhow = "1.0" +hyper = "0.14" +juniper = { version = "0.16.0-dev", path = "../juniper", features = ["expose-test-schema"] } tokio = { version = "1.20", features = ["full"] } -tokio-tungstenite = "0.17.2" -tokio-stream = "0.1.9" -tower = "0.4.13" -hyper = "0.14.20" -juniper = { path = "../juniper", features = ["expose-test-schema"] } -anyhow = "1.0" \ No newline at end of file +tokio-stream = "0.1" +tokio-tungstenite = "0.20" +tower = "0.4" diff --git a/juniper_axum/LICENCE b/juniper_axum/LICENCE index f060a794d..05fc83f5a 100644 --- a/juniper_axum/LICENCE +++ b/juniper_axum/LICENCE @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2022, Benno Tielen +Copyright (c) 2022-2023, Benno Tielen All rights reserved. Redistribution and use in source and binary forms, with or without From 5f504740aaf4225293b227f7ffd90450e65150c7 Mon Sep 17 00:00:00 2001 From: tyranron Date: Tue, 7 Nov 2023 17:35:05 +0200 Subject: [PATCH 12/33] Refactor solution, vol.1 --- juniper_actix/src/lib.rs | 1 - juniper_axum/Cargo.toml | 4 +-- juniper_axum/src/lib.rs | 41 +++++++++++++++++++++++++++---- juniper_axum/src/response.rs | 4 +-- juniper_axum/src/subscriptions.rs | 6 ++--- 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/juniper_actix/src/lib.rs b/juniper_actix/src/lib.rs index b24a5c293..f0731a469 100644 --- a/juniper_actix/src/lib.rs +++ b/juniper_actix/src/lib.rs @@ -143,7 +143,6 @@ where /// 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>, diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index d5bc03f26..f12345175 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -19,11 +19,11 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -subscriptions = ["axum/ws", "dep:juniper_graphql_ws"] +subscriptions = ["axum/ws", "dep:futures", "dep:juniper_graphql_ws"] [dependencies] axum = "0.6" -futures = "0.3.22" +futures = { version = "0.3.22", optional = true } juniper = { version = "0.16.0-dev", path = "../juniper", default-features = false } juniper_graphql_ws = { version = "0.4.0-dev", path = "../juniper_graphql_ws", features = ["graphql-transport-ws", "graphql-ws"], optional = true } serde = { version = "1.0.122", features = ["derive"] } diff --git a/juniper_axum/src/lib.rs b/juniper_axum/src/lib.rs index ca1f1b083..24ce7eee2 100644 --- a/juniper_axum/src/lib.rs +++ b/juniper_axum/src/lib.rs @@ -5,17 +5,46 @@ pub mod extract; pub mod response; +#[cfg(feature = "subscriptions")] pub mod subscriptions; +use std::future; + use axum::response::Html; -use futures::future; -/// Add a GraphQL Playground +/// Creates a handler that replies with an HTML page containing [GraphiQL]. +/// +/// This does not handle routing, so you can mount it on any endpoint. +/// +/// # Example +/// +/// ```rust +/// use axum::{ +/// routing::get, +/// Router +/// }; +/// use axum::body::Body; +/// use juniper_axum::graphiql; +/// +/// let app: Router = Router::new().route("/", get(graphiql("/graphql", "/subscriptions"))); +/// ``` /// -/// # Arguments +/// [GraphiQL]: https://github.com/graphql/graphiql +pub fn graphiql<'a>( + graphql_endpoint_url: &str, + subscriptions_endpoint_url: impl Into>, +) -> impl FnOnce() -> future::Ready> + Clone + Send { + let html = Html(juniper::http::graphiql::graphiql_source( + graphql_endpoint_url, + subscriptions_endpoint_url.into(), + )); + + || future::ready(html) +} + +/// Creates a handler that replies with an HTML page containing [GraphQL Playground]. /// -/// * `graphql_endpoint_url` - The graphql endpoint you configured -/// * `subscriptions_endpoint_url` - An optional subscription endpoint +/// This does not handle routing, so you can mount it on any endpoint. /// /// # Example /// @@ -29,6 +58,8 @@ use futures::future; /// /// let app: Router = Router::new().route("/", get(playground("/graphql", "/subscriptions"))); /// ``` +/// +/// [GraphQL Playground]: https://github.com/prisma/graphql-playground pub fn playground<'a>( graphql_endpoint_url: &str, subscriptions_endpoint_url: impl Into>, diff --git a/juniper_axum/src/response.rs b/juniper_axum/src/response.rs index 8803e9af7..85b785f78 100644 --- a/juniper_axum/src/response.rs +++ b/juniper_axum/src/response.rs @@ -7,8 +7,8 @@ use axum::{ }; use juniper::http::GraphQLBatchResponse; -/// A wrapper around [`GraphQLBatchResponse`] that implements [`IntoResponse`] -/// so it can be returned from axum handlers. +/// Wrapper around a [`GraphQLBatchResponse`], implementing [`IntoResponse`], so it can be returned +/// from [`axum`] handlers. pub struct JuniperResponse(pub GraphQLBatchResponse); impl IntoResponse for JuniperResponse { diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs index 196bb5865..b398541b7 100644 --- a/juniper_axum/src/subscriptions.rs +++ b/juniper_axum/src/subscriptions.rs @@ -1,10 +1,8 @@ //! Definitions for handling GraphQL subscriptions. use axum::extract::ws::{Message, WebSocket}; -use juniper::{ - futures::{SinkExt, StreamExt, TryStreamExt}, - ScalarValue, -}; +use futures::{SinkExt as _, StreamExt as _, TryStreamExt as _}; +use juniper::ScalarValue; use juniper_graphql_ws::{ClientMessage, Connection, ConnectionConfig, Schema, WebsocketError}; #[derive(Debug)] From 224e30c92a15c184f8a367826ddf16e2558c08a7 Mon Sep 17 00:00:00 2001 From: tyranron Date: Tue, 7 Nov 2023 18:48:04 +0200 Subject: [PATCH 13/33] Refactor solution, vol.2 [skip ci] --- juniper_axum/src/extract.rs | 177 ++++++++++++++++++------------------ 1 file changed, 87 insertions(+), 90 deletions(-) diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs index 0b66c5c0d..4b4ab33a7 100644 --- a/juniper_axum/src/extract.rs +++ b/juniper_axum/src/extract.rs @@ -1,11 +1,14 @@ //! Types and traits for extracting data from requests. +use std::fmt; + use axum::{ async_trait, body::Body, - extract::{FromRequest, Query, RequestParts}, - http::{Method, StatusCode}, - Json, + extract::{FromRequest, FromRequestParts, Query}, + http::{HeaderValue, Method, Request, StatusCode}, + response::{IntoResponse as _, Response}, + Json, RequestExt as _, }; use juniper::{ http::{GraphQLBatchRequest, GraphQLRequest}, @@ -14,8 +17,8 @@ use juniper::{ use serde::Deserialize; use serde_json::{Map, Value}; -/// The query variables for a GET request -#[derive(Deserialize, Debug)] +/// Query variables of a GET request. +#[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct GetQueryVariables { query: String, @@ -23,15 +26,15 @@ struct GetQueryVariables { variables: Option, } -/// The request body for JSON POST -#[derive(Deserialize, Debug)] +/// Request body of a JSON POST request. +#[derive(Debug, Deserialize)] #[serde(untagged)] enum JsonRequestBody { Single(SingleRequestBody), Batch(Vec), } -/// The request body for a single JSON POST request +/// Request body of a single JSON POST request. #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct SingleRequestBody { @@ -41,7 +44,7 @@ struct SingleRequestBody { } impl JsonRequestBody { - /// Returns true if the request body is an empty array + /// Indicates whether the request body represents an empty array. fn is_empty_batch(&self) -> bool { match self { JsonRequestBody::Batch(r) => r.is_empty(), @@ -50,7 +53,7 @@ impl JsonRequestBody { } } -/// An extractor for Axum to Extract a JuniperRequest +/// Extractor for [`axum`] to extract a [`JuniperRequest`]. /// /// # Example /// @@ -145,12 +148,12 @@ impl TryFrom for JuniperRequest { fn try_from(value: JsonRequestBody) -> Result { match value { - JsonRequestBody::Single(r) => JuniperRequest::try_from(r), + JsonRequestBody::Single(req) => req.try_into(), JsonRequestBody::Batch(requests) => { let mut graphql_requests: Vec = Vec::new(); - for request in requests { - graphql_requests.push(GraphQLRequest::try_from(request)?); + for req in requests { + graphql_requests.push(GraphQLRequest::try_from(req)?); } Ok(JuniperRequest(GraphQLBatchRequest::Batch(graphql_requests))) @@ -182,92 +185,86 @@ impl TryFrom for JuniperRequest { } } -/// Helper trait to get some nice clean code #[async_trait] -trait TryFromRequest { - type Rejection; - - /// Get `content-type` header from request - fn try_get_content_type_header(&self) -> Result, Self::Rejection>; - - /// Try to convert GET request to RequestBody - async fn try_from_get_request(&mut self) -> Result; - - /// Try to convert POST json request to RequestBody - async fn try_from_json_post_request(&mut self) -> Result; - - /// Try to convert POST graphql request to RequestBody - async fn try_from_graphql_post_request(&mut self) -> Result; -} - -#[async_trait] -impl TryFromRequest for RequestParts { - type Rejection = (StatusCode, &'static str); - - fn try_get_content_type_header(&self) -> Result, Self::Rejection> { - self.headers() - .get("content-Type") - .map(|header| header.to_str()) +impl FromRequest for JuniperRequest +where + S: Sync, + Query: FromRequestParts, + Json: FromRequest, + as FromRequest>::Rejection: fmt::Display, + String: FromRequest, +{ + type Rejection = Response; + + async fn from_request(mut req: Request, state: &S) -> Result { + let content_type = req + .headers() + .get("content-type") + .map(HeaderValue::to_str) .transpose() - .map_err(|_e| { + .map_err(|_| { ( StatusCode::BAD_REQUEST, - "content-type header not a valid string", + "`Content-Type` header is not a valid header string", ) - }) - } - - async fn try_from_get_request(&mut self) -> Result { - let query_vars = Query::::from_request(self) - .await - .map(|result| result.0) - .map_err(|_err| (StatusCode::BAD_REQUEST, "Request not valid"))?; - - JuniperRequest::try_from(query_vars) - .map_err(|_err| (StatusCode::BAD_REQUEST, "Could not convert variables")) - } + .into_response() + })?; - async fn try_from_json_post_request(&mut self) -> Result { - let json_body = Json::::from_request(self) - .await - .map_err(|_err| (StatusCode::BAD_REQUEST, "JSON invalid")) - .map(|result| result.0)?; - - if json_body.is_empty_batch() { - return Err((StatusCode::BAD_REQUEST, "Batch request can not be empty")); - } - - JuniperRequest::try_from(json_body) - .map_err(|_err| (StatusCode::BAD_REQUEST, "Could not convert variables")) - } - - async fn try_from_graphql_post_request(&mut self) -> Result { - String::from_request(self) - .await - .map(|s| s.into()) - .map_err(|_err| (StatusCode::BAD_REQUEST, "Not valid utf-8")) - } -} - -#[async_trait] -impl FromRequest for JuniperRequest { - type Rejection = (StatusCode, &'static str); - - async fn from_request(req: &mut RequestParts) -> Result { - let content_type = req.try_get_content_type_header()?; - - // Convert `req` to JuniperRequest based on request method and content-type header match (req.method(), content_type) { - (&Method::GET, _) => req.try_from_get_request().await, - (&Method::POST, Some("application/json")) => req.try_from_json_post_request().await, - (&Method::POST, Some("application/graphql")) => { - req.try_from_graphql_post_request().await + (&Method::GET, _) => { + let query_vars = req + .extract_parts::>() + .await + .map(|result| result.0) + .map_err(|e| { + (StatusCode::BAD_REQUEST, format!("Invalid request: {e}")).into_response() + })?; + + Self::try_from(query_vars).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Could not convert variables: {e}"), + ) + .into_response() + }) + } + (&Method::POST, Some("application/json")) => { + let json_body = Json::::from_request(req, state) + .await + .map(|result| result.0) + .map_err(|e| { + (StatusCode::BAD_REQUEST, format!("Invalid JSON: {e}")).into_response() + })?; + + if json_body.is_empty_batch() { + return Err( + (StatusCode::BAD_REQUEST, "Batch request cannot be empty").into_response() + ); + } + + Self::try_from(json_body).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Could not convert variables: {e}"), + ) + .into_response() + }) } + (&Method::POST, Some("application/graphql")) => String::from_request(req, state) + .await + .map(Into::into) + .map_err(|_| (StatusCode::BAD_REQUEST, "Not valid UTF-8").into_response()), (&Method::POST, _) => Err(( - StatusCode::BAD_REQUEST, - "Header content-type is not application/json or application/graphql", - )), - _ => Err((StatusCode::METHOD_NOT_ALLOWED, "Method not supported")), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "`Content-Type` header is expected to be either `application/json` or \ + `application/graphql`", + ) + .into_response()), + _ => Err(( + StatusCode::METHOD_NOT_ALLOWED, + "HTTP method is expected to be either GET or POST", + ) + .into_response()), } } } From 072717eacbefcda4621cbef43d07e32b885d83d4 Mon Sep 17 00:00:00 2001 From: tyranron Date: Tue, 7 Nov 2023 18:52:35 +0200 Subject: [PATCH 14/33] Get rid of `GetQueryVariables` --- juniper_axum/src/extract.rs | 40 +++++-------------------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs index 4b4ab33a7..7f164f06b 100644 --- a/juniper_axum/src/extract.rs +++ b/juniper_axum/src/extract.rs @@ -17,15 +17,6 @@ use juniper::{ use serde::Deserialize; use serde_json::{Map, Value}; -/// Query variables of a GET request. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct GetQueryVariables { - query: String, - operation_name: Option, - variables: Option, -} - /// Request body of a JSON POST request. #[derive(Debug, Deserialize)] #[serde(untagged)] @@ -170,26 +161,11 @@ impl From for JuniperRequest { } } -impl TryFrom for JuniperRequest { - type Error = serde_json::Error; - - fn try_from(value: GetQueryVariables) -> Result { - let variables: Option = value - .variables - .map(|var| serde_json::from_str(&var)) - .transpose()?; - - Ok(JuniperRequest(GraphQLBatchRequest::Single( - GraphQLRequest::new(value.query, value.operation_name, variables), - ))) - } -} - #[async_trait] impl FromRequest for JuniperRequest where S: Sync, - Query: FromRequestParts, + Query: FromRequestParts, Json: FromRequest, as FromRequest>::Rejection: fmt::Display, String: FromRequest, @@ -212,21 +188,15 @@ where match (req.method(), content_type) { (&Method::GET, _) => { - let query_vars = req - .extract_parts::>() + let query = req + .extract_parts::>() .await - .map(|result| result.0) + .map(|q| q.0) .map_err(|e| { (StatusCode::BAD_REQUEST, format!("Invalid request: {e}")).into_response() })?; - Self::try_from(query_vars).map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("Could not convert variables: {e}"), - ) - .into_response() - }) + Ok(Self(GraphQLBatchRequest::Single(query))) } (&Method::POST, Some("application/json")) => { let json_body = Json::::from_request(req, state) From aa03df314bb95ed676a5057735f512a617ea1cdc Mon Sep 17 00:00:00 2001 From: tyranron Date: Tue, 7 Nov 2023 19:17:47 +0200 Subject: [PATCH 15/33] Get rid of `JsonRequestBody` [skip ci] --- juniper/src/http/mod.rs | 10 ++- juniper_axum/Cargo.toml | 2 - juniper_axum/src/extract.rs | 147 ++++++------------------------------ 3 files changed, 31 insertions(+), 128 deletions(-) diff --git a/juniper/src/http/mod.rs b/juniper/src/http/mod.rs index 227ddf274..955e40b42 100644 --- a/juniper/src/http/mod.rs +++ b/juniper/src/http/mod.rs @@ -37,6 +37,7 @@ where pub operation_name: Option, /// Optional variables to execute the GraphQL operation with. + // TODO: Use `Variables` instead of `InputValue`? #[serde(bound( deserialize = "InputValue: Deserialize<'de>", serialize = "InputValue: Serialize", @@ -238,11 +239,11 @@ where /// A batch operation request. /// /// Empty batch is considered as invalid value, so cannot be deserialized. - #[serde(deserialize_with = "deserialize_non_empty_vec")] + #[serde(deserialize_with = "deserialize_non_empty_batch")] Batch(Vec>), } -fn deserialize_non_empty_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +fn deserialize_non_empty_batch<'de, D, T>(deserializer: D) -> Result, D::Error> where D: de::Deserializer<'de>, T: Deserialize<'de>, @@ -251,7 +252,10 @@ where let v = Vec::::deserialize(deserializer)?; if v.is_empty() { - Err(D::Error::invalid_length(0, &"a positive integer")) + Err(D::Error::invalid_length( + 0, + &"non-empty batch of GraphQL requests", + )) } else { Ok(v) } diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index f12345175..f2eb8961e 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -26,8 +26,6 @@ axum = "0.6" futures = { version = "0.3.22", optional = true } juniper = { version = "0.16.0-dev", path = "../juniper", default-features = false } juniper_graphql_ws = { version = "0.4.0-dev", path = "../juniper_graphql_ws", features = ["graphql-transport-ws", "graphql-ws"], optional = true } -serde = { version = "1.0.122", features = ["derive"] } -serde_json = "1.0.18" [dev-dependencies] anyhow = "1.0" diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs index 7f164f06b..00455e713 100644 --- a/juniper_axum/src/extract.rs +++ b/juniper_axum/src/extract.rs @@ -10,39 +10,7 @@ use axum::{ response::{IntoResponse as _, Response}, Json, RequestExt as _, }; -use juniper::{ - http::{GraphQLBatchRequest, GraphQLRequest}, - InputValue, -}; -use serde::Deserialize; -use serde_json::{Map, Value}; - -/// Request body of a JSON POST request. -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum JsonRequestBody { - Single(SingleRequestBody), - Batch(Vec), -} - -/// Request body of a single JSON POST request. -#[derive(Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -struct SingleRequestBody { - query: String, - operation_name: Option, - variables: Option>, -} - -impl JsonRequestBody { - /// Indicates whether the request body represents an empty array. - fn is_empty_batch(&self) -> bool { - match self { - JsonRequestBody::Batch(r) => r.is_empty(), - JsonRequestBody::Single(_) => false, - } - } -} +use juniper::http::{GraphQLBatchRequest, GraphQLRequest}; /// Extractor for [`axum`] to extract a [`JuniperRequest`]. /// @@ -104,70 +72,13 @@ impl JsonRequestBody { #[derive(Debug, PartialEq)] pub struct JuniperRequest(pub GraphQLBatchRequest); -impl TryFrom for JuniperRequest { - type Error = serde_json::Error; - - fn try_from(value: SingleRequestBody) -> Result { - Ok(JuniperRequest(GraphQLBatchRequest::Single( - GraphQLRequest::try_from(value)?, - ))) - } -} - -impl TryFrom for GraphQLRequest { - type Error = serde_json::Error; - - fn try_from(value: SingleRequestBody) -> Result { - // Convert Map to InputValue with the help of serde_json - let variables: Option = value - .variables - .map(|vars| serde_json::to_string(&vars)) - .transpose()? - .map(|s| serde_json::from_str(&s)) - .transpose()?; - - Ok(GraphQLRequest::new( - value.query, - value.operation_name, - variables, - )) - } -} - -impl TryFrom for JuniperRequest { - type Error = serde_json::Error; - - fn try_from(value: JsonRequestBody) -> Result { - match value { - JsonRequestBody::Single(req) => req.try_into(), - JsonRequestBody::Batch(requests) => { - let mut graphql_requests: Vec = Vec::new(); - - for req in requests { - graphql_requests.push(GraphQLRequest::try_from(req)?); - } - - Ok(JuniperRequest(GraphQLBatchRequest::Batch(graphql_requests))) - } - } - } -} - -impl From for JuniperRequest { - fn from(query: String) -> Self { - JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( - query, None, None, - ))) - } -} - #[async_trait] impl FromRequest for JuniperRequest where S: Sync, Query: FromRequestParts, - Json: FromRequest, - as FromRequest>::Rejection: fmt::Display, + Json: FromRequest, + as FromRequest>::Rejection: fmt::Display, String: FromRequest, { type Rejection = Response; @@ -181,49 +92,39 @@ where .map_err(|_| { ( StatusCode::BAD_REQUEST, - "`Content-Type` header is not a valid header string", + "`Content-Type` header is not a valid HTTP header string", ) .into_response() })?; match (req.method(), content_type) { - (&Method::GET, _) => { - let query = req - .extract_parts::>() - .await - .map(|q| q.0) - .map_err(|e| { - (StatusCode::BAD_REQUEST, format!("Invalid request: {e}")).into_response() - })?; - - Ok(Self(GraphQLBatchRequest::Single(query))) - } - (&Method::POST, Some("application/json")) => { - let json_body = Json::::from_request(req, state) - .await - .map(|result| result.0) - .map_err(|e| { - (StatusCode::BAD_REQUEST, format!("Invalid JSON: {e}")).into_response() - })?; - - if json_body.is_empty_batch() { - return Err( - (StatusCode::BAD_REQUEST, "Batch request cannot be empty").into_response() - ); - } - - Self::try_from(json_body).map_err(|e| { + (&Method::GET, _) => req + .extract_parts::>() + .await + .map(|query| Self(GraphQLBatchRequest::Single(query.0))) + .map_err(|e| { ( StatusCode::BAD_REQUEST, - format!("Could not convert variables: {e}"), + format!("Invalid request query string: {e}"), ) .into_response() - }) + }), + (&Method::POST, Some("application/json")) => { + Json::::from_request(req, state) + .await + .map(|req| Self(req.0)) + .map_err(|e| { + (StatusCode::BAD_REQUEST, format!("Invalid JSON body: {e}")).into_response() + }) } (&Method::POST, Some("application/graphql")) => String::from_request(req, state) .await - .map(Into::into) - .map_err(|_| (StatusCode::BAD_REQUEST, "Not valid UTF-8").into_response()), + .map(|body| { + Self(GraphQLBatchRequest::Single(GraphQLRequest::new( + body, None, None, + ))) + }) + .map_err(|_| (StatusCode::BAD_REQUEST, "Not valid UTF-8 body").into_response()), (&Method::POST, _) => Err(( StatusCode::UNSUPPORTED_MEDIA_TYPE, "`Content-Type` header is expected to be either `application/json` or \ From baea7e63f0c7bd4058c96b19eb3d5f6e6115dc35 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 12:53:34 +0200 Subject: [PATCH 16/33] Parametrize with `ScalarValue` --- juniper_axum/src/extract.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs index 00455e713..1a9402220 100644 --- a/juniper_axum/src/extract.rs +++ b/juniper_axum/src/extract.rs @@ -10,7 +10,10 @@ use axum::{ response::{IntoResponse as _, Response}, Json, RequestExt as _, }; -use juniper::http::{GraphQLBatchRequest, GraphQLRequest}; +use juniper::{ + http::{GraphQLBatchRequest, GraphQLRequest}, + DefaultScalarValue, ScalarValue, +}; /// Extractor for [`axum`] to extract a [`JuniperRequest`]. /// @@ -70,20 +73,23 @@ use juniper::http::{GraphQLBatchRequest, GraphQLRequest}; /// JuniperResponse(request.execute(&schema, &context).await) /// } #[derive(Debug, PartialEq)] -pub struct JuniperRequest(pub GraphQLBatchRequest); +pub struct JuniperRequest(pub GraphQLBatchRequest) +where + S: ScalarValue; #[async_trait] -impl FromRequest for JuniperRequest +impl FromRequest for JuniperRequest where - S: Sync, - Query: FromRequestParts, - Json: FromRequest, - as FromRequest>::Rejection: fmt::Display, - String: FromRequest, + S: ScalarValue, + State: Sync, + Query>: FromRequestParts, + Json>: FromRequest, + > as FromRequest>::Rejection: fmt::Display, + String: FromRequest, { type Rejection = Response; - async fn from_request(mut req: Request, state: &S) -> Result { + async fn from_request(mut req: Request, state: &State) -> Result { let content_type = req .headers() .get("content-type") @@ -99,7 +105,7 @@ where match (req.method(), content_type) { (&Method::GET, _) => req - .extract_parts::>() + .extract_parts::>>() .await .map(|query| Self(GraphQLBatchRequest::Single(query.0))) .map_err(|e| { @@ -110,7 +116,7 @@ where .into_response() }), (&Method::POST, Some("application/json")) => { - Json::::from_request(req, state) + Json::>::from_request(req, state) .await .map(|req| Self(req.0)) .map_err(|e| { From 89616312ecb8492b60cbfd98d64472dccabc7e00 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 14:09:07 +0200 Subject: [PATCH 17/33] Fix `extract` unit tests --- juniper_axum/Cargo.toml | 9 +++- juniper_axum/src/extract.rs | 99 ++++++++++++++++++++---------------- juniper_axum/src/lib.rs | 1 - juniper_axum/src/response.rs | 8 +-- 4 files changed, 65 insertions(+), 52 deletions(-) diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index f2eb8961e..08bedd8b5 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -28,10 +28,15 @@ juniper = { version = "0.16.0-dev", path = "../juniper", default-features = fals juniper_graphql_ws = { version = "0.4.0-dev", path = "../juniper_graphql_ws", features = ["graphql-transport-ws", "graphql-ws"], optional = true } [dev-dependencies] -anyhow = "1.0" hyper = "0.14" juniper = { version = "0.16.0-dev", path = "../juniper", features = ["expose-test-schema"] } -tokio = { version = "1.20", features = ["full"] } +tokio = { version = "1.20", features = ["macros"] } +urlencoding = "2.1" + + + +anyhow = "1.0" tokio-stream = "0.1" tokio-tungstenite = "0.20" tower = "0.4" + diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs index 1a9402220..0e32dcc05 100644 --- a/juniper_axum/src/extract.rs +++ b/juniper_axum/src/extract.rs @@ -147,55 +147,42 @@ where } #[cfg(test)] -mod tests { - use axum::http::Request; - use juniper::http::GraphQLRequest; +mod juniper_request_tests { + use std::fmt; - use super::*; + use axum::{ + body::{Body, Bytes, HttpBody}, + extract::FromRequest as _, + http::Request, + }; + use juniper::http::{GraphQLBatchRequest, GraphQLRequest}; - #[test] - fn convert_simple_request_body_to_juniper_request() { - let request_body = SingleRequestBody { - query: "{ add(a: 2, b: 3) }".to_string(), - operation_name: None, - variables: None, - }; - - let expected = JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( - "{ add(a: 2, b: 3) }".to_string(), - None, - None, - ))); - - assert_eq!(JuniperRequest::try_from(request_body).unwrap(), expected); - } + use super::JuniperRequest; #[tokio::test] - async fn convert_get_request_to_juniper_request() { - // /?query={ add(a: 2, b: 3) } - let request = Request::get("/?query=%7B%20add%28a%3A%202%2C%20b%3A%203%29%20%7D") - .body(Body::empty()) - .unwrap(); - let mut parts = RequestParts::new(request); + async fn from_get_request() { + let req = Request::get(&format!( + "/?query={}", + urlencoding::encode("{ add(a: 2, b: 3) }") + )) + .body(Body::empty()) + .unwrap_or_else(|e| panic!("cannot build `Request`: {e}")); let expected = JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( - "{ add(a: 2, b: 3) }".to_string(), + "{ add(a: 2, b: 3) }".into(), None, None, ))); - let result = JuniperRequest::from_request(&mut parts).await.unwrap(); - assert_eq!(result, expected) + assert_eq!(do_from_request(req).await, expected); } #[tokio::test] - async fn convert_simple_post_request_to_juniper_request() { - let json = String::from(r#"{ "query": "{ add(a: 2, b: 3) }"}"#); - let request = Request::post("/") + async fn from_json_post_request() { + let req = Request::post("/") .header("content-type", "application/json") - .body(Body::from(json)) - .unwrap(); - let mut parts = RequestParts::new(request); + .body(Body::from(r#"{"query": "{ add(a: 2, b: 3) }"}"#)) + .unwrap_or_else(|e| panic!("cannot build `Request`: {e}")); let expected = JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( "{ add(a: 2, b: 3) }".to_string(), @@ -203,18 +190,15 @@ mod tests { None, ))); - let result = JuniperRequest::from_request(&mut parts).await.unwrap(); - assert_eq!(result, expected) + assert_eq!(do_from_request(req).await, expected); } #[tokio::test] - async fn convert_simple_post_request_to_juniper_request_2() { - let body = String::from(r#"{ add(a: 2, b: 3) }"#); - let request = Request::post("/") + async fn from_graphql_post_request() { + let req = Request::post("/") .header("content-type", "application/graphql") - .body(Body::from(body)) - .unwrap(); - let mut parts = RequestParts::new(request); + .body(Body::from(r#"{ add(a: 2, b: 3) }"#)) + .unwrap_or_else(|e| panic!("cannot build `Request`: {e}")); let expected = JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( "{ add(a: 2, b: 3) }".to_string(), @@ -222,7 +206,32 @@ mod tests { None, ))); - let result = JuniperRequest::from_request(&mut parts).await.unwrap(); - assert_eq!(result, expected) + assert_eq!(do_from_request(req).await, expected); + } + + /// Performs [`JuniperRequest::from_request()`]. + async fn do_from_request(req: Request) -> JuniperRequest { + match JuniperRequest::from_request(req, &()).await { + Ok(resp) => resp, + Err(resp) => { + panic!( + "`JuniperRequest::from_request()` failed with `{}` status and body:\n{}", + resp.status(), + display_body(resp.into_body()).await, + ) + } + } + } + + /// Converts the provided [`HttpBody`] into a [`String`]. + async fn display_body(body: B) -> String + where + B: HttpBody, + B::Error: fmt::Display, + { + let bytes = hyper::body::to_bytes(body) + .await + .unwrap_or_else(|e| panic!("failed to represent `Body` as `Bytes`: {e}")); + String::from_utf8(bytes.into()).unwrap_or_else(|e| panic!("not UTF-8 body: {e}")) } } diff --git a/juniper_axum/src/lib.rs b/juniper_axum/src/lib.rs index 24ce7eee2..3d64c0039 100644 --- a/juniper_axum/src/lib.rs +++ b/juniper_axum/src/lib.rs @@ -1,7 +1,6 @@ #![doc = include_str!("../README.md")] #![cfg_attr(docsrs, feature(doc_cfg))] #![deny(missing_docs)] -#![deny(warnings)] pub mod extract; pub mod response; diff --git a/juniper_axum/src/response.rs b/juniper_axum/src/response.rs index 85b785f78..aac08b4e7 100644 --- a/juniper_axum/src/response.rs +++ b/juniper_axum/src/response.rs @@ -13,10 +13,10 @@ pub struct JuniperResponse(pub GraphQLBatchResponse); impl IntoResponse for JuniperResponse { fn into_response(self) -> Response { - if !self.0.is_ok() { - return (StatusCode::BAD_REQUEST, Json(self.0)).into_response(); + if self.0.is_ok() { + Json(self.0).into_response() + } else { + (StatusCode::BAD_REQUEST, Json(self.0)).into_response() } - - Json(self.0).into_response() } } From 2a819fd757a7702a4ab40a51949a8cf3ceb9843a Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 15:40:30 +0200 Subject: [PATCH 18/33] Rework `graphql-ws` subscriptions --- juniper_actix/src/lib.rs | 2 +- juniper_axum/Cargo.toml | 6 +- juniper_axum/src/subscriptions.rs | 164 +++++++++++++++++++++--------- 3 files changed, 120 insertions(+), 52 deletions(-) diff --git a/juniper_actix/src/lib.rs b/juniper_actix/src/lib.rs index f0731a469..4e8e63315 100644 --- a/juniper_actix/src/lib.rs +++ b/juniper_actix/src/lib.rs @@ -418,7 +418,7 @@ pub mod subscriptions { /// Possible errors of serving an [`actix_ws`] connection. #[derive(Debug)] enum Error { - /// Deserializing of a client or server message failed. + /// Deserializing of a client [`actix_ws::Message`] failed. Serde(serde_json::Error), /// Unexpected client [`actix_ws::Message`]. diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index 08bedd8b5..840902871 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -19,24 +19,26 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -subscriptions = ["axum/ws", "dep:futures", "dep:juniper_graphql_ws"] +subscriptions = ["axum/ws", "dep:futures", "dep:juniper_graphql_ws", "dep:serde_json"] [dependencies] axum = "0.6" futures = { version = "0.3.22", optional = true } juniper = { version = "0.16.0-dev", path = "../juniper", default-features = false } juniper_graphql_ws = { version = "0.4.0-dev", path = "../juniper_graphql_ws", features = ["graphql-transport-ws", "graphql-ws"], optional = true } +serde_json = { version = "1.0.18", optional = true } [dev-dependencies] +axum = { version = "0.6", features = ["macros"] } hyper = "0.14" juniper = { version = "0.16.0-dev", path = "../juniper", features = ["expose-test-schema"] } tokio = { version = "1.20", features = ["macros"] } +tokio-stream = "0.1" urlencoding = "2.1" anyhow = "1.0" -tokio-stream = "0.1" tokio-tungstenite = "0.20" tower = "0.4" diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs index b398541b7..8e8c4032a 100644 --- a/juniper_axum/src/subscriptions.rs +++ b/juniper_axum/src/subscriptions.rs @@ -1,29 +1,30 @@ //! Definitions for handling GraphQL subscriptions. -use axum::extract::ws::{Message, WebSocket}; -use futures::{SinkExt as _, StreamExt as _, TryStreamExt as _}; -use juniper::ScalarValue; -use juniper_graphql_ws::{ClientMessage, Connection, ConnectionConfig, Schema, WebsocketError}; - -#[derive(Debug)] -struct AxumMessage(Message); +use std::fmt; -#[derive(Debug)] -enum SubscriptionError { - Juniper(WebsocketError), - Axum(axum::Error), - Serde(serde_json::Error), -} - -impl TryFrom for ClientMessage { - type Error = serde_json::Error; - - fn try_from(msg: AxumMessage) -> serde_json::Result { - serde_json::from_slice(&msg.0.into_data()) - } -} +use axum::extract::ws::{self, WebSocket}; +use futures::{future, SinkExt as _, StreamExt as _}; +use juniper::ScalarValue; +use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; -/// Redirect the axum [`Websocket`] to a juniper [`Connection`] and vice versa. +/// Serves the [legacy `graphql-ws` GraphQL over WebSocket Protocol][old] on the provided +/// [`WebSocket`]. +/// +/// > __WARNING__: This function doesn't check or set the `Sec-Websocket-Protocol` HTTP header value +/// > as `graphql-ws`, this should be done manually outside. +/// > For fully baked [`axum`] handler for +/// > [legacy `graphql-ws` GraphQL over WebSocket Protocol][old], use [`graphql_ws()`] +/// > handler instead. +/// +/// The `init` argument is used to provide the context and additional configuration for +/// connections. This can be a [`juniper_graphql_ws::ConnectionConfig`] if the context and +/// configuration are already known, or it can be a closure that gets executed asynchronously +/// when the client sends the `GQL_CONNECTION_INIT` message. Using a closure allows to perform +/// an authentication based on the parameters provided by a client. +/// +/// > __WARNING__: This protocol has been deprecated in favor of the +/// [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new], which is +/// provided by the [`graphql_transport_ws_handler()`] function. /// /// # Example /// @@ -35,14 +36,15 @@ impl TryFrom for ClientMessage { /// body::Body, /// response::Response, /// routing::get, -/// Extension, Router +/// Extension, Router, /// }; /// use futures::{Stream, StreamExt as _}; /// use juniper::{ /// graphql_object, graphql_subscription, EmptyMutation, FieldError, /// RootNode, /// }; -/// use juniper_axum::{playground, subscriptions::handle_graphql_socket}; +/// use juniper_axum::{playground, subscriptions}; +/// use juniper_graphql_ws::ConnectionConfig; /// use tokio::time::interval; /// use tokio_stream::wrappers::IntervalStream; /// @@ -84,8 +86,10 @@ impl TryFrom for ClientMessage { /// ws.protocols(["graphql-ws"]) /// .max_frame_size(1024) /// .max_message_size(1024) -/// .max_send_queue(100) -/// .on_upgrade(move |socket| handle_graphql_socket(socket, schema, ())) +/// .max_write_buffer_size(100) +/// .on_upgrade(move |socket| { +/// subscriptions::serve_graphql_ws(socket, schema, ConnectionConfig::new(())) +/// }) /// } /// /// let schema = Schema::new(Query, EmptyMutation::new(), Subscription); @@ -94,33 +98,95 @@ impl TryFrom for ClientMessage { /// .route("/subscriptions", get(juniper_subscriptions)) /// .layer(Extension(schema)); /// ``` -pub async fn handle_graphql_socket(socket: WebSocket, schema: S, context: S::Context) { - let config = ConnectionConfig::new(context); +/// +/// [new]: https://github.com/enisdenjo/graphql-ws/blob/v5.14.0/PROTOCOL.md +/// [old]: https://github.com/apollographql/subscriptions-transport-ws/blob/v0.11.0/PROTOCOL.md +pub async fn serve_graphql_ws(socket: WebSocket, schema: S, init: I) +where + S: Schema, + I: Init + Send, +{ let (ws_tx, ws_rx) = socket.split(); - let (juniper_tx, juniper_rx) = Connection::new(schema, config).split(); + let (s_tx, s_rx) = graphql_ws::Connection::new(schema, init).split(); + + let input = ws_rx + .map(|r| r.map(Message)) + .forward(s_tx.sink_map_err(|e| match e {})); + + let output = s_rx + .map(|msg| { + Ok(serde_json::to_string(&msg) + .map(ws::Message::Text) + .unwrap_or_else(|e| { + ws::Message::Close(Some(ws::CloseFrame { + code: 1011, // CloseCode::Error + reason: format!("error serializing response: {e}").into(), + })) + })) + }) + .forward(ws_tx); - // In the following section we make the streams and sinks from - // Axum and Juniper compatible with each other. This makes it - // possible to forward an incoming message from Axum to Juniper - // and vice versa. - let juniper_tx = juniper_tx.sink_map_err(SubscriptionError::Juniper); + // No errors can be returned here, so ignoring is OK. + _ = future::select(input, output).await; +} - let send_websocket_message_to_juniper = ws_rx - .map_err(SubscriptionError::Axum) - .map(|result| result.map(AxumMessage)) - .forward(juniper_tx); +/// Wrapper around [`ws::Message`] allowing to define custom conversions. +#[derive(Debug)] +struct Message(ws::Message); - let ws_tx = ws_tx.sink_map_err(SubscriptionError::Axum); +impl TryFrom for graphql_transport_ws::Input { + type Error = Error; - let send_juniper_message_to_axum = juniper_rx - .map(|msg| serde_json::to_string(&msg).map(Message::Text)) - .map_err(SubscriptionError::Serde) - .forward(ws_tx); + fn try_from(msg: Message) -> Result { + match msg.0 { + ws::Message::Text(text) => serde_json::from_slice(text.as_bytes()) + .map(Self::Message) + .map_err(Error::Serde), + ws::Message::Binary(bytes) => serde_json::from_slice(bytes.as_ref()) + .map(Self::Message) + .map_err(Error::Serde), + ws::Message::Close(_) => Ok(Self::Close), + other => Err(Error::UnexpectedClientMessage(other)), + } + } +} + +impl TryFrom for graphql_ws::ClientMessage { + type Error = Error; - // Start listening for messages from axum, and redirect them to juniper - let _result = futures::future::select( - send_websocket_message_to_juniper, - send_juniper_message_to_axum, - ) - .await; + fn try_from(msg: Message) -> Result { + match msg.0 { + ws::Message::Text(text) => { + serde_json::from_slice(text.as_bytes()).map_err(Error::Serde) + } + ws::Message::Binary(bytes) => { + serde_json::from_slice(bytes.as_ref()).map_err(Error::Serde) + } + ws::Message::Close(_) => Ok(Self::ConnectionTerminate), + other => Err(Error::UnexpectedClientMessage(other)), + } + } } + +#[derive(Debug)] +enum Error { + //Axum(axum::Error), + /// Deserializing of a client [`ws::Message`] failed. + Serde(serde_json::Error), + + /// Unexpected client [`ws::Message`]. + UnexpectedClientMessage(ws::Message), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Serde(e) => write!(f, "`serde` error: {e}"), + Self::UnexpectedClientMessage(m) => { + write!(f, "unexpected message received from client: {m:?}") + } + } + } +} + +impl std::error::Error for Error {} From ac97a3a55333246fe0ad5c7626211b338e044633 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 15:46:12 +0200 Subject: [PATCH 19/33] Fix doc tests --- juniper_axum/src/extract.rs | 24 +++++++++--------------- juniper_axum/src/lib.rs | 18 ++++++------------ juniper_axum/src/subscriptions.rs | 21 ++++++++++----------- 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs index 0e32dcc05..e2c6f3fff 100644 --- a/juniper_axum/src/extract.rs +++ b/juniper_axum/src/extract.rs @@ -1,4 +1,4 @@ -//! Types and traits for extracting data from requests. +//! Types and traits for extracting data from [`Request`]s. use std::fmt; @@ -22,15 +22,8 @@ use juniper::{ /// ```rust /// use std::sync::Arc; /// -/// use axum::{ -/// body::Body, -/// Json, -/// routing::post, -/// Router, -/// Extension, -/// }; +/// use axum::{routing::post, Extension, Json, Router}; /// use juniper::{ -/// http::GraphQLBatchResponse, /// RootNode, EmptySubscription, EmptyMutation, graphql_object, /// }; /// use juniper_axum::{extract::JuniperRequest, response::JuniperResponse}; @@ -60,17 +53,18 @@ use juniper::{ /// /// let context = Context; /// -/// let app: Router = Router::new() +/// let app: Router = Router::new() /// .route("/graphql", post(graphql)) -/// .layer(Extension(schema)) +/// .layer(Extension(Arc::new(schema))) /// .layer(Extension(context)); /// +/// # #[axum::debug_handler] /// async fn graphql( -/// JuniperRequest(request): JuniperRequest, -/// Extension(schema): Extension, -/// Extension(context): Extension +/// Extension(schema): Extension>, +/// Extension(context): Extension, +/// JuniperRequest(req): JuniperRequest, // should be the last argument as consumes `Request` /// ) -> JuniperResponse { -/// JuniperResponse(request.execute(&schema, &context).await) +/// JuniperResponse(req.execute(&*schema, &context).await) /// } #[derive(Debug, PartialEq)] pub struct JuniperRequest(pub GraphQLBatchRequest) diff --git a/juniper_axum/src/lib.rs b/juniper_axum/src/lib.rs index 3d64c0039..665e24d14 100644 --- a/juniper_axum/src/lib.rs +++ b/juniper_axum/src/lib.rs @@ -18,14 +18,11 @@ use axum::response::Html; /// # Example /// /// ```rust -/// use axum::{ -/// routing::get, -/// Router -/// }; -/// use axum::body::Body; +/// use axum::{routing::get, Router}; /// use juniper_axum::graphiql; /// -/// let app: Router = Router::new().route("/", get(graphiql("/graphql", "/subscriptions"))); +/// let app: Router = Router::new() +/// .route("/", get(graphiql("/graphql", "/subscriptions"))); /// ``` /// /// [GraphiQL]: https://github.com/graphql/graphiql @@ -48,14 +45,11 @@ pub fn graphiql<'a>( /// # Example /// /// ```rust -/// use axum::{ -/// routing::get, -/// Router -/// }; -/// use axum::body::Body; +/// use axum::{routing::get, Router}; /// use juniper_axum::playground; /// -/// let app: Router = Router::new().route("/", get(playground("/graphql", "/subscriptions"))); +/// let app: Router = Router::new() +/// .route("/", get(playground("/graphql", "/subscriptions"))); /// ``` /// /// [GraphQL Playground]: https://github.com/prisma/graphql-playground diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs index 8e8c4032a..a72a5d946 100644 --- a/juniper_axum/src/subscriptions.rs +++ b/juniper_axum/src/subscriptions.rs @@ -11,8 +11,8 @@ use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; /// [`WebSocket`]. /// /// > __WARNING__: This function doesn't check or set the `Sec-Websocket-Protocol` HTTP header value -/// > as `graphql-ws`, this should be done manually outside. -/// > For fully baked [`axum`] handler for +/// > as `graphql-ws`, so this should be done manually outside (see the example below). +/// > To have fully baked [`axum`] handler for /// > [legacy `graphql-ws` GraphQL over WebSocket Protocol][old], use [`graphql_ws()`] /// > handler instead. /// @@ -29,16 +29,15 @@ use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; /// # Example /// /// ```rust -/// use std::{pin::Pin, time::Duration}; +/// use std::{sync::Arc, time::Duration}; /// /// use axum::{ /// extract::WebSocketUpgrade, -/// body::Body, /// response::Response, /// routing::get, /// Extension, Router, /// }; -/// use futures::{Stream, StreamExt as _}; +/// use futures::stream::{BoxStream, Stream, StreamExt as _}; /// use juniper::{ /// graphql_object, graphql_subscription, EmptyMutation, FieldError, /// RootNode, @@ -55,7 +54,7 @@ use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; /// /// #[graphql_object] /// impl Query { -/// /// Add two numbers a and b +/// /// Adds two `a` and `b` numbers. /// fn add(a: i32, b: i32) -> i32 { /// a + b /// } @@ -64,11 +63,11 @@ use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; /// #[derive(Clone, Copy, Debug)] /// pub struct Subscription; /// -/// type NumberStream = Pin> + Send>>; +/// type NumberStream = BoxStream<'static, Result>; /// /// #[graphql_subscription] /// impl Subscription { -/// /// Count seconds +/// /// Counts seconds. /// async fn count() -> NumberStream { /// let mut value = 0; /// let stream = IntervalStream::new(interval(Duration::from_secs(1))).map(move |_| { @@ -80,7 +79,7 @@ use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; /// } /// /// async fn juniper_subscriptions( -/// Extension(schema): Extension, +/// Extension(schema): Extension>, /// ws: WebSocketUpgrade, /// ) -> Response { /// ws.protocols(["graphql-ws"]) @@ -94,9 +93,9 @@ use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; /// /// let schema = Schema::new(Query, EmptyMutation::new(), Subscription); /// -/// let app: Router = Router::new() +/// let app: Router = Router::new() /// .route("/subscriptions", get(juniper_subscriptions)) -/// .layer(Extension(schema)); +/// .layer(Extension(Arc::new(schema))); /// ``` /// /// [new]: https://github.com/enisdenjo/graphql-ws/blob/v5.14.0/PROTOCOL.md From 46006864790470e2963e7af46cb356e9f7ea4d77 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 15:54:18 +0200 Subject: [PATCH 20/33] Add `graphql-transport-ws` impl --- juniper_axum/src/subscriptions.rs | 130 +++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs index a72a5d946..0592d5551 100644 --- a/juniper_axum/src/subscriptions.rs +++ b/juniper_axum/src/subscriptions.rs @@ -7,6 +7,134 @@ use futures::{future, SinkExt as _, StreamExt as _}; use juniper::ScalarValue; use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; +/// Serves the [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new] on the provided +/// [`WebSocket`]. +/// +/// > __WARNING__: This function doesn't check or set the `Sec-Websocket-Protocol` HTTP header value +/// > as `graphql-transport-ws`, so this should be done manually outside (see the +/// > example below). +/// > To have fully baked [`axum`] handler for +/// > [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new], use +/// > [`graphql_transport_ws()`] handler instead. +/// +/// The `init` argument is used to provide the context and additional configuration for +/// connections. This can be a [`juniper_graphql_ws::ConnectionConfig`] if the context and +/// configuration are already known, or it can be a closure that gets executed asynchronously +/// when the client sends the `ConnectionInit` message. Using a closure allows to perform an +/// authentication based on the parameters provided by a client. +/// +/// # Example +/// +/// ```rust +/// use std::{sync::Arc, time::Duration}; +/// +/// use axum::{ +/// extract::WebSocketUpgrade, +/// response::Response, +/// routing::get, +/// Extension, Router, +/// }; +/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use juniper::{ +/// graphql_object, graphql_subscription, EmptyMutation, FieldError, +/// RootNode, +/// }; +/// use juniper_axum::{playground, subscriptions}; +/// use juniper_graphql_ws::ConnectionConfig; +/// use tokio::time::interval; +/// use tokio_stream::wrappers::IntervalStream; +/// +/// type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Query; +/// +/// #[graphql_object] +/// impl Query { +/// /// Adds two `a` and `b` numbers. +/// fn add(a: i32, b: i32) -> i32 { +/// a + b +/// } +/// } +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Subscription; +/// +/// type NumberStream = BoxStream<'static, Result>; +/// +/// #[graphql_subscription] +/// impl Subscription { +/// /// Counts seconds. +/// async fn count() -> NumberStream { +/// let mut value = 0; +/// let stream = IntervalStream::new(interval(Duration::from_secs(1))).map(move |_| { +/// value += 1; +/// Ok(value) +/// }); +/// Box::pin(stream) +/// } +/// } +/// +/// async fn juniper_subscriptions( +/// Extension(schema): Extension>, +/// ws: WebSocketUpgrade, +/// ) -> Response { +/// ws.protocols(["graphql-transport-ws"]) +/// .max_frame_size(1024) +/// .max_message_size(1024) +/// .max_write_buffer_size(100) +/// .on_upgrade(move |socket| { +/// subscriptions::serve_graphql_transport_ws(socket, schema, ConnectionConfig::new(())) +/// }) +/// } +/// +/// let schema = Schema::new(Query, EmptyMutation::new(), Subscription); +/// +/// let app: Router = Router::new() +/// .route("/subscriptions", get(juniper_subscriptions)) +/// .layer(Extension(Arc::new(schema))); +/// ``` +/// +/// [new]: https://github.com/enisdenjo/graphql-ws/blob/v5.14.0/PROTOCOL.md +pub async fn serve_graphql_transport_ws(socket: WebSocket, schema: S, init: I) +where + S: Schema, + I: Init + Send, +{ + let (ws_tx, ws_rx) = socket.split(); + let (s_tx, s_rx) = graphql_transport_ws::Connection::new(schema, init).split(); + + let input = ws_rx + .map(|r| r.map(Message)) + .forward(s_tx.sink_map_err(|e| match e {})); + + let output = s_rx + .map(|output| { + Ok(match output { + graphql_transport_ws::Output::Message(msg) => { + serde_json::to_string(&msg) + .map(ws::Message::Text) + .unwrap_or_else(|e| { + ws::Message::Close(Some(ws::CloseFrame { + code: 1011, // CloseCode::Error + reason: format!("error serializing response: {e}").into(), + })) + }) + } + graphql_transport_ws::Output::Close { code, message } => { + ws::Message::Close(Some(ws::CloseFrame { + code, + reason: message.into(), + })) + } + }) + }) + .forward(ws_tx); + + // No errors can be returned here, so ignoring is OK. + _ = future::select(input, output).await; +} + /// Serves the [legacy `graphql-ws` GraphQL over WebSocket Protocol][old] on the provided /// [`WebSocket`]. /// @@ -24,7 +152,7 @@ use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; /// /// > __WARNING__: This protocol has been deprecated in favor of the /// [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new], which is -/// provided by the [`graphql_transport_ws_handler()`] function. +/// provided by the [`serve_graphql_transport_ws()`] function. /// /// # Example /// From 3b3e7e3ec0881098e6eeb7f12c25093b5a3b3d9a Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 16:04:29 +0200 Subject: [PATCH 21/33] Impl auto-selection between new `graphql-transport-ws` and old `graphql-ws` protocols --- juniper_axum/src/subscriptions.rs | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs index 0592d5551..853ae965e 100644 --- a/juniper_axum/src/subscriptions.rs +++ b/juniper_axum/src/subscriptions.rs @@ -7,6 +7,107 @@ use futures::{future, SinkExt as _, StreamExt as _}; use juniper::ScalarValue; use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; +/// Serves on the provided [`WebSocket`] by auto-selecting between the +/// [legacy `graphql-ws` GraphQL over WebSocket Protocol][old] and the +/// [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new], based on the +/// `Sec-Websocket-Protocol` HTTP header value. +/// +/// > __WARNING__: This function doesn't set (only checks) the `Sec-Websocket-Protocol` HTTP header +/// > value, so this should be done manually outside (see the example below). +/// > To have fully baked [`axum`] handler, use [`ws()`] handler instead. +/// +/// The `init` argument is used to provide the custom [`juniper::Context`] and additional +/// configuration for connections. This can be a [`juniper_graphql_ws::ConnectionConfig`] if the +/// context and configuration are already known, or it can be a closure that gets executed +/// asynchronously whenever a client sends the subscription initialization message. Using a +/// closure allows to perform an authentication based on the parameters provided by a client. +/// +/// # Example +/// +/// ```rust +/// use std::{sync::Arc, time::Duration}; +/// +/// use axum::{ +/// extract::WebSocketUpgrade, +/// response::Response, +/// routing::get, +/// Extension, Router, +/// }; +/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use juniper::{ +/// graphql_object, graphql_subscription, EmptyMutation, FieldError, +/// RootNode, +/// }; +/// use juniper_axum::{playground, subscriptions}; +/// use juniper_graphql_ws::ConnectionConfig; +/// use tokio::time::interval; +/// use tokio_stream::wrappers::IntervalStream; +/// +/// type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Query; +/// +/// #[graphql_object] +/// impl Query { +/// /// Adds two `a` and `b` numbers. +/// fn add(a: i32, b: i32) -> i32 { +/// a + b +/// } +/// } +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Subscription; +/// +/// type NumberStream = BoxStream<'static, Result>; +/// +/// #[graphql_subscription] +/// impl Subscription { +/// /// Counts seconds. +/// async fn count() -> NumberStream { +/// let mut value = 0; +/// let stream = IntervalStream::new(interval(Duration::from_secs(1))).map(move |_| { +/// value += 1; +/// Ok(value) +/// }); +/// Box::pin(stream) +/// } +/// } +/// +/// async fn juniper_subscriptions( +/// Extension(schema): Extension>, +/// ws: WebSocketUpgrade, +/// ) -> Response { +/// ws.protocols(["graphql-transport-ws", "graphql-ws"]) +/// .max_frame_size(1024) +/// .max_message_size(1024) +/// .max_write_buffer_size(100) +/// .on_upgrade(move |socket| { +/// subscriptions::serve_ws(socket, schema, ConnectionConfig::new(())) +/// }) +/// } +/// +/// let schema = Schema::new(Query, EmptyMutation::new(), Subscription); +/// +/// let app: Router = Router::new() +/// .route("/subscriptions", get(juniper_subscriptions)) +/// .layer(Extension(Arc::new(schema))); +/// ``` +/// +/// [new]: https://github.com/enisdenjo/graphql-ws/blob/v5.14.0/PROTOCOL.md +/// [old]: https://github.com/apollographql/subscriptions-transport-ws/blob/v0.11.0/PROTOCOL.md +pub async fn serve_ws(socket: WebSocket, schema: S, init: I) +where + S: Schema, + I: Init + Send, +{ + if socket.protocol().map(AsRef::as_ref) == Some("graphql-ws".as_bytes()) { + serve_graphql_ws(socket, schema, init).await; + } else { + serve_graphql_transport_ws(socket, schema, init).await; + } +} + /// Serves the [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new] on the provided /// [`WebSocket`]. /// From 4d89d51e9e873d12b05b0dc8fdb30d257309f631 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 16:35:52 +0200 Subject: [PATCH 22/33] Add ready-to-go `graphql` handler --- juniper_axum/src/extract.rs | 4 +- juniper_axum/src/lib.rs | 68 ++++++++++++++++++++++++++++++- juniper_axum/src/response.rs | 8 ++-- juniper_axum/src/subscriptions.rs | 2 +- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs index e2c6f3fff..036335315 100644 --- a/juniper_axum/src/extract.rs +++ b/juniper_axum/src/extract.rs @@ -51,12 +51,10 @@ use juniper::{ /// EmptySubscription::::new() /// ); /// -/// let context = Context; -/// /// let app: Router = Router::new() /// .route("/graphql", post(graphql)) /// .layer(Extension(Arc::new(schema))) -/// .layer(Extension(context)); +/// .layer(Extension(Context)); /// /// # #[axum::debug_handler] /// async fn graphql( diff --git a/juniper_axum/src/lib.rs b/juniper_axum/src/lib.rs index 665e24d14..eeab707e6 100644 --- a/juniper_axum/src/lib.rs +++ b/juniper_axum/src/lib.rs @@ -9,7 +9,73 @@ pub mod subscriptions; use std::future; -use axum::response::Html; +use axum::{extract::Extension, response::Html}; +use juniper_graphql_ws::Schema; + +use self::{extract::JuniperRequest, response::JuniperResponse}; + +/// Handles a [`JuniperRequest`] with the specified [`Schema`], by [`extract`]ing it from +/// [`Extension`]s and initializing its fresh [`Schema::Context`] as a [`Default`] one. +/// +/// > __NOTE__: This is a ready-to-go default [`axum`] handler for serving GraphQL requests. If you +/// > need to customize it (for example, extract [`Schema::Context`] from [`Extension`]s +/// > instead initializing a [`Default`] one), create your own handler accepting a +/// > [`JuniperRequest`] (see its documentation for examples). +/// +/// # Example +/// +/// ```rust +/// use std::sync::Arc; +/// +/// use axum::{routing::post, Extension, Json, Router}; +/// use juniper::{ +/// RootNode, EmptySubscription, EmptyMutation, graphql_object, +/// }; +/// use juniper_axum::graphql; +/// +/// #[derive(Clone, Copy, Debug, Default)] +/// pub struct Context; +/// +/// impl juniper::Context for Context {} +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Query; +/// +/// #[graphql_object(context = Context)] +/// impl Query { +/// fn add(a: i32, b: i32) -> i32 { +/// a + b +/// } +/// } +/// +/// type Schema = RootNode<'static, Query, EmptyMutation, EmptySubscription>; +/// +/// let schema = Schema::new( +/// Query, +/// EmptyMutation::::new(), +/// EmptySubscription::::new() +/// ); +/// +/// let app: Router = Router::new() +/// .route("/graphql", post(graphql::>)) +/// .layer(Extension(Arc::new(schema))); +/// ``` +/// +/// [`extract`]: axum::extract +#[cfg_attr(text, axum::debug_handler)] +pub async fn graphql( + Extension(schema): Extension, + JuniperRequest(req): JuniperRequest, +) -> JuniperResponse +where + S: Schema, + S::Context: Default, +{ + JuniperResponse( + req.execute(schema.root_node(), &S::Context::default()) + .await, + ) +} /// Creates a handler that replies with an HTML page containing [GraphiQL]. /// diff --git a/juniper_axum/src/response.rs b/juniper_axum/src/response.rs index aac08b4e7..bd9759759 100644 --- a/juniper_axum/src/response.rs +++ b/juniper_axum/src/response.rs @@ -5,13 +5,15 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use juniper::http::GraphQLBatchResponse; +use juniper::{http::GraphQLBatchResponse, DefaultScalarValue, ScalarValue}; /// Wrapper around a [`GraphQLBatchResponse`], implementing [`IntoResponse`], so it can be returned /// from [`axum`] handlers. -pub struct JuniperResponse(pub GraphQLBatchResponse); +pub struct JuniperResponse(pub GraphQLBatchResponse) +where + S: ScalarValue; -impl IntoResponse for JuniperResponse { +impl IntoResponse for JuniperResponse { fn into_response(self) -> Response { if self.0.is_ok() { Json(self.0).into_response() diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs index 853ae965e..f689dd376 100644 --- a/juniper_axum/src/subscriptions.rs +++ b/juniper_axum/src/subscriptions.rs @@ -396,9 +396,9 @@ impl TryFrom for graphql_ws::ClientMessage { } } +/// Possible errors of serving a [`WebSocket`] connection. #[derive(Debug)] enum Error { - //Axum(axum::Error), /// Deserializing of a client [`ws::Message`] failed. Serde(serde_json::Error), From ccac7008d5746afe82e740bc799aac9520b73e15 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 17:23:14 +0200 Subject: [PATCH 23/33] Add ready-to-go `ws`, `graphql_ws` and `graphql_transport_ws` handlers --- juniper_axum/src/lib.rs | 7 +- juniper_axum/src/subscriptions.rs | 280 +++++++++++++++++++++++++++++- 2 files changed, 281 insertions(+), 6 deletions(-) diff --git a/juniper_axum/src/lib.rs b/juniper_axum/src/lib.rs index eeab707e6..ac057073c 100644 --- a/juniper_axum/src/lib.rs +++ b/juniper_axum/src/lib.rs @@ -17,9 +17,9 @@ use self::{extract::JuniperRequest, response::JuniperResponse}; /// Handles a [`JuniperRequest`] with the specified [`Schema`], by [`extract`]ing it from /// [`Extension`]s and initializing its fresh [`Schema::Context`] as a [`Default`] one. /// -/// > __NOTE__: This is a ready-to-go default [`axum`] handler for serving GraphQL requests. If you -/// > need to customize it (for example, extract [`Schema::Context`] from [`Extension`]s -/// > instead initializing a [`Default`] one), create your own handler accepting a +/// > __NOTE__: This is a ready-to-go default [`Handler`] for serving GraphQL requests. If you need +/// > to customize it (for example, extract [`Schema::Context`] from [`Extension`]s +/// > instead initializing a [`Default`] one), create your own [`Handler`] accepting a /// > [`JuniperRequest`] (see its documentation for examples). /// /// # Example @@ -62,6 +62,7 @@ use self::{extract::JuniperRequest, response::JuniperResponse}; /// ``` /// /// [`extract`]: axum::extract +/// [`Handler`]: axum::handler::Handler #[cfg_attr(text, axum::debug_handler)] pub async fn graphql( Extension(schema): Extension, diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs index f689dd376..376500f0c 100644 --- a/juniper_axum/src/subscriptions.rs +++ b/juniper_axum/src/subscriptions.rs @@ -2,11 +2,285 @@ use std::fmt; -use axum::extract::ws::{self, WebSocket}; +use axum::{ + extract::{ + ws::{self, WebSocket, WebSocketUpgrade}, + Extension, + }, + response::Response, +}; use futures::{future, SinkExt as _, StreamExt as _}; use juniper::ScalarValue; use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; +/// Creates a [`Handler`] with the specified [`Schema`], which will serve either the +/// [legacy `graphql-ws` GraphQL over WebSocket Protocol][old] or the +/// [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new], by auto-selecting between +/// them, based on the `Sec-Websocket-Protocol` HTTP header value. +/// +/// > __NOTE__: This is a ready-to-go default [`Handler`] for serving GraphQL over WebSocket +/// > Protocol. If you need to customize it (for example, configure [`WebSocketUpgrade`] +/// > parameters), create your own [`Handler`] invoking the [`serve_ws()`] function (see +/// > its documentation for examples). +/// +/// [`Schema`] is [`extract`]ed from [`Extension`]s. +/// +/// The `init` argument is used to provide the custom [`juniper::Context`] and additional +/// configuration for connections. This can be a [`juniper_graphql_ws::ConnectionConfig`] if the +/// context and configuration are already known, or it can be a closure that gets executed +/// asynchronously whenever a client sends the subscription initialization message. Using a +/// closure allows to perform an authentication based on the parameters provided by a client. +/// +/// # Example +/// +/// ```rust +/// use std::{sync::Arc, time::Duration}; +/// +/// use axum::{routing::get, Extension, Router}; +/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use juniper::{ +/// graphql_object, graphql_subscription, EmptyMutation, FieldError, +/// RootNode, +/// }; +/// use juniper_axum::{playground, subscriptions}; +/// use juniper_graphql_ws::ConnectionConfig; +/// use tokio::time::interval; +/// use tokio_stream::wrappers::IntervalStream; +/// +/// type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Query; +/// +/// #[graphql_object] +/// impl Query { +/// /// Adds two `a` and `b` numbers. +/// fn add(a: i32, b: i32) -> i32 { +/// a + b +/// } +/// } +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Subscription; +/// +/// type NumberStream = BoxStream<'static, Result>; +/// +/// #[graphql_subscription] +/// impl Subscription { +/// /// Counts seconds. +/// async fn count() -> NumberStream { +/// let mut value = 0; +/// let stream = IntervalStream::new(interval(Duration::from_secs(1))).map(move |_| { +/// value += 1; +/// Ok(value) +/// }); +/// Box::pin(stream) +/// } +/// } +/// +/// let schema = Schema::new(Query, EmptyMutation::new(), Subscription); +/// +/// let app: Router = Router::new() +/// .route("/subscriptions", get(subscriptions::ws::>(ConnectionConfig::new(())))) +/// .layer(Extension(Arc::new(schema))); +/// ``` +/// +/// [`extract`]: axum::extract +/// [`Handler`]: axum::handler::Handler +/// [new]: https://github.com/enisdenjo/graphql-ws/blob/v5.14.0/PROTOCOL.md +/// [old]: https://github.com/apollographql/subscriptions-transport-ws/blob/v0.11.0/PROTOCOL.md +pub fn ws( + init: impl Init + Clone + Send, +) -> impl FnOnce(Extension, WebSocketUpgrade) -> future::Ready + Clone + Send { + move |Extension(schema), ws| { + future::ready( + ws.protocols(["graphql-transport-ws", "graphql-ws"]) + .on_upgrade(move |socket| serve_ws(socket, schema, init)), + ) + } +} + +/// Creates a [`Handler`] with the specified [`Schema`], which will serve the +/// [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new]. +/// +/// > __NOTE__: This is a ready-to-go default [`Handler`] for serving the +/// > [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new]. If you need to +/// > customize it (for example, configure [`WebSocketUpgrade`] parameters), create your +/// > own [`Handler`] invoking the [`serve_graphql_transport_ws()`] function (see its +/// > documentation for examples). +/// +/// [`Schema`] is [`extract`]ed from [`Extension`]s. +/// +/// The `init` argument is used to provide the context and additional configuration for +/// connections. This can be a [`juniper_graphql_ws::ConnectionConfig`] if the context and +/// configuration are already known, or it can be a closure that gets executed asynchronously +/// when the client sends the `ConnectionInit` message. Using a closure allows to perform an +/// authentication based on the parameters provided by a client. +/// +/// # Example +/// +/// ```rust +/// use std::{sync::Arc, time::Duration}; +/// +/// use axum::{routing::get, Extension, Router}; +/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use juniper::{ +/// graphql_object, graphql_subscription, EmptyMutation, FieldError, +/// RootNode, +/// }; +/// use juniper_axum::{playground, subscriptions}; +/// use juniper_graphql_ws::ConnectionConfig; +/// use tokio::time::interval; +/// use tokio_stream::wrappers::IntervalStream; +/// +/// type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Query; +/// +/// #[graphql_object] +/// impl Query { +/// /// Adds two `a` and `b` numbers. +/// fn add(a: i32, b: i32) -> i32 { +/// a + b +/// } +/// } +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Subscription; +/// +/// type NumberStream = BoxStream<'static, Result>; +/// +/// #[graphql_subscription] +/// impl Subscription { +/// /// Counts seconds. +/// async fn count() -> NumberStream { +/// let mut value = 0; +/// let stream = IntervalStream::new(interval(Duration::from_secs(1))).map(move |_| { +/// value += 1; +/// Ok(value) +/// }); +/// Box::pin(stream) +/// } +/// } +/// +/// let schema = Schema::new(Query, EmptyMutation::new(), Subscription); +/// +/// let app: Router = Router::new() +/// .route( +/// "/subscriptions", +/// get(subscriptions::graphql_transport_ws::>(ConnectionConfig::new(()))), +/// ) +/// .layer(Extension(Arc::new(schema))); +/// ``` +/// +/// [`extract`]: axum::extract +/// [`Handler`]: axum::handler::Handler +/// [new]: https://github.com/enisdenjo/graphql-ws/blob/v5.14.0/PROTOCOL.md +pub fn graphql_transport_ws( + init: impl Init + Clone + Send, +) -> impl FnOnce(Extension, WebSocketUpgrade) -> future::Ready + Clone + Send { + move |Extension(schema), ws| { + future::ready( + ws.protocols(["graphql-transport-ws"]) + .on_upgrade(move |socket| serve_graphql_transport_ws(socket, schema, init)), + ) + } +} + +/// Creates a [`Handler`] with the specified [`Schema`], which will serve the +/// [legacy `graphql-ws` GraphQL over WebSocket Protocol][old]. +/// +/// > __NOTE__: This is a ready-to-go default [`Handler`] for serving the +/// > [legacy `graphql-ws` GraphQL over WebSocket Protocol][old]. If you need to customize +/// > it (for example, configure [`WebSocketUpgrade`] parameters), create your own +/// > [`Handler`] invoking the [`serve_graphql_ws()`] function (see its documentation for +/// > examples). +/// +/// [`Schema`] is [`extract`]ed from [`Extension`]s. +/// +/// The `init` argument is used to provide the context and additional configuration for +/// connections. This can be a [`juniper_graphql_ws::ConnectionConfig`] if the context and +/// configuration are already known, or it can be a closure that gets executed asynchronously +/// when the client sends the `GQL_CONNECTION_INIT` message. Using a closure allows to perform +/// an authentication based on the parameters provided by a client. +/// +/// > __WARNING__: This protocol has been deprecated in favor of the +/// > [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new], which is +/// > provided by the [`graphql_transport_ws()`] function. +/// +/// # Example +/// +/// ```rust +/// use std::{sync::Arc, time::Duration}; +/// +/// use axum::{routing::get, Extension, Router}; +/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use juniper::{ +/// graphql_object, graphql_subscription, EmptyMutation, FieldError, +/// RootNode, +/// }; +/// use juniper_axum::{playground, subscriptions}; +/// use juniper_graphql_ws::ConnectionConfig; +/// use tokio::time::interval; +/// use tokio_stream::wrappers::IntervalStream; +/// +/// type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Query; +/// +/// #[graphql_object] +/// impl Query { +/// /// Adds two `a` and `b` numbers. +/// fn add(a: i32, b: i32) -> i32 { +/// a + b +/// } +/// } +/// +/// #[derive(Clone, Copy, Debug)] +/// pub struct Subscription; +/// +/// type NumberStream = BoxStream<'static, Result>; +/// +/// #[graphql_subscription] +/// impl Subscription { +/// /// Counts seconds. +/// async fn count() -> NumberStream { +/// let mut value = 0; +/// let stream = IntervalStream::new(interval(Duration::from_secs(1))).map(move |_| { +/// value += 1; +/// Ok(value) +/// }); +/// Box::pin(stream) +/// } +/// } +/// +/// let schema = Schema::new(Query, EmptyMutation::new(), Subscription); +/// +/// let app: Router = Router::new() +/// .route( +/// "/subscriptions", +/// get(subscriptions::graphql_ws::>(ConnectionConfig::new(()))), +/// ) +/// .layer(Extension(Arc::new(schema))); +/// ``` +/// +/// [`extract`]: axum::extract +/// [`Handler`]: axum::handler::Handler +/// [new]: https://github.com/enisdenjo/graphql-ws/blob/v5.14.0/PROTOCOL.md +/// [old]: https://github.com/apollographql/subscriptions-transport-ws/blob/v0.11.0/PROTOCOL.md +pub fn graphql_ws( + init: impl Init + Clone + Send, +) -> impl FnOnce(Extension, WebSocketUpgrade) -> future::Ready + Clone + Send { + move |Extension(schema), ws| { + future::ready( + ws.protocols(["graphql-ws"]) + .on_upgrade(move |socket| serve_graphql_ws(socket, schema, init)), + ) + } +} + /// Serves on the provided [`WebSocket`] by auto-selecting between the /// [legacy `graphql-ws` GraphQL over WebSocket Protocol][old] and the /// [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new], based on the @@ -252,8 +526,8 @@ where /// an authentication based on the parameters provided by a client. /// /// > __WARNING__: This protocol has been deprecated in favor of the -/// [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new], which is -/// provided by the [`serve_graphql_transport_ws()`] function. +/// > [new `graphql-transport-ws` GraphQL over WebSocket Protocol][new], which is +/// > provided by the [`serve_graphql_transport_ws()`] function. /// /// # Example /// From fa73e0b60e83fb6afa741e4481ab4f4521b9cd88 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 17:40:51 +0200 Subject: [PATCH 24/33] Fix docs --- juniper_actix/Cargo.toml | 2 +- juniper_actix/README.md | 6 +-- juniper_axum/Cargo.toml | 9 ++-- juniper_axum/{LICENCE => LICENSE} | 2 +- juniper_axum/README.md | 88 ++++++++----------------------- juniper_axum/src/lib.rs | 14 +++-- 6 files changed, 42 insertions(+), 79 deletions(-) rename juniper_axum/{LICENCE => LICENSE} (96%) diff --git a/juniper_actix/Cargo.toml b/juniper_actix/Cargo.toml index 84c38f23e..4af15a479 100644 --- a/juniper_actix/Cargo.toml +++ b/juniper_actix/Cargo.toml @@ -2,7 +2,7 @@ name = "juniper_actix" version = "0.5.0-dev" edition = "2021" -rust-version = "1.68" +rust-version = "1.73" description = "`juniper` GraphQL integration with `actix-web`." license = "BSD-2-Clause" authors = ["Jordao Rosario "] diff --git a/juniper_actix/README.md b/juniper_actix/README.md index b94de8afd..0df1dad66 100644 --- a/juniper_actix/README.md +++ b/juniper_actix/README.md @@ -4,7 +4,7 @@ [![Crates.io](https://img.shields.io/crates/v/juniper_actix.svg?maxAge=2592000)](https://crates.io/crates/juniper_actix) [![Documentation](https://docs.rs/juniper_actix/badge.svg)](https://docs.rs/juniper_actix) [![CI](https://github.com/graphql-rust/juniper/workflows/CI/badge.svg?branch=master "CI")](https://github.com/graphql-rust/juniper/actions?query=workflow%3ACI+branch%3Amaster) -[![Rust 1.68+](https://img.shields.io/badge/rustc-1.68+-lightgray.svg "Rust 1.68+")](https://blog.rust-lang.org/2023/03/09/Rust-1.68.0.html) +[![Rust 1.73+](https://img.shields.io/badge/rustc-1.73+-lightgray.svg "Rust 1.73+")](https://blog.rust-lang.org/2023/10/05/Rust-1.73.0.html) - [Changelog](https://github.com/graphql-rust/juniper/blob/master/juniper_actix/CHANGELOG.md) @@ -26,7 +26,7 @@ A basic usage example can also be found in the [API docs][`juniper_actix`]. ## Examples -Check [`examples/actix_server.rs`][1] for example code of a working [`actix-web`] server with [GraphQL] handlers. +Check [`examples/subscription.rs`][1] for example code of a working [`actix-web`] server with [GraphQL] handlers. @@ -46,5 +46,5 @@ This project is licensed under [BSD 2-Clause License](https://github.com/graphql [Juniper Book]: https://graphql-rust.github.io [Rust]: https://www.rust-lang.org -[1]: https://github.com/graphql-rust/juniper/blob/master/juniper_actix/examples/actix_server.rs +[1]: https://github.com/graphql-rust/juniper/blob/master/juniper_actix/examples/subscription.rs diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index 840902871..bcae7eaa3 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -5,14 +5,17 @@ edition = "2021" rust-version = "1.73" description = "`juniper` GraphQL integration with `axum`." license = "BSD-2-Clause" -authors = ["Benno Tielen "] +authors = [ + "Benno Tielen ", + "Kai Ren ", +] documentation = "https://docs.rs/juniper_axum" homepage = "https://github.com/graphql-rust/juniper/tree/master/juniper_axum" repository = "https://github.com/graphql-rust/juniper" readme = "README.md" categories = ["asynchronous", "web-programming", "web-programming::http-server"] keywords = ["apollo", "axum", "graphql", "juniper", "websocket"] -exclude = ["/examples/", "/release.toml"] +exclude = ["/examples/", "/tests/", "/release.toml"] [package.metadata.docs.rs] all-features = true @@ -37,7 +40,7 @@ tokio-stream = "0.1" urlencoding = "2.1" - +# TODO anyhow = "1.0" tokio-tungstenite = "0.20" tower = "0.4" diff --git a/juniper_axum/LICENCE b/juniper_axum/LICENSE similarity index 96% rename from juniper_axum/LICENCE rename to juniper_axum/LICENSE index 05fc83f5a..7967e75f4 100644 --- a/juniper_axum/LICENCE +++ b/juniper_axum/LICENSE @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2022-2023, Benno Tielen +Copyright (c) 2022-2023, Benno Tielen, Kai Ren All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/juniper_axum/README.md b/juniper_axum/README.md index 128f00462..7d59c60c4 100644 --- a/juniper_axum/README.md +++ b/juniper_axum/README.md @@ -1,78 +1,31 @@ `juniper_axum` crate ==================== -[![Crates.io](https://img.shields.io/crates/v/juniper_axum.svg?maxAge=2592000)](https://crates.io/crates/juniper_warp) +[![Crates.io](https://img.shields.io/crates/v/juniper_axum.svg?maxAge=2592000)](https://crates.io/crates/juniper_axum) [![Documentation](https://docs.rs/juniper_axum/badge.svg)](https://docs.rs/juniper_axum) [![CI](https://github.com/graphql-rust/juniper/workflows/CI/badge.svg?branch=master "CI")](https://github.com/graphql-rust/juniper/actions?query=workflow%3ACI+branch%3Amaster) +[![Rust 1.73+](https://img.shields.io/badge/rustc-1.73+-lightgray.svg "Rust 1.73+")](https://blog.rust-lang.org/2023/10/05/Rust-1.73.0.html) - [Changelog](https://github.com/graphql-rust/juniper/blob/master/juniper_axum/CHANGELOG.md) [`axum`] web server integration for [`juniper`] ([GraphQL] implementation for [Rust]). -## Getting started - -The best way to get started is to examine the `simple` example in the `examples` directory. To execute -this example run - -`cargo run --example simple` - -Open your browser and navigate to `127.0.0.1:3000`. A GraphQL Playground opens. The -following commands are available in the playground. - -```graphql -{ - add(a: 2, b: 40) -} -``` - -```graphql -subscription { - count -} -``` - -## Queries and mutations -This crate provides an extractor and response for axum to work with juniper. - -```rust,ignore -use juniper_axum::response::JuniperResponse; - -let app: Router = Router::new() - .route("/graphql", post(graphql)) - .layer(Extension(schema)) - .layer(Extension(context)); - -async fn graphql( - JuniperRequest(request): JuniperRequest, - Extension(schema): Extension>, - Extension(context): Extension> -) -> JuniperResponse { - JuniperResponse(request.execute(&schema, &context).await) -} -``` - -## Subscriptions -This crate provides a helper function to easily work with graphql subscriptions over a websocket. -```rust,ignore -use juniper_axum::subscription::handle_graphql_socket; - -let app: Router = Router::new() - .route("/subscriptions", get(juniper_subscriptions)) - .layer(Extension(schema)) - .layer(Extension(context)); - -pub async fn juniper_subscriptions( - Extension(schema): Extension>, - Extension(context): Extension, - ws: WebSocketUpgrade, -) -> Response { - ws.protocols(["graphql-ws", "graphql-transport-ws"]) - .max_frame_size(1024) - .max_message_size(1024) - .max_send_queue(100) - .on_upgrade(|socket| handle_graphql_socket(socket, schema, context)) -} -``` + + + +## Documentation + +For documentation, including guides and examples, check out [Juniper Book]. + +A basic usage example can also be found in the [API docs][`juniper_axum`]. + + + + +## Examples + +Check [`examples/simple.rs`][1] and [`examples/custom.rs`][1] for example code of a working [`axum`] server with [GraphQL] handlers. + @@ -83,11 +36,12 @@ This project is licensed under [BSD 2-Clause License](https://github.com/graphql +[`axum`]: https://docs.rs/axum [`juniper`]: https://docs.rs/juniper [`juniper_axum`]: https://docs.rs/juniper_axum -[`axum`]: https://docs.rs/axum [GraphQL]: http://graphql.org [Juniper Book]: https://graphql-rust.github.io [Rust]: https://www.rust-lang.org -[1]: https://github.com/graphql-rust/juniper/blob/master/juniper_warp/examples/warp_server.rs +[1]: https://github.com/graphql-rust/juniper/blob/master/juniper_axum/examples/simple.rs +[2]: https://github.com/graphql-rust/juniper/blob/master/juniper_axum/examples/custom.rs diff --git a/juniper_axum/src/lib.rs b/juniper_axum/src/lib.rs index ac057073c..24738f197 100644 --- a/juniper_axum/src/lib.rs +++ b/juniper_axum/src/lib.rs @@ -14,8 +14,12 @@ use juniper_graphql_ws::Schema; use self::{extract::JuniperRequest, response::JuniperResponse}; -/// Handles a [`JuniperRequest`] with the specified [`Schema`], by [`extract`]ing it from -/// [`Extension`]s and initializing its fresh [`Schema::Context`] as a [`Default`] one. +#[cfg(feature = "subscriptions")] +#[doc(inline)] +pub use self::subscriptions::{graphql_transport_ws, graphql_ws, ws}; + +/// [`Handler`], which handles a [`JuniperRequest`] with the specified [`Schema`], by [`extract`]ing +/// it from [`Extension`]s and initializing its fresh [`Schema::Context`] as a [`Default`] one. /// /// > __NOTE__: This is a ready-to-go default [`Handler`] for serving GraphQL requests. If you need /// > to customize it (for example, extract [`Schema::Context`] from [`Extension`]s @@ -78,7 +82,7 @@ where ) } -/// Creates a handler that replies with an HTML page containing [GraphiQL]. +/// Creates a [`Handler`] that replies with an HTML page containing [GraphiQL]. /// /// This does not handle routing, so you can mount it on any endpoint. /// @@ -92,6 +96,7 @@ where /// .route("/", get(graphiql("/graphql", "/subscriptions"))); /// ``` /// +/// [`Handler`]: axum::handler::Handler /// [GraphiQL]: https://github.com/graphql/graphiql pub fn graphiql<'a>( graphql_endpoint_url: &str, @@ -105,7 +110,7 @@ pub fn graphiql<'a>( || future::ready(html) } -/// Creates a handler that replies with an HTML page containing [GraphQL Playground]. +/// Creates a [`Handler`] that replies with an HTML page containing [GraphQL Playground]. /// /// This does not handle routing, so you can mount it on any endpoint. /// @@ -119,6 +124,7 @@ pub fn graphiql<'a>( /// .route("/", get(playground("/graphql", "/subscriptions"))); /// ``` /// +/// [`Handler`]: axum::handler::Handler /// [GraphQL Playground]: https://github.com/prisma/graphql-playground pub fn playground<'a>( graphql_endpoint_url: &str, From 549155692a7a3e93e7b75aa3b738296b33971486 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 17:41:57 +0200 Subject: [PATCH 25/33] Add `release.toml` [skip ci] --- juniper_axum/release.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 juniper_axum/release.toml diff --git a/juniper_axum/release.toml b/juniper_axum/release.toml new file mode 100644 index 000000000..559c6e3bb --- /dev/null +++ b/juniper_axum/release.toml @@ -0,0 +1,12 @@ +[[pre-release-replacements]] +file = "CHANGELOG.md" +max = 1 +min = 0 +search = "## master" +replace = "## [{{version}}] ยท {{date}}\n[{{version}}]: /../../tree/{{crate_name}}-v{{version}}/{{crate_name}}" + +[[pre-release-replacements]] +file = "README.md" +exactly = 4 +search = "graphql-rust/juniper/blob/[^/]+/" +replace = "graphql-rust/juniper/blob/{{crate_name}}-v{{version}}/" From 90bcc3ce19ede48a3bd62ada76c102793a57a1af Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 17:48:07 +0200 Subject: [PATCH 26/33] Update workspace --- README.md | 2 ++ juniper/README.md | 3 +++ juniper/release.toml | 6 ++++++ juniper_graphql_ws/release.toml | 6 ++++++ 4 files changed, 17 insertions(+) diff --git a/README.md b/README.md index e5c1f96dc..3aa641e5b 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ your Schemas automatically. ### Web Frameworks - [actix][actix] +- [axum][axum] - [hyper][hyper] - [rocket][rocket] - [iron][iron] @@ -93,6 +94,7 @@ your Schemas automatically. Juniper has not reached 1.0 yet, thus some API instability should be expected. [actix]: https://actix.rs/ +[axum]: https://docs.rs/axum [graphql]: http://graphql.org [graphiql]: https://github.com/graphql/graphiql [playground]: https://github.com/prisma/graphql-playground diff --git a/juniper/README.md b/juniper/README.md index bd3a77bf0..b72bbd6c3 100644 --- a/juniper/README.md +++ b/juniper/README.md @@ -58,6 +58,7 @@ As an exception to other [GraphQL] libraries for other languages, [Juniper] buil ### Web servers - [`actix-web`] ([`juniper_actix`] crate) +- [`axum`] ([`juniper_axum`] crate) - [`hyper`] ([`juniper_hyper`] crate) - [`iron`] ([`juniper_iron`] crate) - [`rocket`] ([`juniper_rocket`] crate) @@ -81,11 +82,13 @@ This project is licensed under [BSD 2-Clause License](https://github.com/graphql [`actix-web`]: https://docs.rs/actix-web +[`axum`]: https://docs.rs/axum [`bigdecimal`]: https://docs.rs/bigdecimal [`bson`]: https://docs.rs/bson [`chrono`]: https://docs.rs/chrono [`chrono-tz`]: https://docs.rs/chrono-tz [`juniper_actix`]: https://docs.rs/juniper_actix +[`juniper_axum`]: https://docs.rs/juniper_axum [`juniper_hyper`]: https://docs.rs/juniper_hyper [`juniper_iron`]: https://docs.rs/juniper_iron [`juniper_rocket`]: https://docs.rs/juniper_rocket diff --git a/juniper/release.toml b/juniper/release.toml index f490007ea..fe1d02018 100644 --- a/juniper/release.toml +++ b/juniper/release.toml @@ -40,6 +40,12 @@ exactly = 2 search = "juniper = \\{ version = \"[^\"]+\"" replace = "juniper = { version = \"{{version}}\"" +[[pre-release-replacements]] +file = "../juniper_axum/Cargo.toml" +exactly = 2 +search = "juniper = \\{ version = \"[^\"]+\"" +replace = "juniper = { version = \"{{version}}\"" + [[pre-release-replacements]] file = "../juniper_graphql_ws/Cargo.toml" exactly = 1 diff --git a/juniper_graphql_ws/release.toml b/juniper_graphql_ws/release.toml index 5fa8d5860..048205a69 100644 --- a/juniper_graphql_ws/release.toml +++ b/juniper_graphql_ws/release.toml @@ -4,6 +4,12 @@ exactly = 1 search = "juniper_graphql_ws = \\{ version = \"[^\"]+\"" replace = "juniper_graphql_ws = { version = \"{{version}}\"" +[[pre-release-replacements]] +file = "../juniper_axum/Cargo.toml" +exactly = 1 +search = "juniper_graphql_ws = \\{ version = \"[^\"]+\"" +replace = "juniper_graphql_ws = { version = \"{{version}}\"" + [[pre-release-replacements]] file = "../juniper_warp/Cargo.toml" exactly = 1 From cac125ffe26ecf45f1cfe041f2502aa632bb52d6 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 18:21:35 +0200 Subject: [PATCH 27/33] Rework `simple` example --- juniper_actix/Cargo.toml | 2 +- juniper_axum/Cargo.toml | 9 ++- juniper_axum/examples/simple.rs | 95 ++++++++++++++----------------- juniper_axum/examples/starwars.rs | 4 +- juniper_axum/src/subscriptions.rs | 12 ++-- 5 files changed, 61 insertions(+), 61 deletions(-) diff --git a/juniper_actix/Cargo.toml b/juniper_actix/Cargo.toml index 4af15a479..722024b87 100644 --- a/juniper_actix/Cargo.toml +++ b/juniper_actix/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/graphql-rust/juniper" readme = "README.md" categories = ["asynchronous", "web-programming", "web-programming::http-server"] keywords = ["actix-web", "apollo", "graphql", "juniper", "websocket"] -exclude = ["/examples/", "/release.toml"] +exclude = ["/release.toml"] [package.metadata.docs.rs] all-features = true diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index bcae7eaa3..38a2e81f2 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -15,7 +15,7 @@ repository = "https://github.com/graphql-rust/juniper" readme = "README.md" categories = ["asynchronous", "web-programming", "web-programming::http-server"] keywords = ["apollo", "axum", "graphql", "juniper", "websocket"] -exclude = ["/examples/", "/tests/", "/release.toml"] +exclude = ["/tests/", "/release.toml"] [package.metadata.docs.rs] all-features = true @@ -35,8 +35,10 @@ serde_json = { version = "1.0.18", optional = true } axum = { version = "0.6", features = ["macros"] } hyper = "0.14" juniper = { version = "0.16.0-dev", path = "../juniper", features = ["expose-test-schema"] } -tokio = { version = "1.20", features = ["macros"] } +tokio = { version = "1.20", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1" +tracing = "0.1" +tracing-subscriber = "0.3" urlencoding = "2.1" @@ -45,3 +47,6 @@ anyhow = "1.0" tokio-tungstenite = "0.20" tower = "0.4" +[[example]] +name = "simple" +required-features = ["subscriptions"] \ No newline at end of file diff --git a/juniper_axum/examples/simple.rs b/juniper_axum/examples/simple.rs index b3af13f9b..9ccace307 100644 --- a/juniper_axum/examples/simple.rs +++ b/juniper_axum/examples/simple.rs @@ -1,31 +1,25 @@ -use std::{net::SocketAddr, pin::Pin, time::Duration}; +//! This example demonstrates simple default integration with [`axum`]. + +use std::{net::SocketAddr, sync::Arc, time::Duration}; use axum::{ - extract::WebSocketUpgrade, - response::Response, - routing::{get, post}, + response::Html, + routing::{get, on, MethodFilter}, Extension, Router, }; -use futures::{Stream, StreamExt}; +use futures::stream::{BoxStream, StreamExt as _}; use juniper::{graphql_object, graphql_subscription, EmptyMutation, FieldError, RootNode}; -use juniper_axum::{ - extract::JuniperRequest, playground, response::JuniperResponse, - subscriptions::handle_graphql_socket, -}; +use juniper_axum::{graphiql, graphql, playground, ws}; +use juniper_graphql_ws::ConnectionConfig; use tokio::time::interval; use tokio_stream::wrappers::IntervalStream; -#[derive(Clone, Copy, Debug)] -pub struct Context; - -impl juniper::Context for Context {} - #[derive(Clone, Copy, Debug)] pub struct Query; -#[graphql_object(context = Context)] +#[graphql_object] impl Query { - /// Add two numbers a and b + /// Adds two `a` and `b` numbers. fn add(a: i32, b: i32) -> i32 { a + b } @@ -34,11 +28,11 @@ impl Query { #[derive(Clone, Copy, Debug)] pub struct Subscription; -type NumberStream = Pin> + Send>>; +type NumberStream = BoxStream<'static, Result>; -#[graphql_subscription(context = Context)] +#[graphql_subscription] impl Subscription { - /// Count seconds + /// Counts seconds. async fn count() -> NumberStream { let mut value = 0; let stream = IntervalStream::new(interval(Duration::from_secs(1))).map(move |_| { @@ -49,46 +43,45 @@ impl Subscription { } } -type AppSchema = RootNode<'static, Query, EmptyMutation, Subscription>; +type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; + +async fn homepage() -> Html<&'static str> { + "

juniper_axum/simple example

\ +
visit GraphiQL
\ + \ + " + .into() +} #[tokio::main] async fn main() { - let schema = AppSchema::new(Query, EmptyMutation::new(), Subscription); + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); - let context = Context; + let schema = Schema::new(Query, EmptyMutation::new(), Subscription); let app = Router::new() - .route("/", get(playground("/graphql", "/subscriptions"))) - .route("/graphql", post(graphql)) - .route("/subscriptions", get(juniper_subscriptions)) - .layer(Extension(schema)) - .layer(Extension(context)); + .route( + "/graphql", + on( + MethodFilter::GET | MethodFilter::POST, + graphql::>, + ), + ) + .route( + "/subscriptions", + get(ws::>(ConnectionConfig::new(()))), + ) + .route("/graphiql", get(graphiql("/graphql", "/subscriptions"))) + .route("/playground", get(playground("/graphql", "/subscriptions"))) + .route("/", get(homepage)) + .layer(Extension(Arc::new(schema))); - // run it - let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); - println!("listening on {}", addr); + let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); + tracing::info!("listening on {addr}"); axum::Server::bind(&addr) .serve(app.into_make_service()) .await - .unwrap(); -} - -pub async fn juniper_subscriptions( - Extension(schema): Extension, - Extension(context): Extension, - ws: WebSocketUpgrade, -) -> Response { - ws.protocols(["graphql-ws"]) - .max_frame_size(1024) - .max_message_size(1024) - .max_send_queue(100) - .on_upgrade(move |socket| handle_graphql_socket(socket, schema, context)) -} - -async fn graphql( - JuniperRequest(request): JuniperRequest, - Extension(schema): Extension, - Extension(context): Extension, -) -> JuniperResponse { - JuniperResponse(request.execute(&schema, &context).await) + .unwrap_or_else(|e| panic!("failed to run `axum::Server`: {e}")); } diff --git a/juniper_axum/examples/starwars.rs b/juniper_axum/examples/starwars.rs index 4f1667418..1b45d5dc5 100644 --- a/juniper_axum/examples/starwars.rs +++ b/juniper_axum/examples/starwars.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddr; +/*use std::net::SocketAddr; use axum::{ extract::WebSocketUpgrade, @@ -57,3 +57,5 @@ async fn graphql( ) -> JuniperResponse { JuniperResponse(request.execute(&schema, &context).await) } +*/ +fn main() {} diff --git a/juniper_axum/src/subscriptions.rs b/juniper_axum/src/subscriptions.rs index 376500f0c..cc1ec7d70 100644 --- a/juniper_axum/src/subscriptions.rs +++ b/juniper_axum/src/subscriptions.rs @@ -37,7 +37,7 @@ use juniper_graphql_ws::{graphql_transport_ws, graphql_ws, Init, Schema}; /// use std::{sync::Arc, time::Duration}; /// /// use axum::{routing::get, Extension, Router}; -/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use futures::stream::{BoxStream, StreamExt as _}; /// use juniper::{ /// graphql_object, graphql_subscription, EmptyMutation, FieldError, /// RootNode, @@ -123,7 +123,7 @@ pub fn ws( /// use std::{sync::Arc, time::Duration}; /// /// use axum::{routing::get, Extension, Router}; -/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use futures::stream::{BoxStream, StreamExt as _}; /// use juniper::{ /// graphql_object, graphql_subscription, EmptyMutation, FieldError, /// RootNode, @@ -215,7 +215,7 @@ pub fn graphql_transport_ws( /// use std::{sync::Arc, time::Duration}; /// /// use axum::{routing::get, Extension, Router}; -/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use futures::stream::{BoxStream, StreamExt as _}; /// use juniper::{ /// graphql_object, graphql_subscription, EmptyMutation, FieldError, /// RootNode, @@ -307,7 +307,7 @@ pub fn graphql_ws( /// routing::get, /// Extension, Router, /// }; -/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use futures::stream::{BoxStream, StreamExt as _}; /// use juniper::{ /// graphql_object, graphql_subscription, EmptyMutation, FieldError, /// RootNode, @@ -409,7 +409,7 @@ where /// routing::get, /// Extension, Router, /// }; -/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use futures::stream::{BoxStream, StreamExt as _}; /// use juniper::{ /// graphql_object, graphql_subscription, EmptyMutation, FieldError, /// RootNode, @@ -540,7 +540,7 @@ where /// routing::get, /// Extension, Router, /// }; -/// use futures::stream::{BoxStream, Stream, StreamExt as _}; +/// use futures::stream::{BoxStream, StreamExt as _}; /// use juniper::{ /// graphql_object, graphql_subscription, EmptyMutation, FieldError, /// RootNode, From afa95217b996ae79ce83ea133ed42e1890d08590 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 18:38:25 +0200 Subject: [PATCH 28/33] Rework `strawars` example as `custom` --- juniper_axum/Cargo.toml | 4 ++ juniper_axum/examples/custom.rs | 86 +++++++++++++++++++++++++++++++ juniper_axum/examples/starwars.rs | 61 ---------------------- 3 files changed, 90 insertions(+), 61 deletions(-) create mode 100644 juniper_axum/examples/custom.rs delete mode 100644 juniper_axum/examples/starwars.rs diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index 38a2e81f2..50f4fe73c 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -47,6 +47,10 @@ anyhow = "1.0" tokio-tungstenite = "0.20" tower = "0.4" +[[example]] +name = "custom" +required-features = ["subscriptions"] + [[example]] name = "simple" required-features = ["subscriptions"] \ No newline at end of file diff --git a/juniper_axum/examples/custom.rs b/juniper_axum/examples/custom.rs new file mode 100644 index 000000000..a2d88b008 --- /dev/null +++ b/juniper_axum/examples/custom.rs @@ -0,0 +1,86 @@ +//! This example demonstrates custom [`Handler`]s with [`axum`], using the [`starwars::schema`]. +//! +//! [`Handler`]: axum::handler::Handler +//! [`starwars::schema`]: juniper::tests::fixtures::starwars::schema + +use std::{net::SocketAddr, sync::Arc}; + +use axum::{ + extract::WebSocketUpgrade, + response::{Html, Response}, + routing::{get, on, MethodFilter}, + Extension, Router, +}; +use juniper::{ + tests::fixtures::starwars::schema::{Database, Query, Subscription}, + EmptyMutation, RootNode, +}; +use juniper_axum::{ + extract::JuniperRequest, graphiql, playground, response::JuniperResponse, subscriptions, +}; +use juniper_graphql_ws::ConnectionConfig; + +type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; + +async fn homepage() -> Html<&'static str> { + "

juniper_axum/custom example

\ +
visit GraphiQL
\ + \ + " + .into() +} + +pub async fn custom_subscriptions( + Extension(schema): Extension>, + Extension(database): Extension, + ws: WebSocketUpgrade, +) -> Response { + ws.protocols(["graphql-transport-ws", "graphql-ws"]) + .max_frame_size(1024) + .max_message_size(1024) + .max_write_buffer_size(100) + .on_upgrade(move |socket| { + subscriptions::serve_ws( + socket, + schema, + ConnectionConfig::new(database).with_max_in_flight_operations(10), + ) + }) +} + +async fn custom_graphql( + Extension(schema): Extension>, + Extension(database): Extension, + JuniperRequest(request): JuniperRequest, +) -> JuniperResponse { + JuniperResponse(request.execute(&*schema, &database).await) +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let schema = Schema::new(Query, EmptyMutation::new(), Subscription); + let database = Database::new(); + + let app = Router::new() + .route( + "/graphql", + on(MethodFilter::GET | MethodFilter::POST, custom_graphql), + ) + .route("/subscriptions", get(custom_subscriptions)) + .route("/graphiql", get(graphiql("/graphql", "/subscriptions"))) + .route("/playground", get(playground("/graphql", "/subscriptions"))) + .route("/", get(homepage)) + .layer(Extension(Arc::new(schema))) + .layer(Extension(database)); + + let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); + tracing::info!("listening on {addr}"); + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap_or_else(|e| panic!("failed to run `axum::Server`: {e}")); +} diff --git a/juniper_axum/examples/starwars.rs b/juniper_axum/examples/starwars.rs deleted file mode 100644 index 1b45d5dc5..000000000 --- a/juniper_axum/examples/starwars.rs +++ /dev/null @@ -1,61 +0,0 @@ -/*use std::net::SocketAddr; - -use axum::{ - extract::WebSocketUpgrade, - response::Response, - routing::{get, post}, - Extension, Router, -}; -use juniper::{ - tests::fixtures::starwars::schema::{Database, Query, Subscription}, - EmptyMutation, RootNode, -}; -use juniper_axum::{ - extract::JuniperRequest, playground, response::JuniperResponse, - subscriptions::handle_graphql_socket, -}; - -type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; - -#[tokio::main] -async fn main() { - let schema = Schema::new(Query, EmptyMutation::new(), Subscription); - - let context = Database::new(); - - let app = Router::new() - .route("/", get(playground("/graphql", "/subscriptions"))) - .route("/graphql", post(graphql)) - .route("/subscriptions", get(juniper_subscriptions)) - .layer(Extension(schema)) - .layer(Extension(context)); - - let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); - println!("listening on {}", addr); - axum::Server::bind(&addr) - .serve(app.into_make_service()) - .await - .unwrap(); -} - -pub async fn juniper_subscriptions( - Extension(schema): Extension, - Extension(context): Extension, - ws: WebSocketUpgrade, -) -> Response { - ws.protocols(["graphql-ws"]) - .max_frame_size(1024) - .max_message_size(1024) - .max_send_queue(100) - .on_upgrade(|socket| handle_graphql_socket(socket, schema, context)) -} - -async fn graphql( - JuniperRequest(request): JuniperRequest, - Extension(schema): Extension, - Extension(context): Extension, -) -> JuniperResponse { - JuniperResponse(request.execute(&schema, &context).await) -} -*/ -fn main() {} From e2561c7d558b2149db57d52ed9bebf9326212a12 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 19:57:20 +0200 Subject: [PATCH 29/33] Reworking integration tests, vol.1 --- juniper_axum/Cargo.toml | 23 +-- juniper_axum/src/extract.rs | 72 ++++++++- juniper_axum/src/lib.rs | 2 +- juniper_axum/tests/http_test_suite.rs | 112 +++++++++++++ juniper_axum/tests/juniper_http_test_suite.rs | 118 -------------- juniper_axum/tests/simple_schema.rs | 81 ---------- ...iper_ws_test_suite.rs => ws_test_suite.rs} | 147 ++++++++---------- 7 files changed, 259 insertions(+), 296 deletions(-) create mode 100644 juniper_axum/tests/http_test_suite.rs delete mode 100644 juniper_axum/tests/juniper_http_test_suite.rs delete mode 100644 juniper_axum/tests/simple_schema.rs rename juniper_axum/tests/{juniper_ws_test_suite.rs => ws_test_suite.rs} (51%) diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index 50f4fe73c..80dd91acb 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -15,42 +15,43 @@ repository = "https://github.com/graphql-rust/juniper" readme = "README.md" categories = ["asynchronous", "web-programming", "web-programming::http-server"] keywords = ["apollo", "axum", "graphql", "juniper", "websocket"] -exclude = ["/tests/", "/release.toml"] +exclude = ["/release.toml"] [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -subscriptions = ["axum/ws", "dep:futures", "dep:juniper_graphql_ws", "dep:serde_json"] +subscriptions = ["axum/ws", "juniper_graphql_ws/graphql-ws", "dep:futures"] [dependencies] axum = "0.6" futures = { version = "0.3.22", optional = true } juniper = { version = "0.16.0-dev", path = "../juniper", default-features = false } -juniper_graphql_ws = { version = "0.4.0-dev", path = "../juniper_graphql_ws", features = ["graphql-transport-ws", "graphql-ws"], optional = true } -serde_json = { version = "1.0.18", optional = true } +juniper_graphql_ws = { version = "0.4.0-dev", path = "../juniper_graphql_ws", features = ["graphql-transport-ws"] } +serde = { version = "1.0.122", features = ["derive"] } +serde_json = "1.0.18" [dev-dependencies] +anyhow = "1.0" axum = { version = "0.6", features = ["macros"] } hyper = "0.14" juniper = { version = "0.16.0-dev", path = "../juniper", features = ["expose-test-schema"] } tokio = { version = "1.20", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1" +tokio-tungstenite = "0.20" tracing = "0.1" tracing-subscriber = "0.3" urlencoding = "2.1" - -# TODO -anyhow = "1.0" -tokio-tungstenite = "0.20" -tower = "0.4" - [[example]] name = "custom" required-features = ["subscriptions"] [[example]] name = "simple" -required-features = ["subscriptions"] \ No newline at end of file +required-features = ["subscriptions"] + +[[test]] +name = "ws_test_suite" +required-features = ["subscriptions"] diff --git a/juniper_axum/src/extract.rs b/juniper_axum/src/extract.rs index 036335315..ef42f3841 100644 --- a/juniper_axum/src/extract.rs +++ b/juniper_axum/src/extract.rs @@ -14,6 +14,7 @@ use juniper::{ http::{GraphQLBatchRequest, GraphQLRequest}, DefaultScalarValue, ScalarValue, }; +use serde::Deserialize; /// Extractor for [`axum`] to extract a [`JuniperRequest`]. /// @@ -74,7 +75,7 @@ impl FromRequest for JuniperRequest where S: ScalarValue, State: Sync, - Query>: FromRequestParts, + Query: FromRequestParts, Json>: FromRequest, > as FromRequest>::Rejection: fmt::Display, String: FromRequest, @@ -97,15 +98,27 @@ where match (req.method(), content_type) { (&Method::GET, _) => req - .extract_parts::>>() + .extract_parts::>() .await - .map(|query| Self(GraphQLBatchRequest::Single(query.0))) .map_err(|e| { ( StatusCode::BAD_REQUEST, format!("Invalid request query string: {e}"), ) .into_response() + }) + .and_then(|query| { + query + .0 + .try_into() + .map(|q| Self(GraphQLBatchRequest::Single(q))) + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid request query `variables`: {e}"), + ) + .into_response() + }) }), (&Method::POST, Some("application/json")) => { Json::>::from_request(req, state) @@ -138,6 +151,33 @@ where } } +/// Workaround for a [`GraphQLRequest`] not being [`Deserialize`]d properly from a GET query string, +/// containing `variables` in JSON format. +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +struct GetRequest { + query: String, + #[serde(rename = "operationName")] + operation_name: Option, + variables: Option, +} + +impl TryFrom for GraphQLRequest { + type Error = serde_json::Error; + fn try_from(req: GetRequest) -> Result { + let GetRequest { + query, + operation_name, + variables, + } = req; + Ok(Self::new( + query, + operation_name, + variables.map(|v| serde_json::from_str(&v)).transpose()?, + )) + } +} + #[cfg(test)] mod juniper_request_tests { use std::fmt; @@ -147,7 +187,10 @@ mod juniper_request_tests { extract::FromRequest as _, http::Request, }; - use juniper::http::{GraphQLBatchRequest, GraphQLRequest}; + use juniper::{ + graphql_input_value, + http::{GraphQLBatchRequest, GraphQLRequest}, + }; use super::JuniperRequest; @@ -169,6 +212,27 @@ mod juniper_request_tests { assert_eq!(do_from_request(req).await, expected); } + #[tokio::test] + async fn from_get_request_with_variables() { + let req = Request::get(&format!( + "/?query={}&variables={}", + urlencoding::encode( + "query($id: String!) { human(id: $id) { id, name, appearsIn, homePlanet } }", + ), + urlencoding::encode(r#"{"id": "1000"}"#), + )) + .body(Body::empty()) + .unwrap_or_else(|e| panic!("cannot build `Request`: {e}")); + + let expected = JuniperRequest(GraphQLBatchRequest::Single(GraphQLRequest::new( + "query($id: String!) { human(id: $id) { id, name, appearsIn, homePlanet } }".into(), + None, + Some(graphql_input_value!({"id": "1000"})), + ))); + + assert_eq!(do_from_request(req).await, expected); + } + #[tokio::test] async fn from_json_post_request() { let req = Request::post("/") diff --git a/juniper_axum/src/lib.rs b/juniper_axum/src/lib.rs index 24738f197..205669a67 100644 --- a/juniper_axum/src/lib.rs +++ b/juniper_axum/src/lib.rs @@ -73,7 +73,7 @@ pub async fn graphql( JuniperRequest(req): JuniperRequest, ) -> JuniperResponse where - S: Schema, + S: Schema, // TODO: Refactor in the way we don't depend on `juniper_graphql_ws::Schema` here. S::Context: Default, { JuniperResponse( diff --git a/juniper_axum/tests/http_test_suite.rs b/juniper_axum/tests/http_test_suite.rs new file mode 100644 index 000000000..2a93405c5 --- /dev/null +++ b/juniper_axum/tests/http_test_suite.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use axum::{ + http::Request, + response::Response, + routing::{get, post}, + Extension, Router, +}; +use hyper::{service::Service, Body}; +use juniper::{ + http::tests::{run_http_test_suite, HttpIntegration, TestResponse}, + tests::fixtures::starwars::schema::{Database, Query}, + EmptyMutation, EmptySubscription, RootNode, +}; +use juniper_axum::{extract::JuniperRequest, response::JuniperResponse}; + +type Schema = RootNode<'static, Query, EmptyMutation, EmptySubscription>; + +struct TestApp(Router); + +impl TestApp { + fn new() -> Self { + #[axum::debug_handler] + async fn graphql( + Extension(schema): Extension>, + Extension(database): Extension, + JuniperRequest(request): JuniperRequest, + ) -> JuniperResponse { + JuniperResponse(request.execute(&*schema, &database).await) + } + + let schema = Schema::new(Query, EmptyMutation::new(), EmptySubscription::new()); + let database = Database::new(); + + Self( + Router::new() + .route("/", get(graphql)) + .route("/", post(graphql)) + .layer(Extension(Arc::new(schema))) + .layer(Extension(database)), + ) + } + + fn make_request(&self, req: Request) -> TestResponse { + let mut app = self.0.clone(); + + let task = app.call(req); + + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + // PANIC: Unwrapping is OK here, because `task` is `Infallible`. + let resp = task.await.unwrap(); + into_test_response(resp).await + }) + } +} + +impl HttpIntegration for TestApp { + fn get(&self, url: &str) -> TestResponse { + let req = Request::get(url).body(Body::empty()).unwrap(); + self.make_request(req) + } + + fn post_json(&self, url: &str, body: &str) -> TestResponse { + let req = Request::post(url) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + self.make_request(req) + } + + fn post_graphql(&self, url: &str, body: &str) -> TestResponse { + let req = Request::post(url) + .header("content-type", "application/graphql") + .body(Body::from(body.to_string())) + .unwrap(); + self.make_request(req) + } +} + +/// Converts the provided [`Response`] into to a [`TestResponse`]. +async fn into_test_response(resp: Response) -> TestResponse { + let status_code = resp.status().as_u16().into(); + + let content_type: String = resp + .headers() + .get("content-type") + .map(|header| { + String::from_utf8(header.as_bytes().into()) + .unwrap_or_else(|e| panic!("not UTF-8 header: {e}")) + }) + .unwrap_or_default(); + + let body = hyper::body::to_bytes(resp.into_body()) + .await + .unwrap_or_else(|e| panic!("failed to represent `Body` as `Bytes`: {e}")); + let body = String::from_utf8(body.into()).unwrap_or_else(|e| panic!("not UTF-8 body: {e}")); + + TestResponse { + status_code, + content_type, + body: Some(body), + } +} + +#[test] +fn test_axum_integration() { + run_http_test_suite(&TestApp::new()) +} diff --git a/juniper_axum/tests/juniper_http_test_suite.rs b/juniper_axum/tests/juniper_http_test_suite.rs deleted file mode 100644 index b17d98621..000000000 --- a/juniper_axum/tests/juniper_http_test_suite.rs +++ /dev/null @@ -1,118 +0,0 @@ -use std::str::from_utf8; - -use axum::{ - http::Request, - response::Response, - routing::{get, post}, - Extension, Router, -}; -use hyper::{service::Service, Body}; -use juniper::{ - http::tests::{run_http_test_suite, HttpIntegration, TestResponse}, - tests::fixtures::starwars::schema::{Database, Query}, - EmptyMutation, EmptySubscription, RootNode, -}; -use juniper_axum::{extract::JuniperRequest, response::JuniperResponse}; - -/// The app we want to test -struct AxumApp(Router); - -type Schema = RootNode<'static, Query, EmptyMutation, EmptySubscription>; - -/// Create a new axum app to test -fn test_app() -> AxumApp { - let schema = Schema::new( - Query, - EmptyMutation::::new(), - EmptySubscription::::new(), - ); - - let context = Database::new(); - - let router = Router::new() - .route("/", get(graphql)) - .route("/", post(graphql)) - .layer(Extension(schema)) - .layer(Extension(context)); - - AxumApp(router) -} - -async fn graphql( - JuniperRequest(request): JuniperRequest, - Extension(schema): Extension, - Extension(context): Extension, -) -> JuniperResponse { - JuniperResponse(request.execute(&schema, &context).await) -} - -/// Implement HttpIntegration to enable standard tests -impl HttpIntegration for AxumApp { - fn get(&self, url: &str) -> TestResponse { - let request = Request::get(url).body(Body::empty()).unwrap(); - - self.make_request(request) - } - - fn post_json(&self, url: &str, body: &str) -> TestResponse { - let request = Request::post(url) - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - - self.make_request(request) - } - - fn post_graphql(&self, url: &str, body: &str) -> TestResponse { - let request = Request::post(url) - .header("content-type", "application/graphql") - .body(Body::from(body.to_string())) - .unwrap(); - - self.make_request(request) - } -} - -impl AxumApp { - /// Make a request to the Axum app - fn make_request(&self, request: Request) -> TestResponse { - let mut app = self.0.clone(); - - let task = app.call(request); - - // Call async code with tokio runtime - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async move { - let response = task.await.unwrap(); - create_test_response(response).await - }) - } -} - -/// Convert an Axum Response to a Juniper TestResponse -async fn create_test_response(response: Response) -> TestResponse { - let status_code: i32 = response.status().as_u16().into(); - let content_type: String = response - .headers() - .get("content-type") - .map(|header| from_utf8(header.as_bytes()).unwrap().to_string()) - .unwrap_or_default(); - - let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); - let body: Option = Some(from_utf8(&body).map(|s| s.to_string()).unwrap()); - - TestResponse { - status_code, - content_type, - body, - } -} - -#[test] -fn test_axum_integration() { - let test_app = test_app(); - run_http_test_suite(&test_app) -} diff --git a/juniper_axum/tests/simple_schema.rs b/juniper_axum/tests/simple_schema.rs deleted file mode 100644 index 8c2b1ef0b..000000000 --- a/juniper_axum/tests/simple_schema.rs +++ /dev/null @@ -1,81 +0,0 @@ -use axum::{ - body::Body, - http::{Request, StatusCode}, - routing::{get, post}, - Extension, Router, -}; -use juniper::{graphql_object, EmptyMutation, EmptySubscription, RootNode}; -use juniper_axum::{extract::JuniperRequest, playground, response::JuniperResponse}; -use serde_json::{json, Value}; -use tower::util::ServiceExt; - -const GRAPHQL_ENDPOINT: &str = "/graphql"; - -#[derive(Clone, Copy, Debug)] -pub struct Context; - -impl juniper::Context for Context {} - -#[derive(Clone, Copy, Debug)] -pub struct Query; - -#[graphql_object(context = Context)] -impl Query { - fn add(a: i32, b: i32) -> i32 { - a + b - } -} - -type Schema = RootNode<'static, Query, EmptyMutation, EmptySubscription>; - -fn app() -> Router { - let schema = Schema::new( - Query, - EmptyMutation::::new(), - EmptySubscription::::new(), - ); - - let context = Context; - - Router::new() - .route("/", get(playground(GRAPHQL_ENDPOINT, None))) - .route(GRAPHQL_ENDPOINT, post(graphql)) - .layer(Extension(schema)) - .layer(Extension(context)) -} - -async fn graphql( - JuniperRequest(request): JuniperRequest, - Extension(schema): Extension, - Extension(context): Extension, -) -> JuniperResponse { - JuniperResponse(request.execute(&schema, &context).await) -} - -#[tokio::test] -async fn add_two_and_three() { - let app = app(); - - let request_json = Body::from(r#"{ "query": "{ add(a: 2, b: 3) }"}"#); - let request = Request::post(GRAPHQL_ENDPOINT) - .header("content-type", "application/json") - .body(request_json) - .unwrap(); - let response = app.oneshot(request).await.unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); - let body: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(body, json!({ "data": { "add": 5 } })); -} - -#[tokio::test] -async fn playground_is_ok() { - let app = app(); - - let request = Request::get("/").body(Body::empty()).unwrap(); - let response = app.oneshot(request).await.unwrap(); - - assert_eq!(response.status(), StatusCode::OK); -} diff --git a/juniper_axum/tests/juniper_ws_test_suite.rs b/juniper_axum/tests/ws_test_suite.rs similarity index 51% rename from juniper_axum/tests/juniper_ws_test_suite.rs rename to juniper_axum/tests/ws_test_suite.rs index aeb9f6d37..5e4ea964c 100644 --- a/juniper_axum/tests/juniper_ws_test_suite.rs +++ b/juniper_axum/tests/ws_test_suite.rs @@ -1,111 +1,101 @@ use std::{ net::{SocketAddr, TcpListener}, + sync::Arc, time::Duration, }; use anyhow::anyhow; -use axum::{extract::WebSocketUpgrade, response::Response, routing::get, Extension, Router}; +use axum::{routing::get, Extension, Router}; use futures::{SinkExt, StreamExt}; use juniper::{ - http::tests::{run_ws_test_suite, WsIntegration, WsIntegrationMessage}, + http::tests::{graphql_transport_ws, graphql_ws, WsIntegration, WsIntegrationMessage}, tests::fixtures::starwars::schema::{Database, Query, Subscription}, EmptyMutation, LocalBoxFuture, RootNode, }; -use juniper_axum::subscriptions::handle_graphql_socket; +use juniper_axum::subscriptions; +use juniper_graphql_ws::ConnectionConfig; use serde_json::Value; use tokio::net::TcpStream; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; -/// The app we want to test -#[derive(Clone)] -struct AxumApp(Router); - type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; -/// Create a new axum app to test -fn test_app() -> AxumApp { - let schema = Schema::new(Query, EmptyMutation::::new(), Subscription); - - let context = Database::new(); - - let router = Router::new() - .route("/subscriptions", get(juniper_subscriptions)) - .layer(Extension(schema)) - .layer(Extension(context)); +#[derive(Clone)] +struct TestApp(Router); + +impl TestApp { + fn new(protocol: &'static str) -> Self { + let schema = Schema::new(Query, EmptyMutation::new(), Subscription); + + let mut router = Router::new(); + router = if protocol == "graphql-ws" { + router.route( + "/subscriptions", + get(subscriptions::graphql_ws::>( + ConnectionConfig::new(Database::new()), + )), + ) + } else { + router.route( + "/subscriptions", + get(subscriptions::graphql_transport_ws::>( + ConnectionConfig::new(Database::new()), + )), + ) + }; + router = router.layer(Extension(Arc::new(schema))); + + Self(router) + } - AxumApp(router) -} + async fn run(self, messages: Vec) -> Result<(), anyhow::Error> { + let listener = TcpListener::bind("0.0.0.0:0".parse::().unwrap()).unwrap(); + let addr = listener.local_addr().unwrap(); -/// Axum handler for websockets -pub async fn juniper_subscriptions( - Extension(schema): Extension, - Extension(context): Extension, - ws: WebSocketUpgrade, -) -> Response { - ws.on_upgrade(|socket| handle_graphql_socket(socket, schema, context)) -} + tokio::spawn(async move { + axum::Server::from_tcp(listener) + .unwrap() + .serve(self.0.into_make_service()) + .await + .unwrap(); + }); -/// Test a vector of WsIntegrationMessages by -/// - sending messages to server -/// - receiving messages from server -/// -/// This function will result in an error if -/// - Message couldn't be send -/// - receiving the message timed out -/// - an error happened during receiving -/// - the received message was not a text message -/// - if expected_message != received_message -async fn run_async_tests( - app: AxumApp, - messages: Vec, -) -> Result<(), anyhow::Error> { - // Spawn test server - let listener = TcpListener::bind("0.0.0.0:0".parse::().unwrap()).unwrap(); - let addr = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::Server::from_tcp(listener) - .unwrap() - .serve(app.0.into_make_service()) + let (mut websocket, _) = connect_async(format!("ws://{}/subscriptions", addr)) .await .unwrap(); - }); - // Connect to server with tokio-tungstenite library - let (mut websocket, _) = connect_async(format!("ws://{}/subscriptions", addr)) - .await - .unwrap(); + for msg in messages { + process_message(&mut websocket, msg).await?; + } - // Send and receive messages - for message in messages { - process_message(&mut websocket, message).await?; + Ok(()) } +} - Ok(()) +impl WsIntegration for TestApp { + fn run( + &self, + messages: Vec, + ) -> LocalBoxFuture> { + Box::pin(self.clone().run(messages)) + } } -/// Send or receive an message to the server async fn process_message( mut websocket: &mut WebSocketStream>, message: WsIntegrationMessage, ) -> Result<(), anyhow::Error> { match message { - WsIntegrationMessage::Send(mes) => send_message(&mut websocket, mes).await, + WsIntegrationMessage::Send(msg) => websocket.send(Message::Text(msg.to_string())).await + .map_err(|e| anyhow!("Could not send message: {e}")) + .map(drop), WsIntegrationMessage::Expect(expected, timeout) => { receive_message_from_socket_and_test(&mut websocket, &expected, timeout).await } } } -async fn send_message( - websocket: &mut &mut WebSocketStream>, - mes: String, -) -> Result<(), anyhow::Error> { - match websocket.send(Message::Text(mes)).await { - Ok(_) => Ok(()), - Err(err) => Err(anyhow!("Could not send message: {:?}", err)), - } -} + async fn receive_message_from_socket_and_test( websocket: &mut WebSocketStream>, @@ -153,19 +143,14 @@ fn is_the_same(expected: &String, received: &String) -> Result<(), anyhow::Error Ok(()) } -/// Implement WsIntegration trait so we can automize our tests -impl WsIntegration for AxumApp { - fn run( - &self, - messages: Vec, - ) -> LocalBoxFuture> { - let app = self.clone(); - Box::pin(run_async_tests(app, messages)) - } +#[tokio::test] +async fn test_graphql_ws_integration() { + let app = TestApp::new("graphql-ws"); + graphql_ws::run_test_suite(&app).await; } #[tokio::test] -async fn juniper_ws_test_suite() { - let app = test_app(); - run_ws_test_suite(&app).await; +async fn test_graphql_transport_integration() { + let app = TestApp::new("graphql-transport-ws"); + graphql_transport_ws::run_test_suite(&app).await; } From 8ba8de5729391096d4db734011d447d8245232b8 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 20:13:50 +0200 Subject: [PATCH 30/33] Reworking integration tests, vol.2 --- juniper/src/http/mod.rs | 2 +- juniper_axum/Cargo.toml | 2 +- juniper_axum/tests/ws_test_suite.rs | 116 ++++++++++++---------------- 3 files changed, 52 insertions(+), 68 deletions(-) diff --git a/juniper/src/http/mod.rs b/juniper/src/http/mod.rs index 955e40b42..b8b4cb002 100644 --- a/juniper/src/http/mod.rs +++ b/juniper/src/http/mod.rs @@ -794,7 +794,7 @@ pub mod tests { #[allow(missing_docs)] pub async fn run_test_suite(integration: &T) { - println!("Running `graphql-ws` test suite for integration"); + println!("Running `graphql-transport-ws` test suite for integration"); println!(" - graphql_ws::test_simple_subscription"); test_simple_subscription(integration).await; diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index 80dd91acb..9adab7938 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -37,7 +37,7 @@ anyhow = "1.0" axum = { version = "0.6", features = ["macros"] } hyper = "0.14" juniper = { version = "0.16.0-dev", path = "../juniper", features = ["expose-test-schema"] } -tokio = { version = "1.20", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.20", features = ["macros", "rt-multi-thread", "time"] } tokio-stream = "0.1" tokio-tungstenite = "0.20" tracing = "0.1" diff --git a/juniper_axum/tests/ws_test_suite.rs b/juniper_axum/tests/ws_test_suite.rs index 5e4ea964c..6462b9c89 100644 --- a/juniper_axum/tests/ws_test_suite.rs +++ b/juniper_axum/tests/ws_test_suite.rs @@ -1,7 +1,6 @@ use std::{ net::{SocketAddr, TcpListener}, sync::Arc, - time::Duration, }; use anyhow::anyhow; @@ -14,8 +13,7 @@ use juniper::{ }; use juniper_axum::subscriptions; use juniper_graphql_ws::ConnectionConfig; -use serde_json::Value; -use tokio::net::TcpStream; +use tokio::{net::TcpStream, time::timeout}; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; type Schema = RootNode<'static, Query, EmptyMutation, Subscription>; @@ -65,11 +63,59 @@ impl TestApp { .unwrap(); for msg in messages { - process_message(&mut websocket, msg).await?; + Self::process_message(&mut websocket, msg).await?; } Ok(()) } + + async fn process_message( + websocket: &mut WebSocketStream>, + message: WsIntegrationMessage, + ) -> Result<(), anyhow::Error> { + match message { + WsIntegrationMessage::Send(msg) => websocket + .send(Message::Text(msg.to_string())) + .await + .map_err(|e| anyhow!("Could not send message: {e}")) + .map(drop), + + WsIntegrationMessage::Expect(expected, duration) => { + let message = timeout(duration, websocket.next()) + .await + .map_err(|e| anyhow!("Timed out receiving message. Elapsed: {e}"))?; + match message { + None => Err(anyhow!("No message received")), + Some(Err(e)) => Err(anyhow!("WebSocket error: {e}")), + Some(Ok(Message::Text(json))) => { + let actual: serde_json::Value = serde_json::from_str(&json) + .map_err(|e| anyhow!("Cannot deserialize received message: {e}"))?; + if actual != expected { + return Err(anyhow!( + "Expected message: {expected}. \ + Received message: {actual}", + )); + } + Ok(()) + } + Some(Ok(Message::Close(Some(frame)))) => { + let actual = serde_json::json!({ + "code": u16::from(frame.code), + "description": frame.reason, + }); + if actual != expected { + return Err(anyhow!( + "Expected message: {expected}. \ + Received message: {actual}", + )); + } + Ok(()) + } + Some(Ok(msg)) => Err(anyhow!("Received non-text message: {msg:?}")), + } + } + } + } } impl WsIntegration for TestApp { @@ -81,68 +127,6 @@ impl WsIntegration for TestApp { } } -async fn process_message( - mut websocket: &mut WebSocketStream>, - message: WsIntegrationMessage, -) -> Result<(), anyhow::Error> { - match message { - WsIntegrationMessage::Send(msg) => websocket.send(Message::Text(msg.to_string())).await - .map_err(|e| anyhow!("Could not send message: {e}")) - .map(drop), - WsIntegrationMessage::Expect(expected, timeout) => { - receive_message_from_socket_and_test(&mut websocket, &expected, timeout).await - } - } -} - - - -async fn receive_message_from_socket_and_test( - websocket: &mut WebSocketStream>, - expected: &String, - timeout: u64, -) -> Result<(), anyhow::Error> { - let message = tokio::time::timeout(Duration::from_millis(timeout), websocket.next()) - .await - .map_err(|e| anyhow!("Timed out receiving message. Elapsed: {e}"))?; - - match message { - None => Err(anyhow!("No Message received")), - Some(Err(e)) => Err(anyhow!("Websocket error: {:?}", e)), - Some(Ok(message)) => equals_received_text_message(&expected, message), - } -} - -fn equals_received_text_message(expected: &String, message: Message) -> Result<(), anyhow::Error> { - match message { - Message::Text(received) => is_the_same(&expected, &received), - Message::Binary(_) => Err(anyhow!("Received binary message, but expected text")), - Message::Ping(_) => Err(anyhow!("Received ping message, but expected text")), - Message::Pong(_) => Err(anyhow!("Received pong message, but expected text")), - Message::Close(_) => Err(anyhow!("Received close message, but expected text")), - Message::Frame(_) => Err(anyhow!("Received frame message, but expected text")), - } -} - -/// Check if expected == received by transforming both to a JSON value -fn is_the_same(expected: &String, received: &String) -> Result<(), anyhow::Error> { - let expected: Value = - serde_json::from_str(&expected).map_err(|e| anyhow::anyhow!("Serde error: {e:?}"))?; - - let received: Value = - serde_json::from_str(&received).map_err(|e| anyhow::anyhow!("Serde error: {e:?}"))?; - - if received != expected { - return Err(anyhow!( - "Expected: {:?}\nReceived: {:?}", - expected, - received - )); - } - - Ok(()) -} - #[tokio::test] async fn test_graphql_ws_integration() { let app = TestApp::new("graphql-ws"); From c44f5cdc29d942153d4f5720250705b09a6c08ca Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 20:31:15 +0200 Subject: [PATCH 31/33] Fill-up CHANGELOG --- juniper_axum/CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/juniper_axum/CHANGELOG.md b/juniper_axum/CHANGELOG.md index f01ff17a0..94ae98005 100644 --- a/juniper_axum/CHANGELOG.md +++ b/juniper_axum/CHANGELOG.md @@ -6,5 +6,38 @@ All user visible changes to `juniper_axum` crate will be documented in this file +## master + +### Initialized + +- Dependent on 0.6 version of [`axum` crate]. ([#1088]) +- Dependent on 0.16 version of [`juniper` crate]. ([#1088]) +- Dependent on 0.4 version of [`juniper_graphql_ws` crate]. ([#1088]) + +### Added + +- `extract::JuniperRequest` and `response::JuniperResponse` for using in custom [`axum` crate] handlers. ([#1088]) +- `graphql` handler processing [GraphQL] requests for the specified schema. ([#1088], [#1184]) +- `subscriptions::graphql_transport_ws()` handler and `subscriptions::serve_graphql_transport_ws()` function allowing to process the [new `graphql-transport-ws` GraphQL over WebSocket Protocol][graphql-transport-ws]. ([#1088], [#986]) +- `subscriptions::graphql_ws()` handler and `subscriptions::serve_graphql_ws()` function allowing to process the [legacy `graphql-ws` GraphQL over WebSocket Protocol][graphql-ws]. ([#1088], [#986]) +- `subscriptions::ws()` handler and `subscriptions::serve_ws()` function allowing to auto-select between the [legacy `graphql-ws` GraphQL over WebSocket Protocol][graphql-ws] and the [new `graphql-transport-ws` GraphQL over WebSocket Protocol][graphql-transport-ws], based on the `Sec-Websocket-Protocol` HTTP header value. ([#1088], [#986]) +- `graphiql` handler serving [GraphiQL]. ([#1088]) +- `playground` handler serving [GraphQL Playground]. ([#1088]) +- `simple.rs` and `custom.rs` integration examples. ([#1088], [#986], [#1184]) + +[#986]: /../../issues/986 +[#1088]: /../../pull/1088 +[#1184]: /../../issues/1184 + + + + +[`axum` crate]: https://docs.rs/axum [`juniper` crate]: https://docs.rs/juniper +[`juniper_graphql_ws` crate]: https://docs.rs/juniper_graphql_ws +[GraphiQL]: https://github.com/graphql/graphiql +[GraphQL]: http://graphql.org +[GraphQL Playground]: https://github.com/prisma/graphql-playground [Semantic Versioning 2.0.0]: https://semver.org +[graphql-transport-ws]: https://github.com/enisdenjo/graphql-ws/blob/v5.14.0/PROTOCOL.md +[graphql-ws]: https://github.com/apollographql/subscriptions-transport-ws/blob/v0.11.0/PROTOCOL.md \ No newline at end of file From c9cf94e4d2c6dccc2eb9436527e0fc1afcebd022 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 20:40:13 +0200 Subject: [PATCH 32/33] Fix tests --- juniper_axum/Cargo.toml | 4 ++++ juniper_axum/tests/ws_test_suite.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index 9adab7938..7c38d0ace 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -32,6 +32,10 @@ juniper_graphql_ws = { version = "0.4.0-dev", path = "../juniper_graphql_ws", fe serde = { version = "1.0.122", features = ["derive"] } serde_json = "1.0.18" +# Fixes for `minimal-versions` check. +# TODO: Try remove on upgrade of `axum` crate. +bytes = "1.2" + [dev-dependencies] anyhow = "1.0" axum = { version = "0.6", features = ["macros"] } diff --git a/juniper_axum/tests/ws_test_suite.rs b/juniper_axum/tests/ws_test_suite.rs index 6462b9c89..614479063 100644 --- a/juniper_axum/tests/ws_test_suite.rs +++ b/juniper_axum/tests/ws_test_suite.rs @@ -1,3 +1,5 @@ +#![cfg(not(windows))] + use std::{ net::{SocketAddr, TcpListener}, sync::Arc, From bb6dcaa04fb038e4a161044a84ec731d7eb78af5 Mon Sep 17 00:00:00 2001 From: tyranron Date: Wed, 8 Nov 2023 20:56:30 +0200 Subject: [PATCH 33/33] Try fix MSRV --- juniper_axum/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/juniper_axum/Cargo.toml b/juniper_axum/Cargo.toml index 7c38d0ace..b7cd896e6 100644 --- a/juniper_axum/Cargo.toml +++ b/juniper_axum/Cargo.toml @@ -25,7 +25,7 @@ rustdoc-args = ["--cfg", "docsrs"] subscriptions = ["axum/ws", "juniper_graphql_ws/graphql-ws", "dep:futures"] [dependencies] -axum = "0.6" +axum = "0.6.20" futures = { version = "0.3.22", optional = true } juniper = { version = "0.16.0-dev", path = "../juniper", default-features = false } juniper_graphql_ws = { version = "0.4.0-dev", path = "../juniper_graphql_ws", features = ["graphql-transport-ws"] }