From 65f8dab3086b3ffb244e154b0573a9d61ecec67c Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 8 Feb 2026 15:48:17 -0700 Subject: [PATCH 1/3] refactor: Break out test utilities --- Tests/GraphQLVaporTests/HTTPTests.swift | 65 ++------------------ Tests/GraphQLVaporTests/Utilities.swift | 28 +++++++++ Tests/GraphQLVaporTests/WebSocketTests.swift | 2 - 3 files changed, 34 insertions(+), 61 deletions(-) create mode 100644 Tests/GraphQLVaporTests/Utilities.swift diff --git a/Tests/GraphQLVaporTests/HTTPTests.swift b/Tests/GraphQLVaporTests/HTTPTests.swift index c343ab2..ebad7d2 100644 --- a/Tests/GraphQLVaporTests/HTTPTests.swift +++ b/Tests/GraphQLVaporTests/HTTPTests.swift @@ -13,7 +13,7 @@ struct HTTPTests { EmptyContext() } - try await app.test(.POST, "/graphql", headers: defaultHeaders) { req in + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in try req.content.encode(GraphQLRequest(query: "{ hello }")) } afterResponse: { response in #expect(response.status == .ok) @@ -52,7 +52,7 @@ struct HTTPTests { EmptyContext() } - try await app.test(.POST, "/graphql", headers: defaultHeaders) { req in + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in try req.content.encode( GraphQLRequest( query: "query Greet($name: String) { greet(name: $name) }", @@ -97,7 +97,7 @@ struct HTTPTests { Context(message: "Hello from context!") } - try await app.test(.POST, "/graphql", headers: defaultHeaders) { req in + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in try req.content.encode(GraphQLRequest(query: "{ contextMessage }")) } afterResponse: { response in #expect(response.status == .ok) @@ -116,7 +116,7 @@ struct HTTPTests { EmptyContext() } - try await app.test(.POST, "/graphql", headers: ["Accept": HTTPMediaType.json.serialize()]) { req in + try await app.test(.POST, "/graphql", headers: jsonHeaders) { req in try req.content.encode(GraphQLRequest(query: "{ hello }"), as: .json) } afterResponse: { response in #expect(response.status == .ok) @@ -162,46 +162,13 @@ struct HTTPTests { } } - @Test func resolverError() async throws { - try await withApp { app in - let schema = try GraphQLSchema( - query: GraphQLObjectType( - name: "Query", - fields: [ - "error": GraphQLField( - type: GraphQLString, - resolve: { _, _, _, _ in - throw GraphQLError(message: "Something went wrong") - } - ), - ] - ) - ) - - app.graphql(schema: schema) { _ in - EmptyContext() - } - - try await app.test(.POST, "/graphql", headers: defaultHeaders) { req in - try req.content.encode(GraphQLRequest(query: "{ error }")) - } afterResponse: { response in - #expect(response.status == .ok) - #expect(response.headers.contentType == .jsonGraphQL) - - let response = try response.content.decode(GraphQLResult.self) - #expect(!response.errors.isEmpty) - #expect(response.errors.first?.message == "Something went wrong") - } - } - } - @Test func allowGetRequest() async throws { try await withApp { app in app.graphql(schema: helloWorldSchema) { _ in EmptyContext() } - try await app.test(.GET, "/graphql?query=%7Bhello%7D", headers: defaultHeaders) { _ in + try await app.test(.GET, "/graphql?query=%7Bhello%7D", headers: jsonGraphQLHeaders) { _ in } afterResponse: { response in #expect(response.status == .ok) @@ -223,7 +190,7 @@ struct HTTPTests { EmptyContext() } - try await app.test(.GET, "/graphql?query=%7Bhello%7D", headers: defaultHeaders) { _ in + try await app.test(.GET, "/graphql?query=%7Bhello%7D", headers: jsonGraphQLHeaders) { _ in } afterResponse: { response in #expect(response.status == .methodNotAllowed) } @@ -255,24 +222,4 @@ struct HTTPTests { } } } - - let defaultHeaders: HTTPHeaders = [ - "Accept": HTTPMediaType.jsonGraphQL.serialize(), - ] - - let helloWorldSchema = try! GraphQLSchema( - query: GraphQLObjectType( - name: "Query", - fields: [ - "hello": GraphQLField( - type: GraphQLString, - resolve: { _, _, _, _ in - "World" - } - ), - ] - ) - ) - - struct EmptyContext: Sendable {} } diff --git a/Tests/GraphQLVaporTests/Utilities.swift b/Tests/GraphQLVaporTests/Utilities.swift new file mode 100644 index 0000000..1a3f810 --- /dev/null +++ b/Tests/GraphQLVaporTests/Utilities.swift @@ -0,0 +1,28 @@ +import GraphQL +import Vapor + +let jsonGraphQLHeaders: HTTPHeaders = [ + "Accept": HTTPMediaType.jsonGraphQL.serialize(), + "Content-Type": HTTPMediaType.jsonGraphQL.serialize(), +] + +let jsonHeaders: HTTPHeaders = [ + "Accept": HTTPMediaType.json.serialize(), + "Content-Type": HTTPMediaType.json.serialize(), +] + +let helloWorldSchema = try! GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "hello": GraphQLField( + type: GraphQLString, + resolve: { _, _, _, _ in + "World" + } + ), + ] + ) +) + +struct EmptyContext: Sendable {} diff --git a/Tests/GraphQLVaporTests/WebSocketTests.swift b/Tests/GraphQLVaporTests/WebSocketTests.swift index 012d924..39c94e2 100644 --- a/Tests/GraphQLVaporTests/WebSocketTests.swift +++ b/Tests/GraphQLVaporTests/WebSocketTests.swift @@ -171,8 +171,6 @@ struct WebSocketTests { } } } - - struct EmptyContext: Sendable {} } /// A very simple publish/subscriber used for testing From 6ff6a0034903f85462d401cd46f00d23fd2565cb Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 8 Feb 2026 21:14:44 -0700 Subject: [PATCH 2/3] test: Adds GraphQLOverHTTP error example tests --- .../HTTPStatusCodeGraphQLJSONTests.swift | 167 ++++++++++++++++++ .../HTTPStatusCodeJSONTests.swift | 167 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 Tests/GraphQLVaporTests/HTTPStatusCodeGraphQLJSONTests.swift create mode 100644 Tests/GraphQLVaporTests/HTTPStatusCodeJSONTests.swift diff --git a/Tests/GraphQLVaporTests/HTTPStatusCodeGraphQLJSONTests.swift b/Tests/GraphQLVaporTests/HTTPStatusCodeGraphQLJSONTests.swift new file mode 100644 index 0000000..024f959 --- /dev/null +++ b/Tests/GraphQLVaporTests/HTTPStatusCodeGraphQLJSONTests.swift @@ -0,0 +1,167 @@ +import Foundation +import GraphQL +import GraphQLTransportWS +@testable import GraphQLVapor +import GraphQLWS +import NIOFoundationCompat +import Testing +import Vapor +import VaporTesting + +/// Validates status code behavior for the `application/graphql-response+json` media type. +/// +/// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#applicationgraphql-responsejson +@Suite +struct HTTPStatusCodeGraphQLJSONTests { + @Test func parsingFailureGivesBadRequest() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#json-parsing-failure-1 + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in + try req.content.encode(#"{"query":"#) + } afterResponse: { response in + #expect(response.status == .badRequest) + } + } + } + + @Test func invalidParametersGivesBadRequest() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#invalid-parameters-1 + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in + try req.content.encode(["qeury": "{__typename}"]) + } afterResponse: { response in + #expect(response.status == .badRequest) + } + } + } + + @Test func documentParsingFailureGivesBadRequest() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#document-parsing-failure-1 + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in + try req.content.encode(GraphQLRequest( + query: "{" + )) + } afterResponse: { response in + #expect(response.status == .badRequest) + } + } + } + + @Test func documentValidationFailureGivesBadRequest() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#document-validation-failure-1 + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in + // Fails "No Unused Variables" validation rule + try req.content.encode(GraphQLRequest( + query: "query A($name: String) { hello }" + )) + } afterResponse: { response in + #expect(response.status == .badRequest) + } + } + } + + @Test func operationCannotBeDeterminedGivesBadRequest() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#operation-cannot-be-determined-1 + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in + try req.content.encode(GraphQLRequest( + query: "abc { hello }" + )) + } afterResponse: { response in + #expect(response.status == .badRequest) + } + } + } + + @Test func variableCoercionFailureGivesBadRequest() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#variable-coercion-failure-1 + try await withApp { app in + let schema = try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "get": GraphQLField( + type: GraphQLString, + args: [ + "name": GraphQLArgument(type: GraphQLString), + ], + resolve: { _, args, _, _ in + guard let name = args["name"].string else { + throw GraphQLError(message: "Name arg is required") + } + return name + } + ), + ] + ) + ) + app.graphql(schema: schema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in + try req.content.encode(GraphQLRequest( + query: "query getName($name: String!) { get(name: $name) }", + variables: ["name": .null] + )) + } afterResponse: { response in + #expect(response.status == .badRequest) + } + } + } + + @Test func fieldErrorGivesOk() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#field-errors-encountered-during-execution-1 + try await withApp { app in + let schema = try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "error": GraphQLField( + type: GraphQLString, + resolve: { _, _, _, _ in + throw GraphQLError(message: "Something went wrong") + } + ), + ] + ) + ) + app.graphql(schema: schema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonGraphQLHeaders) { req in + try req.content.encode(GraphQLRequest(query: "{ error }")) + } afterResponse: { response in + #expect(response.status == .ok) + #expect(response.headers.contentType == .jsonGraphQL) + + let response = try response.content.decode(GraphQLResult.self) + #expect(!response.errors.isEmpty) + #expect(response.errors.first?.message == "Something went wrong") + } + } + } +} diff --git a/Tests/GraphQLVaporTests/HTTPStatusCodeJSONTests.swift b/Tests/GraphQLVaporTests/HTTPStatusCodeJSONTests.swift new file mode 100644 index 0000000..4fbaa54 --- /dev/null +++ b/Tests/GraphQLVaporTests/HTTPStatusCodeJSONTests.swift @@ -0,0 +1,167 @@ +import Foundation +import GraphQL +import GraphQLTransportWS +@testable import GraphQLVapor +import GraphQLWS +import NIOFoundationCompat +import Testing +import Vapor +import VaporTesting + +/// Validates status code behavior for the `application/json` media type. +/// +/// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#applicationjson +@Suite +struct HTTPStatusCodeJSONTests { + @Test func parsingFailureGivesBadRequest() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#json-parsing-failure + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonHeaders) { req in + try req.content.encode(#"{"query":"#) + } afterResponse: { response in + #expect(response.status == .badRequest) + } + } + } + + @Test func invalidParametersGivesBadRequest() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#invalid-parameters + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonHeaders) { req in + try req.content.encode(["qeury": "{__typename}"]) + } afterResponse: { response in + #expect(response.status == .badRequest) + } + } + } + + @Test func documentParsingFailureGivesOk() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#document-parsing-failure + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonHeaders) { req in + try req.content.encode(GraphQLRequest( + query: "{" + )) + } afterResponse: { response in + #expect(response.status == .ok) + } + } + } + + @Test func documentValidationFailureGivesOk() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#document-validation-failure + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonHeaders) { req in + // Fails "No Unused Variables" validation rule + try req.content.encode(GraphQLRequest( + query: "query A($name: String) { hello }" + )) + } afterResponse: { response in + #expect(response.status == .ok) + } + } + } + + @Test func variableCoercionFailureGivesOk() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#variable-coercion-failure + try await withApp { app in + let schema = try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "get": GraphQLField( + type: GraphQLString, + args: [ + "name": GraphQLArgument(type: GraphQLString), + ], + resolve: { _, args, _, _ in + guard let name = args["name"].string else { + throw GraphQLError(message: "Name arg is required") + } + return name + } + ), + ] + ) + ) + app.graphql(schema: schema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonHeaders) { req in + try req.content.encode(GraphQLRequest( + query: "query getName($name: String!) { get(name: $name) }", + variables: ["name": .null] + )) + } afterResponse: { response in + #expect(response.status == .ok) + } + } + } + + @Test func operationCannotBeDeterminedGivesOk() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#operation-cannot-be-determined + try await withApp { app in + app.graphql(schema: helloWorldSchema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonHeaders) { req in + try req.content.encode(GraphQLRequest( + query: "abc { hello }" + )) + } afterResponse: { response in + #expect(response.status == .ok) + } + } + } + + @Test func fieldErrorGivesOk() async throws { + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#field-errors-encountered-during-execution + try await withApp { app in + let schema = try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "error": GraphQLField( + type: GraphQLString, + resolve: { _, _, _, _ in + throw GraphQLError(message: "Something went wrong") + } + ), + ] + ) + ) + app.graphql(schema: schema) { _ in + EmptyContext() + } + + try await app.test(.POST, "/graphql", headers: jsonHeaders) { req in + try req.content.encode(GraphQLRequest(query: "{ error }")) + } afterResponse: { response in + #expect(response.status == .ok) + #expect(response.headers.contentType == .jsonGraphQL) + + let response = try response.content.decode(GraphQLResult.self) + #expect(!response.errors.isEmpty) + #expect(response.errors.first?.message == "Something went wrong") + } + } + } +} From e18ee4542856e9d6a8bd69b4eccb4eee18888a6b Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 8 Feb 2026 21:44:02 -0700 Subject: [PATCH 3/3] fix: `application/graphql-response+json` status codes Previously these would always return 200 success, but must return 400's for spec compliance. Also improves result error formatting --- .../HTTP/GraphQLHandler+HTTP.swift | 57 +++++++++++-------- .../HTTPStatusCodeJSONTests.swift | 2 +- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/Sources/GraphQLVapor/HTTP/GraphQLHandler+HTTP.swift b/Sources/GraphQLVapor/HTTP/GraphQLHandler+HTTP.swift index a03b4b5..0b3dc53 100644 --- a/Sources/GraphQLVapor/HTTP/GraphQLHandler+HTTP.swift +++ b/Sources/GraphQLVapor/HTTP/GraphQLHandler+HTTP.swift @@ -16,7 +16,7 @@ extension GraphQLHandler { throw Abort(.methodNotAllowed, reason: "Mutations using GET are disallowed") } let context = try await computeContext(request) - let result = try await execute( + let result = await execute( graphQLRequest: graphQLRequest, context: context, additionalValidationRules: config.additionalValidationRules @@ -31,7 +31,7 @@ extension GraphQLHandler { } let graphQLRequest = try request.content.decode(GraphQLRequest.self) let context = try await computeContext(request) - let result = try await execute( + let result = await execute( graphQLRequest: graphQLRequest, context: context, additionalValidationRules: config.additionalValidationRules @@ -43,7 +43,7 @@ extension GraphQLHandler { graphQLRequest: GraphQLRequest, context: Context, additionalValidationRules: [@Sendable (ValidationContext) -> Visitor] - ) async throws -> GraphQLResult { + ) async -> GraphQLResult { // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#validation let validationRules = GraphQL.specifiedRules + additionalValidationRules @@ -59,9 +59,11 @@ extension GraphQLHandler { operationName: graphQLRequest.operationName, validationRules: validationRules ) - } catch { + } catch let error as GraphQLError { // This indicates a request parsing error - throw Abort(.badRequest, reason: error.localizedDescription) + return GraphQLResult(data: nil, errors: [error]) + } catch { + return GraphQLResult(data: nil, errors: [GraphQLError(message: error.localizedDescription)]) } return result } @@ -74,36 +76,45 @@ extension GraphQLHandler { let response = Response() + let configuredMediaTypes: Set = [.jsonGraphQL, .json] + // Try to respond with the best matching media type, in order + var selectedMediaType: HTTPMediaType? = nil for mediaType in headers.accept.mediaTypes { - do { - try response.content.encode(result, as: mediaType) - return response - } catch { - continue + if configuredMediaTypes.contains(mediaType) { + selectedMediaType = mediaType + break } } // If no exact matches, look for any matching wildcards - let acceptableMediaSet = HTTPMediaTypeSet(mediaTypes: headers.accept.mediaTypes) - for mediaType: HTTPMediaType in [.jsonGraphQL, .json] { - if acceptableMediaSet.contains(mediaType) { - do { - try response.content.encode(result, as: mediaType) - return response - } catch { - continue + if selectedMediaType == nil { + let acceptableMediaSet = HTTPMediaTypeSet(mediaTypes: headers.accept.mediaTypes) + for mediaType in configuredMediaTypes { + if acceptableMediaSet.contains(mediaType) { + selectedMediaType = mediaType + break } } } // Use the default if configured to do so - if config.allowMissingAcceptHeader { - try response.content.encode(result, as: .jsonGraphQL) - return response + if selectedMediaType == nil, config.allowMissingAcceptHeader { + selectedMediaType = .jsonGraphQL + } + + guard let selectedMediaType else { + // Fail + throw Abort(.notAcceptable) + } + + if selectedMediaType == .jsonGraphQL, result.data == nil { + // We must return `bad request` with the content if there were failures preventing a partial result + // https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#applicationgraphql-responsejson + response.status = .badRequest } - // Fail - throw Abort(.notAcceptable) + try response.content.encode(result, as: selectedMediaType) + return response } } diff --git a/Tests/GraphQLVaporTests/HTTPStatusCodeJSONTests.swift b/Tests/GraphQLVaporTests/HTTPStatusCodeJSONTests.swift index 4fbaa54..7147d0f 100644 --- a/Tests/GraphQLVaporTests/HTTPStatusCodeJSONTests.swift +++ b/Tests/GraphQLVaporTests/HTTPStatusCodeJSONTests.swift @@ -156,7 +156,7 @@ struct HTTPStatusCodeJSONTests { try req.content.encode(GraphQLRequest(query: "{ error }")) } afterResponse: { response in #expect(response.status == .ok) - #expect(response.headers.contentType == .jsonGraphQL) + #expect(response.headers.contentType == .json) let response = try response.content.decode(GraphQLResult.self) #expect(!response.errors.isEmpty)