diff --git a/Examples/HelloWorld/Package.resolved b/Examples/HelloWorld/Package.resolved index 68215b1..3ba4d2c 100644 --- a/Examples/HelloWorld/Package.resolved +++ b/Examples/HelloWorld/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GraphQLSwift/GraphQLTransportWS.git", "state" : { - "revision" : "43a64cbec3177d05e834b9a70051ab858744b0d3", - "version" : "0.2.1" + "revision" : "6d62d9f980346f0644a5f4dc1593f2b0f1d07b44", + "version" : "1.0.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GraphQLSwift/GraphQLWS.git", "state" : { - "revision" : "66b2570775436ce83f048cb8532a2baa9cbc7540", - "version" : "0.2.1" + "revision" : "5a3b62e478668199d22f52df7cc7d44b51394cb4", + "version" : "1.0.0" } }, { diff --git a/Package.resolved b/Package.resolved index a83e555..375b369 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "94ec184f7218ac0d0951ffe67092d4077c477ceee9ee85135752c9e150a39c73", + "originHash" : "f2124c90d97730f0c1ccfefce1998ffd49b45cc9e2a3815bb08e726361f2b6ce", "pins" : [ { "identity" : "async-http-client", @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GraphQLSwift/GraphQLTransportWS.git", "state" : { - "revision" : "43a64cbec3177d05e834b9a70051ab858744b0d3", - "version" : "0.2.1" + "revision" : "6d62d9f980346f0644a5f4dc1593f2b0f1d07b44", + "version" : "1.0.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GraphQLSwift/GraphQLWS.git", "state" : { - "revision" : "66b2570775436ce83f048cb8532a2baa9cbc7540", - "version" : "0.2.1" + "revision" : "5a3b62e478668199d22f52df7cc7d44b51394cb4", + "version" : "1.0.0" } }, { diff --git a/Package.swift b/Package.swift index 8d8dd3f..c6c119f 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.0.0"), - .package(url: "https://github.com/GraphQLSwift/GraphQLTransportWS.git", from: "0.2.1"), - .package(url: "https://github.com/GraphQLSwift/GraphQLWS.git", from: "0.2.1"), + .package(url: "https://github.com/GraphQLSwift/GraphQLTransportWS.git", from: "1.0.0"), + .package(url: "https://github.com/GraphQLSwift/GraphQLWS.git", from: "1.0.0"), .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), .package(url: "https://github.com/hummingbird-project/hummingbird-websocket.git", from: "2.0.0"), ], diff --git a/Sources/GraphQLHummingbird/GraphQLConfig.swift b/Sources/GraphQLHummingbird/GraphQLConfig.swift index 665fdb1..754995f 100644 --- a/Sources/GraphQLHummingbird/GraphQLConfig.swift +++ b/Sources/GraphQLHummingbird/GraphQLConfig.swift @@ -3,7 +3,11 @@ import GraphQL import Hummingbird /// Configuration options for GraphQLHummingbird -public struct GraphQLConfig: Sendable { +public struct GraphQLConfig< + Context: RequestContext, + WebSocketInit: Equatable & Codable & Sendable, + WebSocketInitResult: Sendable +>: Sendable { let allowGet: Bool let allowMissingAcceptHeader: Bool let coders: Coders @@ -28,7 +32,7 @@ public struct GraphQLConfig: Send subscriptionProtocols: Set = [.websocket], websocket: WebSocket = .init( // Including this strongly-typed argument is required to avoid compiler failures on Swift 6.2.3. - onWebSocketInit: { (_: EmptyWebSocketInit) in } + onWebSocketInit: { (_: EmptyWebSocketInit, _: Request, _: Context) in } ), additionalValidationRules: [@Sendable (ValidationContext) -> Visitor] = [] ) { @@ -94,14 +98,14 @@ public struct GraphQLConfig: Send } public struct WebSocket: Sendable { - let onWebSocketInit: @Sendable (WebSocketInit) async throws -> Void + let onWebSocketInit: @Sendable (WebSocketInit, Request, Context) async throws -> WebSocketInitResult /// GraphQL over WebSocket configuration /// - Parameter onWebSocketInit: A custom callback run during `connection_init` resolution that allows /// authorization using the `payload` field of the `connection_init` message. /// Throw from this closure to indicate that authorization has failed. public init( - onWebSocketInit: @Sendable @escaping (WebSocketInit) async throws -> Void = { (_: EmptyWebSocketInit) in } + onWebSocketInit: @Sendable @escaping (WebSocketInit, Request, Context) async throws -> WebSocketInitResult = { (_: EmptyWebSocketInit, _: Request, _: Context) in } ) { self.onWebSocketInit = onWebSocketInit } diff --git a/Sources/GraphQLHummingbird/GraphQLHandler.swift b/Sources/GraphQLHummingbird/GraphQLHandler.swift index 03902e0..15f90eb 100644 --- a/Sources/GraphQLHummingbird/GraphQLHandler.swift +++ b/Sources/GraphQLHummingbird/GraphQLHandler.swift @@ -4,18 +4,30 @@ import Hummingbird struct GraphQLHandler< Context: RequestContext, GraphQLContext: Sendable, - WebSocketInit: Equatable & Codable & Sendable + WebSocketInit: Equatable & Codable & Sendable, + WebSocketInitResult: Sendable >: Sendable { let schema: GraphQLSchema let rootValue: any Sendable - let config: GraphQLConfig - let computeContext: @Sendable (GraphQLContextComputationInputs) async throws -> GraphQLContext + let config: GraphQLConfig + let computeContext: @Sendable (GraphQLContextComputationInputs) async throws -> GraphQLContext } +/// Request metadata that can be used to construct a GraphQL context public struct GraphQLContextComputationInputs< - Context: RequestContext + Context: RequestContext, + WebSocketInitResult: Sendable >: Sendable { + /// The Hummingbird request that initiated the GraphQL request. In WebSockets, this is the upgrade GET request. public let hummingbirdRequest: Request + + /// The Hummingbird context from the request that initiated the GraphQL request. In WebSockets, this is the context from the upgrade GET request. public let hummingbirdContext: Context + + /// The decoded GraphQL request, including the raw query, variables, and more public let graphQLRequest: GraphQLRequest + + /// The result of the WebSocket's initialization closure. This can be used to customize GraphQL context creation based on the init + /// message metadata as opposed to only the upgrade request. In non-WebSocket contexts, this is nil. + public let websocketInitResult: WebSocketInitResult? } diff --git a/Sources/GraphQLHummingbird/HTTP/GraphQLHandler+HTTP.swift b/Sources/GraphQLHummingbird/HTTP/GraphQLHandler+HTTP.swift index a1ea490..d0e5c4e 100644 --- a/Sources/GraphQLHummingbird/HTTP/GraphQLHandler+HTTP.swift +++ b/Sources/GraphQLHummingbird/HTTP/GraphQLHandler+HTTP.swift @@ -24,10 +24,11 @@ extension GraphQLHandler { guard operationType != .mutation else { throw HTTPError(.methodNotAllowed, message: "Mutations using GET are disallowed") } - let graphQLContextComputationInputs = GraphQLContextComputationInputs( + let graphQLContextComputationInputs = GraphQLContextComputationInputs( hummingbirdRequest: request, hummingbirdContext: context, - graphQLRequest: graphQLRequest + graphQLRequest: graphQLRequest, + websocketInitResult: nil ) let graphQLContext = try await computeContext(graphQLContextComputationInputs) let result = await execute( @@ -63,10 +64,11 @@ extension GraphQLHandler { throw HTTPError(.unsupportedMediaType) } - let graphQLContextComputationInputs = GraphQLContextComputationInputs( + let graphQLContextComputationInputs = GraphQLContextComputationInputs( hummingbirdRequest: request, hummingbirdContext: context, - graphQLRequest: graphQLRequest + graphQLRequest: graphQLRequest, + websocketInitResult: nil ) let graphQLContext = try await computeContext(graphQLContextComputationInputs) let result = await execute( diff --git a/Sources/GraphQLHummingbird/Router+graphql.swift b/Sources/GraphQLHummingbird/Router+graphql.swift index 52cb4c0..4f2446f 100644 --- a/Sources/GraphQLHummingbird/Router+graphql.swift +++ b/Sources/GraphQLHummingbird/Router+graphql.swift @@ -19,11 +19,11 @@ public extension RouterMethods { _ path: RouterPath = "graphql", schema: GraphQLSchema, rootValue: any Sendable = (), - config: GraphQLConfig = .init(), - computeContext: @Sendable @escaping (GraphQLContextComputationInputs) async throws -> GraphQLContext + config: GraphQLConfig = .init(), + computeContext: @Sendable @escaping (GraphQLContextComputationInputs) async throws -> GraphQLContext ) -> Self { // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#request - let handler = GraphQLHandler( + let handler = GraphQLHandler( schema: schema, rootValue: rootValue, config: config, @@ -78,15 +78,16 @@ public extension RouterMethods where Context: WebSocketRequestContext { @discardableResult func graphqlWebSocket< GraphQLContext: Sendable, - WebSocketInit: Equatable & Codable & Sendable + WebSocketInit: Equatable & Codable & Sendable, + WebSocketInitResult: Sendable >( _ path: RouterPath = "graphql", schema: GraphQLSchema, rootValue: any Sendable = (), - config: GraphQLConfig = GraphQLConfig(), - computeContext: @Sendable @escaping (GraphQLContextComputationInputs) async throws -> GraphQLContext + config: GraphQLConfig, + computeContext: @Sendable @escaping (GraphQLContextComputationInputs) async throws -> GraphQLContext ) -> Self { - let handler = GraphQLHandler( + let handler = GraphQLHandler( schema: schema, rootValue: rootValue, config: config, @@ -107,4 +108,34 @@ public extension RouterMethods where Context: WebSocketRequestContext { } return self } + + /// Registers a graphql websocket route that responds using the provided schema. + /// + /// WebSocket requests support the + /// [`graphql-transport-ws`](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) + /// and [`graphql-ws`](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md) + /// subprotocols. + /// + /// - Parameters: + /// - path: The route that should respond to GraphQL requests. Both `GET` and `POST` routes are registered. + /// - schema: The GraphQL schema that should be used to respond to requests. + /// - rootValue: The `rootValue` GraphQL execution arg. This is the object passed to the root resolvers. + /// - computeContext: A closure used to compute the GraphQL context from incoming requests. This must be provided. + @discardableResult + func graphqlWebSocket( + _ path: RouterPath = "graphql", + schema: GraphQLSchema, + rootValue: any Sendable = (), + computeContext: @Sendable @escaping (GraphQLContextComputationInputs) async throws -> GraphQLContext + ) -> Self { + // This is just an overload that allows not passing `config`, since we cannot use a default argument that + // uses `Context` without getting `error: generic parameter 'Self' could not be inferred` compilation errors + graphqlWebSocket( + path, + schema: schema, + rootValue: rootValue, + config: GraphQLConfig(), + computeContext: computeContext + ) + } } diff --git a/Sources/GraphQLHummingbird/WebSocket/GraphQLHandler+handleWebSocket.swift b/Sources/GraphQLHummingbird/WebSocket/GraphQLHandler+handleWebSocket.swift index b83252b..af4b307 100644 --- a/Sources/GraphQLHummingbird/WebSocket/GraphQLHandler+handleWebSocket.swift +++ b/Sources/GraphQLHummingbird/WebSocket/GraphQLHandler+handleWebSocket.swift @@ -14,18 +14,32 @@ extension GraphQLHandler where Context: WebSocketRequestContext { subProtocol: WebSocketSubProtocol, logger: Logger ) async throws { - let messenger = WebSocketMessenger(inbound: inbound, outbound: outbound, logger: logger) + let messenger = WebSocketMessenger(outbound: outbound, logger: logger) + + // TODO: Make maxSize configurable + let messageStream = inbound.messages(maxSize: 1024 * 1024).compactMap { message -> String? in + // TODO: Add binary support + guard case let .text(text) = message else { + return nil + } + logger.trace("GraphQL server received: \(message)") + return text + } switch subProtocol { case .graphqlTransportWs: // https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md - let server = GraphQLTransportWS.Server>( + let server = GraphQLTransportWS.Server>( messenger: messenger, - onExecute: { graphQLRequest in - let graphQLContextComputationInputs = GraphQLContextComputationInputs( + onInit: { initPayload in + try await config.websocket.onWebSocketInit(initPayload, context.request, context.requestContext) + }, + onExecute: { graphQLRequest, initResult in + let graphQLContextComputationInputs = GraphQLContextComputationInputs( hummingbirdRequest: context.request, hummingbirdContext: context.requestContext, - graphQLRequest: graphQLRequest + graphQLRequest: graphQLRequest, + websocketInitResult: initResult ) let graphQLContext = try await computeContext(graphQLContextComputationInputs) return try await graphql( @@ -37,11 +51,12 @@ extension GraphQLHandler where Context: WebSocketRequestContext { operationName: graphQLRequest.operationName ) }, - onSubscribe: { graphQLRequest in - let graphQLContextComputationInputs = GraphQLContextComputationInputs( + onSubscribe: { graphQLRequest, initResult in + let graphQLContextComputationInputs = GraphQLContextComputationInputs( hummingbirdRequest: context.request, hummingbirdContext: context.requestContext, - graphQLRequest: graphQLRequest + graphQLRequest: graphQLRequest, + websocketInitResult: initResult ) let graphQLContext = try await computeContext(graphQLContextComputationInputs) return try await graphqlSubscribe( @@ -54,19 +69,20 @@ extension GraphQLHandler where Context: WebSocketRequestContext { ).get() } ) - server.onMessage { message in - logger.trace("GraphQL server received: \(String(message))") - } - server.auth(config.websocket.onWebSocketInit) + try await server.listen(to: messageStream) case .graphqlWs: // https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md - let server = GraphQLWS.Server>( + let server = GraphQLWS.Server>( messenger: messenger, - onExecute: { graphQLRequest in - let graphQLContextComputationInputs = GraphQLContextComputationInputs( + onInit: { initPayload in + try await config.websocket.onWebSocketInit(initPayload, context.request, context.requestContext) + }, + onExecute: { graphQLRequest, initResult in + let graphQLContextComputationInputs = GraphQLContextComputationInputs( hummingbirdRequest: context.request, hummingbirdContext: context.requestContext, - graphQLRequest: graphQLRequest + graphQLRequest: graphQLRequest, + websocketInitResult: initResult ) let graphQLContext = try await computeContext(graphQLContextComputationInputs) return try await graphql( @@ -78,11 +94,12 @@ extension GraphQLHandler where Context: WebSocketRequestContext { operationName: graphQLRequest.operationName ) }, - onSubscribe: { graphQLRequest in - let graphQLContextComputationInputs = GraphQLContextComputationInputs( + onSubscribe: { graphQLRequest, initResult in + let graphQLContextComputationInputs = GraphQLContextComputationInputs( hummingbirdRequest: context.request, hummingbirdContext: context.requestContext, - graphQLRequest: graphQLRequest + graphQLRequest: graphQLRequest, + websocketInitResult: initResult ) let graphQLContext = try await computeContext(graphQLContextComputationInputs) return try await graphqlSubscribe( @@ -95,12 +112,8 @@ extension GraphQLHandler where Context: WebSocketRequestContext { ).get() } ) - server.onMessage { message in - logger.trace("GraphQL server received: \(String(message))") - } - server.auth(config.websocket.onWebSocketInit) + try await server.listen(to: messageStream) } - try await messenger.start() } func shouldUpgrade(request: Request) throws -> RouterShouldUpgrade { diff --git a/Sources/GraphQLHummingbird/WebSocket/WebSocketMessenger.swift b/Sources/GraphQLHummingbird/WebSocket/WebSocketMessenger.swift index 05019bc..79135d7 100644 --- a/Sources/GraphQLHummingbird/WebSocket/WebSocketMessenger.swift +++ b/Sources/GraphQLHummingbird/WebSocket/WebSocketMessenger.swift @@ -6,44 +6,22 @@ import NIOCore /// Messenger wrapper for WebSockets class WebSocketMessenger: GraphQLTransportWS.Messenger, GraphQLWS.Messenger, @unchecked Sendable { - private let inbound: WebSocketInboundStream private let outbound: WebSocketOutboundWriter private let logger: Logger - private var onReceive: (String) async throws -> Void = { _ in } - init( - inbound: WebSocketInboundStream, outbound: WebSocketOutboundWriter, logger: Logger ) { - self.inbound = inbound self.outbound = outbound self.logger = logger } - /// A blocking method to start listening to the inbound messages and bind them to the onReceive callback - func start() async throws { - // TODO: Make maxSize configurable - for try await message in inbound.messages(maxSize: 1024 * 1024) { - guard case let .text(text) = message else { continue } - do { - try await onReceive(text) - } catch { - try? await self.error("\(error)", code: 4400) - } - } - } - func send(_ message: S) async throws where S.Element == Character { logger.trace("GraphQL server sent: \(String(message))") try await outbound.write(.text(String(message))) } - func onReceive(callback: @escaping (String) async throws -> Void) { - onReceive = callback - } - func error(_ message: String, code: Int) async throws { try await outbound.close(.init(codeNumber: code), reason: message) } diff --git a/Tests/GraphQLHummingbirdTests/WebSocketTests.swift b/Tests/GraphQLHummingbirdTests/WebSocketTests.swift index 2a53fa3..311996f 100644 --- a/Tests/GraphQLHummingbirdTests/WebSocketTests.swift +++ b/Tests/GraphQLHummingbirdTests/WebSocketTests.swift @@ -89,6 +89,102 @@ struct WebSocketTests { } } + @Test func subscriptionWithInitPayloadError() async throws { + let pubsub = SimplePubSub() + let schema = try GraphQLSchema( + subscription: GraphQLObjectType( + name: "Subscription", + fields: [ + "hello": GraphQLField( + type: GraphQLString, + resolve: { source, _, _, _ in + source as! String + }, + subscribe: { _, _, _, _ in + await pubsub.subscribe() + } + ), + ] + ) + ) + + struct InitPayload: Equatable, Codable, Sendable { + let code: String + } + let acceptedCode = "abc" + + let router = Router(context: TestWebSocketContext.self) + router.graphqlWebSocket( + schema: schema, + config: .init( + subscriptionProtocols: [.websocket], + websocket: .init( + onWebSocketInit: { (initPayload: InitPayload, _, _) in + initPayload.code == acceptedCode + } + ) + ) + ) { inputs in + // If the codes don't match, this will fail on the subscribe/execute request + guard let codeIsValid = inputs.websocketInitResult, codeIsValid else { + throw GraphQLError(message: "Unauthorized") + } + return EmptyContext() + } + let app = Application( + router: Router(), + server: .http1WebSocketUpgrade(webSocketRouter: router), + configuration: .init(address: .hostname("127.0.0.1", port: 0)) + ) + + _ = try await app.test(.live) { client in + try await client.ws( + "/graphql", + configuration: .init(additionalHeaders: [.secWebSocketProtocol: "graphql-transport-ws"]) + ) { inbound, outbound, _ in + // Start the sequence + // Send incorrect code, expect an "Unauthorized" error on the subscribe call + try await outbound.write(.text(#"{"type": "connection_init", "payload": {"code": "def"}}"#)) + for try await message in inbound.messages(maxSize: 1024 * 1024) { + guard case let .text(message) = message else { return } + #expect(!message.starts(with: "44")) + let response = try #require(message.data(using: .utf8)) + if let _ = try? decoder.decode(GraphQLTransportWS.ConnectionAckResponse.self, from: response) { + try await outbound.write(.text(#""" + { + "type": "subscribe", + "payload": { + "query": "subscription { hello }" + }, + "id": "1" + } + """#)) + // Must wait for a few milliseconds for the subscription to get set up. + try await Task.sleep(for: .milliseconds(10)) + // Force the server to emit an event + await pubsub.emit(event: "World") + } else if let _ = try? decoder.decode(GraphQLTransportWS.NextResponse.self, from: response) { + Issue.record("Expected Error: \(message)") + await pubsub.cancel() + break + } else if let _ = try? decoder.decode(GraphQLTransportWS.CompleteResponse.self, from: response) { + Issue.record("Expected Error: \(message)") + try await outbound.close(.goingAway, reason: nil) + break + } else if let errorResult = try? decoder.decode(GraphQLTransportWS.ErrorResponse.self, from: response) { + #expect(errorResult.payload[0].message == "Unauthorized") + await pubsub.cancel() + try await outbound.close(.goingAway, reason: nil) + break + } else { + Issue.record("Unrecognized message: \(message)") + break + } + } + } + } + } + @Test func subscription_GraphQLWS() async throws { let pubsub = SimplePubSub() let schema = try GraphQLSchema(