Skip to content

Commit c7c0b7e

Browse files
Merge pull request #8 from GraphQLSwift/fix/graphql-json-response-status
Fixes `application/graphql-response+json` status codes
2 parents e75e80a + b3ef84e commit c7c0b7e

7 files changed

Lines changed: 492 additions & 67 deletions

File tree

Sources/GraphQLHummingbird/Coding/GraphQLJSONEncoder+encode.swift

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,24 @@ extension GraphQLJSONEncoder: @retroactive ResponseEncoder {
77
/// - value: Value to encode
88
/// - request: Request used to generate response
99
/// - context: Request context
10-
public func encode(_ value: some Encodable, from _: Request, context _: some RequestContext) throws -> Response {
10+
public func encode(_ value: some Encodable, from request: Request, context: some RequestContext) throws -> Response {
11+
try encode(value, status: .ok, from: request, context: context)
12+
}
13+
}
14+
15+
extension GraphQLJSONEncoder {
16+
/// Extend GraphQLJSONEncoder to support generating a ``HummingbirdCore/Response``. Sets body and header values. Similar to the
17+
/// `ResponseEncoder`-required version, except it allows setting the reponse status as well.
18+
/// - Parameters:
19+
/// - value: Value to encode
20+
/// - status: The status of the response
21+
/// - request: Request used to generate response
22+
/// - context: Request context
23+
func encode(_ value: some Encodable, status: HTTPResponse.Status, from _: Request, context _: some RequestContext) throws -> Response {
1124
let data = try encode(value)
1225
let buffer = ByteBuffer(bytes: data)
1326
return Response(
14-
status: .ok,
27+
status: status,
1528
headers: [
1629
.contentType: "\(MediaType.applicationJsonGraphQL); charset=utf-8",
1730
.contentLength: data.count.description,
@@ -20,3 +33,20 @@ extension GraphQLJSONEncoder: @retroactive ResponseEncoder {
2033
)
2134
}
2235
}
36+
37+
extension GraphQLJSONEncoder {
38+
/// Overload for GraphQLJSONEncoder to support generating a ``HummingbirdCore/Response`` from a GraphQLResult. Sets body, headers, and status, according to [GraphQL-over-HTTP spec](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#applicationgraphql-responsejson).
39+
/// - Parameters:
40+
/// - value: GraphQLResult to encode
41+
/// - request: Request used to generate response
42+
/// - context: Request context
43+
func encode(_ value: GraphQLResult, from request: Request, context: some RequestContext) throws -> Response {
44+
var status = HTTPResponse.Status.ok
45+
// We must return `bad request` with the content if there were failures preventing a partial result
46+
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#applicationgraphql-responsejson
47+
if value.data == nil {
48+
status = .badRequest
49+
}
50+
return try encode(value, status: status, from: request, context: context)
51+
}
52+
}

Sources/GraphQLHummingbird/HTTP/GraphQLHandler+HTTP.swift

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ extension GraphQLHandler {
2525
throw HTTPError(.methodNotAllowed, message: "Mutations using GET are disallowed")
2626
}
2727
let graphqlContext = try await computeContext(request, context)
28-
let result = try await execute(
28+
let result = await execute(
2929
graphQLRequest: graphQLRequest,
3030
context: graphqlContext,
3131
additionalValidationRules: config.additionalValidationRules
@@ -43,15 +43,23 @@ extension GraphQLHandler {
4343
let graphQLRequest: GraphQLRequest
4444
switch mediaType {
4545
case .applicationJson, .applicationJsonGraphQL:
46-
graphQLRequest = try await JSONDecoder().decode(GraphQLRequest.self, from: request, context: context)
46+
do {
47+
graphQLRequest = try await JSONDecoder().decode(GraphQLRequest.self, from: request, context: context)
48+
} catch {
49+
throw HTTPError(.badRequest, message: error.localizedDescription)
50+
}
4751
case .applicationUrlEncoded:
48-
graphQLRequest = try await URLEncodedFormDecoder().decode(GraphQLRequest.self, from: request, context: context)
52+
do {
53+
graphQLRequest = try await URLEncodedFormDecoder().decode(GraphQLRequest.self, from: request, context: context)
54+
} catch {
55+
throw HTTPError(.badRequest, message: error.localizedDescription)
56+
}
4957
default:
5058
throw HTTPError(.unsupportedMediaType)
5159
}
5260

5361
let graphqlContext = try await computeContext(request, context)
54-
let result = try await execute(
62+
let result = await execute(
5563
graphQLRequest: graphQLRequest,
5664
context: graphqlContext,
5765
additionalValidationRules: config.additionalValidationRules
@@ -63,7 +71,7 @@ extension GraphQLHandler {
6371
graphQLRequest: GraphQLRequest,
6472
context: GraphQLContext,
6573
additionalValidationRules: [@Sendable (ValidationContext) -> Visitor]
66-
) async throws -> GraphQLResult {
74+
) async -> GraphQLResult {
6775
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#validation
6876
let validationRules = GraphQL.specifiedRules + additionalValidationRules
6977

@@ -79,9 +87,11 @@ extension GraphQLHandler {
7987
operationName: graphQLRequest.operationName,
8088
validationRules: validationRules
8189
)
82-
} catch {
90+
} catch let error as GraphQLError {
8391
// This indicates a request parsing error
84-
throw HTTPError(.badRequest, message: error.localizedDescription)
92+
return GraphQLResult(data: nil, errors: [error])
93+
} catch {
94+
return GraphQLResult(data: nil, errors: [GraphQLError(message: error.localizedDescription)])
8595
}
8696
return result
8797
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import Foundation
2+
import GraphQL
3+
@testable import GraphQLHummingbird
4+
import GraphQLTransportWS
5+
import GraphQLWS
6+
import Hummingbird
7+
import HummingbirdTesting
8+
import NIOFoundationCompat
9+
import Testing
10+
11+
/// Validates status code behavior for the `application/graphql-response+json` media type.
12+
///
13+
/// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#applicationgraphql-responsejson
14+
@Suite
15+
struct HTTPStatusCodeGraphQLJSONTests {
16+
@Test func parsingFailureGivesBadRequest() async throws {
17+
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#json-parsing-failure-1
18+
let router = Router()
19+
router.graphql(schema: helloWorldSchema) { _, _ in
20+
EmptyContext()
21+
}
22+
let app = Application(router: router)
23+
24+
try await app.test(.router) { client in
25+
try await client.execute(
26+
uri: "/graphql",
27+
method: .post,
28+
headers: jsonGraphQLHeaders,
29+
body: .init(data: #require(#"{"query":"#.data(using: .utf8)))
30+
) { response in
31+
#expect(response.status == .badRequest)
32+
}
33+
}
34+
}
35+
36+
@Test func invalidParametersGivesBadRequest() async throws {
37+
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#invalid-parameters-1
38+
let router = Router()
39+
router.graphql(schema: helloWorldSchema) { _, _ in
40+
EmptyContext()
41+
}
42+
let app = Application(router: router)
43+
44+
try await app.test(.router) { client in
45+
try await client.execute(
46+
uri: "/graphql",
47+
method: .post,
48+
headers: jsonGraphQLHeaders,
49+
body: .init(data: #require(#"{"qeury": "{__typename}"}"#.data(using: .utf8)))
50+
) { response in
51+
#expect(response.status == .badRequest)
52+
}
53+
}
54+
}
55+
56+
@Test func documentParsingFailureGivesBadRequest() async throws {
57+
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#document-parsing-failure-1
58+
let router = Router()
59+
router.graphql(schema: helloWorldSchema) { _, _ in
60+
EmptyContext()
61+
}
62+
let app = Application(router: router)
63+
64+
try await app.test(.router) { client in
65+
try await client.execute(
66+
uri: "/graphql",
67+
method: .post,
68+
headers: jsonGraphQLHeaders,
69+
body: .init(data: #require(#"{"query": "{"}"#.data(using: .utf8)))
70+
) { response in
71+
#expect(response.status == .badRequest)
72+
}
73+
}
74+
}
75+
76+
@Test func documentValidationFailureGivesBadRequest() async throws {
77+
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#document-validation-failure-1
78+
let router = Router()
79+
router.graphql(schema: helloWorldSchema) { _, _ in
80+
EmptyContext()
81+
}
82+
let app = Application(router: router)
83+
84+
try await app.test(.router) { client in
85+
try await client.execute(
86+
uri: "/graphql",
87+
method: .post,
88+
headers: jsonGraphQLHeaders,
89+
// Fails "No Unused Variables" validation rule
90+
body: .init(data: #require(#"{"query": "query A($name: String) { hello }"}"#.data(using: .utf8)))
91+
) { response in
92+
#expect(response.status == .badRequest)
93+
}
94+
}
95+
}
96+
97+
@Test func operationCannotBeDeterminedGivesBadRequest() async throws {
98+
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#operation-cannot-be-determined-1
99+
let router = Router()
100+
router.graphql(schema: helloWorldSchema) { _, _ in
101+
EmptyContext()
102+
}
103+
let app = Application(router: router)
104+
105+
try await app.test(.router) { client in
106+
try await client.execute(
107+
uri: "/graphql",
108+
method: .post,
109+
headers: jsonGraphQLHeaders,
110+
body: .init(data: #require(#"{"query": "abc { hello }"}"#.data(using: .utf8)))
111+
) { response in
112+
#expect(response.status == .badRequest)
113+
}
114+
}
115+
}
116+
117+
@Test func variableCoercionFailureGivesBadRequest() async throws {
118+
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#variable-coercion-failure-1
119+
let schema = try GraphQLSchema(
120+
query: GraphQLObjectType(
121+
name: "Query",
122+
fields: [
123+
"get": GraphQLField(
124+
type: GraphQLString,
125+
args: [
126+
"name": GraphQLArgument(type: GraphQLString),
127+
],
128+
resolve: { _, args, _, _ in
129+
guard let name = args["name"].string else {
130+
throw GraphQLError(message: "Name arg is required")
131+
}
132+
return name
133+
}
134+
),
135+
]
136+
)
137+
)
138+
let router = Router()
139+
router.graphql(schema: schema) { _, _ in
140+
EmptyContext()
141+
}
142+
let app = Application(router: router)
143+
144+
try await app.test(.router) { client in
145+
try await client.execute(
146+
uri: "/graphql",
147+
method: .post,
148+
headers: jsonGraphQLHeaders,
149+
body: .init(data: #require(
150+
#"{"query": "query getName($name: String!) { get(name: $name) }", "variables": { "name": null }}"#.data(using: .utf8)
151+
))
152+
) { response in
153+
#expect(response.status == .badRequest)
154+
}
155+
}
156+
}
157+
158+
@Test func fieldErrorGivesOk() async throws {
159+
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#field-errors-encountered-during-execution-1
160+
let schema = try GraphQLSchema(
161+
query: GraphQLObjectType(
162+
name: "Query",
163+
fields: [
164+
"error": GraphQLField(
165+
type: GraphQLString,
166+
resolve: { _, _, _, _ in
167+
throw GraphQLError(message: "Something went wrong")
168+
}
169+
),
170+
]
171+
)
172+
)
173+
let router = Router()
174+
router.graphql(schema: schema) { _, _ in
175+
EmptyContext()
176+
}
177+
let app = Application(router: router)
178+
179+
try await app.test(.router) { client in
180+
try await client.execute(
181+
uri: "/graphql",
182+
method: .post,
183+
headers: jsonGraphQLHeaders,
184+
body: .init(data: JSONEncoder().encode(GraphQLRequest(query: "{ error }")))
185+
) { response in
186+
#expect(response.status == .ok)
187+
#expect(response.headers[.contentType] == "application/graphql-response+json; charset=utf-8")
188+
189+
let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body)
190+
#expect(!result.errors.isEmpty)
191+
#expect(result.errors.first?.message == "Something went wrong")
192+
}
193+
}
194+
}
195+
}

0 commit comments

Comments
 (0)