Skip to content

Commit

Permalink
Merge pull request #3 from aaronsky/graphql_content_as_sum_type
Browse files Browse the repository at this point in the history
Trying out GraphQL.Content as a sum type to see if this is compatible with the API
  • Loading branch information
aaronsky authored May 27, 2020
2 parents 8af051e + bd8c3a9 commit e679c00
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 77 deletions.
102 changes: 35 additions & 67 deletions Sources/Buildkite/Networking/JSONValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Foundation
import FoundationNetworking
#endif

@dynamicMemberLookup
public enum JSONValue {
case null
case bool(Bool)
Expand All @@ -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]
}
}

Expand All @@ -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)
}
Expand All @@ -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")
}
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/Buildkite/Networking/StatusCode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
35 changes: 29 additions & 6 deletions Sources/Buildkite/Resources/GraphQL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,42 @@ public struct GraphQL<T: Decodable>: 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
Expand All @@ -54,3 +74,6 @@ public struct GraphQL<T: Decodable>: Resource, HasResponseBody, HasRequestBody {
request.httpMethod = "POST"
}
}

extension GraphQL.Content: Equatable where T: Equatable {}
extension GraphQL.Content: Hashable where T: Hashable {}
25 changes: 25 additions & 0 deletions Tests/BuildkiteTests/Networking/JSONValueTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

}
}
70 changes: 68 additions & 2 deletions Tests/BuildkiteTests/Resources/GraphQLTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -25,12 +25,78 @@ class GraphQLTests: XCTestCase {
context.client.send(GraphQL<JSONValue>(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)
}
expectation.fulfill()
}
wait(for: [expectation])
}

func testGraphQLErrors() throws {
let expectedErrors: [GraphQL<JSONValue>.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<JSONValue>.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<JSONValue>(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<JSONValue>(rawQuery: "", variables: [:])) {
try? XCTAssertThrowsError($0.get(), "Expected to have failed with an error, but closure fulfilled normally")
expectation.fulfill()
}
wait(for: [expectation])
}
}

0 comments on commit e679c00

Please sign in to comment.