diff --git a/Documentation/pages/docs/features/graphql-middleware.mdx b/Documentation/pages/docs/features/graphql-middleware.mdx index 2def96b..4faaadd 100644 --- a/Documentation/pages/docs/features/graphql-middleware.mdx +++ b/Documentation/pages/docs/features/graphql-middleware.mdx @@ -8,16 +8,25 @@ Resolver-based middleware is still a work in progress. It is currently only supported as extensions to [Graphiti](https://github.com/GraphQLSwift/Graphiti), and only available in `v1.2.0-beta.1` or higher. -Middleware are useful reusable code that can be easily attached to resolvers. -By using middleware we can extract the commonly used code from our resolvers and then declaratively attach it whenever it's needed. +Middlewares are useful reusable code that can be easily attached to resolvers. +It allows you to perform operations on incoming operations before they get to the field resolver and on outgoing responses before they are sent back. ## Creating a middleware GraphQL middleware is a function that takes 2 arguments: - 1. Resolver parameters - _the same as resolvers (root, args, context)_ - 2. Next function - _used to control the execution of the next middleware and the resolver to which it is attached_ + 1. Resolver parameters - _All information given to the resolver (root, args, context)_ + 2. Next function - _Function to control the execution of the next middleware and the resolver to which it is attached_ + +```swift {5} showLineNumbers copy filename="Specific resolver middleware" +public func MinZeroResult( + params: ResolverParameters, + next: @escaping () -> Int +) async throws -> Int { + try await min(next(), 0) +} +``` -```swift {3,5-6} showLineNumbers copy +```swift {3,5-6} showLineNumbers copy filename="Reusable generic middleware" public func Trace() -> GraphQLMiddleware { return { params, next in let before = Date() @@ -65,20 +74,14 @@ public func Cached( Attaching middlewares can be done through the `use` parameter from `Field`. The order of the middleware is also important. -```swift {4-5,12-13} showLineNumbers copy +```swift {3,8} showLineNumbers copy Query { - Field("books", - at: Resolver.books, - // Trace -> Cached -> Resolver -> Cached -> Trace - use: [Trace(), Cached(key: "query:books")] - ) + // Trace -> Cached -> Resolver + Field("books", at: Resolver.books, use: [Trace(), Cached(key: "query:books")]) } Mutation { - Field("createBook" - at: Resolver.createBook, - // Auth -> Trace -> Resolver -> Trace -> Auth - use: [Auth(), Trace()] - ) + // Auth -> Trace -> Resolver + Field("createBook", at: Resolver.createBook, use: [Auth(), Trace()]) } ``` \ No newline at end of file diff --git a/Sources/Pioneer/GraphQL/BuiltinTypes.swift b/Sources/Pioneer/GraphQL/BuiltinTypes.swift index feabba3..de0065e 100644 --- a/Sources/Pioneer/GraphQL/BuiltinTypes.swift +++ b/Sources/Pioneer/GraphQL/BuiltinTypes.swift @@ -85,7 +85,7 @@ public struct ID: Codable, ExpressibleByStringLiteral, CustomStringConvertible, } /// Conversion Error for the ID scalar - public struct ConversionError: Error { + public struct ConversionError: Error, @unchecked Sendable { /// The ID in question as string public var id: String diff --git a/Sources/Pioneer/GraphQL/Extensions/Field+Middleware.swift b/Sources/Pioneer/GraphQL/Extensions/Field+Middleware.swift index e1ab237..fdf86ad 100644 --- a/Sources/Pioneer/GraphQL/Extensions/Field+Middleware.swift +++ b/Sources/Pioneer/GraphQL/Extensions/Field+Middleware.swift @@ -18,22 +18,11 @@ public extension Field where FieldType: Encodable { use middlewares: [GraphQLMiddleware], @ArgumentComponentBuilder _ argument: () -> ArgumentComponent ) { - let resolve: ConcurrentResolve = { type in - { context, arguments in - let info = ResolverParameters(root: type, context: context, args: arguments) - let result = middlewares - .reversed() - .reduce({ () async throws -> FieldType in - try function(type)(context, arguments) - }) { acc, middleware in - { () async throws -> FieldType in - try await middleware(info, acc) - } - } - return try await result() - } - } - self.init(name: name, arguments: [argument()], concurrentResolve: resolve) + self.init( + name: name, + arguments: [argument()], + concurrentResolve: buildResolver(from: function, using: middlewares) + ) } convenience init( @@ -43,22 +32,11 @@ public extension Field where FieldType: Encodable { @ArgumentComponentBuilder _ arguments: () -> [ArgumentComponent] = { [] } ) { - let resolve: ConcurrentResolve = { type in - { context, arguments in - let info = ResolverParameters(root: type, context: context, args: arguments) - let result = middlewares - .reversed() - .reduce({ () async throws -> FieldType in - try function(type)(context, arguments) - }) { acc, middleware in - { () async throws -> FieldType in - try await middleware(info, acc) - } - } - return try await result() - } - } - self.init(name: name, arguments: arguments(), concurrentResolve: resolve) + self.init( + name: name, + arguments: arguments(), + concurrentResolve: buildResolver(from: function, using: middlewares) + ) } convenience init( @@ -67,20 +45,11 @@ public extension Field where FieldType: Encodable { use middlewares: [GraphQLMiddleware], @ArgumentComponentBuilder _ argument: () -> ArgumentComponent ) { - let resolve: ConcurrentResolve = { type in - { context, arguments in - let info = ResolverParameters(root: type, context: context, args: arguments) - let result = middlewares - .reversed() - .reduce({ try await function(type)(context, arguments) }) { acc, middleware in - { () async throws -> FieldType in - try await middleware(info, acc) - } - } - return try await result() - } - } - self.init(name: name, arguments: [argument()], concurrentResolve: resolve) + self.init( + name: name, + arguments: [argument()], + concurrentResolve: buildResolver(from: function, using: middlewares) + ) } convenience init( @@ -90,19 +59,10 @@ public extension Field where FieldType: Encodable { @ArgumentComponentBuilder _ arguments: () -> [ArgumentComponent] = { [] } ) { - let resolve: ConcurrentResolve = { type in - { context, arguments in - let info = ResolverParameters(root: type, context: context, args: arguments) - let result = middlewares - .reversed() - .reduce({ try await function(type)(context, arguments) }) { acc, middleware in - { () async throws -> FieldType in - try await middleware(info, acc) - } - } - return try await result() - } - } - self.init(name: name, arguments: arguments(), concurrentResolve: resolve) + self.init( + name: name, + arguments: arguments(), + concurrentResolve: buildResolver(from: function, using: middlewares) + ) } } diff --git a/Sources/Pioneer/GraphQL/GraphQLMessage.swift b/Sources/Pioneer/GraphQL/GraphQLMessage.swift index 40e8ee1..fe7bef0 100644 --- a/Sources/Pioneer/GraphQL/GraphQLMessage.swift +++ b/Sources/Pioneer/GraphQL/GraphQLMessage.swift @@ -9,7 +9,7 @@ import Foundation import GraphQL /// GraphQL Websocket Message according to all sub-protocol -public struct GraphQLMessage: Codable { +public struct GraphQLMessage: Codable, @unchecked Sendable { /// Operation based ID if any public var id: String? /// Message type specified to allow differentiation @@ -44,7 +44,7 @@ public struct GraphQLMessage: Codable { } /// Variant type to escape constraint on payload, use only for cases where certain payload break the object spec - public struct Variance: Codable { + public struct Variance: Codable, @unchecked Sendable { public var id: String? public var type: String public var payload: Map? diff --git a/Sources/Pioneer/GraphQL/GraphQLMiddleware.swift b/Sources/Pioneer/GraphQL/GraphQLMiddleware.swift index 9f6bf6c..57d63ff 100644 --- a/Sources/Pioneer/GraphQL/GraphQLMiddleware.swift +++ b/Sources/Pioneer/GraphQL/GraphQLMiddleware.swift @@ -5,6 +5,9 @@ // Created by d-exclaimation on 17:38. // +import typealias Graphiti.ConcurrentResolve +import typealias Graphiti.SyncResolve + /// A struct to group of all parameters for a resolvers public struct ResolverParameters { /// The root element @@ -25,3 +28,51 @@ public typealias GraphQLMiddleware = ( _ params: ResolverParameters, _ next: @escaping () async throws -> ResolveType ) async throws -> ResolveType + +/// Build a single resolver with a single base resolver and a handful middlewares +/// - Parameters: +/// - function: The base resolver +/// - middlewares: The middlewares to wrap the resolvers +/// - Returns: A single resolver with middleware applied +public func buildResolver( + from function: @escaping SyncResolve, + using middlewares: [GraphQLMiddleware] +) -> ConcurrentResolve { + { root in + { ctx, args in + let info = ResolverParameters(root: root, context: ctx, args: args) + let result = middlewares + .reversed() + .reduce({ () async throws in try function(root)(ctx, args) }) { next, middleware in + { () async throws in + try await middleware(info, next) + } + } + return try await result() + } + } +} + +/// Build a single resolver with a single base resolver and a handful middlewares +/// - Parameters: +/// - function: The base async resolver +/// - middlewares: The middlewares to wrap the resolvers +/// - Returns: A single resolver with middleware applied +public func buildResolver( + from function: @escaping ConcurrentResolve, + using middlewares: [GraphQLMiddleware] +) -> ConcurrentResolve { + { root in + { ctx, args in + let info = ResolverParameters(root: root, context: ctx, args: args) + let result = middlewares + .reversed() + .reduce({ try await function(root)(ctx, args) }) { next, middleware in + { () async throws in + try await middleware(info, next) + } + } + return try await result() + } + } +} diff --git a/Sources/Pioneer/GraphQL/GraphQLRequest.swift b/Sources/Pioneer/GraphQL/GraphQLRequest.swift index 57bc440..58cc2aa 100644 --- a/Sources/Pioneer/GraphQL/GraphQLRequest.swift +++ b/Sources/Pioneer/GraphQL/GraphQLRequest.swift @@ -9,7 +9,7 @@ import Foundation import GraphQL /// GraphQL Request according to the spec -public struct GraphQLRequest: Codable { +public struct GraphQLRequest: Codable, @unchecked Sendable { private enum Key: String, CodingKey, CaseIterable { case query, operationName, variables, extensions } @@ -105,11 +105,11 @@ public struct GraphQLRequest: Codable { } /// Known possible failure in parsing GraphQLRequest - public enum ParsingIssue: Error { + public enum ParsingIssue: Error, @unchecked Sendable { case missingQuery case invalidForm } /// GraphQL over HTTP spec accept-type - static var acceptType = "application/graphql-response+json" + static var mediaType = "application/graphql-response+json" } diff --git a/Sources/Pioneer/Http/HTTPGraphQL.swift b/Sources/Pioneer/Http/HTTPGraphQL.swift index 4f65bb2..f2c53a1 100644 --- a/Sources/Pioneer/Http/HTTPGraphQL.swift +++ b/Sources/Pioneer/Http/HTTPGraphQL.swift @@ -14,7 +14,7 @@ import enum NIOHTTP1.HTTPResponseStatus public extension Pioneer { /// HTTP-based GraphQL Response - struct HTTPGraphQLResponse { + struct HTTPGraphQLResponse: @unchecked Sendable { /// GraphQL Result for this response public var result: GraphQLResult @@ -33,7 +33,7 @@ public extension Pioneer { } /// HTTP-based GraphQL request - struct HTTPGraphQLRequest { + struct HTTPGraphQLRequest: Sendable { /// GraphQL Request for this request public var request: GraphQLRequest @@ -60,5 +60,10 @@ public extension Pioneer { self.headers = headers self.method = method } + + /// Is request accepting GraphQL media type + public var isAcceptingGraphQLResponse: Bool { + self.headers[.accept].contains(GraphQLRequest.mediaType) + } } } diff --git a/Sources/Pioneer/Vapor/Extensions/Request/Request+GraphQLRequest.swift b/Sources/Pioneer/Vapor/Extensions/Request/Request+GraphQLRequest.swift index e98db7b..9e14491 100644 --- a/Sources/Pioneer/Vapor/Extensions/Request/Request+GraphQLRequest.swift +++ b/Sources/Pioneer/Vapor/Extensions/Request/Request+GraphQLRequest.swift @@ -27,17 +27,16 @@ public extension Request { return GraphQLRequest(query: query, operationName: operationName, variables: variables) case .POST: - let accept = self.headers[.accept] - guard !self.headers[.contentType].isEmpty else { + guard !headers[.contentType].isEmpty else { throw Abort(.badRequest, reason: "Invalid content-type") } do { return try self.content.decode(GraphQLRequest.self) } catch GraphQLRequest.ParsingIssue.missingQuery { - throw Abort(accept.contains(GraphQLRequest.acceptType) ? .badRequest : .ok, + throw Abort(isAcceptingGraphQLResponse ? .badRequest : .ok, reason: "Missing query parameter") } catch GraphQLRequest.ParsingIssue.invalidForm { - throw Abort(accept.contains(GraphQLRequest.acceptType) ? .badRequest : .ok, + throw Abort(isAcceptingGraphQLResponse ? .badRequest : .ok, reason: "Invalid GraphQL request form") } catch { throw Abort(.badRequest, reason: "Unable to parse JSON") @@ -48,4 +47,9 @@ public extension Request { } } } + + /// Is request accepting GraphQL media type + var isAcceptingGraphQLResponse: Bool { + headers[.accept].contains(GraphQLRequest.mediaType) + } } diff --git a/Sources/Pioneer/Vapor/Http/Pioneer+Http.swift b/Sources/Pioneer/Vapor/Http/Pioneer+Http.swift index 553b17f..48b19e1 100644 --- a/Sources/Pioneer/Vapor/Http/Pioneer+Http.swift +++ b/Sources/Pioneer/Vapor/Http/Pioneer+Http.swift @@ -40,8 +40,8 @@ public extension Pioneer { try res.content.encode(httpRes.result, using: encoder) res.status = httpRes.status - if req.headers[.accept].contains(GraphQLRequest.acceptType) { - res.headers.replaceOrAdd(name: .contentType, value: "\(GraphQLRequest.acceptType); charset=utf-8, \(GraphQLRequest.acceptType)") + if httpReq.isAcceptingGraphQLResponse { + res.headers.replaceOrAdd(name: .contentType, value: "\(GraphQLRequest.mediaType); charset=utf-8, \(GraphQLRequest.mediaType)") } return res } catch let error as AbortError {