Skip to content

Commit b457761

Browse files
authored
fix: Fix ExecutionResult Decoder to Conform to GraphQL Spec (#144)
1 parent 1b369f1 commit b457761

File tree

4 files changed

+209
-3
lines changed

4 files changed

+209
-3
lines changed

Sources/GraphQL/Execution.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ extension ExecutionArgs: Hashable {
4343
public struct ExecutionResult: Equatable, Encodable, Decodable {
4444

4545
/// Result of a successfull execution of a query.
46+
/// - NOTE: AnyCodable is represented as a non-nullable value because it's easier to handle results if we represent `nil` value as `AnyCodable(nil)` value.
47+
/// Because GraphQL Specification allows the possibility of missing `data` field, we manually decode execution result.
4648
public var data: AnyCodable
4749

4850
/// Any errors that occurred during the GraphQL execution of the server.
@@ -65,6 +67,20 @@ public struct ExecutionResult: Equatable, Encodable, Decodable {
6567
self.hasNext = hasNext
6668
self.extensions = extensions
6769
}
70+
71+
public init(from decoder: Decoder) throws {
72+
let container = try decoder.container(keyedBy: CodingKeys.self)
73+
74+
// NOTE: GraphQL Specification allows the possibility of missing `data` field, but the
75+
// code in the library assumes that AnyCodable value is always present.
76+
// As a workaround, we manually construct a nil literal using AnyCodable to simplify further processing.
77+
let data = try container.decodeIfPresent(AnyCodable.self, forKey: .data)
78+
79+
self.data = data ?? AnyCodable.init(nilLiteral: ())
80+
self.errors = try container.decodeIfPresent([GraphQLError].self, forKey: .errors)
81+
self.hasNext = try container.decodeIfPresent(Bool.self, forKey: .hasNext)
82+
self.extensions = try container.decodeIfPresent([String : AnyCodable].self, forKey: .extensions)
83+
}
6884
}
6985

7086
// MARK: - Extra

Sources/SwiftGraphQL/Selection/Selection+Transform.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public extension Selection {
5555
switch fields.__state {
5656
case let .decoding(data):
5757
switch data.value {
58-
case is Void:
58+
case is Void, is NSNull:
5959
throw ObjectDecodingError.unexpectedNilValue
6060
default:
6161
return try self.__decode(data: data)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import GraphQL
2+
import XCTest
3+
4+
/// A suite of tests that check all edge cases of the response format as described in [GraphQL Spec Response Format](http://spec.graphql.org/October2021/#sec-Response-Format) section.
5+
final class ExecutionTests: XCTestCase {
6+
7+
func testExecutionWithDataAndErrors() throws {
8+
let result: ExecutionResult = """
9+
{
10+
"data": "Hello World!",
11+
"errors": [
12+
{
13+
"message": "Message.",
14+
"locations": [ { "line": 6, "column": 7 } ],
15+
"path": [ "hero", "heroFriends", 1, "name" ]
16+
}
17+
]
18+
}
19+
""".decode()
20+
21+
XCTAssertEqual(
22+
result,
23+
ExecutionResult(
24+
data: AnyCodable("Hello World!"),
25+
errors: [
26+
GraphQL.GraphQLError(
27+
message: "Message.",
28+
locations: [
29+
GraphQL.GraphQLError.Location(line: 6, column: 7)
30+
],
31+
path: [
32+
GraphQL.GraphQLError.PathLink.path("hero"),
33+
GraphQL.GraphQLError.PathLink.path("heroFriends"),
34+
GraphQL.GraphQLError.PathLink.index(1),
35+
GraphQL.GraphQLError.PathLink.path("name")
36+
],
37+
extensions: nil
38+
)
39+
],
40+
hasNext: nil,
41+
extensions: nil
42+
)
43+
)
44+
}
45+
46+
func testExecutionWithErrorsField() throws {
47+
let result: ExecutionResult = """
48+
{
49+
"errors": [
50+
{
51+
"message": "Message.",
52+
"locations": [ { "line": 6, "column": 7 } ],
53+
"path": [ "hero", "heroFriends", 1, "name" ]
54+
}
55+
]
56+
}
57+
""".decode()
58+
59+
XCTAssertEqual(
60+
result,
61+
GraphQL.ExecutionResult(
62+
data: nil,
63+
errors: [
64+
GraphQL.GraphQLError(
65+
message: "Message.",
66+
locations: [
67+
GraphQL.GraphQLError.Location(line: 6, column: 7)
68+
],
69+
path: [
70+
GraphQL.GraphQLError.PathLink.path("hero"),
71+
GraphQL.GraphQLError.PathLink.path("heroFriends"),
72+
GraphQL.GraphQLError.PathLink.index(1),
73+
GraphQL.GraphQLError.PathLink.path("name")
74+
],
75+
extensions: nil
76+
)
77+
],
78+
hasNext: nil,
79+
extensions: nil
80+
)
81+
)
82+
}
83+
84+
func testExecutionWithoutErrorsField() throws {
85+
let result: ExecutionResult = """
86+
{
87+
"data": "Hello World!"
88+
}
89+
""".decode()
90+
91+
XCTAssertEqual(
92+
result,
93+
GraphQL.ExecutionResult(
94+
data: AnyCodable("Hello World!"),
95+
errors: nil,
96+
hasNext: nil,
97+
extensions: nil
98+
)
99+
)
100+
}
101+
102+
func testExecutionWithErrorsWithExtensions() throws {
103+
let result: ExecutionResult = """
104+
{
105+
"errors": [
106+
{
107+
"message": "Bad Request Exception",
108+
"extensions": {
109+
"code": "BAD_USER_INPUT",
110+
}
111+
}
112+
],
113+
"data": null
114+
}
115+
""".decode()
116+
117+
XCTAssertEqual(
118+
result,
119+
GraphQL.ExecutionResult(
120+
data: nil,
121+
errors: [
122+
GraphQL.GraphQLError(
123+
message: "Bad Request Exception",
124+
locations: nil,
125+
path: nil,
126+
extensions: [
127+
"code": AnyCodable("BAD_USER_INPUT")
128+
]
129+
)
130+
],
131+
hasNext: nil,
132+
extensions: nil
133+
)
134+
)
135+
}
136+
}
137+
138+
139+
extension String {
140+
/// Converts a string representation of a GraphQL result into the execution result if possible.
141+
fileprivate func decode() -> ExecutionResult {
142+
let decoder = JSONDecoder()
143+
return try! decoder.decode(ExecutionResult.self, from: self.data(using: .utf8)!)
144+
}
145+
}

Tests/SwiftGraphQLTests/Selection/SelectionDecodingTests.swift

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,25 @@ final class SelectionDecodingTests: XCTestCase {
4646
XCTAssertEqual(decoded, nil)
4747
XCTAssertEqual(result.errors, nil)
4848
}
49+
50+
func testMissingDataField() throws {
51+
let result: ExecutionResult = """
52+
{}
53+
""".execution()
54+
55+
let selection = Selection<String?, String?> {
56+
switch $0.__state {
57+
case let .decoding(data):
58+
return try String?(from: data)
59+
case .selecting:
60+
return "wrong"
61+
}
62+
}
63+
64+
let decoded = try selection.decode(raw: result.data)
65+
XCTAssertEqual(decoded, nil)
66+
XCTAssertEqual(result.errors, nil)
67+
}
4968

5069
func testNullableNSNull() throws {
5170
let result: ExecutionResult = """
@@ -194,13 +213,39 @@ final class SelectionDecodingTests: XCTestCase {
194213

195214
XCTAssertThrowsError(try selection.nonNullOrFail.decode(raw: result.data)) { (error) -> Void in
196215
switch error {
197-
case let ScalarDecodingError.unexpectedScalarType(expected: expected, received: _):
198-
XCTAssertEqual(expected, "String")
216+
case ObjectDecodingError.unexpectedNilValue:
217+
()
199218
default:
200219
XCTFail()
201220
}
202221
}
203222
}
223+
224+
func testInvalidScalarError() throws {
225+
let result: ExecutionResult = """
226+
{
227+
"data": 1
228+
}
229+
""".execution()
230+
231+
let selection = Selection<String, String> {
232+
switch $0.__state {
233+
case let .decoding(data):
234+
return try String(from: data)
235+
case .selecting:
236+
return "wrong"
237+
}
238+
}
239+
240+
XCTAssertThrowsError(try selection.nonNullOrFail.decode(raw: result.data)) { (error) -> Void in
241+
switch error {
242+
case let ScalarDecodingError.unexpectedScalarType(expected: expected, received: _):
243+
XCTAssertEqual(expected, "String")
244+
default:
245+
XCTFail()
246+
}
247+
}
248+
}
204249

205250
func testCustomError() throws {
206251
let result: ExecutionResult = """

0 commit comments

Comments
 (0)