diff --git a/Sources/Pioneer/GraphQL/GraphQLRequest.swift b/Sources/Pioneer/GraphQL/GraphQLRequest.swift index b43cd22..cdb80c8 100644 --- a/Sources/Pioneer/GraphQL/GraphQLRequest.swift +++ b/Sources/Pioneer/GraphQL/GraphQLRequest.swift @@ -105,11 +105,8 @@ public struct GraphQLRequest: Codable, @unchecked Sendable { } /// Known possible failure in parsing GraphQLRequest - public enum ParsingIssue: Error, @unchecked Sendable { + public enum ParsingIssue: Error, Sendable { case missingQuery case invalidForm } - - /// GraphQL over HTTP spec accept-type - static var mediaType = "application/graphql-response+json" } diff --git a/Sources/Pioneer/Http/HTTPGraphQL.swift b/Sources/Pioneer/Http/HTTPGraphQL.swift index f2c53a1..f2468cb 100644 --- a/Sources/Pioneer/Http/HTTPGraphQL.swift +++ b/Sources/Pioneer/Http/HTTPGraphQL.swift @@ -12,58 +12,126 @@ import struct NIOHTTP1.HTTPHeaders import enum NIOHTTP1.HTTPMethod import enum NIOHTTP1.HTTPResponseStatus -public extension Pioneer { - /// HTTP-based GraphQL Response - struct HTTPGraphQLResponse: @unchecked Sendable { - /// GraphQL Result for this response - public var result: GraphQLResult - - /// HTTP status code for this response - public var status: HTTPResponseStatus - - public init(result: GraphQLResult, status: HTTPResponseStatus) { - self.result = result - self.status = status - } +/// HTTP-based GraphQL Response +public struct HTTPGraphQLResponse: @unchecked Sendable { + /// GraphQL Result for this response + public var result: GraphQLResult - public init(data: Map? = nil, errors: [GraphQLError] = [], status: HTTPResponseStatus) { - self.result = .init(data: data, errors: errors) - self.status = status - } + /// HTTP status code for this response + public var status: HTTPResponseStatus + + /// Any additional HTTP headers for this response + public var headers: HTTPHeaders? + + public init(result: GraphQLResult, status: HTTPResponseStatus, headers: HTTPHeaders? = nil) { + self.result = result + self.status = status + self.headers = headers + } + + public init(data: Map? = nil, errors: [GraphQLError] = [], status: HTTPResponseStatus, headers: HTTPHeaders? = nil) { + self.result = .init(data: data, errors: errors) + self.status = status + self.headers = headers } +} - /// HTTP-based GraphQL request - struct HTTPGraphQLRequest: Sendable { - /// GraphQL Request for this request - public var request: GraphQLRequest +/// HTTP-based GraphQL request +public struct HTTPGraphQLRequest: Sendable { + /// GraphQL Request for this request + public var request: GraphQLRequest - /// HTTP headers given in this request - public var headers: HTTPHeaders + /// HTTP headers given in this request + public var headers: HTTPHeaders - /// HTTP method for this request - public var method: HTTPMethod + /// HTTP method for this request + public var method: HTTPMethod - public init(request: GraphQLRequest, headers: HTTPHeaders, method: HTTPMethod) { - self.request = request - self.headers = headers - self.method = method - } + public init(request: GraphQLRequest, headers: HTTPHeaders, method: HTTPMethod) { + self.request = request + self.headers = headers + self.method = method + } + + public init( + query: String, + operationName: String? = nil, + variables: [String: Map]? = nil, + headers: HTTPHeaders, + method: HTTPMethod + ) { + self.request = .init(query: query, operationName: operationName, variables: variables) + self.headers = headers + self.method = method + } + + /// Is request accepting GraphQL media type + public var isAcceptingGraphQLResponse: Bool { + self.headers[.accept].contains(HTTPGraphQLRequest.mediaType) + } + + /// GraphQL over HTTP spec's accept media type + public static var mediaType = "application/graphql-response+json" + + /// GraphQL over HTTP spec's content type + public static var contentType = "\(mediaType); charset=utf-8, \(mediaType)" + + /// Known possible failure in converting HTTP into GraphQL over HTTP request + public enum Issue: Error, Sendable { + case invalidMethod + case invalidContentType + } +} - public init( - query: String, - operationName: String? = nil, - variables: [String: Map]? = nil, - headers: HTTPHeaders, - method: HTTPMethod - ) { - self.request = .init(query: query, operationName: operationName, variables: variables) - self.headers = headers - self.method = method +/// A type that can be transformed into GraphQLRequest and HTTPGraphQLRequest +public protocol GraphQLRequestConvertible { + /// HTTP headers given in this request + var headers: HTTPHeaders { get } + + /// HTTP method for this request + var method: HTTPMethod { get } + + /// Decode / parse body into a specific decodable type + /// - Parameter decodable: Decodable type + /// - Returns: Parsed body + func body(_ decodable: T.Type) throws -> T + + /// Decode with a specific key name if possible from URL Query / Search Parameters + /// - Parameters: + /// - decodable: Decodable type + /// - at: Name of field to decode + /// - Returns: The parsed payload if possible, otherwise nil + func urlQuery(_ decodable: T.Type, at: String) -> T? +} + +public extension GraphQLRequestConvertible { + /// GraphQLRequest from this type + var graphql: GraphQLRequest { + get throws { + switch method { + case .GET: + guard let query = urlQuery(String.self, at: "query") else { + throw GraphQLRequest.ParsingIssue.missingQuery + } + let variables: [String: Map]? = self.urlQuery(String.self, at: "variables") + .flatMap { $0.data(using: .utf8)?.to([String: Map].self) } + let operationName: String? = self.urlQuery(String.self, at: "operationName") + return GraphQLRequest(query: query, operationName: operationName, variables: variables) + case .POST: + guard !headers[.contentType].isEmpty else { + throw HTTPGraphQLRequest.Issue.invalidContentType + } + return try body(GraphQLRequest.self) + default: + throw HTTPGraphQLRequest.Issue.invalidMethod + } } + } - /// Is request accepting GraphQL media type - public var isAcceptingGraphQLResponse: Bool { - self.headers[.accept].contains(GraphQLRequest.mediaType) + /// HTTPGraphQLRequest from this type + var httpGraphQL: HTTPGraphQLRequest { + get throws { + try .init(request: graphql, headers: headers, method: method) } } } diff --git a/Sources/Pioneer/Pioneer.swift b/Sources/Pioneer/Pioneer.swift index 8dffd03..3b66dff 100644 --- a/Sources/Pioneer/Pioneer.swift +++ b/Sources/Pioneer/Pioneer.swift @@ -151,9 +151,12 @@ public struct Pioneer { guard errors.isEmpty else { return .init(result: .init(data: nil, errors: errors), status: .badRequest) } - let result = await executeOperation(for: gql, with: context, using: eventLoop) - return .init(result: result, status: .ok) + return .init( + result: result, + status: .ok, + headers: req.isAcceptingGraphQLResponse ? ["Content-Type": HTTPGraphQLRequest.contentType] : nil + ) } /// Handle messages that follow the websocket protocol for a specific client using Pioneer.Probe diff --git a/Sources/Pioneer/Vapor/Extensions/Request/Request+GraphQLRequest.swift b/Sources/Pioneer/Vapor/Extensions/Request/Request+GraphQLRequest.swift index 9e14491..9548399 100644 --- a/Sources/Pioneer/Vapor/Extensions/Request/Request+GraphQLRequest.swift +++ b/Sources/Pioneer/Vapor/Extensions/Request/Request+GraphQLRequest.swift @@ -10,46 +10,16 @@ import enum GraphQL.Map import struct Vapor.Abort import class Vapor.Request -public extension Request { - /// Get the GraphQLRequest from the request - var graphql: GraphQLRequest { - get throws { - switch self.method { - case .GET: - // Query is most important and should always be there, otherwise reject request - guard let query: String = self.query[String.self, at: "query"] else { - throw Abort(.badRequest, - reason: "Unable to parse query and identify operation. Specify the 'query' query string parameter with the GraphQL query.") - } - let variables: [String: Map]? = self.query[String.self, at: "variables"] - .flatMap { $0.data(using: .utf8)?.to([String: Map].self) } - let operationName: String? = self.query[String.self, at: "operationName"] - return GraphQLRequest(query: query, operationName: operationName, variables: variables) - - case .POST: - 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(isAcceptingGraphQLResponse ? .badRequest : .ok, - reason: "Missing query parameter") - } catch GraphQLRequest.ParsingIssue.invalidForm { - throw Abort(isAcceptingGraphQLResponse ? .badRequest : .ok, - reason: "Invalid GraphQL request form") - } catch { - throw Abort(.badRequest, reason: "Unable to parse JSON") - } +extension Request: GraphQLRequestConvertible { + public func body(_ decodable: T.Type) throws -> T where T: Decodable { + try content.decode(decodable) + } - default: - throw Abort(.badRequest, reason: "Invalid operation method for GraphQL request") - } - } + public func urlQuery(_ decodable: T.Type, at: String) -> T? where T: Decodable { + query[decodable, at: at] } - /// Is request accepting GraphQL media type - var isAcceptingGraphQLResponse: Bool { - headers[.accept].contains(GraphQLRequest.mediaType) + public var isAcceptingGraphQLResponse: Bool { + headers[.accept].contains(HTTPGraphQLRequest.mediaType) } } diff --git a/Sources/Pioneer/Vapor/Http/Pioneer+Http.swift b/Sources/Pioneer/Vapor/Http/Pioneer+Http.swift index 48b19e1..55477e0 100644 --- a/Sources/Pioneer/Vapor/Http/Pioneer+Http.swift +++ b/Sources/Pioneer/Vapor/Http/Pioneer+Http.swift @@ -31,19 +31,29 @@ public extension Pioneer { let res = Response() do { // Parsing GraphQLRequest and Context - let gql = try req.graphql + let httpReq = try req.httpGraphQL let context = try await context(req, res) - let httpReq = HTTPGraphQLRequest(request: gql, headers: req.headers, method: req.method) // Executing into GraphQLResult let httpRes = await executeHTTPGraphQLRequest(for: httpReq, with: context, using: req.eventLoop) try res.content.encode(httpRes.result, using: encoder) res.status = httpRes.status - - if httpReq.isAcceptingGraphQLResponse { - res.headers.replaceOrAdd(name: .contentType, value: "\(GraphQLRequest.mediaType); charset=utf-8, \(GraphQLRequest.mediaType)") + httpRes.headers?.forEach { + res.headers.replaceOrAdd(name: $0, value: $1) } return res + } catch GraphQLRequest.ParsingIssue.missingQuery { + return try GraphQLError(message: "Missing query parameter") + .response(with: req.isAcceptingGraphQLResponse ? .badRequest : .ok) + } catch GraphQLRequest.ParsingIssue.invalidForm { + return try GraphQLError(message: "nvalid GraphQL request form") + .response(with: req.isAcceptingGraphQLResponse ? .badRequest : .ok) + } catch HTTPGraphQLRequest.Issue.invalidMethod { + return try GraphQLError(message: "Invalid HTTP method for a GraphQL request") + .response(with: .badRequest) + } catch HTTPGraphQLRequest.Issue.invalidContentType { + return try GraphQLError(message: "Invalid or missing content-type") + .response(with: .badRequest) } catch let error as AbortError { return try error.response(using: res) } catch { diff --git a/Tests/PioneerTests/VaporTests/HTTPStrategyTests.swift b/Tests/PioneerTests/VaporTests/HTTPStrategyTests.swift index 8a3dcde..203032a 100644 --- a/Tests/PioneerTests/VaporTests/HTTPStrategyTests.swift +++ b/Tests/PioneerTests/VaporTests/HTTPStrategyTests.swift @@ -389,7 +389,7 @@ final class HTTPStrategyTests: XCTestCase { headers: .init([("Content-Type", "multipart/form-data"), ("Content-Length", body1.writableBytes.description)]), body: body1 ) { res in - XCTAssertEqual(res.status, .badRequest) + XCTAssertNotEqual(res.status, .ok) } } }