diff --git a/Sources/Buildkite/Networking/JSONValue.swift b/Sources/Buildkite/Networking/JSONValue.swift index 6d31297..88969b3 100644 --- a/Sources/Buildkite/Networking/JSONValue.swift +++ b/Sources/Buildkite/Networking/JSONValue.swift @@ -14,6 +14,7 @@ import Foundation import FoundationNetworking #endif +@dynamicMemberLookup public enum JSONValue { case null case bool(Bool) @@ -24,53 +25,31 @@ public enum JSONValue { } public extension JSONValue { - subscript(_ index: Int) -> JSONValue? { - get { - guard case let .array(array) = self else { - return nil - } - return array[index] - } - set { - guard case var .array(array) = self, - let newValue = newValue else { - return - } - array[index] = newValue - } + subscript(dynamicMember key: JSONValue) -> JSONValue? { + self[key] } - - subscript(_ key: String) -> JSONValue? { - get { - guard case let .object(object) = self else { - return nil - } - return object[key] - } - set { - guard case var .object(object) = self else { - return - } - object[key] = newValue + + subscript(_ key: JSONValue) -> JSONValue? { + if case let .number(key) = key { + return self[Int(key)] + } else if case let .string(key) = key { + return self[key] } + return nil } - - subscript(_ key: JSONValue) -> JSONValue? { - get { - if case let .number(key) = key { - return self[Int(key)] - } else if case let .string(key) = key { - return self[key] - } + + subscript(_ index: Int) -> JSONValue? { + guard case let .array(array) = self else { return nil } - set { - if case let .number(key) = key { - self[Int(key)] = newValue - } else if case let .string(key) = key { - self[key] = newValue - } + return array[index] + } + + subscript(_ key: String) -> JSONValue? { + guard case let .object(object) = self else { + return nil } + return object[key] } } @@ -80,16 +59,16 @@ extension JSONValue: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .null: - try container.encodeNil() + case .null: + try container.encodeNil() case .bool(let boolValue): try container.encode(boolValue) case .number(let doubleValue): try container.encode(doubleValue) - case .string(let stringValue): - try container.encode(stringValue) - case .array(let arrayValue): - try container.encode(arrayValue) + case .string(let stringValue): + try container.encode(stringValue) + case .array(let arrayValue): + try container.encode(arrayValue) case .object(let objectValue): try container.encode(objectValue) } @@ -102,32 +81,21 @@ extension JSONValue: Decodable { if singleValueContainer.decodeNil() { self = .null - return - } - if let boolValue = try? singleValueContainer.decode(Bool.self) { + } else if let boolValue = try? singleValueContainer.decode(Bool.self) { self = .bool(boolValue) - return - } - if let doubleValue = try? singleValueContainer.decode(Double.self) { + } else if let doubleValue = try? singleValueContainer.decode(Double.self) { self = .number(doubleValue) - return - } - if let stringValue = try? singleValueContainer.decode(String.self) { + } else if let stringValue = try? singleValueContainer.decode(String.self) { self = .string(stringValue) - return - } - if let arrayValue = try? singleValueContainer.decode([JSONValue].self) { + } else if let arrayValue = try? singleValueContainer.decode([JSONValue].self) { self = .array(arrayValue) - return - } - if let objectValue = try? singleValueContainer.decode([String: JSONValue].self) { + } else if let objectValue = try? singleValueContainer.decode([String: JSONValue].self) { self = .object(objectValue) - return + } else { + throw DecodingError.dataCorruptedError( + in: singleValueContainer, + debugDescription: "invalid JSON structure or the input was not JSON") } - - throw DecodingError.dataCorruptedError( - in: singleValueContainer, - debugDescription: "invalid JSON structure or the input was not JSON") } } diff --git a/Sources/Buildkite/Networking/StatusCode.swift b/Sources/Buildkite/Networking/StatusCode.swift index c05ecf7..55b55a4 100644 --- a/Sources/Buildkite/Networking/StatusCode.swift +++ b/Sources/Buildkite/Networking/StatusCode.swift @@ -21,7 +21,7 @@ public enum StatusCode: Int, Error, Codable { /// The request has been accepted, but not yet processed. case accepted = 202 - + /// The request has been successfully processed, and is not returning any content case noContent = 204 @@ -48,7 +48,7 @@ public enum StatusCode: Int, Error, Codable { /// An internal error occurred in Buildkite. case internalServerError = 500 - + /// The server was acting as a gateway or proxy and received an invalid response from the upstream server. case badGateway = 502 diff --git a/Sources/Buildkite/Resources/GraphQL.swift b/Sources/Buildkite/Resources/GraphQL.swift index 7a3a763..910dbe4 100644 --- a/Sources/Buildkite/Resources/GraphQL.swift +++ b/Sources/Buildkite/Resources/GraphQL.swift @@ -20,22 +20,42 @@ public struct GraphQL: Resource, HasResponseBody, HasRequestBody { public var variables: JSONValue } - public struct Content: Decodable { - public var data: T? - public var type: String? - public var errors: [Error]? + // Making a design decision here to forbid supplying data and errors + // simultaneously. The GraphQL spec permits it but multiple implementations, + // seemingly including Buildkite's, choose not to. + public enum Content: Error, Decodable { + case data(T) + case errors([Error], type: String?) - public struct Error: Swift.Error, Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let errors = try container.decodeIfPresent([Error].self, forKey: .errors) { + let type = try container.decodeIfPresent(String.self, forKey: .type) + self = .errors(errors, type: type) + } else if let data = try container.decodeIfPresent(T.self, forKey: .data) { + self = .data(data) + } else { + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "The GraphQL response does not contain either errors or data. One is required. If errors are present, they will be considered instead of any data that may have also been sent.")) + } + } + + public struct Error: Swift.Error, Equatable, Hashable, Decodable { public var message: String public var locations: [Location]? public var path: [String]? public var extensions: JSONValue? - public struct Location: Decodable { + public struct Location: Equatable, Hashable, Decodable { public var line: Int public var column: Int } } + + private enum CodingKeys: String, CodingKey { + case data + case type + case errors + } } public var body: Body @@ -54,3 +74,6 @@ public struct GraphQL: Resource, HasResponseBody, HasRequestBody { request.httpMethod = "POST" } } + +extension GraphQL.Content: Equatable where T: Equatable {} +extension GraphQL.Content: Hashable where T: Hashable {} diff --git a/Tests/BuildkiteTests/Networking/JSONValueTests.swift b/Tests/BuildkiteTests/Networking/JSONValueTests.swift index ade1cea..40cf402 100644 --- a/Tests/BuildkiteTests/Networking/JSONValueTests.swift +++ b/Tests/BuildkiteTests/Networking/JSONValueTests.swift @@ -113,4 +113,29 @@ final class JSONValueTests: XCTestCase { let actual = try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(expected)) XCTAssertEqual(expected, actual) } + + func testSubscripts() throws { + let abcs: JSONValue = ["a", "b", "c"] + let oneTwoThrees: JSONValue = ["1": 1, "2": 2, "3": 3] + let expected: JSONValue = ["foo": ["bar": false, "baz": abcs, "qux": oneTwoThrees]] + + XCTAssertNil(expected[expected]) + XCTAssertNil(expected[abcs]) + XCTAssertNil(expected[false]) + XCTAssertNil(expected[1]) + + XCTAssertNil(abcs["0"]) + XCTAssertNotNil(abcs[0]) + XCTAssertEqual(abcs[1], abcs[.number(1)]) + + XCTAssertNil(oneTwoThrees[1]) + XCTAssertNotNil(oneTwoThrees["1"]) + XCTAssertEqual(oneTwoThrees["1"], oneTwoThrees[.string("1")]) + + XCTAssertEqual(expected.foo, expected["foo"]) + XCTAssertEqual(expected.foo?.bar, false) + XCTAssertEqual(expected.foo?.baz, abcs) + XCTAssertEqual(expected.foo?.qux, oneTwoThrees) + + } } diff --git a/Tests/BuildkiteTests/Resources/GraphQLTests.swift b/Tests/BuildkiteTests/Resources/GraphQLTests.swift index 7dfaa02..fc2003a 100644 --- a/Tests/BuildkiteTests/Resources/GraphQLTests.swift +++ b/Tests/BuildkiteTests/Resources/GraphQLTests.swift @@ -15,7 +15,7 @@ import FoundationNetworking #endif class GraphQLTests: XCTestCase { - func testGraphQL() throws { + func testGraphQLSuccess() throws { let expected: JSONValue = ["jeff": [1, 2, 3], "horses": false] let content: JSONValue = ["data": expected] let context = try MockContext(content: content) @@ -25,7 +25,7 @@ class GraphQLTests: XCTestCase { context.client.send(GraphQL(rawQuery: "query MyQuery{jeff,horses}", variables: [:])) { result in do { let response = try result.get() - XCTAssertEqual(expected, response.content.data) + XCTAssertEqual(GraphQL.Content.data(expected), response.content) } catch { XCTFail(error.localizedDescription) } @@ -33,4 +33,70 @@ class GraphQLTests: XCTestCase { } wait(for: [expectation]) } + + func testGraphQLErrors() throws { + let expectedErrors: [GraphQL.Content.Error] = [ + .init(message: "Field 'id' doesn't exist on type 'Query'", + locations: [.init(line: 2, column: 3)], + path: ["query SimpleQuery", "id"], + extensions: [ + "code": "undefinedField", + "typeName": "Query", + "fieldName": "id" + ]) + ] + let expected: GraphQL.Content = .errors(expectedErrors, type: nil) + let content: JSONValue = [ + "errors": .array(expectedErrors.map { error in + let messageJSON: JSONValue = .string(error.message) + let locationsJSON: JSONValue + if let locations = error.locations { + locationsJSON = .array(locations.map { .object(["line": .number(Double($0.line)), "column": .number(Double($0.column))]) }) + } else { + locationsJSON = .null + } + let pathJSON: JSONValue + if let path = error.path { + pathJSON = .array(path.map { .string($0) }) + } else { + pathJSON = .null + } + let extensionsJSON = error.extensions ?? .null + + return [ + "message": messageJSON, + "locations": locationsJSON, + "path": pathJSON, + "extensions": extensionsJSON + ] + }) + ] + let context = try MockContext(content: content) + + let expectation = XCTestExpectation() + + context.client.send(GraphQL(rawQuery: "query MyQuery{jeff,horses}", variables: [:])) { result in + do { + let response = try result.get() + XCTAssertEqual(expected, response.content) + } catch { + XCTFail(error.localizedDescription) + } + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testGraphQLIncompatibleResponse() throws { + let content: JSONValue = [:] + let context = try MockContext(content: content) + + let expectation = XCTestExpectation() + + context.client.send(GraphQL(rawQuery: "", variables: [:])) { + try? XCTAssertThrowsError($0.get(), "Expected to have failed with an error, but closure fulfilled normally") + expectation.fulfill() + } + wait(for: [expectation]) + } }