Skip to content

Commit

Permalink
Merge pull request #123 from d-exclaimation/cleanup-7
Browse files Browse the repository at this point in the history
feat: Moved most GraphQL spec logic out of Vapor
  • Loading branch information
d-exclaimation authored Jan 7, 2023
2 parents ce9245e + a64d7b4 commit 0d2cea0
Show file tree
Hide file tree
Showing 6 changed files with 141 additions and 93 deletions.
5 changes: 1 addition & 4 deletions Sources/Pioneer/GraphQL/GraphQLRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
154 changes: 111 additions & 43 deletions Sources/Pioneer/Http/HTTPGraphQL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Decodable>(_ 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<T: Decodable>(_ 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)
}
}
}
7 changes: 5 additions & 2 deletions Sources/Pioneer/Pioneer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,12 @@ public struct Pioneer<Resolver, Context> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(_ 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<T>(_ 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)
}
}
20 changes: 15 additions & 5 deletions Sources/Pioneer/Vapor/Http/Pioneer+Http.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Tests/PioneerTests/VaporTests/HTTPStrategyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

0 comments on commit 0d2cea0

Please sign in to comment.