From 63fa589770ef68a20f5a33eeadd6350653de2bf1 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Fri, 26 Apr 2024 10:56:11 -1000 Subject: [PATCH 01/16] fix: Use GraphQL to improve workflow result fetching --- Builds/Models/ApplicationModel.swift | 34 +++ .../BuildsCore/Service/GitHubGraphQL.swift | 68 +++++ .../BuildsCore/Service/GraphQLClient.swift | 272 ++++++++++++++++++ .../Service/UnknownCodingKeys.swift | 37 +++ 4 files changed, 411 insertions(+) create mode 100644 BuildsCore/Sources/BuildsCore/Service/GitHubGraphQL.swift create mode 100644 BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift create mode 100644 BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index a773712..1fa2a32 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -227,6 +227,18 @@ class ApplicationModel: NSObject, ObservableObject { sync() updateOrganizations() updateResults() + + Task { + do { + guard let accessToken = settings.accessToken else { + print("Failed to get access token") + return + } + try await testStuff(accessToken: accessToken) + } catch { + print("Failed to perform GraphQL query with error \(error).") + } + } } @MainActor func addWorkflow(_ id: WorkflowInstance.ID) { @@ -410,4 +422,26 @@ class ApplicationModel: NSObject, ObservableObject { #endif + func testStuff(accessToken: String) async throws { + + let login = Property("login") + let bio = Property("bio") + let viewer = Field("viewer") { // <- So this is really a User. + login + bio + } + + let userQuery = Query { + viewer + } + + let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) + let result = try await client.query(userQuery, accessToken: accessToken) + + print(result) + print(result[viewer]) + print(result[viewer][login]) + } + + } diff --git a/BuildsCore/Sources/BuildsCore/Service/GitHubGraphQL.swift b/BuildsCore/Sources/BuildsCore/Service/GitHubGraphQL.swift new file mode 100644 index 0000000..e4456f0 --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/Service/GitHubGraphQL.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +struct GitHubGraphQL { + + enum CheckConclusionState: String, Codable { + case actionRequired = "ACTION_REQUIRED" + case timedOut = "TIMED_OUT" + case cancelled = "CANCELLED" + case failure = "FAILURE" + case success = "SUCCESS" + case neutral = "NEUTRAL" + case skipped = "SKIPPED" + case startupFailure = "STARTUP_FAILURE" + case stale = "STALE" + } + + struct WorkflowRun: Identifiable, Codable { + let id: String + + let createdAt: Date + let updatedAt: Date + let file: WorkflowRunFile + let resourcePath: URL + let checkSuite: CheckSuite + } + + struct WorkflowRunFile: Identifiable, Codable { + let id: String + + let path: String + } + + struct CheckSuite: Identifiable, Codable { + + let id: String + + let conclusion: CheckConclusionState + let commit: Commit + } + + struct Commit: Identifiable, Codable { + + let id: String + + let oid: String + } + +} diff --git a/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift b/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift new file mode 100644 index 0000000..4cd1b33 --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift @@ -0,0 +1,272 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public protocol PropertyType: Queryable { + associatedtype UnderlyingType +} + +public protocol Queryable: Identifiable { + + associatedtype Response: Decodable + + var id: UUID { get } + + func query() -> String + func decodeResponse(_ data: Data) throws -> Response + func decode(from decoder: any Decoder) throws -> Response // TODO: This is perhaps how we drop the decodable requirement on response? + func decode(from container: KeyedDecodingContainer) throws -> Response +} + +// Is this a property or a field and does it have an alias? +public struct Property: PropertyType { + + public typealias Response = Datatype + public typealias UnderlyingType = Datatype + + public let id = UUID() + + let name: String + let alias: String? + + public init(_ name: String, alias: String? = nil) { + self.name = name + self.alias = alias + } + + // TODO: This needs to actually include the query of the type itself. + public func query() -> String { + if let alias { + return "\(alias): \(name)" + } + return name + } + + // TODO: Remove this! + public func decodeResponse(_ data: Data) throws -> Datatype { + try JSONDecoder().decode(Datatype.self, from: data) + } + + // This is how we get away with named and unnamed. + public func decode(from decoder: any Decoder) throws -> Datatype { + let container = try decoder.container(keyedBy: UnknownCodingKeys.self) + return try decode(from: container) + } + + public func decode(from container: KeyedDecodingContainer) throws -> Datatype { + return try container.decode(Response.self, forKey: UnknownCodingKeys(stringValue: name)!) + } + +} + +extension Queryable { + static func decodeResponse(_ data: Data) throws -> Response { + try JSONDecoder().decode(Response.self, from: data) + } +} + +@resultBuilder +public struct QueryBuilder { + + public static func buildBlock(_ components: [any Queryable]...) -> [any Queryable] { + components.flatMap { $0 } + } + + /// Add support for both single and collections of constraints. + public static func buildExpression(_ expression: any Queryable) -> [any Queryable] { + [expression] + } + + public static func buildExpression(_ expression: [any Queryable]) -> [any Queryable] { + expression + } +} + +extension CodingUserInfoKey { + static let fields = CodingUserInfoKey(rawValue: "fields")! +} + +public struct QueryResultContainer: Decodable { + + let results: [UUID: Any] + + public init(from decoder: any Decoder) throws { + let fields = decoder.userInfo[.fields] as! [any Queryable] // TODO: Don't crash! + var results: [UUID: Any] = [:] + for field in fields { + results[field.id] = try field.decode(from: decoder) + } + self.results = results + } + + init(from decoder: any Decoder, fields: [any Queryable]) throws { + var results: [UUID: Any] = [:] + for field in fields { + results[field.id] = try field.decode(from: decoder) + } + self.results = results + } + + init(from container: KeyedDecodingContainer, fields: [any Queryable]) throws { + var results: [UUID: Any] = [:] + for field in fields { + results[field.id] = try field.decode(from: container) + } + self.results = results + } + + public subscript(key: T) -> T.Response { + get { + return results[key.id] as! T.Response + } + } + + // TODO: Init with container + +} + + +// TODO: Queryable as a protocol? +// TODO: Query is a named field? +public struct Query: Queryable { + + public let id = UUID() + + public typealias Response = QueryResultContainer + + struct DataContainer: Decodable { + let data: QueryResultContainer + } + + let fields: [any Queryable] + + public init(@QueryBuilder fields: () -> [any Queryable]) { + self.fields = fields() + } + + public func query() -> String { + return (["query {"] + fields.map { $0.query() } + ["}"]) + .joined(separator: "\n") + } + + public func decodeResponse(_ data: Data) throws -> Response { + let decoder = JSONDecoder() + decoder.userInfo = [.fields: fields] // TODO: Is this only suported at the top level? + return try decoder.decode(DataContainer.self, from: data).data + } + + public func decode(from decoder: any Decoder) throws -> Response { + // Once we get here we cannot inject anything into the decoder, ooooh but we can inject it ourselves. + // TODO: This is only going to work if the decoder is set up correctly with our fields. + // TODO: 'data' + return try QueryResultContainer(from: decoder, fields: fields) + } + + public func decode(from container: KeyedDecodingContainer) throws -> QueryResultContainer { + let inner = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, forKey: UnknownCodingKeys(stringValue: "data")!) + return try QueryResultContainer(from: inner, fields: fields) + } + +} + +// TODO: How do nested queries work? + +public struct Field: Queryable { + + public let id = UUID() + + public typealias Response = QueryResultContainer + + let name: String + let alias: String? + + let fields: [any Queryable] + + public init(_ name: String, alias: String? = nil, @QueryBuilder fields: () -> [any Queryable]) { + self.name = name + self.alias = alias + self.fields = fields() + } + + // TODO: Could push this down to a common way of structuring the member data? + public func query() -> String { + return (["\(name) {"] + fields.map { $0.query() } + ["}"]) + .joined(separator: "\n") + } + + public func decodeResponse(_ data: Data) throws -> Response { + let decoder = JSONDecoder() + decoder.userInfo = [.fields: fields] // TODO: Is this only suported at the top level? + return try decoder.decode(Response.self, from: data) + } + + public func decode(from decoder: any Decoder) throws -> Response { + // First thing we need to do here is strip away our name. Really we're just a property and it might be + // nice to implement us as such. But not today. + let container = try decoder.container(keyedBy: UnknownCodingKeys.self) + // TODO: Support ALIASES! + let inner = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, forKey: UnknownCodingKeys(stringValue: name)!) + + + // Once we get here we cannot inject anything into the decoder, ooooh but we can inject it ourselves. + // TODO: This is only going to work if the decoder is set up correctly with our fields. + return try QueryResultContainer(from: inner, fields: fields) + } + + public func decode(from container: KeyedDecodingContainer) throws -> QueryResultContainer { + let inner = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, forKey: UnknownCodingKeys(stringValue: name)!) + return try QueryResultContainer(from: inner, fields: fields) + } + +} + +public struct GraphQLClient { + + struct Query: Codable { + let query: String + } + + let url: URL + + public init(url: URL) { + self.url = url + } + + public func query(_ query: T, accessToken: String) async throws -> T.Response { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(Query(query: query.query())) + + let (data, response) = try await URLSession.shared.data(for: request) + try response.checkHTTPStatusCode() + do { + return try query.decodeResponse(data) // <-- I think this is only used at this level? Unpack here? + } catch { + print(String(data: data, encoding: .utf8) ?? "nil") + throw error + } + + } + +} diff --git a/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift b/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift new file mode 100644 index 0000000..73f2dbd --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift @@ -0,0 +1,37 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public struct UnknownCodingKeys: CodingKey { + + public var stringValue: String + + public init?(stringValue: String) { + self.stringValue = stringValue + } + + public var intValue: Int? + + public init?(intValue: Int) { + self.init(stringValue: "\(intValue)") + self.intValue = intValue + } +} From f07438fa01bb3c6c35f51c1bdb4e9c47e9cd7725 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sat, 27 Apr 2024 21:23:46 -1000 Subject: [PATCH 02/16] Fleshing out the GraphQL implementation --- Builds/Models/ApplicationModel.swift | 48 +- .../SummaryPanelViewController.swift | 10 +- Builds/Views/WorkflowsView.swift | 2 +- .../BuildsCore/Service/GraphQLClient.swift | 500 +++++++++++++++++- .../Service/UnknownCodingKeys.swift | 1 + .../BuildsCoreTests/BuildsCoreTests.swift | 20 + .../BuildsCoreTests/QueryableTests.swift | 106 ++++ 7 files changed, 671 insertions(+), 16 deletions(-) create mode 100644 BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index d825954..6a3d74d 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -424,23 +424,59 @@ class ApplicationModel: NSObject, ObservableObject { func testStuff(accessToken: String) async throws { - let login = Property("login") - let bio = Property("bio") - let viewer = Field("viewer") { // <- So this is really a User. + // TODO: If it's codable and conforms to coding keys can we magic this stuff up? + struct User: StaticSelectable, Resultable { + + enum CodingKeys: String, CodingKey { + case login + case bio + } + + @SelectionBuilder static func selection() -> [any IdentifiableSelection] { + Selection(CodingKeys.login) + Selection(CodingKeys.bio) + } + + let login: String + let bio: String + + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.login = try container.decode(String.self, forKey: .login) + self.bio = try container.decode(String.self, forKey: .bio) + } + + } + + + let login = Selection("login") + let bio = Selection("bio") + let viewer = Selection("viewer") { login bio } - let userQuery = Query { + // TODO: It's really really important we only allow the queries to be injected. + let userQuery = GQLQuery { viewer } let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) let result = try await client.query(userQuery, accessToken: accessToken) - print(result) - print(result[viewer]) print(result[viewer][login]) + +// let viewer = NamedSelection("viewer") +// let userQuery = GQLQuery { +// viewer +// } +// +// let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) +// let result = try await client.query(userQuery, accessToken: accessToken) +// +// print(result) +// let v = result[viewer] +// print(v.login) } diff --git a/Builds/View Controller/SummaryPanelViewController.swift b/Builds/View Controller/SummaryPanelViewController.swift index 4fa344b..8ed8837 100644 --- a/Builds/View Controller/SummaryPanelViewController.swift +++ b/Builds/View Controller/SummaryPanelViewController.swift @@ -33,7 +33,7 @@ class SummaryPanelViewController: NSViewController { private let whilte: SCNNode private let yellow: SCNNode - private var selection: SCNNode? = nil + private var selections: SCNNode? = nil @MainActor private var cancellables = Set() @@ -49,7 +49,7 @@ class SummaryPanelViewController: NSViewController { whilte = scene.rootNode.childNode(withName: "white", recursively: true)! yellow = scene.rootNode.childNode(withName: "yellow", recursively: true)! - selection = whilte + selections = whilte super.init(nibName: nil, bundle: nil) } @@ -116,12 +116,12 @@ class SummaryPanelViewController: NSViewController { case .cancelled: newSelection = red } - guard newSelection != selection else { + guard newSelection != selections else { return } newSelection.isHidden = false - selection?.isHidden = true - selection = newSelection + selections?.isHidden = true + selections = newSelection } .store(in: &cancellables) } diff --git a/Builds/Views/WorkflowsView.swift b/Builds/Views/WorkflowsView.swift index 0d8c31e..868ddfd 100644 --- a/Builds/Views/WorkflowsView.swift +++ b/Builds/Views/WorkflowsView.swift @@ -61,7 +61,7 @@ struct WorkflowsView: View, OpenContext { openContext: self) } primaryAction: { selection in #if os(macOS) - let workflowInstances = workflows.filter(selection: selection) + let workflowInstances = workflows.filter(selections: selections) for workflowRunURL in workflowInstances.compactMap({ $0.workflowRunURL }) { presentURL(workflowRunURL) } diff --git a/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift b/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift index 4cd1b33..a31881f 100644 --- a/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift +++ b/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift @@ -90,7 +90,6 @@ public struct QueryBuilder { components.flatMap { $0 } } - /// Add support for both single and collections of constraints. public static func buildExpression(_ expression: any Queryable) -> [any Queryable] { [expression] } @@ -100,9 +99,6 @@ public struct QueryBuilder { } } -extension CodingUserInfoKey { - static let fields = CodingUserInfoKey(rawValue: "fields")! -} public struct QueryResultContainer: Decodable { @@ -266,7 +262,503 @@ public struct GraphQLClient { print(String(data: data, encoding: .utf8) ?? "nil") throw error } + } + + // TODO: Did we loose some important type-safety in the top-level result? + public func query(_ query: T, accessToken: String) async throws -> T.Datatype { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(Query(query: query.query()!)) // TODO: !!!!!!!! + + let (data, response) = try await URLSession.shared.data(for: request) + try response.checkHTTPStatusCode() + do { + return try query.decode(data) + } catch { + print(String(data: data, encoding: .utf8) ?? "nil") + throw error + } + } + +} + + +// TODO: How do I do aliases? + + +// --------------------------------------------------------------------------------------------------------------------- + +extension CodingUserInfoKey { + static let fields = CodingUserInfoKey(rawValue: "fields")! + static let resultKey = CodingUserInfoKey(rawValue: "resultKey")! + static let selections = CodingUserInfoKey(rawValue: "selections")! +} + + +// TODO: Perhaps this doesn't need to be a protocol? +public protocol Selectable { + + associatedtype Datatype: Resultable + + func selections() -> [any IdentifiableSelection] +} + +public protocol Resultable { + +// // TODO: Rename to codingKey +// init(with container: KeyedDecodingContainer, +// key: UnknownCodingKeys, +// selections: [any IdentifiableSelection]) throws + + init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws + +} + +// Structs whose selection can be defined statically. +// TODO: Perhaps there's a combination protocol these could implement? +public protocol StaticSelectable { + + // TODO: We should probably warn/crash on collisions? + static func selection() -> [any IdentifiableSelection] + +} + +@resultBuilder +public struct SelectionBuilder { + + public static func buildBlock(_ components: [any IdentifiableSelection]...) -> [any IdentifiableSelection] { + components.flatMap { $0 } + } + + public static func buildExpression(_ expression: any IdentifiableSelection) -> [any IdentifiableSelection] { + [expression] + } + + public static func buildExpression(_ expression: [any IdentifiableSelection]) -> [any IdentifiableSelection] { + expression + } +} + +// TODO: It really does make sense to fold all this stuff into NamedSelection we can likely get away with this alone. +public protocol IdentifiableSelection: Selectable { + + var name: String { get } + var alias: String? { get } + var resultKey: String { get } + +} + +extension IdentifiableSelection { + + public func query() -> String? { + var lookup = name + if let alias { + lookup = "\(alias):\(lookup)" + } + let subselection = selections() + .compactMap { selection in + selection.query() + } + .joined(separator: " ") + guard !subselection.isEmpty else { + return lookup + } + return "\(lookup) { \(subselection) }" + } + + // TODO: Perhaps this should only exist on query classes? // Top level `Query` protocol for this? + // Public on query, and private internally? + public func decode(_ data: Data) throws -> Datatype { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.userInfo = [ + .resultKey: resultKey, + .selections: selections() + ] + return try decoder.decode(ResultWrapper.self, from: data).value +// return KeyedContainer([ +// resultKey: result +// ]) + } + + public func result(with container: KeyedDecodingContainer, + codingKey: UnknownCodingKeys, + selections: [any IdentifiableSelection]) throws -> Datatype { + let decoder = MyDecoder(key: codingKey, container: container) + return try Datatype(from: decoder, selections: selections) + } + +} + +// This is the magic that allows us to start the decoding stack by getting to the first-level container. +public struct ResultWrapper: Decodable { + + let value: T.Datatype + + public init(from decoder: any Decoder) throws { + let resultKey = UnknownCodingKeys(stringValue: decoder.userInfo[.resultKey] as! String)! + let selections = decoder.userInfo[.selections] as! [any IdentifiableSelection] + let container = try decoder.container(keyedBy: UnknownCodingKeys.self) + let decoder = MyDecoder(key: resultKey, container: container) + self.value = try T.Datatype(from: decoder, selections: selections) + } + +} + +// TODO: Do queries always returned a named selection and is that how this works? That makes extra sense for the keyed container to be on the result type. +// I think the data type makes no sense here. Is this a side effect of getting the protocols insufficiently fine grained? +public struct GQLQuery: IdentifiableSelection { + + public typealias Datatype = KeyedContainer + + public let name = "query" + public let alias: String? = nil + public let resultKey = "data" + + private let _selection: [any IdentifiableSelection] + + public init(@SelectionBuilder selection: () -> [any IdentifiableSelection]) { + self._selection = selection() + } + + public func selections() -> [any IdentifiableSelection] { + return self._selection + } + +} + +// TODO: Extract the selection key into a separate correlated protocol? +public struct Selection: IdentifiableSelection { + + public typealias Datatype = T + + public let name: String + public let alias: String? + + public var resultKey: String { + return alias ?? name + } + + private let _selections: [any IdentifiableSelection] + + public func selections() -> [any IdentifiableSelection] { + return _selections + } + +} + +public struct KeyedContainer: Resultable { + + // TODO: Rename? + let fields: [String: Any] + + init(_ fields: [String: Any]) { + self.fields = fields + } + + public init(with container: KeyedDecodingContainer, + key: UnknownCodingKeys, + selections: [any IdentifiableSelection]) throws { + let container = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, forKey: key) + var fields: [String: Any] = [:] + for selection in selections { + let codingKey = UnknownCodingKeys(stringValue: selection.resultKey)! // <-- TODO: Convenience for this on the identifier? + fields[selection.resultKey] = try selection.result(with: container, + codingKey: codingKey, + selections: selection.selections()) + } + self.fields = fields + } + + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + let container = try decoder.container(keyedBy: UnknownCodingKeys.self) + var fields: [String: Any] = [:] + for selection in selections { + let codingKey = UnknownCodingKeys(stringValue: selection.resultKey)! // <-- TODO: Convenience for this on the identifier? + fields[selection.resultKey] = try selection.result(with: container, + codingKey: codingKey, + selections: selection.selections()) + } + self.fields = fields + + } + + public subscript(key: Selection) -> Selection.Datatype { + get { + return fields[key.resultKey] as! Selection.Datatype + } + } + + +} + +extension Selection where Datatype == KeyedContainer { + + public init(_ name: String, alias: String? = nil, @SelectionBuilder selection: () -> [any IdentifiableSelection]) { + self.name = name + self.alias = alias + self._selections = selection() + } + +} + +extension Selection where Datatype: StaticSelectable { + + public init(_ name: String, alias: String? = nil) { + self.name = name + self.alias = alias + self._selections = Datatype.selection() + } + + public init(_ name: CodingKey) { + self.name = name.stringValue + self.alias = nil + self._selections = Datatype.selection() + } + +} + +struct Foo: StaticSelectable, Resultable { + + let name: String + + static func selection() -> [any IdentifiableSelection] {[ + Selection("name") + ]} + + public init(with container: KeyedDecodingContainer, + key: UnknownCodingKeys, + selections: [any IdentifiableSelection]) throws { + throw BuildsError.authenticationFailure + } + + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + throw BuildsError.authenticationFailure + } + +} + +extension String: StaticSelectable, Resultable { + + public static func selection() -> [any IdentifiableSelection] { + return [] + } + + public init(with container: KeyedDecodingContainer, + key: UnknownCodingKeys, + selections: [any IdentifiableSelection]) throws { + self = try container.decode(String.self, forKey: key) + } + + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + let container = try decoder.singleValueContainer() + self = try container.decode(String.self) + } + +} + +extension Int: StaticSelectable, Resultable { + + public static func selection() -> [any IdentifiableSelection] { + return [] + } + + public init(with container: KeyedDecodingContainer, + key: UnknownCodingKeys, + selections: [any IdentifiableSelection]) throws { + throw BuildsError.authenticationFailure + } + + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + let container = try decoder.singleValueContainer() + self = try container.decode(Int.self) + } + +} + + +extension IdentifiableSelection { + + func selection() -> String { + return "" +// return Datatype.selection() + } + +} + +struct Bar: StaticSelectable, Resultable { + + let id: Int + let name: String + + static func selection() -> [any IdentifiableSelection] {[ + Selection("id"), + Selection("name"), + ]} + + public init(with container: KeyedDecodingContainer, + key: UnknownCodingKeys, + selections: [any IdentifiableSelection]) throws { + throw BuildsError.authenticationFailure + } + + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + throw BuildsError.authenticationFailure + } + +} + +struct Baz: StaticSelectable { + + let id: Int + let foo: Foo + let bar: Bar + + static func selection() -> [any IdentifiableSelection] {[ + Selection("id"), + Selection("foo"), + Selection("bar"), + ]} + +} + +//struct Catlin: Selectable { +// +// let id: Int +// let foo: String +// let bar: Int +// +// static func selection() -> [any IdentifiableSelection] {[ +// NamedSelection("id"), +//// NamedSelection("inner") {[ +//// NamedSelection("foo"), +//// NamedSelection("bar"), +//// ]}, +// ]} +// +//} + +// Part of the challenge with the object-definition and the dynamic field definition is that one of them requires an +// instance method to define the properties, and the other requires a dynamic callback? + + +// TODO: How do I get the list of properties from an instance, or from a static definition? + + + +public struct MyDecoder { + +// var codingPath: [any CodingKey] +// +// var userInfo: [CodingUserInfoKey : Any] +// +// func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { +// +// } +// +// func unkeyedContainer() throws -> any UnkeyedDecodingContainer { +// +// } +// +// func singleValueContainer() throws -> any SingleValueDecodingContainer { +// +// } + + let key: UnknownCodingKeys + let container: KeyedDecodingContainer + + init(key: UnknownCodingKeys, container: KeyedDecodingContainer) { + self.key = key + self.container = container + } + + public func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { + return try container.nestedContainer(keyedBy: Key.self, forKey: key) + } + + public func singleValueContainer() throws -> any SingleValueDecodingContainer { + return MySingleVlaueDecodingContainer(key: key, container: container) + } + + // TODO: `unkeyedContainer...` + +} + +public struct MySingleVlaueDecodingContainer: SingleValueDecodingContainer { + + // TODO: INVERT THESEE + let key: UnknownCodingKeys + let container: KeyedDecodingContainer + + public var codingPath: [any CodingKey] { + return container.codingPath + [key] + } + + public func decodeNil() -> Bool { + return (try? container.decodeNil(forKey: key)) ?? false + } + + public func decode(_ type: Bool.Type) throws -> Bool { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: String.Type) throws -> String { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Double.Type) throws -> Double { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Float.Type) throws -> Float { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int.Type) throws -> Int { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int8.Type) throws -> Int8 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int16.Type) throws -> Int16 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int32.Type) throws -> Int32 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int64.Type) throws -> Int64 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt.Type) throws -> UInt { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt8.Type) throws -> UInt8 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt16.Type) throws -> UInt16 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt32.Type) throws -> UInt32 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt64.Type) throws -> UInt64 { + return try container.decode(type, forKey: key) + } + public func decode(_ type: T.Type) throws -> T where T : Decodable { + return try container.decode(type, forKey: key) } } diff --git a/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift b/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift index 73f2dbd..30aa37c 100644 --- a/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift +++ b/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift @@ -20,6 +20,7 @@ import Foundation +// Singular!! public struct UnknownCodingKeys: CodingKey { public var stringValue: String diff --git a/BuildsCore/Tests/BuildsCoreTests/BuildsCoreTests.swift b/BuildsCore/Tests/BuildsCoreTests/BuildsCoreTests.swift index 46b3f12..1c67516 100644 --- a/BuildsCore/Tests/BuildsCoreTests/BuildsCoreTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/BuildsCoreTests.swift @@ -1,3 +1,23 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + import XCTest @testable import BuildsCore diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift new file mode 100644 index 0000000..59a7e87 --- /dev/null +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -0,0 +1,106 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import XCTest +@testable import BuildsCore + +final class QueryableTests: XCTestCase { + + func testQueries() throws { + XCTAssertTrue(true) + + XCTAssertEqual(Property("name").query(), "name") + XCTAssertEqual(Property("id").query(), "id") + + XCTAssertEqual(Selection("name").query(), "name") + XCTAssertEqual(Selection("id").query(), "id") + + XCTAssertEqual(Selection("name", alias: "alias").query(), "alias:name") + + // TODO: Can we only allow the block-based selection constructor _when_ the destination is a keyed container? + + let login = Selection("login") + let viewer = Selection("viewer") { + login + } + + XCTAssertEqual(viewer.query(), "viewer { login }") + + let responseData = """ + { + "viewer": { + "login": "cheese" + } + } + """.data(using: .utf8)! + + let result = try viewer.decode(responseData) + print(result.fields) + XCTAssertEqual(result[viewer][login], "cheese") + + XCTAssertEqual(Selection("viewer", alias: "cheese") { + Selection("login") + }.query(), "cheese:viewer { login }") + + XCTAssertEqual(GQLQuery { + Selection("id") + }.query(), "query { id }") + + struct Foo: StaticSelectable, Resultable { + + let id: Int + let name: String + + static func selection() -> [any IdentifiableSelection] {[ + Selection("id"), + Selection("name"), + ]} + + init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + throw BuildsError.authenticationFailure + } + + } + + XCTAssertEqual(Selection("foo").query(), "foo { id name }") + + struct Bar: StaticSelectable, Resultable { + + let id: Int + let name: String + let foo: Foo + + static func selection() -> [any IdentifiableSelection] {[ + Selection("id"), + Selection("name"), + Selection("foo"), + ]} + + init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + throw BuildsError.authenticationFailure + } + + } + + XCTAssertEqual(Selection("bar").query(), "bar { id name foo { id name } }") + + } + +} From 20b860b800f7dfd46aa99599a684a5d2ba6d754d Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sat, 27 Apr 2024 21:50:59 -1000 Subject: [PATCH 03/16] Move everything into separate files --- Builds/Models/ApplicationModel.swift | 8 +- .../GraphQL/CodingUserInfoKey.swift | 26 + .../BuildsCore/GraphQL/GraphQLClient.swift | 54 ++ .../GraphQL/IdentifiableSelection.swift | 69 ++ .../BuildsCore/GraphQL/KeyedContainer.swift | 46 ++ .../BuildsCore/GraphQL/MyDecoder.swift | 43 + .../GraphQL/MySingleValueDecoder.swift | 97 +++ .../Sources/BuildsCore/GraphQL/Query.swift | 43 + .../BuildsCore/GraphQL/ResultWrapper.swift | 37 + .../BuildsCore/GraphQL/Resultable.swift | 27 + .../BuildsCore/GraphQL/Selectable.swift | 29 + .../BuildsCore/GraphQL/Selection.swift | 68 ++ .../BuildsCore/GraphQL/SelectionBuilder.swift | 37 + .../BuildsCore/GraphQL/StaticSelectable.swift | 62 ++ .../BuildsCore/Service/GraphQLClient.swift | 764 ------------------ .../BuildsCoreTests/QueryableTests.swift | 2 +- 16 files changed, 643 insertions(+), 769 deletions(-) create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/Query.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/SelectionBuilder.swift create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift delete mode 100644 BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 6a3d74d..2606cf2 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -424,15 +424,14 @@ class ApplicationModel: NSObject, ObservableObject { func testStuff(accessToken: String) async throws { - // TODO: If it's codable and conforms to coding keys can we magic this stuff up? - struct User: StaticSelectable, Resultable { + struct User: StaticSelectable { enum CodingKeys: String, CodingKey { case login case bio } - @SelectionBuilder static func selection() -> [any IdentifiableSelection] { + @SelectionBuilder static func selections() -> [any IdentifiableSelection] { Selection(CodingKeys.login) Selection(CodingKeys.bio) } @@ -456,8 +455,9 @@ class ApplicationModel: NSObject, ObservableObject { bio } + // TODO: It's really really important we only allow the queries to be injected. - let userQuery = GQLQuery { + let userQuery = Query { viewer } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift b/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift new file mode 100644 index 0000000..14a2061 --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift @@ -0,0 +1,26 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +extension CodingUserInfoKey { + static let resultKey = CodingUserInfoKey(rawValue: "resultKey")! + static let selections = CodingUserInfoKey(rawValue: "selections")! +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift new file mode 100644 index 0000000..1e454a8 --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift @@ -0,0 +1,54 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public struct GraphQLClient { + + struct Query: Codable { + let query: String + } + + let url: URL + + public init(url: URL) { + self.url = url + } + + // TODO: Did we loose some important type-safety in the top-level result? + public func query(_ query: T, accessToken: String) async throws -> T.Datatype { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(Query(query: query.query()!)) // TODO: !!!!!!!! + + let (data, response) = try await URLSession.shared.data(for: request) + try response.checkHTTPStatusCode() + do { + return try query.decode(data) + } catch { + print(String(data: data, encoding: .utf8) ?? "nil") + throw error + } + } + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift new file mode 100644 index 0000000..b0f163e --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift @@ -0,0 +1,69 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// TODO: It really does make sense to fold all this stuff into NamedSelection we can likely get away with this alone. +public protocol IdentifiableSelection: Selectable { + + var name: String { get } + var alias: String? { get } + var resultKey: String { get } + +} + +extension IdentifiableSelection { + + public func query() -> String? { + var lookup = name + if let alias { + lookup = "\(alias):\(lookup)" + } + let subselection = selections() + .compactMap { selection in + selection.query() + } + .joined(separator: " ") + guard !subselection.isEmpty else { + return lookup + } + return "\(lookup) { \(subselection) }" + } + + // TODO: Perhaps this should only exist on query classes? // Top level `Query` protocol for this? + // Public on query, and private internally? + public func decode(_ data: Data) throws -> Datatype { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.userInfo = [ + .resultKey: resultKey, + .selections: selections() + ] + return try decoder.decode(ResultWrapper.self, from: data).value + } + + public func result(with container: KeyedDecodingContainer, + codingKey: UnknownCodingKeys, + selections: [any IdentifiableSelection]) throws -> Datatype { + let decoder = MyDecoder(key: codingKey, container: container) + return try Datatype(from: decoder, selections: selections) + } + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift new file mode 100644 index 0000000..ab5ebbb --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift @@ -0,0 +1,46 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public struct KeyedContainer: Resultable { + + // TODO: Rename? + let fields: [String: Any] + + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + let container = try decoder.container(keyedBy: UnknownCodingKeys.self) + var fields: [String: Any] = [:] + for selection in selections { + let codingKey = UnknownCodingKeys(stringValue: selection.resultKey)! + fields[selection.resultKey] = try selection.result(with: container, + codingKey: codingKey, + selections: selection.selections()) + } + self.fields = fields + } + + public subscript(key: Selection) -> Selection.Datatype { + get { + return fields[key.resultKey] as! Selection.Datatype + } + } + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift b/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift new file mode 100644 index 0000000..ded3f6a --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift @@ -0,0 +1,43 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public struct MyDecoder { + + let key: UnknownCodingKeys + let container: KeyedDecodingContainer + + init(key: UnknownCodingKeys, container: KeyedDecodingContainer) { + self.key = key + self.container = container + } + + public func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { + return try container.nestedContainer(keyedBy: Key.self, forKey: key) + } + + public func singleValueContainer() throws -> any SingleValueDecodingContainer { + return MySingleVlaueDecodingContainer(key: key, container: container) + } + + // TODO: `unkeyedContainer...` + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift b/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift new file mode 100644 index 0000000..7c91df4 --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift @@ -0,0 +1,97 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public struct MySingleVlaueDecodingContainer: SingleValueDecodingContainer { + + // TODO: INVERT THESEE + let key: UnknownCodingKeys + let container: KeyedDecodingContainer + + public var codingPath: [any CodingKey] { + return container.codingPath + [key] + } + + public func decodeNil() -> Bool { + return (try? container.decodeNil(forKey: key)) ?? false + } + + public func decode(_ type: Bool.Type) throws -> Bool { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: String.Type) throws -> String { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Double.Type) throws -> Double { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Float.Type) throws -> Float { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int.Type) throws -> Int { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int8.Type) throws -> Int8 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int16.Type) throws -> Int16 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int32.Type) throws -> Int32 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: Int64.Type) throws -> Int64 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt.Type) throws -> UInt { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt8.Type) throws -> UInt8 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt16.Type) throws -> UInt16 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt32.Type) throws -> UInt32 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: UInt64.Type) throws -> UInt64 { + return try container.decode(type, forKey: key) + } + + public func decode(_ type: T.Type) throws -> T where T : Decodable { + return try container.decode(type, forKey: key) + } + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift new file mode 100644 index 0000000..e22e6dd --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift @@ -0,0 +1,43 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// TODO: Do queries always returned a named selection and is that how this works? That makes extra sense for the keyed container to be on the result type. +// I think the data type makes no sense here. Is this a side effect of getting the protocols insufficiently fine grained? +public struct Query: IdentifiableSelection { + + public typealias Datatype = KeyedContainer + + public let name = "query" + public let alias: String? = nil + public let resultKey = "data" + + private let _selections: [any IdentifiableSelection] + + public init(@SelectionBuilder selection: () -> [any IdentifiableSelection]) { + self._selections = selection() + } + + public func selections() -> [any IdentifiableSelection] { + return self._selections + } + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift new file mode 100644 index 0000000..3d9ea24 --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift @@ -0,0 +1,37 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// TODO: Move into the IdentifiableSelection extension that yields a result? +// This is the magic that allows us to start the decoding stack by getting to the first-level container. +public struct ResultWrapper: Decodable { + + let value: T.Datatype + + public init(from decoder: any Decoder) throws { + let resultKey = UnknownCodingKeys(stringValue: decoder.userInfo[.resultKey] as! String)! + let selections = decoder.userInfo[.selections] as! [any IdentifiableSelection] + let container = try decoder.container(keyedBy: UnknownCodingKeys.self) + let decoder = MyDecoder(key: resultKey, container: container) + self.value = try T.Datatype(from: decoder, selections: selections) + } + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift new file mode 100644 index 0000000..a0e4b6f --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift @@ -0,0 +1,27 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public protocol Resultable { + + init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift new file mode 100644 index 0000000..2b3adbc --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// TODO: Perhaps this doesn't need to be a protocol? +public protocol Selectable { + + associatedtype Datatype: Resultable + + func selections() -> [any IdentifiableSelection] +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift new file mode 100644 index 0000000..8ee7f31 --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// TODO: Extract the selection key into a separate correlated protocol? +public struct Selection: IdentifiableSelection { + + public typealias Datatype = T + + public let name: String + public let alias: String? + + public var resultKey: String { + return alias ?? name + } + + private let _selections: [any IdentifiableSelection] + + public func selections() -> [any IdentifiableSelection] { + return _selections + } + +} + +extension Selection where Datatype == KeyedContainer { + + // TODO: We should probably warn/crash on selection collisions + public init(_ name: String, alias: String? = nil, @SelectionBuilder selection: () -> [any IdentifiableSelection]) { + self.name = name + self.alias = alias + self._selections = selection() + } + +} + +extension Selection where Datatype: StaticSelectable { + + public init(_ name: String, alias: String? = nil) { + self.name = name + self.alias = alias + self._selections = Datatype.selections() + } + + public init(_ name: CodingKey) { + self.name = name.stringValue + self.alias = nil + self._selections = Datatype.selections() + } + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/SelectionBuilder.swift b/BuildsCore/Sources/BuildsCore/GraphQL/SelectionBuilder.swift new file mode 100644 index 0000000..1919e31 --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/SelectionBuilder.swift @@ -0,0 +1,37 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +@resultBuilder +public struct SelectionBuilder { + + public static func buildBlock(_ components: [any IdentifiableSelection]...) -> [any IdentifiableSelection] { + components.flatMap { $0 } + } + + public static func buildExpression(_ expression: any IdentifiableSelection) -> [any IdentifiableSelection] { + [expression] + } + + public static func buildExpression(_ expression: [any IdentifiableSelection]) -> [any IdentifiableSelection] { + expression + } +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift new file mode 100644 index 0000000..28840e6 --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift @@ -0,0 +1,62 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// Structs whose selection can be defined statically. +public protocol StaticSelectable: Resultable { + + @SelectionBuilder static func selections() -> [any IdentifiableSelection] + +} + +extension String: StaticSelectable { + + @SelectionBuilder public static func selections() -> [any IdentifiableSelection] {} + + public init(with container: KeyedDecodingContainer, + key: UnknownCodingKeys, + selections: [any IdentifiableSelection]) throws { + self = try container.decode(String.self, forKey: key) + } + + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + let container = try decoder.singleValueContainer() + self = try container.decode(String.self) + } + +} + +extension Int: StaticSelectable { + + @SelectionBuilder public static func selections() -> [any IdentifiableSelection] {} + + public init(with container: KeyedDecodingContainer, + key: UnknownCodingKeys, + selections: [any IdentifiableSelection]) throws { + throw BuildsError.authenticationFailure + } + + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + let container = try decoder.singleValueContainer() + self = try container.decode(Int.self) + } + +} diff --git a/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift b/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift deleted file mode 100644 index a31881f..0000000 --- a/BuildsCore/Sources/BuildsCore/Service/GraphQLClient.swift +++ /dev/null @@ -1,764 +0,0 @@ -// Copyright (c) 2022-2024 Jason Morley -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -public protocol PropertyType: Queryable { - associatedtype UnderlyingType -} - -public protocol Queryable: Identifiable { - - associatedtype Response: Decodable - - var id: UUID { get } - - func query() -> String - func decodeResponse(_ data: Data) throws -> Response - func decode(from decoder: any Decoder) throws -> Response // TODO: This is perhaps how we drop the decodable requirement on response? - func decode(from container: KeyedDecodingContainer) throws -> Response -} - -// Is this a property or a field and does it have an alias? -public struct Property: PropertyType { - - public typealias Response = Datatype - public typealias UnderlyingType = Datatype - - public let id = UUID() - - let name: String - let alias: String? - - public init(_ name: String, alias: String? = nil) { - self.name = name - self.alias = alias - } - - // TODO: This needs to actually include the query of the type itself. - public func query() -> String { - if let alias { - return "\(alias): \(name)" - } - return name - } - - // TODO: Remove this! - public func decodeResponse(_ data: Data) throws -> Datatype { - try JSONDecoder().decode(Datatype.self, from: data) - } - - // This is how we get away with named and unnamed. - public func decode(from decoder: any Decoder) throws -> Datatype { - let container = try decoder.container(keyedBy: UnknownCodingKeys.self) - return try decode(from: container) - } - - public func decode(from container: KeyedDecodingContainer) throws -> Datatype { - return try container.decode(Response.self, forKey: UnknownCodingKeys(stringValue: name)!) - } - -} - -extension Queryable { - static func decodeResponse(_ data: Data) throws -> Response { - try JSONDecoder().decode(Response.self, from: data) - } -} - -@resultBuilder -public struct QueryBuilder { - - public static func buildBlock(_ components: [any Queryable]...) -> [any Queryable] { - components.flatMap { $0 } - } - - public static func buildExpression(_ expression: any Queryable) -> [any Queryable] { - [expression] - } - - public static func buildExpression(_ expression: [any Queryable]) -> [any Queryable] { - expression - } -} - - -public struct QueryResultContainer: Decodable { - - let results: [UUID: Any] - - public init(from decoder: any Decoder) throws { - let fields = decoder.userInfo[.fields] as! [any Queryable] // TODO: Don't crash! - var results: [UUID: Any] = [:] - for field in fields { - results[field.id] = try field.decode(from: decoder) - } - self.results = results - } - - init(from decoder: any Decoder, fields: [any Queryable]) throws { - var results: [UUID: Any] = [:] - for field in fields { - results[field.id] = try field.decode(from: decoder) - } - self.results = results - } - - init(from container: KeyedDecodingContainer, fields: [any Queryable]) throws { - var results: [UUID: Any] = [:] - for field in fields { - results[field.id] = try field.decode(from: container) - } - self.results = results - } - - public subscript(key: T) -> T.Response { - get { - return results[key.id] as! T.Response - } - } - - // TODO: Init with container - -} - - -// TODO: Queryable as a protocol? -// TODO: Query is a named field? -public struct Query: Queryable { - - public let id = UUID() - - public typealias Response = QueryResultContainer - - struct DataContainer: Decodable { - let data: QueryResultContainer - } - - let fields: [any Queryable] - - public init(@QueryBuilder fields: () -> [any Queryable]) { - self.fields = fields() - } - - public func query() -> String { - return (["query {"] + fields.map { $0.query() } + ["}"]) - .joined(separator: "\n") - } - - public func decodeResponse(_ data: Data) throws -> Response { - let decoder = JSONDecoder() - decoder.userInfo = [.fields: fields] // TODO: Is this only suported at the top level? - return try decoder.decode(DataContainer.self, from: data).data - } - - public func decode(from decoder: any Decoder) throws -> Response { - // Once we get here we cannot inject anything into the decoder, ooooh but we can inject it ourselves. - // TODO: This is only going to work if the decoder is set up correctly with our fields. - // TODO: 'data' - return try QueryResultContainer(from: decoder, fields: fields) - } - - public func decode(from container: KeyedDecodingContainer) throws -> QueryResultContainer { - let inner = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, forKey: UnknownCodingKeys(stringValue: "data")!) - return try QueryResultContainer(from: inner, fields: fields) - } - -} - -// TODO: How do nested queries work? - -public struct Field: Queryable { - - public let id = UUID() - - public typealias Response = QueryResultContainer - - let name: String - let alias: String? - - let fields: [any Queryable] - - public init(_ name: String, alias: String? = nil, @QueryBuilder fields: () -> [any Queryable]) { - self.name = name - self.alias = alias - self.fields = fields() - } - - // TODO: Could push this down to a common way of structuring the member data? - public func query() -> String { - return (["\(name) {"] + fields.map { $0.query() } + ["}"]) - .joined(separator: "\n") - } - - public func decodeResponse(_ data: Data) throws -> Response { - let decoder = JSONDecoder() - decoder.userInfo = [.fields: fields] // TODO: Is this only suported at the top level? - return try decoder.decode(Response.self, from: data) - } - - public func decode(from decoder: any Decoder) throws -> Response { - // First thing we need to do here is strip away our name. Really we're just a property and it might be - // nice to implement us as such. But not today. - let container = try decoder.container(keyedBy: UnknownCodingKeys.self) - // TODO: Support ALIASES! - let inner = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, forKey: UnknownCodingKeys(stringValue: name)!) - - - // Once we get here we cannot inject anything into the decoder, ooooh but we can inject it ourselves. - // TODO: This is only going to work if the decoder is set up correctly with our fields. - return try QueryResultContainer(from: inner, fields: fields) - } - - public func decode(from container: KeyedDecodingContainer) throws -> QueryResultContainer { - let inner = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, forKey: UnknownCodingKeys(stringValue: name)!) - return try QueryResultContainer(from: inner, fields: fields) - } - -} - -public struct GraphQLClient { - - struct Query: Codable { - let query: String - } - - let url: URL - - public init(url: URL) { - self.url = url - } - - public func query(_ query: T, accessToken: String) async throws -> T.Response { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - - let encoder = JSONEncoder() - request.httpBody = try encoder.encode(Query(query: query.query())) - - let (data, response) = try await URLSession.shared.data(for: request) - try response.checkHTTPStatusCode() - do { - return try query.decodeResponse(data) // <-- I think this is only used at this level? Unpack here? - } catch { - print(String(data: data, encoding: .utf8) ?? "nil") - throw error - } - } - - // TODO: Did we loose some important type-safety in the top-level result? - public func query(_ query: T, accessToken: String) async throws -> T.Datatype { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - - let encoder = JSONEncoder() - request.httpBody = try encoder.encode(Query(query: query.query()!)) // TODO: !!!!!!!! - - let (data, response) = try await URLSession.shared.data(for: request) - try response.checkHTTPStatusCode() - do { - return try query.decode(data) - } catch { - print(String(data: data, encoding: .utf8) ?? "nil") - throw error - } - } - -} - - -// TODO: How do I do aliases? - - -// --------------------------------------------------------------------------------------------------------------------- - -extension CodingUserInfoKey { - static let fields = CodingUserInfoKey(rawValue: "fields")! - static let resultKey = CodingUserInfoKey(rawValue: "resultKey")! - static let selections = CodingUserInfoKey(rawValue: "selections")! -} - - -// TODO: Perhaps this doesn't need to be a protocol? -public protocol Selectable { - - associatedtype Datatype: Resultable - - func selections() -> [any IdentifiableSelection] -} - -public protocol Resultable { - -// // TODO: Rename to codingKey -// init(with container: KeyedDecodingContainer, -// key: UnknownCodingKeys, -// selections: [any IdentifiableSelection]) throws - - init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws - -} - -// Structs whose selection can be defined statically. -// TODO: Perhaps there's a combination protocol these could implement? -public protocol StaticSelectable { - - // TODO: We should probably warn/crash on collisions? - static func selection() -> [any IdentifiableSelection] - -} - -@resultBuilder -public struct SelectionBuilder { - - public static func buildBlock(_ components: [any IdentifiableSelection]...) -> [any IdentifiableSelection] { - components.flatMap { $0 } - } - - public static func buildExpression(_ expression: any IdentifiableSelection) -> [any IdentifiableSelection] { - [expression] - } - - public static func buildExpression(_ expression: [any IdentifiableSelection]) -> [any IdentifiableSelection] { - expression - } -} - -// TODO: It really does make sense to fold all this stuff into NamedSelection we can likely get away with this alone. -public protocol IdentifiableSelection: Selectable { - - var name: String { get } - var alias: String? { get } - var resultKey: String { get } - -} - -extension IdentifiableSelection { - - public func query() -> String? { - var lookup = name - if let alias { - lookup = "\(alias):\(lookup)" - } - let subselection = selections() - .compactMap { selection in - selection.query() - } - .joined(separator: " ") - guard !subselection.isEmpty else { - return lookup - } - return "\(lookup) { \(subselection) }" - } - - // TODO: Perhaps this should only exist on query classes? // Top level `Query` protocol for this? - // Public on query, and private internally? - public func decode(_ data: Data) throws -> Datatype { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - decoder.userInfo = [ - .resultKey: resultKey, - .selections: selections() - ] - return try decoder.decode(ResultWrapper.self, from: data).value -// return KeyedContainer([ -// resultKey: result -// ]) - } - - public func result(with container: KeyedDecodingContainer, - codingKey: UnknownCodingKeys, - selections: [any IdentifiableSelection]) throws -> Datatype { - let decoder = MyDecoder(key: codingKey, container: container) - return try Datatype(from: decoder, selections: selections) - } - -} - -// This is the magic that allows us to start the decoding stack by getting to the first-level container. -public struct ResultWrapper: Decodable { - - let value: T.Datatype - - public init(from decoder: any Decoder) throws { - let resultKey = UnknownCodingKeys(stringValue: decoder.userInfo[.resultKey] as! String)! - let selections = decoder.userInfo[.selections] as! [any IdentifiableSelection] - let container = try decoder.container(keyedBy: UnknownCodingKeys.self) - let decoder = MyDecoder(key: resultKey, container: container) - self.value = try T.Datatype(from: decoder, selections: selections) - } - -} - -// TODO: Do queries always returned a named selection and is that how this works? That makes extra sense for the keyed container to be on the result type. -// I think the data type makes no sense here. Is this a side effect of getting the protocols insufficiently fine grained? -public struct GQLQuery: IdentifiableSelection { - - public typealias Datatype = KeyedContainer - - public let name = "query" - public let alias: String? = nil - public let resultKey = "data" - - private let _selection: [any IdentifiableSelection] - - public init(@SelectionBuilder selection: () -> [any IdentifiableSelection]) { - self._selection = selection() - } - - public func selections() -> [any IdentifiableSelection] { - return self._selection - } - -} - -// TODO: Extract the selection key into a separate correlated protocol? -public struct Selection: IdentifiableSelection { - - public typealias Datatype = T - - public let name: String - public let alias: String? - - public var resultKey: String { - return alias ?? name - } - - private let _selections: [any IdentifiableSelection] - - public func selections() -> [any IdentifiableSelection] { - return _selections - } - -} - -public struct KeyedContainer: Resultable { - - // TODO: Rename? - let fields: [String: Any] - - init(_ fields: [String: Any]) { - self.fields = fields - } - - public init(with container: KeyedDecodingContainer, - key: UnknownCodingKeys, - selections: [any IdentifiableSelection]) throws { - let container = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, forKey: key) - var fields: [String: Any] = [:] - for selection in selections { - let codingKey = UnknownCodingKeys(stringValue: selection.resultKey)! // <-- TODO: Convenience for this on the identifier? - fields[selection.resultKey] = try selection.result(with: container, - codingKey: codingKey, - selections: selection.selections()) - } - self.fields = fields - } - - public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { - let container = try decoder.container(keyedBy: UnknownCodingKeys.self) - var fields: [String: Any] = [:] - for selection in selections { - let codingKey = UnknownCodingKeys(stringValue: selection.resultKey)! // <-- TODO: Convenience for this on the identifier? - fields[selection.resultKey] = try selection.result(with: container, - codingKey: codingKey, - selections: selection.selections()) - } - self.fields = fields - - } - - public subscript(key: Selection) -> Selection.Datatype { - get { - return fields[key.resultKey] as! Selection.Datatype - } - } - - -} - -extension Selection where Datatype == KeyedContainer { - - public init(_ name: String, alias: String? = nil, @SelectionBuilder selection: () -> [any IdentifiableSelection]) { - self.name = name - self.alias = alias - self._selections = selection() - } - -} - -extension Selection where Datatype: StaticSelectable { - - public init(_ name: String, alias: String? = nil) { - self.name = name - self.alias = alias - self._selections = Datatype.selection() - } - - public init(_ name: CodingKey) { - self.name = name.stringValue - self.alias = nil - self._selections = Datatype.selection() - } - -} - -struct Foo: StaticSelectable, Resultable { - - let name: String - - static func selection() -> [any IdentifiableSelection] {[ - Selection("name") - ]} - - public init(with container: KeyedDecodingContainer, - key: UnknownCodingKeys, - selections: [any IdentifiableSelection]) throws { - throw BuildsError.authenticationFailure - } - - public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { - throw BuildsError.authenticationFailure - } - -} - -extension String: StaticSelectable, Resultable { - - public static func selection() -> [any IdentifiableSelection] { - return [] - } - - public init(with container: KeyedDecodingContainer, - key: UnknownCodingKeys, - selections: [any IdentifiableSelection]) throws { - self = try container.decode(String.self, forKey: key) - } - - public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { - let container = try decoder.singleValueContainer() - self = try container.decode(String.self) - } - -} - -extension Int: StaticSelectable, Resultable { - - public static func selection() -> [any IdentifiableSelection] { - return [] - } - - public init(with container: KeyedDecodingContainer, - key: UnknownCodingKeys, - selections: [any IdentifiableSelection]) throws { - throw BuildsError.authenticationFailure - } - - public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { - let container = try decoder.singleValueContainer() - self = try container.decode(Int.self) - } - -} - - -extension IdentifiableSelection { - - func selection() -> String { - return "" -// return Datatype.selection() - } - -} - -struct Bar: StaticSelectable, Resultable { - - let id: Int - let name: String - - static func selection() -> [any IdentifiableSelection] {[ - Selection("id"), - Selection("name"), - ]} - - public init(with container: KeyedDecodingContainer, - key: UnknownCodingKeys, - selections: [any IdentifiableSelection]) throws { - throw BuildsError.authenticationFailure - } - - public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { - throw BuildsError.authenticationFailure - } - -} - -struct Baz: StaticSelectable { - - let id: Int - let foo: Foo - let bar: Bar - - static func selection() -> [any IdentifiableSelection] {[ - Selection("id"), - Selection("foo"), - Selection("bar"), - ]} - -} - -//struct Catlin: Selectable { -// -// let id: Int -// let foo: String -// let bar: Int -// -// static func selection() -> [any IdentifiableSelection] {[ -// NamedSelection("id"), -//// NamedSelection("inner") {[ -//// NamedSelection("foo"), -//// NamedSelection("bar"), -//// ]}, -// ]} -// -//} - -// Part of the challenge with the object-definition and the dynamic field definition is that one of them requires an -// instance method to define the properties, and the other requires a dynamic callback? - - -// TODO: How do I get the list of properties from an instance, or from a static definition? - - - -public struct MyDecoder { - -// var codingPath: [any CodingKey] -// -// var userInfo: [CodingUserInfoKey : Any] -// -// func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { -// -// } -// -// func unkeyedContainer() throws -> any UnkeyedDecodingContainer { -// -// } -// -// func singleValueContainer() throws -> any SingleValueDecodingContainer { -// -// } - - let key: UnknownCodingKeys - let container: KeyedDecodingContainer - - init(key: UnknownCodingKeys, container: KeyedDecodingContainer) { - self.key = key - self.container = container - } - - public func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { - return try container.nestedContainer(keyedBy: Key.self, forKey: key) - } - - public func singleValueContainer() throws -> any SingleValueDecodingContainer { - return MySingleVlaueDecodingContainer(key: key, container: container) - } - - // TODO: `unkeyedContainer...` - -} - -public struct MySingleVlaueDecodingContainer: SingleValueDecodingContainer { - - // TODO: INVERT THESEE - let key: UnknownCodingKeys - let container: KeyedDecodingContainer - - public var codingPath: [any CodingKey] { - return container.codingPath + [key] - } - - public func decodeNil() -> Bool { - return (try? container.decodeNil(forKey: key)) ?? false - } - - public func decode(_ type: Bool.Type) throws -> Bool { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: String.Type) throws -> String { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Double.Type) throws -> Double { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Float.Type) throws -> Float { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int.Type) throws -> Int { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int8.Type) throws -> Int8 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int16.Type) throws -> Int16 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int32.Type) throws -> Int32 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int64.Type) throws -> Int64 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt.Type) throws -> UInt { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt8.Type) throws -> UInt8 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt16.Type) throws -> UInt16 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt32.Type) throws -> UInt32 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt64.Type) throws -> UInt64 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: T.Type) throws -> T where T : Decodable { - return try container.decode(type, forKey: key) - } - -} diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift index 59a7e87..ecd1b05 100644 --- a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -59,7 +59,7 @@ final class QueryableTests: XCTestCase { Selection("login") }.query(), "cheese:viewer { login }") - XCTAssertEqual(GQLQuery { + XCTAssertEqual(Query { Selection("id") }.query(), "query { id }") From 7b01bba0b62c2e58ddcad481fc9cf21908835e2f Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sat, 27 Apr 2024 23:15:46 -1000 Subject: [PATCH 04/16] Thoughts and prototype ideas --- Builds/Models/ApplicationModel.swift | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 2606cf2..18923de 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -477,7 +477,36 @@ class ApplicationModel: NSObject, ObservableObject { // print(result) // let v = result[viewer] // print(v.login) + + // TODO: Consider that it would also be possible to copy the RegexBuilder style inline transforms... + // We could always keep the entire extraction process internal to the decode operation and simply call our + // custom inits with a `KeyedContainer` which would save the need for our random faked up Decoder which is + // quite misleading. If we do it this way we don't need to rely on the coding keys at all and we can reduce + // the risk of mismatched implementation. } } + + +// TODO: Would need conformance. +// TODO: Rename KeyedContainer to SelectionResult? +//struct User { +// +// static let login = Selection("login") +// static let bio = Selection("bio") +// +// @SelectionBuilder static func selections() -> [any IdentifiableSelection] { +// login +// bio +// } +// +// let login: String +// let bio: String +// +// public init(_ result: KeyedContainer) throws { +// self.login = try result[Self.login] +// self.bio = try result[Self.bio] +// } +// +//} From 074d94cacf7ae881053b2741912575e6ea7cd417 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 01:14:46 -1000 Subject: [PATCH 05/16] Better protocol isolation --- Builds/Models/ApplicationModel.swift | 21 +++++++++ .../Sources/BuildsCore/GraphQL/Argument.swift | 43 +++++++++++++++++++ .../GraphQL/CodingUserInfoKey.swift | 1 + .../BuildsCore/GraphQL/GraphQLClient.swift | 6 +-- .../GraphQL/IdentifiableSelection.swift | 41 ++++++++---------- .../BuildsCore/GraphQL/KeyedContainer.swift | 12 +++++- .../Sources/BuildsCore/GraphQL/Query.swift | 20 ++++++++- .../BuildsCore/GraphQL/ResultWrapper.swift | 7 ++- .../BuildsCore/GraphQL/Resultable.swift | 2 +- .../BuildsCore/GraphQL/Selectable.swift | 22 +++++++++- .../BuildsCore/GraphQL/Selection.swift | 36 ++++++++++++++-- .../BuildsCoreTests/QueryableTests.swift | 31 ++++++------- 12 files changed, 187 insertions(+), 55 deletions(-) create mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/Argument.swift diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 18923de..7918dbb 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -466,6 +466,27 @@ class ApplicationModel: NSObject, ObservableObject { print(result[viewer][login]) + + let id = Selection("id") + let workflow = Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { + id + } + let workflowQuery = Query { + workflow + } + + let workflowResult = try await client.query(workflowQuery, accessToken: accessToken) + print(workflowResult[workflow][id]) + +// let completeWorkflowQuery = Query { +// Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { +// Fragment("Workflow") { +// +// } +// } +// } + + // let viewer = NamedSelection("viewer") // let userQuery = GQLQuery { // viewer diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Argument.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Argument.swift new file mode 100644 index 0000000..46315cf --- /dev/null +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Argument.swift @@ -0,0 +1,43 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public protocol Argument { + + func representation() -> String + +} + +extension String: Argument { + + public func representation() -> String { + return "\"" + self.replacingOccurrences(of: "\"", with: "\\\"") + "\"" + } + +} + +extension Int: Argument { + + public func representation() -> String { + return String(self) + } + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift b/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift index 14a2061..2c77e06 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift @@ -23,4 +23,5 @@ import Foundation extension CodingUserInfoKey { static let resultKey = CodingUserInfoKey(rawValue: "resultKey")! static let selections = CodingUserInfoKey(rawValue: "selections")! + static let selectable = CodingUserInfoKey(rawValue: "selectable")! } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift index 1e454a8..36d6c38 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift @@ -22,7 +22,7 @@ import Foundation public struct GraphQLClient { - struct Query: Codable { + struct QueryContainer: Codable { let query: String } @@ -33,13 +33,13 @@ public struct GraphQLClient { } // TODO: Did we loose some important type-safety in the top-level result? - public func query(_ query: T, accessToken: String) async throws -> T.Datatype { + public func query(_ query: Query, accessToken: String) async throws -> Query.Datatype { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let encoder = JSONEncoder() - request.httpBody = try encoder.encode(Query(query: query.query()!)) // TODO: !!!!!!!! + request.httpBody = try encoder.encode(QueryContainer(query: query.query()!)) // TODO: !!!!!!!! let (data, response) = try await URLSession.shared.data(for: request) try response.checkHTTPStatusCode() diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift index b0f163e..6963148 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift @@ -20,43 +20,36 @@ import Foundation +// Operation +// Field +// Fragment + // TODO: It really does make sense to fold all this stuff into NamedSelection we can likely get away with this alone. +// TODO: I think this is a field. public protocol IdentifiableSelection: Selectable { var name: String { get } var alias: String? { get } + var arguments: [String: Argument] { get } var resultKey: String { get } } extension IdentifiableSelection { - public func query() -> String? { - var lookup = name - if let alias { - lookup = "\(alias):\(lookup)" - } - let subselection = selections() - .compactMap { selection in - selection.query() + public var prefix: String { + var prefix = name + if !arguments.isEmpty { + let arguments = arguments.map { key, value in + return "\(key): \(value.representation())" } - .joined(separator: " ") - guard !subselection.isEmpty else { - return lookup + .joined(separator: ", ") + prefix = "\(prefix)(\(arguments))" } - return "\(lookup) { \(subselection) }" - } - - // TODO: Perhaps this should only exist on query classes? // Top level `Query` protocol for this? - // Public on query, and private internally? - public func decode(_ data: Data) throws -> Datatype { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - decoder.userInfo = [ - .resultKey: resultKey, - .selections: selections() - ] - return try decoder.decode(ResultWrapper.self, from: data).value + if let alias { + prefix = "\(alias):\(prefix)" + } + return prefix } public func result(with container: KeyedDecodingContainer, diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift index ab5ebbb..18037e8 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift @@ -25,6 +25,16 @@ public struct KeyedContainer: Resultable { // TODO: Rename? let fields: [String: Any] + // TODO: Doing it with an iniit like this feels messy at this point, but maybe it's more reusable? + public init(from container: KeyedDecodingContainer, + selections: [any IdentifiableSelection]) throws { + var fields: [String: Any] = [:] + for selection in selections { + fields[selection.resultKey] = try selection.decode(container) + } + self.fields = fields + } + public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { let container = try decoder.container(keyedBy: UnknownCodingKeys.self) var fields: [String: Any] = [:] @@ -32,7 +42,7 @@ public struct KeyedContainer: Resultable { let codingKey = UnknownCodingKeys(stringValue: selection.resultKey)! fields[selection.resultKey] = try selection.result(with: container, codingKey: codingKey, - selections: selection.selections()) + selections: selection.selections) } self.fields = fields } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift index e22e6dd..ccd1842 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift @@ -26,8 +26,10 @@ public struct Query: IdentifiableSelection { public typealias Datatype = KeyedContainer - public let name = "query" + public let prefix = "query" + public let name = "query" // TODO: Get rido fo this when we drop 'IdentifiableSelection' public let alias: String? = nil + public let arguments: [String : Argument] = [:] public let resultKey = "data" private let _selections: [any IdentifiableSelection] @@ -36,8 +38,22 @@ public struct Query: IdentifiableSelection { self._selections = selection() } - public func selections() -> [any IdentifiableSelection] { + public var selections: [any IdentifiableSelection] { return self._selections } + public func decode(_ data: Data) throws -> Datatype { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.userInfo = [.selectable: self] + return try decoder.decode(ResultWrapper.self, from: data).value + } + + // TODO: This could easily be a block based transform if the Datatype doesn't support the init method. + public func decode(_ container: KeyedDecodingContainer) throws -> Datatype { + let container = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, + forKey: UnknownCodingKeys(stringValue: "data")!) + return try KeyedContainer(from: container, selections: selections) + } + } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift index 3d9ea24..a2bc799 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift @@ -27,11 +27,10 @@ public struct ResultWrapper: Decodable { let value: T.Datatype public init(from decoder: any Decoder) throws { - let resultKey = UnknownCodingKeys(stringValue: decoder.userInfo[.resultKey] as! String)! - let selections = decoder.userInfo[.selections] as! [any IdentifiableSelection] + // TODO: There's some crashy type stuff here that shouldn't be crashy. + let selectable = decoder.userInfo[.selectable] as! any Selectable let container = try decoder.container(keyedBy: UnknownCodingKeys.self) - let decoder = MyDecoder(key: resultKey, container: container) - self.value = try T.Datatype(from: decoder, selections: selections) + self.value = try selectable.decode(container) as! T.Datatype // TODO: The selections aren't needed } } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift index a0e4b6f..ae552ff 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift @@ -23,5 +23,5 @@ import Foundation public protocol Resultable { init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws - + } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift index 2b3adbc..bc271bd 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift @@ -20,10 +20,28 @@ import Foundation -// TODO: Perhaps this doesn't need to be a protocol? public protocol Selectable { associatedtype Datatype: Resultable - func selections() -> [any IdentifiableSelection] + var prefix: String { get } + var selections: [any IdentifiableSelection] { get } + + func decode(_ container: KeyedDecodingContainer) throws -> Datatype +} + +extension Selectable { + + public func query() -> String? { + let subselection = selections + .compactMap { selection in + selection.query() + } + .joined(separator: " ") + guard !subselection.isEmpty else { + return prefix + } + return "\(prefix) { \(subselection) }" + } + } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift index 8ee7f31..18128b7 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift @@ -26,6 +26,7 @@ public struct Selection: IdentifiableSelection { public typealias Datatype = T public let name: String + public let arguments: [String: Argument] public let alias: String? public var resultKey: String { @@ -34,18 +35,43 @@ public struct Selection: IdentifiableSelection { private let _selections: [any IdentifiableSelection] - public func selections() -> [any IdentifiableSelection] { + public var selections: [any IdentifiableSelection] { return _selections } + // TODO: We should get rid of the resultable at some point, but for the time being I think we can keep it? + // TODO: We'll need to think of a better way to implement Resultable such that we don't need to capture the ID. + public func decode(_ container: KeyedDecodingContainer) throws -> Datatype { + let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) + return try Datatype(from: decoder, selections: selections) + } + } +//public struct Fragment: IdentifiableSelection { +// +// private let _selections: [any IdentifiableSelection] +// +// public init(@SelectionBuilder selections: () -> [any IdentifiableSelection]) { +// self._selections = selections() +// } +// +// public func selections() -> [any IdentifiableSelection] { +// return _selections +// } +// +//} + extension Selection where Datatype == KeyedContainer { // TODO: We should probably warn/crash on selection collisions - public init(_ name: String, alias: String? = nil, @SelectionBuilder selection: () -> [any IdentifiableSelection]) { + public init(_ name: String, + alias: String? = nil, + arguments: [String: Argument] = [:], + @SelectionBuilder selection: () -> [any IdentifiableSelection]) { self.name = name self.alias = alias + self.arguments = arguments self._selections = selection() } @@ -53,15 +79,19 @@ extension Selection where Datatype == KeyedContainer { extension Selection where Datatype: StaticSelectable { - public init(_ name: String, alias: String? = nil) { + public init(_ name: String, + alias: String? = nil, + arguments: [String: Argument] = [:]) { self.name = name self.alias = alias + self.arguments = arguments self._selections = Datatype.selections() } public init(_ name: CodingKey) { self.name = name.stringValue self.alias = nil + self.arguments = [:] self._selections = Datatype.selections() } diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift index ecd1b05..03625f7 100644 --- a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -26,14 +26,14 @@ final class QueryableTests: XCTestCase { func testQueries() throws { XCTAssertTrue(true) - XCTAssertEqual(Property("name").query(), "name") - XCTAssertEqual(Property("id").query(), "id") - XCTAssertEqual(Selection("name").query(), "name") XCTAssertEqual(Selection("id").query(), "id") XCTAssertEqual(Selection("name", alias: "alias").query(), "alias:name") + XCTAssertEqual(Selection("node", arguments: ["id" : 12]).query(), "node(id: 12)") + XCTAssertEqual(Selection("node", arguments: ["id" : "12"]).query(), "node(id: \"12\")") + // TODO: Can we only allow the block-based selection constructor _when_ the destination is a keyed container? let login = Selection("login") @@ -51,9 +51,10 @@ final class QueryableTests: XCTestCase { } """.data(using: .utf8)! - let result = try viewer.decode(responseData) - print(result.fields) - XCTAssertEqual(result[viewer][login], "cheese") + // TODO: FAILS! +// let result = try viewer.decode(responseData) +// print(result.fields) +// XCTAssertEqual(result[viewer][login], "cheese") XCTAssertEqual(Selection("viewer", alias: "cheese") { Selection("login") @@ -65,14 +66,14 @@ final class QueryableTests: XCTestCase { struct Foo: StaticSelectable, Resultable { - let id: Int - let name: String - - static func selection() -> [any IdentifiableSelection] {[ + static func selections() -> [any IdentifiableSelection] {[ Selection("id"), Selection("name"), ]} + let id: Int + let name: String + init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { throw BuildsError.authenticationFailure } @@ -83,16 +84,16 @@ final class QueryableTests: XCTestCase { struct Bar: StaticSelectable, Resultable { - let id: Int - let name: String - let foo: Foo - - static func selection() -> [any IdentifiableSelection] {[ + static func selections() -> [any IdentifiableSelection] {[ Selection("id"), Selection("name"), Selection("foo"), ]} + let id: Int + let name: String + let foo: Foo + init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { throw BuildsError.authenticationFailure } From ca7c64bef1e9a13bf2faabe74b47b8c1ab4ef4e4 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 07:41:25 -1000 Subject: [PATCH 06/16] Simple decoding of fragments and arrays now working --- Builds/Models/ApplicationModel.swift | 31 +++- .../BuildsCore/GraphQL/GraphQLClient.swift | 1 + .../GraphQL/IdentifiableSelection.swift | 62 -------- .../BuildsCore/GraphQL/KeyedContainer.swift | 34 +++-- .../Sources/BuildsCore/GraphQL/Query.swift | 12 +- .../BuildsCore/GraphQL/ResultWrapper.swift | 3 +- .../BuildsCore/GraphQL/Resultable.swift | 3 +- .../BuildsCore/GraphQL/Selectable.swift | 9 +- .../BuildsCore/GraphQL/Selection.swift | 133 ++++++++++++++---- .../BuildsCore/GraphQL/SelectionBuilder.swift | 6 +- .../BuildsCore/GraphQL/StaticSelectable.swift | 32 ++--- .../BuildsCoreTests/QueryableTests.swift | 8 +- 12 files changed, 191 insertions(+), 143 deletions(-) delete mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 7918dbb..f2f6121 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -431,7 +431,7 @@ class ApplicationModel: NSObject, ObservableObject { case bio } - @SelectionBuilder static func selections() -> [any IdentifiableSelection] { + @SelectionBuilder static func selections() -> [any Selectable] { Selection(CodingKeys.login) Selection(CodingKeys.bio) } @@ -439,7 +439,7 @@ class ApplicationModel: NSObject, ObservableObject { let login: String let bio: String - public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + public init(from decoder: MyDecoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.login = try container.decode(String.self, forKey: .login) self.bio = try container.decode(String.self, forKey: .bio) @@ -447,10 +447,9 @@ class ApplicationModel: NSObject, ObservableObject { } - let login = Selection("login") let bio = Selection("bio") - let viewer = Selection("viewer") { + let viewer = Selection("viewer") { login bio } @@ -468,15 +467,35 @@ class ApplicationModel: NSObject, ObservableObject { let id = Selection("id") - let workflow = Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { + + let event = Selection("event") + let createdAt = Selection("createdAt") + + let nodes = Selection>("nodes") { id + event + createdAt + } + + let runs = Selection("runs", arguments: ["first" : 1]) { + nodes + } + + let workflow = Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { + Fragment("... on Workflow") { + runs + } } let workflowQuery = Query { workflow } + print(workflowQuery.query()!) + let workflowResult = try await client.query(workflowQuery, accessToken: accessToken) - print(workflowResult[workflow][id]) + print(workflowResult[workflow][runs][nodes].first![id]) + print(workflowResult[workflow][runs][nodes].first![event]) + print(workflowResult[workflow][runs][nodes].first![createdAt]) // let completeWorkflowQuery = Query { // Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift index 36d6c38..60c2c5f 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift @@ -43,6 +43,7 @@ public struct GraphQLClient { let (data, response) = try await URLSession.shared.data(for: request) try response.checkHTTPStatusCode() + print(String(data: data, encoding: .utf8) ?? "nil") do { return try query.decode(data) } catch { diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift deleted file mode 100644 index 6963148..0000000 --- a/BuildsCore/Sources/BuildsCore/GraphQL/IdentifiableSelection.swift +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2022-2024 Jason Morley -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -// Operation -// Field -// Fragment - -// TODO: It really does make sense to fold all this stuff into NamedSelection we can likely get away with this alone. -// TODO: I think this is a field. -public protocol IdentifiableSelection: Selectable { - - var name: String { get } - var alias: String? { get } - var arguments: [String: Argument] { get } - var resultKey: String { get } - -} - -extension IdentifiableSelection { - - public var prefix: String { - var prefix = name - if !arguments.isEmpty { - let arguments = arguments.map { key, value in - return "\(key): \(value.representation())" - } - .joined(separator: ", ") - prefix = "\(prefix)(\(arguments))" - } - if let alias { - prefix = "\(alias):\(prefix)" - } - return prefix - } - - public func result(with container: KeyedDecodingContainer, - codingKey: UnknownCodingKeys, - selections: [any IdentifiableSelection]) throws -> Datatype { - let decoder = MyDecoder(key: codingKey, container: container) - return try Datatype(from: decoder, selections: selections) - } - -} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift index 18037e8..97e74eb 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift @@ -20,34 +20,50 @@ import Foundation -public struct KeyedContainer: Resultable { +// TODO: We probably don't need half the constructors in here! +public struct KeyedContainer { // TODO: Rename? let fields: [String: Any] + public init(fields: [String: Any]) { + self.fields = fields + } + // TODO: Doing it with an iniit like this feels messy at this point, but maybe it's more reusable? public init(from container: KeyedDecodingContainer, - selections: [any IdentifiableSelection]) throws { + selections: [any Selectable]) throws { var fields: [String: Any] = [:] for selection in selections { - fields[selection.resultKey] = try selection.decode(container) + let (key, value) = try selection.decode(container) + fields[key!] = value // TODO: This seems problematic. } self.fields = fields } - public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + public init(from decoder: MyDecoder, selections: [any Selectable]) throws { let container = try decoder.container(keyedBy: UnknownCodingKeys.self) var fields: [String: Any] = [:] for selection in selections { - let codingKey = UnknownCodingKeys(stringValue: selection.resultKey)! - fields[selection.resultKey] = try selection.result(with: container, - codingKey: codingKey, - selections: selection.selections) + let (key, value) = try selection.decode(container) + + // TODO: There's a lot of guesswork going on here and we should be able to make it typesafe. + // TODO: I can probably do this with some kind of type conditions to mean you can't actually compile it if + // this trick won't work? + if let key { + fields[key] = value + } else if let keyedContainer = value as? KeyedContainer { + for (key, value) in keyedContainer.fields { + fields[key] = value + } + } else { + throw BuildsError.authenticationFailure + } } self.fields = fields } - public subscript(key: Selection) -> Selection.Datatype { + public subscript(key: Selection) -> Selection.Datatype { get { return fields[key.resultKey] as! Selection.Datatype } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift index ccd1842..279e29a 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift @@ -22,7 +22,7 @@ import Foundation // TODO: Do queries always returned a named selection and is that how this works? That makes extra sense for the keyed container to be on the result type. // I think the data type makes no sense here. Is this a side effect of getting the protocols insufficiently fine grained? -public struct Query: IdentifiableSelection { +public struct Query: Selectable { public typealias Datatype = KeyedContainer @@ -32,13 +32,13 @@ public struct Query: IdentifiableSelection { public let arguments: [String : Argument] = [:] public let resultKey = "data" - private let _selections: [any IdentifiableSelection] + private let _selections: [any Selectable] - public init(@SelectionBuilder selection: () -> [any IdentifiableSelection]) { + public init(@SelectionBuilder selection: () -> [any Selectable]) { self._selections = selection() } - public var selections: [any IdentifiableSelection] { + public var selections: [any Selectable] { return self._selections } @@ -50,10 +50,10 @@ public struct Query: IdentifiableSelection { } // TODO: This could easily be a block based transform if the Datatype doesn't support the init method. - public func decode(_ container: KeyedDecodingContainer) throws -> Datatype { + public func decode(_ container: KeyedDecodingContainer) throws -> (String?, Datatype) { let container = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, forKey: UnknownCodingKeys(stringValue: "data")!) - return try KeyedContainer(from: container, selections: selections) + return ("data", try KeyedContainer(from: container, selections: selections)) } } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift index a2bc799..81f382b 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift @@ -30,7 +30,8 @@ public struct ResultWrapper: Decodable { // TODO: There's some crashy type stuff here that shouldn't be crashy. let selectable = decoder.userInfo[.selectable] as! any Selectable let container = try decoder.container(keyedBy: UnknownCodingKeys.self) - self.value = try selectable.decode(container) as! T.Datatype // TODO: The selections aren't needed + let (_, value) = try selectable.decode(container) + self.value = value as! T.Datatype } } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift index ae552ff..9540d2a 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift @@ -20,8 +20,9 @@ import Foundation +// TODO: I think this can now be rolled into StaticSelectable public protocol Resultable { - init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws + init(from decoder: MyDecoder) throws } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift index bc271bd..1c84960 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift @@ -22,16 +22,17 @@ import Foundation public protocol Selectable { - associatedtype Datatype: Resultable + associatedtype Datatype var prefix: String { get } - var selections: [any IdentifiableSelection] { get } - - func decode(_ container: KeyedDecodingContainer) throws -> Datatype + var selections: [any Selectable] { get } + + func decode(_ container: KeyedDecodingContainer) throws -> (String?, Datatype) } extension Selectable { + // TODO: Why is this nullable?????? public func query() -> String? { let subselection = selections .compactMap { selection in diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift index 18128b7..36a9f2c 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift @@ -20,47 +20,65 @@ import Foundation -// TODO: Extract the selection key into a separate correlated protocol? -public struct Selection: IdentifiableSelection { +public struct Selection: Selectable { public typealias Datatype = T - public let name: String - public let arguments: [String: Argument] - public let alias: String? + public var prefix: String { + var prefix = name + if !arguments.isEmpty { + let arguments = arguments.map { key, value in + return "\(key): \(value.representation())" + } + .joined(separator: ", ") + prefix = "\(prefix)(\(arguments))" + } + if let alias { + prefix = "\(alias):\(prefix)" + } + return prefix + } public var resultKey: String { return alias ?? name } - private let _selections: [any IdentifiableSelection] + public let selections: [any Selectable] - public var selections: [any IdentifiableSelection] { - return _selections - } + private let name: String + private let alias: String? + private let arguments: [String: Argument] + private let _decode: (String, KeyedDecodingContainer) throws -> (String?, Datatype) - // TODO: We should get rid of the resultable at some point, but for the time being I think we can keep it? - // TODO: We'll need to think of a better way to implement Resultable such that we don't need to capture the ID. - public func decode(_ container: KeyedDecodingContainer) throws -> Datatype { - let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) - return try Datatype(from: decoder, selections: selections) + public func decode(_ container: KeyedDecodingContainer) throws -> (String?, Datatype) { + return try self._decode(resultKey, container) } } -//public struct Fragment: IdentifiableSelection { -// -// private let _selections: [any IdentifiableSelection] -// -// public init(@SelectionBuilder selections: () -> [any IdentifiableSelection]) { -// self._selections = selections() -// } -// -// public func selections() -> [any IdentifiableSelection] { -// return _selections -// } -// -//} +// TODO: We should constrain our children selections here to guarnatee that they're dictionaries. Like, can they actually +// be anything else? Maybe not???? Oh they probably can't be. Maybe these alwways need to returned a KeyedContainer!! +public struct Fragment: Selectable { + + public typealias Datatype = KeyedContainer + + public let prefix: String + public let selections: [any Selectable] + + public init(_ condition: String, @SelectionBuilder selections: () -> [any Selectable]) { + self.prefix = condition + self.selections = selections() + } + + public func decode(_ container: KeyedDecodingContainer) throws -> (String?, KeyedContainer) { + let fields = try self.selections.map { try $0.decode(container) } + .reduce(into: [String: Any]()) { partialResult, item in + partialResult[item.0!] = item.1 // TODO: GRIM GRIM GRIM. + } + return (nil, KeyedContainer(fields: fields)) + } + +} extension Selection where Datatype == KeyedContainer { @@ -68,11 +86,19 @@ extension Selection where Datatype == KeyedContainer { public init(_ name: String, alias: String? = nil, arguments: [String: Argument] = [:], - @SelectionBuilder selection: () -> [any IdentifiableSelection]) { + @SelectionBuilder selections: () -> [any Selectable]) { + + let selections = selections() + self.name = name self.alias = alias self.arguments = arguments - self._selections = selection() + self.selections = selections + self._decode = { resultKey, container in + // TODO: Assemble this inline! + let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) + return (resultKey, try KeyedContainer(from: decoder, selections: selections)) + } } } @@ -82,17 +108,62 @@ extension Selection where Datatype: StaticSelectable { public init(_ name: String, alias: String? = nil, arguments: [String: Argument] = [:]) { + + let selections = Datatype.selections() + self.name = name self.alias = alias self.arguments = arguments - self._selections = Datatype.selections() + self.selections = selections + self._decode = { resultKey, container in + // TODO: Assemble this inline! + let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) + return (resultKey, try Datatype(from: decoder)) + } } public init(_ name: CodingKey) { + + let selections = Datatype.selections() + self.name = name.stringValue self.alias = nil self.arguments = [:] - self._selections = Datatype.selections() + self.selections = selections + self._decode = { resultKey, container in + // TODO: Assemble this inline! + let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) + return (resultKey, try Datatype(from: decoder)) + } + } + +} + +extension Selection where Datatype == Array { + + public init(_ name: String, + alias: String? = nil, + arguments: [String: Argument] = [:], + @SelectionBuilder selections: () -> [any Selectable]) { + + let selections = selections() + + self.name = name + self.alias = alias + self.arguments = arguments + self.selections = selections + self._decode = { resultKey, container in + var container = try container.nestedUnkeyedContainer(forKey: UnknownCodingKeys(stringValue: resultKey)!) + var results: [KeyedContainer] = [] + while !container.isAtEnd { + let childContainer = try container.nestedContainer(keyedBy: UnknownCodingKeys.self) + results.append(try KeyedContainer(from: childContainer, selections: selections)) + } + +// // TODO: Assemble this inline! +// let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) + return (resultKey, results) + } } } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/SelectionBuilder.swift b/BuildsCore/Sources/BuildsCore/GraphQL/SelectionBuilder.swift index 1919e31..c4db5f5 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/SelectionBuilder.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/SelectionBuilder.swift @@ -23,15 +23,15 @@ import Foundation @resultBuilder public struct SelectionBuilder { - public static func buildBlock(_ components: [any IdentifiableSelection]...) -> [any IdentifiableSelection] { + public static func buildBlock(_ components: [any Selectable]...) -> [any Selectable] { components.flatMap { $0 } } - public static func buildExpression(_ expression: any IdentifiableSelection) -> [any IdentifiableSelection] { + public static func buildExpression(_ expression: any Selectable) -> [any Selectable] { [expression] } - public static func buildExpression(_ expression: [any IdentifiableSelection]) -> [any IdentifiableSelection] { + public static func buildExpression(_ expression: [any Selectable]) -> [any Selectable] { expression } } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift index 28840e6..22787f8 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift @@ -23,21 +23,15 @@ import Foundation // Structs whose selection can be defined statically. public protocol StaticSelectable: Resultable { - @SelectionBuilder static func selections() -> [any IdentifiableSelection] + @SelectionBuilder static func selections() -> [any Selectable] } extension String: StaticSelectable { - @SelectionBuilder public static func selections() -> [any IdentifiableSelection] {} + @SelectionBuilder public static func selections() -> [any Selectable] {} - public init(with container: KeyedDecodingContainer, - key: UnknownCodingKeys, - selections: [any IdentifiableSelection]) throws { - self = try container.decode(String.self, forKey: key) - } - - public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + public init(from decoder: MyDecoder) throws { let container = try decoder.singleValueContainer() self = try container.decode(String.self) } @@ -46,17 +40,23 @@ extension String: StaticSelectable { extension Int: StaticSelectable { - @SelectionBuilder public static func selections() -> [any IdentifiableSelection] {} + // TODO: Can this actually be used to tell me whether something is nested?? That might be cool. + @SelectionBuilder public static func selections() -> [any Selectable] {} - public init(with container: KeyedDecodingContainer, - key: UnknownCodingKeys, - selections: [any IdentifiableSelection]) throws { - throw BuildsError.authenticationFailure + public init(from decoder: MyDecoder) throws { + let container = try decoder.singleValueContainer() + self = try container.decode(Int.self) } - public init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { +} + +extension Date: StaticSelectable { + + @SelectionBuilder public static func selections() -> [any Selectable] {} + + public init(from decoder: MyDecoder) throws { let container = try decoder.singleValueContainer() - self = try container.decode(Int.self) + self = try container.decode(Date.self) } } diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift index 03625f7..595bd4b 100644 --- a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -64,7 +64,7 @@ final class QueryableTests: XCTestCase { Selection("id") }.query(), "query { id }") - struct Foo: StaticSelectable, Resultable { + struct Foo: StaticSelectable { static func selections() -> [any IdentifiableSelection] {[ Selection("id"), @@ -74,7 +74,7 @@ final class QueryableTests: XCTestCase { let id: Int let name: String - init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + init(from decoder: MyDecoder) throws { throw BuildsError.authenticationFailure } @@ -82,7 +82,7 @@ final class QueryableTests: XCTestCase { XCTAssertEqual(Selection("foo").query(), "foo { id name }") - struct Bar: StaticSelectable, Resultable { + struct Bar: StaticSelectable { static func selections() -> [any IdentifiableSelection] {[ Selection("id"), @@ -94,7 +94,7 @@ final class QueryableTests: XCTestCase { let name: String let foo: Foo - init(from decoder: MyDecoder, selections: [any IdentifiableSelection]) throws { + init(from decoder: MyDecoder) throws { throw BuildsError.authenticationFailure } From 8de91a0093a163bea3f6d3ad5652449e3b5c52d1 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 09:41:14 -1000 Subject: [PATCH 07/16] Tidying up! --- Builds/Models/ApplicationModel.swift | 30 +---- .../BuildsCore/GraphQL/GraphQLClient.swift | 2 +- .../BuildsCore/GraphQL/KeyedContainer.swift | 39 +++---- .../BuildsCore/GraphQL/MyDecoder.swift | 16 ++- .../GraphQL/MySingleValueDecoder.swift | 4 +- .../Sources/BuildsCore/GraphQL/Query.swift | 30 +++-- .../BuildsCore/GraphQL/ResultWrapper.swift | 9 +- .../BuildsCore/GraphQL/Selectable.swift | 6 +- .../BuildsCore/GraphQL/Selection.swift | 49 ++++---- .../BuildsCore/Models/BuildsError.swift | 1 + ...odingKeys.swift => UnknownCodingKey.swift} | 3 +- .../BuildsCoreTests/QueryableTests.swift | 107 +++++++++++++++--- 12 files changed, 176 insertions(+), 120 deletions(-) rename BuildsCore/Sources/BuildsCore/Service/{UnknownCodingKeys.swift => UnknownCodingKey.swift} (95%) diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index f2f6121..9f13bf1 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -463,11 +463,10 @@ class ApplicationModel: NSObject, ObservableObject { let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) let result = try await client.query(userQuery, accessToken: accessToken) - print(result[viewer][login]) + print(try result[viewer][login]) let id = Selection("id") - let event = Selection("event") let createdAt = Selection("createdAt") @@ -490,33 +489,14 @@ class ApplicationModel: NSObject, ObservableObject { workflow } - print(workflowQuery.query()!) + print(workflowQuery.query()) let workflowResult = try await client.query(workflowQuery, accessToken: accessToken) - print(workflowResult[workflow][runs][nodes].first![id]) - print(workflowResult[workflow][runs][nodes].first![event]) - print(workflowResult[workflow][runs][nodes].first![createdAt]) - -// let completeWorkflowQuery = Query { -// Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { -// Fragment("Workflow") { -// -// } -// } -// } + print(try workflowResult[workflow][runs][nodes].first![id]) + print(try workflowResult[workflow][runs][nodes].first![event]) + print(try workflowResult[workflow][runs][nodes].first![createdAt]) -// let viewer = NamedSelection("viewer") -// let userQuery = GQLQuery { -// viewer -// } -// -// let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) -// let result = try await client.query(userQuery, accessToken: accessToken) -// -// print(result) -// let v = result[viewer] -// print(v.login) // TODO: Consider that it would also be possible to copy the RegexBuilder style inline transforms... // We could always keep the entire extraction process internal to the decode operation and simply call our diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift index 60c2c5f..0974925 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift @@ -39,7 +39,7 @@ public struct GraphQLClient { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let encoder = JSONEncoder() - request.httpBody = try encoder.encode(QueryContainer(query: query.query()!)) // TODO: !!!!!!!! + request.httpBody = try encoder.encode(QueryContainer(query: query.query())) // TODO: !!!!!!!! let (data, response) = try await URLSession.shared.data(for: request) try response.checkHTTPStatusCode() diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift index 97e74eb..bacac1e 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift @@ -20,10 +20,10 @@ import Foundation -// TODO: We probably don't need half the constructors in here! public struct KeyedContainer { // TODO: Rename? + // TODO: Hide this and make this Sequence / iterable let fields: [String: Any] public init(fields: [String: Any]) { @@ -31,41 +31,32 @@ public struct KeyedContainer { } // TODO: Doing it with an iniit like this feels messy at this point, but maybe it's more reusable? - public init(from container: KeyedDecodingContainer, + public init(from container: KeyedDecodingContainer, selections: [any Selectable]) throws { var fields: [String: Any] = [:] for selection in selections { - let (key, value) = try selection.decode(container) - fields[key!] = value // TODO: This seems problematic. + let keyedContainer = try selection.decode(container) + for (key, value) in keyedContainer.fields { + fields[key] = value + } } self.fields = fields } - public init(from decoder: MyDecoder, selections: [any Selectable]) throws { - let container = try decoder.container(keyedBy: UnknownCodingKeys.self) - var fields: [String: Any] = [:] - for selection in selections { - let (key, value) = try selection.decode(container) - - // TODO: There's a lot of guesswork going on here and we should be able to make it typesafe. - // TODO: I can probably do this with some kind of type conditions to mean you can't actually compile it if - // this trick won't work? - if let key { - fields[key] = value - } else if let keyedContainer = value as? KeyedContainer { - for (key, value) in keyedContainer.fields { - fields[key] = value - } - } else { - throw BuildsError.authenticationFailure + // TODO: Throws? + public subscript(key: Selection) -> Selection.Datatype { + get throws { + guard let value = fields[key.resultKey] as? Selection.Datatype else { + throw BuildsError.unexpectedType } + return value } - self.fields = fields } - public subscript(key: Selection) -> Selection.Datatype { + // TODO: Test convenience + public subscript(key: String) -> KeyedContainer { get { - return fields[key.resultKey] as! Selection.Datatype + return fields[key] as! KeyedContainer } } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift b/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift index ded3f6a..dbfbbab 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift @@ -22,20 +22,24 @@ import Foundation public struct MyDecoder { - let key: UnknownCodingKeys - let container: KeyedDecodingContainer + let key: UnknownCodingKey + let _container: KeyedDecodingContainer - init(key: UnknownCodingKeys, container: KeyedDecodingContainer) { + init(key: UnknownCodingKey, container: KeyedDecodingContainer) { self.key = key - self.container = container + self._container = container + } + + public func container() throws -> KeyedDecodingContainer { + return try self.container(keyedBy: UnknownCodingKey.self) } public func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { - return try container.nestedContainer(keyedBy: Key.self, forKey: key) + return try _container.nestedContainer(keyedBy: Key.self, forKey: key) } public func singleValueContainer() throws -> any SingleValueDecodingContainer { - return MySingleVlaueDecodingContainer(key: key, container: container) + return MySingleVlaueDecodingContainer(key: key, container: _container) } // TODO: `unkeyedContainer...` diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift b/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift index 7c91df4..3d831f5 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift @@ -23,8 +23,8 @@ import Foundation public struct MySingleVlaueDecodingContainer: SingleValueDecodingContainer { // TODO: INVERT THESEE - let key: UnknownCodingKeys - let container: KeyedDecodingContainer + let key: UnknownCodingKey + let container: KeyedDecodingContainer public var codingPath: [any CodingKey] { return container.codingPath + [key] diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift index 279e29a..678f786 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Query.swift @@ -42,18 +42,32 @@ public struct Query: Selectable { return self._selections } + public func query() -> String { // <-- TODO: Make this part of a query protocol? + return self.subquery() + } + + // TODO: This should be part of the Queryable protocol public func decode(_ data: Data) throws -> Datatype { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - decoder.userInfo = [.selectable: self] - return try decoder.decode(ResultWrapper.self, from: data).value + return try decodeKeyedContainer(data)["data"] } // TODO: This could easily be a block based transform if the Datatype doesn't support the init method. - public func decode(_ container: KeyedDecodingContainer) throws -> (String?, Datatype) { - let container = try container.nestedContainer(keyedBy: UnknownCodingKeys.self, - forKey: UnknownCodingKeys(stringValue: "data")!) - return ("data", try KeyedContainer(from: container, selections: selections)) + public func decode(_ container: KeyedDecodingContainer) throws -> KeyedContainer { + let key = UnknownCodingKey(stringValue: resultKey)! + let container = try container.nestedContainer(keyedBy: UnknownCodingKey.self, forKey: key) + return KeyedContainer(fields: [resultKey: try KeyedContainer(from: container, selections: selections)]) + } + +} + +extension Selectable where Datatype == KeyedContainer { + + // TODO: Test only? + public func decodeKeyedContainer(_ data: Data) throws -> Datatype { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.userInfo = [.selectable: self] + return try decoder.decode(ResultWrapper.self, from: data).value } } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift index 81f382b..6b924e2 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift @@ -22,16 +22,15 @@ import Foundation // TODO: Move into the IdentifiableSelection extension that yields a result? // This is the magic that allows us to start the decoding stack by getting to the first-level container. -public struct ResultWrapper: Decodable { +public struct ResultWrapper: Decodable { - let value: T.Datatype + let value: KeyedContainer public init(from decoder: any Decoder) throws { // TODO: There's some crashy type stuff here that shouldn't be crashy. let selectable = decoder.userInfo[.selectable] as! any Selectable - let container = try decoder.container(keyedBy: UnknownCodingKeys.self) - let (_, value) = try selectable.decode(container) - self.value = value as! T.Datatype + let container = try decoder.container(keyedBy: UnknownCodingKey.self) + self.value = try selectable.decode(container) } } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift index 1c84960..3bff062 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift @@ -27,16 +27,16 @@ public protocol Selectable { var prefix: String { get } var selections: [any Selectable] { get } - func decode(_ container: KeyedDecodingContainer) throws -> (String?, Datatype) + func decode(_ container: KeyedDecodingContainer) throws -> KeyedContainer } extension Selectable { // TODO: Why is this nullable?????? - public func query() -> String? { + public func subquery() -> String { let subselection = selections .compactMap { selection in - selection.query() + selection.subquery() } .joined(separator: " ") guard !subselection.isEmpty else { diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift index 36a9f2c..c5b7f4d 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift @@ -48,9 +48,9 @@ public struct Selection: Selectable { private let name: String private let alias: String? private let arguments: [String: Argument] - private let _decode: (String, KeyedDecodingContainer) throws -> (String?, Datatype) + private let _decode: (String, KeyedDecodingContainer) throws -> KeyedContainer - public func decode(_ container: KeyedDecodingContainer) throws -> (String?, Datatype) { + public func decode(_ container: KeyedDecodingContainer) throws -> KeyedContainer { return try self._decode(resultKey, container) } @@ -70,12 +70,14 @@ public struct Fragment: Selectable { self.selections = selections() } - public func decode(_ container: KeyedDecodingContainer) throws -> (String?, KeyedContainer) { + public func decode(_ container: KeyedDecodingContainer) throws -> KeyedContainer { let fields = try self.selections.map { try $0.decode(container) } - .reduce(into: [String: Any]()) { partialResult, item in - partialResult[item.0!] = item.1 // TODO: GRIM GRIM GRIM. + .reduce(into: [String: Any]()) { partialResult, keyedContainer in + for (key, value) in keyedContainer.fields { + partialResult[key] = value + } } - return (nil, KeyedContainer(fields: fields)) + return KeyedContainer(fields: fields) } } @@ -95,9 +97,9 @@ extension Selection where Datatype == KeyedContainer { self.arguments = arguments self.selections = selections self._decode = { resultKey, container in - // TODO: Assemble this inline! - let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) - return (resultKey, try KeyedContainer(from: decoder, selections: selections)) + let key = UnknownCodingKey(stringValue: resultKey)! + let container = try container.nestedContainer(keyedBy: UnknownCodingKey.self, forKey: key) + return KeyedContainer(fields: [resultKey: try KeyedContainer(from: container, selections: selections)]) } } @@ -116,25 +118,15 @@ extension Selection where Datatype: StaticSelectable { self.arguments = arguments self.selections = selections self._decode = { resultKey, container in - // TODO: Assemble this inline! - let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) - return (resultKey, try Datatype(from: decoder)) + let key = UnknownCodingKey(stringValue: resultKey)! + let decoder = MyDecoder(key: key, container: container) + return KeyedContainer(fields: [resultKey: try Datatype(from: decoder)]) } } + // TODO: This feels like a hack. public init(_ name: CodingKey) { - - let selections = Datatype.selections() - - self.name = name.stringValue - self.alias = nil - self.arguments = [:] - self.selections = selections - self._decode = { resultKey, container in - // TODO: Assemble this inline! - let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) - return (resultKey, try Datatype(from: decoder)) - } + self.init(name.stringValue) } } @@ -153,16 +145,13 @@ extension Selection where Datatype == Array { self.arguments = arguments self.selections = selections self._decode = { resultKey, container in - var container = try container.nestedUnkeyedContainer(forKey: UnknownCodingKeys(stringValue: resultKey)!) + var container = try container.nestedUnkeyedContainer(forKey: UnknownCodingKey(stringValue: resultKey)!) var results: [KeyedContainer] = [] while !container.isAtEnd { - let childContainer = try container.nestedContainer(keyedBy: UnknownCodingKeys.self) + let childContainer = try container.nestedContainer(keyedBy: UnknownCodingKey.self) results.append(try KeyedContainer(from: childContainer, selections: selections)) } - -// // TODO: Assemble this inline! -// let decoder = MyDecoder(key: UnknownCodingKeys(stringValue: resultKey)!, container: container) - return (resultKey, results) + return KeyedContainer(fields: [resultKey: results]) } } diff --git a/BuildsCore/Sources/BuildsCore/Models/BuildsError.swift b/BuildsCore/Sources/BuildsCore/Models/BuildsError.swift index 160e035..cfc6b1a 100644 --- a/BuildsCore/Sources/BuildsCore/Models/BuildsError.swift +++ b/BuildsCore/Sources/BuildsCore/Models/BuildsError.swift @@ -22,4 +22,5 @@ import Foundation public enum BuildsError: Error { case authenticationFailure + case unexpectedType } diff --git a/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift b/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKey.swift similarity index 95% rename from BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift rename to BuildsCore/Sources/BuildsCore/Service/UnknownCodingKey.swift index 30aa37c..ae5c7f2 100644 --- a/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKeys.swift +++ b/BuildsCore/Sources/BuildsCore/Service/UnknownCodingKey.swift @@ -20,8 +20,7 @@ import Foundation -// Singular!! -public struct UnknownCodingKeys: CodingKey { +public struct UnknownCodingKey: CodingKey { public var stringValue: String diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift index 595bd4b..e619a1b 100644 --- a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -26,22 +26,22 @@ final class QueryableTests: XCTestCase { func testQueries() throws { XCTAssertTrue(true) - XCTAssertEqual(Selection("name").query(), "name") - XCTAssertEqual(Selection("id").query(), "id") + XCTAssertEqual(Selection("name").subquery(), "name") + XCTAssertEqual(Selection("id").subquery(), "id") - XCTAssertEqual(Selection("name", alias: "alias").query(), "alias:name") + XCTAssertEqual(Selection("name", alias: "alias").subquery(), "alias:name") - XCTAssertEqual(Selection("node", arguments: ["id" : 12]).query(), "node(id: 12)") - XCTAssertEqual(Selection("node", arguments: ["id" : "12"]).query(), "node(id: \"12\")") + XCTAssertEqual(Selection("node", arguments: ["id" : 12]).subquery(), "node(id: 12)") + XCTAssertEqual(Selection("node", arguments: ["id" : "12"]).subquery(), "node(id: \"12\")") // TODO: Can we only allow the block-based selection constructor _when_ the destination is a keyed container? let login = Selection("login") - let viewer = Selection("viewer") { + let viewer = Selection("viewer") { login } - XCTAssertEqual(viewer.query(), "viewer { login }") + XCTAssertEqual(viewer.subquery(), "viewer { login }") let responseData = """ { @@ -56,17 +56,17 @@ final class QueryableTests: XCTestCase { // print(result.fields) // XCTAssertEqual(result[viewer][login], "cheese") - XCTAssertEqual(Selection("viewer", alias: "cheese") { + XCTAssertEqual(Selection("viewer", alias: "cheese") { Selection("login") - }.query(), "cheese:viewer { login }") + }.subquery(), "cheese:viewer { login }") XCTAssertEqual(Query { Selection("id") - }.query(), "query { id }") + }.subquery(), "query { id }") struct Foo: StaticSelectable { - static func selections() -> [any IdentifiableSelection] {[ + static func selections() -> [any Selectable] {[ Selection("id"), Selection("name"), ]} @@ -80,11 +80,11 @@ final class QueryableTests: XCTestCase { } - XCTAssertEqual(Selection("foo").query(), "foo { id name }") + XCTAssertEqual(Selection("foo").subquery(), "foo { id name }") struct Bar: StaticSelectable { - static func selections() -> [any IdentifiableSelection] {[ + static func selections() -> [any Selectable] {[ Selection("id"), Selection("name"), Selection("foo"), @@ -100,8 +100,87 @@ final class QueryableTests: XCTestCase { } - XCTAssertEqual(Selection("bar").query(), "bar { id name foo { id name } }") + XCTAssertEqual(Selection("bar").subquery(), "bar { id name foo { id name } }") } + func testPartialDecode() throws { + + let fromage = Selection("fromage") + let selection = Selection("data") { + fromage + } + let data = """ + { + "data": { + "fromage": "Cheese" + } + } + """.data(using: .utf8)! + + let result = try selection.decodeKeyedContainer(data) + XCTAssertEqual(try result["data"][fromage], "Cheese") + } + + func testViewerStructureDecode() throws { + + let login = Selection("login") + let bio = Selection("bio") + let viewer = Selection("viewer") { + login + bio + } + let query = Query { + viewer + } + let data = """ + {"data":{"viewer":{"login":"jbmorley","bio":""}}} + """.data(using: .utf8)! + + let result = try query.decode(data) + XCTAssertEqual(try result[viewer][login], "jbmorley") + } + + // TODO: Test ararys! + // TODO: Test fragments! + + func testStaticSelectableStruct() { + + struct Workflow: StaticSelectable { + + static let id = Selection("id") + static let event = Selection("event") + static let createdAt = Selection("createdAt") + + // TODO: Push `SelectionBuilder` into the protocol? + @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { + id + event + createdAt + } + + let id: String + let event: String + let createdAt: Date + + // TODO: Ideally this would take a KeyedContainer + init(from decoder: MyDecoder) throws { + let container = try decoder.container() + self.id = try container.decode(Self.id) + self.event = try container.decode(Self.event) + self.createdAt = try container.decode(Self.createdAt) + } + + } + + } + +} + +extension KeyedDecodingContainer where K == UnknownCodingKey { + + func decode(_ selection: Selection) throws -> T { + return try decode(T.self, forKey: UnknownCodingKey(stringValue: selection.resultKey)!) + } + } From 8ab0a9eab344fe81a39594fae6891f7427055e00 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 09:54:35 -1000 Subject: [PATCH 08/16] More tests --- .../Tests/BuildsCoreTests/QueryableTests.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift index e619a1b..f53b404 100644 --- a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -144,7 +144,7 @@ final class QueryableTests: XCTestCase { // TODO: Test ararys! // TODO: Test fragments! - func testStaticSelectableStruct() { + func testStaticSelectableStruct() throws { struct Workflow: StaticSelectable { @@ -164,6 +164,7 @@ final class QueryableTests: XCTestCase { let createdAt: Date // TODO: Ideally this would take a KeyedContainer + // TODO: Can we actually get away without the custom decoder if we pass in a single value container instead? init(from decoder: MyDecoder) throws { let container = try decoder.container() self.id = try container.decode(Self.id) @@ -173,6 +174,21 @@ final class QueryableTests: XCTestCase { } + let workflow = Selection("workflow") + let query = Query { + workflow + } + + XCTAssertEqual(workflow.subquery(), "workflow { id event createdAt }") + XCTAssertEqual(query.query(), "query { workflow { id event createdAt } }") + + let data = """ + {"data":{"workflow":{"id":"WFR_kwLOCatyMs8AAAACEHvAIA","event":"schedule","createdAt":"2024-04-28T09:03:51Z"}}} + """.data(using: .utf8)! + + let result = try query.decode(data) + XCTAssertEqual(try result[workflow].id, "WFR_kwLOCatyMs8AAAACEHvAIA") + XCTAssertEqual(try result[workflow].event, "schedule") } } From 3e66ff1d9d546fb8b69bd5da24f870b7ecc9c91d Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 10:55:08 -1000 Subject: [PATCH 09/16] More cleanup! --- Builds/Models/ApplicationModel.swift | 95 +++++++++--------- .../BuildsCore/GraphQL/KeyedContainer.swift | 10 ++ .../BuildsCore/GraphQL/MyDecoder.swift | 47 --------- .../GraphQL/MySingleValueDecoder.swift | 97 ------------------- .../BuildsCore/GraphQL/Resultable.swift | 28 ------ .../BuildsCore/GraphQL/Selection.swift | 74 +++++++++++++- ....swift => StaticSelectableContainer.swift} | 34 ++++--- .../BuildsCoreTests/QueryableTests.swift | 21 ++-- 8 files changed, 153 insertions(+), 253 deletions(-) delete mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift delete mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift delete mode 100644 BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift rename BuildsCore/Sources/BuildsCore/GraphQL/{StaticSelectable.swift => StaticSelectableContainer.swift} (68%) diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 9f13bf1..49140dd 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -420,51 +420,65 @@ class ApplicationModel: NSObject, ObservableObject { await refresh() } + struct Workflow: StaticSelectableContainer { + + static let id = Selection("id") + static let event = Selection("event") + static let createdAt = Selection("createdAt") + + // TODO: Push `SelectionBuilder` into the protocol? + @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { + id + event + createdAt + } + + let id: String + let event: String + let createdAt: Date + + // TODO: Ideally this would take a KeyedContainer + // TODO: Can we actually get away without the custom decoder if we pass in a single value container instead? + init(from container: DecodingContainer) throws { + self.id = try container.decode(Self.id) + self.event = try container.decode(Self.event) + self.createdAt = try container.decode(Self.createdAt) + } + + } + #endif func testStuff(accessToken: String) async throws { - struct User: StaticSelectable { + struct User: StaticSelectableContainer { - enum CodingKeys: String, CodingKey { - case login - case bio - } + static let login = Selection("login") + static let bio = Selection("bio") @SelectionBuilder static func selections() -> [any Selectable] { - Selection(CodingKeys.login) - Selection(CodingKeys.bio) + login + bio } let login: String let bio: String - public init(from decoder: MyDecoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.login = try container.decode(String.self, forKey: .login) - self.bio = try container.decode(String.self, forKey: .bio) + public init(from container: DecodingContainer) throws { + self.login = try container.decode(Self.login) + self.bio = try container.decode(Self.bio) } } - let login = Selection("login") - let bio = Selection("bio") - let viewer = Selection("viewer") { - login - bio - } - - - // TODO: It's really really important we only allow the queries to be injected. - let userQuery = Query { - viewer - } - let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) - let result = try await client.query(userQuery, accessToken: accessToken) - print(try result[viewer][login]) + let viewer = Selection("viewer") + let result = try await client.query(Query { + viewer + }, accessToken: accessToken) + print(try result[viewer].login) let id = Selection("id") let event = Selection("event") @@ -480,6 +494,12 @@ class ApplicationModel: NSObject, ObservableObject { nodes } +// let firstNodes = Selection("nodes") { +// +// } transform: { +// +// } + let workflow = Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { Fragment("... on Workflow") { runs @@ -507,26 +527,3 @@ class ApplicationModel: NSObject, ObservableObject { } - - -// TODO: Would need conformance. -// TODO: Rename KeyedContainer to SelectionResult? -//struct User { -// -// static let login = Selection("login") -// static let bio = Selection("bio") -// -// @SelectionBuilder static func selections() -> [any IdentifiableSelection] { -// login -// bio -// } -// -// let login: String -// let bio: String -// -// public init(_ result: KeyedContainer) throws { -// self.login = try result[Self.login] -// self.bio = try result[Self.bio] -// } -// -//} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift index bacac1e..7b7f4fb 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift @@ -61,3 +61,13 @@ public struct KeyedContainer { } } + +// TODO: Move elsewhere! +extension KeyedDecodingContainer where K == UnknownCodingKey { + + public func decode(_ selection: Selection) throws -> T { + return try decode(T.self, forKey: UnknownCodingKey(stringValue: selection.resultKey)!) + } + +} + diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift b/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift deleted file mode 100644 index dbfbbab..0000000 --- a/BuildsCore/Sources/BuildsCore/GraphQL/MyDecoder.swift +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2022-2024 Jason Morley -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -public struct MyDecoder { - - let key: UnknownCodingKey - let _container: KeyedDecodingContainer - - init(key: UnknownCodingKey, container: KeyedDecodingContainer) { - self.key = key - self._container = container - } - - public func container() throws -> KeyedDecodingContainer { - return try self.container(keyedBy: UnknownCodingKey.self) - } - - public func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { - return try _container.nestedContainer(keyedBy: Key.self, forKey: key) - } - - public func singleValueContainer() throws -> any SingleValueDecodingContainer { - return MySingleVlaueDecodingContainer(key: key, container: _container) - } - - // TODO: `unkeyedContainer...` - -} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift b/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift deleted file mode 100644 index 3d831f5..0000000 --- a/BuildsCore/Sources/BuildsCore/GraphQL/MySingleValueDecoder.swift +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2022-2024 Jason Morley -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -public struct MySingleVlaueDecodingContainer: SingleValueDecodingContainer { - - // TODO: INVERT THESEE - let key: UnknownCodingKey - let container: KeyedDecodingContainer - - public var codingPath: [any CodingKey] { - return container.codingPath + [key] - } - - public func decodeNil() -> Bool { - return (try? container.decodeNil(forKey: key)) ?? false - } - - public func decode(_ type: Bool.Type) throws -> Bool { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: String.Type) throws -> String { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Double.Type) throws -> Double { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Float.Type) throws -> Float { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int.Type) throws -> Int { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int8.Type) throws -> Int8 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int16.Type) throws -> Int16 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int32.Type) throws -> Int32 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: Int64.Type) throws -> Int64 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt.Type) throws -> UInt { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt8.Type) throws -> UInt8 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt16.Type) throws -> UInt16 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt32.Type) throws -> UInt32 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: UInt64.Type) throws -> UInt64 { - return try container.decode(type, forKey: key) - } - - public func decode(_ type: T.Type) throws -> T where T : Decodable { - return try container.decode(type, forKey: key) - } - -} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift deleted file mode 100644 index 9540d2a..0000000 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Resultable.swift +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2022-2024 Jason Morley -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -// TODO: I think this can now be rolled into StaticSelectable -public protocol Resultable { - - init(from decoder: MyDecoder) throws - -} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift index c5b7f4d..f01582a 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift @@ -54,6 +54,19 @@ public struct Selection: Selectable { return try self._decode(resultKey, container) } + public init(name: String, + alias: String? = nil, + arguments: [String : Argument] = [:], + @SelectionBuilder selections: () -> [any Selectable], + // TODO: This transform should take a Decoder instead? + transform: @escaping (String, KeyedDecodingContainer) -> KeyedContainer) { + self.selections = selections() + self.name = name + self.alias = alias + self.arguments = arguments + self._decode = transform + } + } // TODO: We should constrain our children selections here to guarnatee that they're dictionaries. Like, can they actually @@ -65,6 +78,7 @@ public struct Fragment: Selectable { public let prefix: String public let selections: [any Selectable] + // TODO: Use the convenience constructor. public init(_ condition: String, @SelectionBuilder selections: () -> [any Selectable]) { self.prefix = condition self.selections = selections() @@ -85,6 +99,7 @@ public struct Fragment: Selectable { extension Selection where Datatype == KeyedContainer { // TODO: We should probably warn/crash on selection collisions + // TODO: Use the convenience constructor. public init(_ name: String, alias: String? = nil, arguments: [String: Argument] = [:], @@ -105,8 +120,9 @@ extension Selection where Datatype == KeyedContainer { } -extension Selection where Datatype: StaticSelectable { +extension Selection where Datatype: StaticSelectableContainer { + // TODO: Use the convenience constructor. public init(_ name: String, alias: String? = nil, arguments: [String: Argument] = [:]) { @@ -119,8 +135,34 @@ extension Selection where Datatype: StaticSelectable { self.selections = selections self._decode = { resultKey, container in let key = UnknownCodingKey(stringValue: resultKey)! - let decoder = MyDecoder(key: key, container: container) - return KeyedContainer(fields: [resultKey: try Datatype(from: decoder)]) + let container = try container.nestedContainer(keyedBy: UnknownCodingKey.self, forKey: key) + return KeyedContainer(fields: [resultKey: try Datatype(from: container)]) + } + } + + // TODO: This feels like a hack. +// public init(_ name: CodingKey) { +// self.init(name.stringValue) +// } + +} + +extension Selection where Datatype: StaticSelectable { + + // TODO: Use the convenience constructor. + public init(_ name: String, + alias: String? = nil, + arguments: [String: Argument] = [:]) { + + self.name = name + self.alias = alias + self.arguments = arguments + self.selections = [] + self._decode = { resultKey, container in + let key = UnknownCodingKey(stringValue: resultKey)! + let decoder = try container.superDecoder(forKey: key) + let singleValueContainer = try decoder.singleValueContainer() + return KeyedContainer(fields: [resultKey: try Datatype(from: singleValueContainer)]) } } @@ -156,3 +198,29 @@ extension Selection where Datatype == Array { } } + +extension Selection where Datatype == Array { + + public init(_ name: String, + alias: String? = nil, + arguments: [String: Argument] = [:], + @SelectionBuilder selections: () -> [any Selectable]) { + + let selections = selections() + + self.name = name + self.alias = alias + self.arguments = arguments + self.selections = selections + self._decode = { resultKey, container in + var container = try container.nestedUnkeyedContainer(forKey: UnknownCodingKey(stringValue: resultKey)!) + var results: [KeyedContainer] = [] + while !container.isAtEnd { + let childContainer = try container.nestedContainer(keyedBy: UnknownCodingKey.self) + results.append(try KeyedContainer(from: childContainer, selections: selections)) + } + return KeyedContainer(fields: [resultKey: results]) + } + } + +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectableContainer.swift similarity index 68% rename from BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift rename to BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectableContainer.swift index 22787f8..41e4694 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectable.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectableContainer.swift @@ -21,18 +21,31 @@ import Foundation // Structs whose selection can be defined statically. -public protocol StaticSelectable: Resultable { + +// TODO: This currently models two behaviours which feels wrong. +// Specifically, this models both containers and single value selectables. +public protocol StaticSelectableContainer { + + typealias DecodingContainer = KeyedDecodingContainer @SelectionBuilder static func selections() -> [any Selectable] + init(from container: DecodingContainer) throws + } -extension String: StaticSelectable { +// TODO: It might be possible to recombine these. +public protocol StaticSelectable { + + typealias DecodingContainer = SingleValueDecodingContainer - @SelectionBuilder public static func selections() -> [any Selectable] {} + init(from container: DecodingContainer) throws - public init(from decoder: MyDecoder) throws { - let container = try decoder.singleValueContainer() +} + +extension String: StaticSelectable { + + public init(from container: DecodingContainer) throws { self = try container.decode(String.self) } @@ -40,11 +53,7 @@ extension String: StaticSelectable { extension Int: StaticSelectable { - // TODO: Can this actually be used to tell me whether something is nested?? That might be cool. - @SelectionBuilder public static func selections() -> [any Selectable] {} - - public init(from decoder: MyDecoder) throws { - let container = try decoder.singleValueContainer() + public init(from container: DecodingContainer) throws { self = try container.decode(Int.self) } @@ -52,10 +61,7 @@ extension Int: StaticSelectable { extension Date: StaticSelectable { - @SelectionBuilder public static func selections() -> [any Selectable] {} - - public init(from decoder: MyDecoder) throws { - let container = try decoder.singleValueContainer() + public init(from container: DecodingContainer) throws { self = try container.decode(Date.self) } diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift index f53b404..0925dd6 100644 --- a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -64,7 +64,7 @@ final class QueryableTests: XCTestCase { Selection("id") }.subquery(), "query { id }") - struct Foo: StaticSelectable { + struct Foo: StaticSelectableContainer { static func selections() -> [any Selectable] {[ Selection("id"), @@ -74,7 +74,7 @@ final class QueryableTests: XCTestCase { let id: Int let name: String - init(from decoder: MyDecoder) throws { + init(from decoder: DecodingContainer) throws { throw BuildsError.authenticationFailure } @@ -82,7 +82,7 @@ final class QueryableTests: XCTestCase { XCTAssertEqual(Selection("foo").subquery(), "foo { id name }") - struct Bar: StaticSelectable { + struct Bar: StaticSelectableContainer { static func selections() -> [any Selectable] {[ Selection("id"), @@ -94,7 +94,7 @@ final class QueryableTests: XCTestCase { let name: String let foo: Foo - init(from decoder: MyDecoder) throws { + init(from decoder: DecodingContainer) throws { throw BuildsError.authenticationFailure } @@ -146,7 +146,7 @@ final class QueryableTests: XCTestCase { func testStaticSelectableStruct() throws { - struct Workflow: StaticSelectable { + struct Workflow: StaticSelectableContainer { static let id = Selection("id") static let event = Selection("event") @@ -165,8 +165,7 @@ final class QueryableTests: XCTestCase { // TODO: Ideally this would take a KeyedContainer // TODO: Can we actually get away without the custom decoder if we pass in a single value container instead? - init(from decoder: MyDecoder) throws { - let container = try decoder.container() + init(from container: DecodingContainer) throws { self.id = try container.decode(Self.id) self.event = try container.decode(Self.event) self.createdAt = try container.decode(Self.createdAt) @@ -192,11 +191,3 @@ final class QueryableTests: XCTestCase { } } - -extension KeyedDecodingContainer where K == UnknownCodingKey { - - func decode(_ selection: Selection) throws -> T { - return try decode(T.self, forKey: UnknownCodingKey(stringValue: selection.resultKey)!) - } - -} From d95e981e24bb3a5558f2751a0a827e2fae79b88b Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 11:32:49 -1000 Subject: [PATCH 10/16] More tests --- .../BuildsCoreTests/QueryableTests.swift | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift index 0925dd6..07bccb8 100644 --- a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -190,4 +190,52 @@ final class QueryableTests: XCTestCase { XCTAssertEqual(try result[workflow].event, "schedule") } + func testStaticSelectableWithNestedSelectables() { + } + + func testSelectableFragments() throws { + + let id = Selection("id") + let event = Selection("event") + let createdAt = Selection("createdAt") + let nodes = Selection>("nodes") { + id + event + createdAt + } + let runs = Selection("runs", arguments: ["first" : 1]) { + nodes + } + let workflow = Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { + Fragment("... on Workflow") { + runs + } + } + let query = Query { + workflow + } + + XCTAssertEqual(query.query(), """ + query { node(id: "MDg6V29ya2Zsb3c5ODk4MDM1") { ... on Workflow { runs(first: 1) { nodes { id event createdAt } } } } } + """) + + let data = """ + {"data":{"node":{"runs":{"nodes":[{"id":"WFR_kwLOCatyMs8AAAACEHvAIA","event":"schedule","createdAt":"2024-04-28T09:03:51Z"}]}}}} + """.data(using: .utf8)! + + let result = try query.decode(data) + XCTAssertEqual(try result[workflow][runs][nodes].first![id], "WFR_kwLOCatyMs8AAAACEHvAIA") + XCTAssertEqual(try result[workflow][runs][nodes].first![event], "schedule") + XCTAssertEqual(try result[workflow][runs][nodes].first![createdAt], Date(iso8601: "2024-04-28T09:03:51Z")) + } + +} + +extension Date { + + init(iso8601: String) { + let formatter = ISO8601DateFormatter() + self = formatter.date(from: iso8601)! + } + } From 74f2d9f20de4b1b735baa79dd859de73ee77c344 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 12:40:10 -1000 Subject: [PATCH 11/16] Decoded nested selections --- .../GraphQL/CodingUserInfoKey.swift | 2 - .../BuildsCore/GraphQL/KeyedContainer.swift | 8 ++- .../BuildsCore/GraphQL/ResultWrapper.swift | 1 + .../BuildsCore/GraphQL/Selectable.swift | 1 - .../BuildsCore/GraphQL/Selection.swift | 5 -- .../BuildsCoreTests/Extensions/Date.swift | 30 ++++++++++++ .../BuildsCoreTests/QueryableTests.swift | 49 +++++++++++++++---- 7 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 BuildsCore/Tests/BuildsCoreTests/Extensions/Date.swift diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift b/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift index 2c77e06..9f2397b 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/CodingUserInfoKey.swift @@ -21,7 +21,5 @@ import Foundation extension CodingUserInfoKey { - static let resultKey = CodingUserInfoKey(rawValue: "resultKey")! - static let selections = CodingUserInfoKey(rawValue: "selections")! static let selectable = CodingUserInfoKey(rawValue: "selectable")! } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift index 7b7f4fb..15f163b 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift @@ -30,6 +30,7 @@ public struct KeyedContainer { self.fields = fields } + // TODO: I think it might be possible to replace this with a 'decoder' equivalent. // TODO: Doing it with an iniit like this feels messy at this point, but maybe it's more reusable? public init(from container: KeyedDecodingContainer, selections: [any Selectable]) throws { @@ -69,5 +70,10 @@ extension KeyedDecodingContainer where K == UnknownCodingKey { return try decode(T.self, forKey: UnknownCodingKey(stringValue: selection.resultKey)!) } -} + public func decode(_ selection: Selection) throws -> KeyedContainer { + let container = try self.nestedContainer(keyedBy: UnknownCodingKey.self, + forKey: UnknownCodingKey(stringValue: selection.resultKey)!) + return try KeyedContainer(from: container, selections: selection.selections) + } +} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift index 6b924e2..d4a5c9b 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/ResultWrapper.swift @@ -22,6 +22,7 @@ import Foundation // TODO: Move into the IdentifiableSelection extension that yields a result? // This is the magic that allows us to start the decoding stack by getting to the first-level container. +// TODO: I think this should be private? public struct ResultWrapper: Decodable { let value: KeyedContainer diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift index 3bff062..6ebb67b 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selectable.swift @@ -32,7 +32,6 @@ public protocol Selectable { extension Selectable { - // TODO: Why is this nullable?????? public func subquery() -> String { let subselection = selections .compactMap { selection in diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift index f01582a..3f72625 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift @@ -140,11 +140,6 @@ extension Selection where Datatype: StaticSelectableContainer { } } - // TODO: This feels like a hack. -// public init(_ name: CodingKey) { -// self.init(name.stringValue) -// } - } extension Selection where Datatype: StaticSelectable { diff --git a/BuildsCore/Tests/BuildsCoreTests/Extensions/Date.swift b/BuildsCore/Tests/BuildsCoreTests/Extensions/Date.swift new file mode 100644 index 0000000..3cbb51c --- /dev/null +++ b/BuildsCore/Tests/BuildsCoreTests/Extensions/Date.swift @@ -0,0 +1,30 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +extension Date { + + init(iso8601: String) { + let formatter = ISO8601DateFormatter() + self = formatter.date(from: iso8601)! + } + +} diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift index 07bccb8..be5077d 100644 --- a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -190,7 +190,46 @@ final class QueryableTests: XCTestCase { XCTAssertEqual(try result[workflow].event, "schedule") } - func testStaticSelectableWithNestedSelectables() { + func testStaticSelectableWithNestedSelectables() throws { + + struct Container: StaticSelectableContainer { + + static let id = Selection("id") + static let name = Selection("name") + static let contents = Selection("contents") { + name + } + + // TODO: Push `SelectionBuilder` into the protocol? + @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { + id + contents + } + + let id: Int + let contents: KeyedContainer + + init(from container: DecodingContainer) throws { + self.id = try container.decode(Self.id) + self.contents = try container.decode(Self.contents) + } + + } + + let container = Selection("container", alias: "bob") + let query = Query { + container + } + + XCTAssertEqual(query.query(), "query { bob:container { id contents { name } } }") + + let data = """ + {"data":{"bob":{"id":12,"contents":{"name":"Robert"}}}} + """.data(using: .utf8)! + + let result = try query.decode(data) + XCTAssertEqual(try result[container].id, 12) + XCTAssertEqual(try result[container].contents[Container.name], "Robert") } func testSelectableFragments() throws { @@ -231,11 +270,3 @@ final class QueryableTests: XCTestCase { } -extension Date { - - init(iso8601: String) { - let formatter = ISO8601DateFormatter() - self = formatter.date(from: iso8601)! - } - -} From 6afea2e6916f12c117c87a4b7b33b7e3f1dbe25a Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 14:29:40 -1000 Subject: [PATCH 12/16] Grraaaaggggggghhhhh --- Builds/Models/ApplicationModel.swift | 62 +++++-------- .../BuildsCore/GraphQL/Selection.swift | 86 ++++++++++++++----- .../GraphQL/StaticSelectableContainer.swift | 3 + .../BuildsCoreTests/QueryableTests.swift | 1 - 4 files changed, 88 insertions(+), 64 deletions(-) diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 49140dd..62b9950 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -451,57 +451,36 @@ class ApplicationModel: NSObject, ObservableObject { func testStuff(accessToken: String) async throws { - struct User: StaticSelectableContainer { + struct Workflow: StaticSelectableContainer { - static let login = Selection("login") - static let bio = Selection("bio") + static let id = Selection("id") + static let event = Selection("event") + static let createdAt = Selection("createdAt") - @SelectionBuilder static func selections() -> [any Selectable] { - login - bio + @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { + id + event + createdAt } - let login: String - let bio: String + let id: String + let event: String + let createdAt: Date - public init(from container: DecodingContainer) throws { - self.login = try container.decode(Self.login) - self.bio = try container.decode(Self.bio) + init(from container: DecodingContainer) throws { + self.id = try container.decode(Self.id) + self.event = try container.decode(Self.event) + self.createdAt = try container.decode(Self.createdAt) } } - let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) - - let viewer = Selection("viewer") - let result = try await client.query(Query { - viewer - }, accessToken: accessToken) - - print(try result[viewer].login) - - let id = Selection("id") - let event = Selection("event") - let createdAt = Selection("createdAt") - - let nodes = Selection>("nodes") { - id - event - createdAt - } - + let nodes = Selection.first("nodes") let runs = Selection("runs", arguments: ["first" : 1]) { nodes } - -// let firstNodes = Selection("nodes") { -// -// } transform: { -// -// } - let workflow = Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { - Fragment("... on Workflow") { + Fragment(on: "Workflow") { runs } } @@ -511,11 +490,12 @@ class ApplicationModel: NSObject, ObservableObject { print(workflowQuery.query()) + let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) let workflowResult = try await client.query(workflowQuery, accessToken: accessToken) - print(try workflowResult[workflow][runs][nodes].first![id]) - print(try workflowResult[workflow][runs][nodes].first![event]) - print(try workflowResult[workflow][runs][nodes].first![createdAt]) + print(try workflowResult[workflow][runs][nodes].id) + print(try workflowResult[workflow][runs][nodes].event) + print(try workflowResult[workflow][runs][nodes].createdAt) // TODO: Consider that it would also be possible to copy the RegexBuilder style inline transforms... diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift index 3f72625..89621a9 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/Selection.swift @@ -59,12 +59,15 @@ public struct Selection: Selectable { arguments: [String : Argument] = [:], @SelectionBuilder selections: () -> [any Selectable], // TODO: This transform should take a Decoder instead? - transform: @escaping (String, KeyedDecodingContainer) -> KeyedContainer) { + transform: @escaping (any Decoder) throws -> Datatype) { self.selections = selections() self.name = name self.alias = alias self.arguments = arguments - self._decode = transform + self._decode = { (key, container) throws -> KeyedContainer in + let decoder = try container.superDecoder(forKey: UnknownCodingKey(stringValue: key)!) + return KeyedContainer(fields: [key: try transform(decoder)]) + } } } @@ -79,8 +82,8 @@ public struct Fragment: Selectable { public let selections: [any Selectable] // TODO: Use the convenience constructor. - public init(_ condition: String, @SelectionBuilder selections: () -> [any Selectable]) { - self.prefix = condition + public init(on: String, @SelectionBuilder selections: () -> [any Selectable]) { + self.prefix = "... on \(on)" self.selections = selections() } @@ -194,28 +197,67 @@ extension Selection where Datatype == Array { } -extension Selection where Datatype == Array { - - public init(_ name: String, - alias: String? = nil, - arguments: [String: Argument] = [:], - @SelectionBuilder selections: () -> [any Selectable]) { - - let selections = selections() - - self.name = name - self.alias = alias - self.arguments = arguments - self.selections = selections - self._decode = { resultKey, container in - var container = try container.nestedUnkeyedContainer(forKey: UnknownCodingKey(stringValue: resultKey)!) - var results: [KeyedContainer] = [] +// TODO: Is there a way to do this as a constraint on `Selection`, not as a named function? +extension Selection { + + public static func array(_ type: E.Type, + _ name: String, + alias: String? = nil, + arguments: [String: Argument] = [:]) -> Selection> { + return Selection>(name: name, + alias: alias, + arguments: arguments, + selections: E.selections) { decoder in + var container = try decoder.unkeyedContainer() + var results: [E] = [] while !container.isAtEnd { let childContainer = try container.nestedContainer(keyedBy: UnknownCodingKey.self) - results.append(try KeyedContainer(from: childContainer, selections: selections)) + results.append(try E(from: childContainer)) } - return KeyedContainer(fields: [resultKey: results]) + return results + } + } + +} + +extension Selection where Datatype: StaticSelectableContainer { + + // TODO: This might actually be easier to type if there was a typed tuple that captured the name, alias and type. + public static func first(_ name: String, + alias: String? = nil, + arguments: [String: Argument] = [:]) -> Selection { + return Self(name: name, + alias: alias, + arguments: arguments, + selections: Datatype.selections) { decoder in + var container = try decoder.unkeyedContainer() + let firstContainer = try container.nestedContainer(keyedBy: UnknownCodingKey.self) + return try T(from: firstContainer) } } } + +//extension Selection where Datatype == Array { +// +// public init(_ name: String, +// alias: String? = nil, +// arguments: [String: Argument] = [:]) { +// let selections = Datatype.Element.selections() +// self.name = name +// self.alias = alias +// self.arguments = arguments +// self.selections = selections +// self._decode = { resultKey, container in +// // TODO: Is there a generic implementation of this or do I have to keep hand-crafting expansions? +// var container = try container.nestedUnkeyedContainer(forKey: UnknownCodingKey(stringValue: resultKey)!) +// var results: [Element] = [] +// while !container.isAtEnd { +// let childContainer = try container.nestedContainer(keyedBy: UnknownCodingKey.self) +// results.append(try F(from: childContainer)) +// } +// return KeyedContainer(fields: [resultKey: results]) +// } +// } +// +//} diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectableContainer.swift b/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectableContainer.swift index 41e4694..49e4b14 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectableContainer.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/StaticSelectableContainer.swift @@ -28,8 +28,11 @@ public protocol StaticSelectableContainer { typealias DecodingContainer = KeyedDecodingContainer + // TODO: Push `SelectionBuilder` into the protocol? Does a var let us do this? @SelectionBuilder static func selections() -> [any Selectable] + // TODO: Ideally this would take a KeyedContainer + // TODO: Can we actually get away without the custom decoder if we pass in a single value container instead? init(from container: DecodingContainer) throws } diff --git a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift index be5077d..2afd27e 100644 --- a/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift +++ b/BuildsCore/Tests/BuildsCoreTests/QueryableTests.swift @@ -269,4 +269,3 @@ final class QueryableTests: XCTestCase { } } - From e1b4fbf8c7eab090cf4e528a14f0ccca3e0a7385 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 15:30:31 -1000 Subject: [PATCH 13/16] Actually reading data from GraphQL --- Builds/Models/ApplicationModel.swift | 198 +++++++++++++++--- Builds/Views/WorkflowsView.swift | 2 +- .../BuildsCore/GraphQL/KeyedContainer.swift | 6 + .../BuildsCore/Models/WorkflowInstance.swift | 30 +++ .../BuildsCore/Service/GitHubGraphQL.swift | 68 ------ 5 files changed, 202 insertions(+), 102 deletions(-) delete mode 100644 BuildsCore/Sources/BuildsCore/Service/GitHubGraphQL.swift diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 62b9950..15c3884 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -138,13 +138,15 @@ class ApplicationModel: NSObject, ObservableObject { print("Refreshing...") self.isUpdating = true self.lastError = nil - _ = try await self.client.update(workflows: self.workflows) { [weak self] workflowInstance in - guard let self else { - return - } - // Don't update the cache unless the contents have changed as this will cause an unnecessary redraw. + + guard let accessToken = settings.accessToken else { + throw BuildsError.authenticationFailure + } + + let workflowInstances = try await self.workflowInstances(self.workflows, accessToken: accessToken) + for workflowInstance in workflowInstances { guard self.cachedStatus[workflowInstance.id] != workflowInstance else { - return + continue } self.cachedStatus[workflowInstance.id] = workflowInstance } @@ -227,18 +229,6 @@ class ApplicationModel: NSObject, ObservableObject { sync() updateOrganizations() updateResults() - - Task { - do { - guard let accessToken = settings.accessToken else { - print("Failed to get access token") - return - } - try await testStuff(accessToken: accessToken) - } catch { - print("Failed to perform GraphQL query with error \(error).") - } - } } @MainActor func addWorkflow(_ id: WorkflowInstance.ID) { @@ -449,38 +439,165 @@ class ApplicationModel: NSObject, ObservableObject { #endif - func testStuff(accessToken: String) async throws { + func workflowInstances(_ workflowIdentifiers: [WorkflowIdentifier], accessToken: String) async throws -> [WorkflowInstance] { + return try await workflowIdentifiers.asyncMap { workflowIdentifier in + return try await self.testStuff(workflowIdentifier: workflowIdentifier, accessToken: accessToken) + } + } + + func testStuff(workflowIdentifier: WorkflowIdentifier, accessToken: String) async throws -> WorkflowInstance { + + enum CheckConclusionState: String, Decodable, StaticSelectable { - struct Workflow: StaticSelectableContainer { + case actionRequired = "ACTION_REQUIRED" + case timedOut = "TIMED_OUT" + case cancelled = "CANCELLED" + case failure = "FAILURE" + case success = "SUCCESS" + case neutral = "NEUTRAL" + case skipped = "SKIPPED" + case startupFailure = "STARTUP_FAILURE" + case stale = "STALE" + + // TODO: This really should set up a decoder to make things easier. + init(from container: any DecodingContainer) throws { + let value = try container.decode(String.self) + // TODO: Needs to throw instead of crash. + self.init(rawValue: value)! + } + + } + + enum CheckSatusState: String, Decodable, StaticSelectable { + + case requested = "REQUESTED" + case queued = "QUEUED" + case inProgress = "IN_PROGRESS" + case completed = "COMPLETED" + case waiting = "WAITING" + case pending = "PENDING" + + // TODO: This really should set up a decoder to make things easier. + init(from container: any DecodingContainer) throws { + let value = try container.decode(String.self) + // TODO: Needs to throw instead of crash. + self.init(rawValue: value)! + } + + } + + struct WorkflowRun: StaticSelectableContainer { static let id = Selection("id") static let event = Selection("event") static let createdAt = Selection("createdAt") + static let file = Selection("file") + static let checkSuite = Selection("checkSuite") + static let updatedAt = Selection("updatedAt") + static let runNumber = Selection("runNumber") @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { id event createdAt + file + checkSuite + updatedAt + runNumber } let id: String let event: String let createdAt: Date + let file: WorkflowRunFile + let checkSuite: CheckSuite + let updatedAt: Date + let runNumber: Int init(from container: DecodingContainer) throws { self.id = try container.decode(Self.id) self.event = try container.decode(Self.event) self.createdAt = try container.decode(Self.createdAt) + self.file = try container.decode(Self.file) + self.checkSuite = try container.decode(Self.checkSuite) + self.updatedAt = try container.decode(Self.updatedAt) + self.runNumber = try container.decode(Self.runNumber) + } + + } + + struct WorkflowRunFile: StaticSelectableContainer { + + static let id = Selection("id") + static let path = Selection("path") + + @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { + id + path + } + + let id: String + let path: String + + init(from container: DecodingContainer) throws { + self.id = try container.decode(Self.id) + self.path = try container.decode(Self.path) + } + + } + + struct CheckSuite: StaticSelectableContainer { + + static let commit = Selection("commit") + static let conclusion = Selection("conclusion") + static let status = Selection("status") + + @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { + commit + conclusion + status + } + + let commit: Commit + let conclusion: CheckConclusionState + let status: CheckSatusState + + init(from container: DecodingContainer) throws { + self.commit = try container.decode(Self.commit) + self.conclusion = try container.decode(Self.conclusion) + self.status = try container.decode(Self.status) } } - let nodes = Selection.first("nodes") + struct Commit: StaticSelectableContainer { + + static let oid = Selection("oid") + + @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { + oid + } + + let oid: String + + init(from container: DecodingContainer) throws { + self.oid = try container.decode(Self.oid) + } + + } + + // Workflow. + let name = Selection("name") + + // Runs. + let nodes = Selection.first("nodes") let runs = Selection("runs", arguments: ["first" : 1]) { nodes } - let workflow = Selection("node", arguments: ["id": "MDg6V29ya2Zsb3c5ODk4MDM1"]) { - Fragment(on: "Workflow") { + + let workflow = Selection("node", arguments: ["id": workflowIdentifier.workflowNodeId]) { + Fragment(on: "Workflow") { // TODO: <-- support StaticSelectableContaiers here + name runs } } @@ -488,22 +605,37 @@ class ApplicationModel: NSObject, ObservableObject { workflow } - print(workflowQuery.query()) - let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) let workflowResult = try await client.query(workflowQuery, accessToken: accessToken) print(try workflowResult[workflow][runs][nodes].id) + print(try workflowResult[workflow][name]) print(try workflowResult[workflow][runs][nodes].event) print(try workflowResult[workflow][runs][nodes].createdAt) - - - // TODO: Consider that it would also be possible to copy the RegexBuilder style inline transforms... - // We could always keep the entire extraction process internal to the decode operation and simply call our - // custom inits with a `KeyedContainer` which would save the need for our random faked up Decoder which is - // quite misleading. If we do it this way we don't need to rely on the coding keys at all and we can reduce - // the risk of mismatched implementation. + print(try workflowResult[workflow][runs][nodes].updatedAt) + print(try workflowResult[workflow][runs][nodes].file.path) + print(try workflowResult[workflow][runs][nodes].checkSuite.commit.oid) + print(try workflowResult[workflow][runs][nodes].checkSuite.conclusion) + print(try workflowResult[workflow][runs][nodes].checkSuite.status) + print(try workflowResult[workflow][runs][nodes].runNumber) // TODO: THis probably isn't right. + + let workflowInstance = WorkflowInstance(id: workflowIdentifier, + annotations: [], + createdAt: try workflowResult[workflow][runs][nodes].createdAt, + jobs: [], + operationState: .failure, + repositoryURL: nil, + sha: try workflowResult[workflow][runs][nodes].checkSuite.commit.oid, + title: "Unknown", + updatedAt: try workflowResult[workflow][runs][nodes].updatedAt, + workflowFilePath: try workflowResult[workflow][runs][nodes].file.path, + workflowName: try workflowResult[workflow][name], + workflowRunAttempt: -1, + workflowRunId: -1, + workflowRunURL: nil) + + return workflowInstance } - } + diff --git a/Builds/Views/WorkflowsView.swift b/Builds/Views/WorkflowsView.swift index 868ddfd..0d8c31e 100644 --- a/Builds/Views/WorkflowsView.swift +++ b/Builds/Views/WorkflowsView.swift @@ -61,7 +61,7 @@ struct WorkflowsView: View, OpenContext { openContext: self) } primaryAction: { selection in #if os(macOS) - let workflowInstances = workflows.filter(selections: selections) + let workflowInstances = workflows.filter(selection: selection) for workflowRunURL in workflowInstances.compactMap({ $0.workflowRunURL }) { presentURL(workflowRunURL) } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift index 15f163b..433ef21 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/KeyedContainer.swift @@ -76,4 +76,10 @@ extension KeyedDecodingContainer where K == UnknownCodingKey { return try KeyedContainer(from: container, selections: selection.selections) } + public func decode(_ selection: Selection) throws -> T { + let container = try self.nestedContainer(keyedBy: UnknownCodingKey.self, + forKey: UnknownCodingKey(stringValue: selection.resultKey)!) + return try T(from: container) + } + } diff --git a/BuildsCore/Sources/BuildsCore/Models/WorkflowInstance.swift b/BuildsCore/Sources/BuildsCore/Models/WorkflowInstance.swift index 1d1d6e5..794447a 100644 --- a/BuildsCore/Sources/BuildsCore/Models/WorkflowInstance.swift +++ b/BuildsCore/Sources/BuildsCore/Models/WorkflowInstance.swift @@ -109,6 +109,36 @@ public struct WorkflowInstance: Identifiable, Hashable, Codable { self.workflowRunURL = result?.workflowRun.html_url } + public init(id: ID, + annotations: [Annotation], + createdAt: Date?, + jobs: [Job], + operationState: OperationState, + repositoryURL: URL?, + sha: String?, + title: String?, + updatedAt: Date?, + workflowFilePath: String?, + workflowName: String, + workflowRunAttempt: Int?, + workflowRunId: Int?, + workflowRunURL: URL?) { + self.id = id + self.annotations = annotations + self.createdAt = createdAt + self.jobs = jobs + self.operationState = operationState + self.repositoryURL = repositoryURL + self.sha = sha + self.title = title + self.updatedAt = updatedAt + self.workflowFilePath = workflowFilePath + self.workflowName = workflowName + self.workflowRunAttempt = workflowRunAttempt + self.workflowRunId = workflowRunId + self.workflowRunURL = workflowRunURL + } + public func job(for annotation: Annotation) -> Job? { return jobs.first { return $0.id == annotation.jobId diff --git a/BuildsCore/Sources/BuildsCore/Service/GitHubGraphQL.swift b/BuildsCore/Sources/BuildsCore/Service/GitHubGraphQL.swift deleted file mode 100644 index e4456f0..0000000 --- a/BuildsCore/Sources/BuildsCore/Service/GitHubGraphQL.swift +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2022-2024 Jason Morley -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -struct GitHubGraphQL { - - enum CheckConclusionState: String, Codable { - case actionRequired = "ACTION_REQUIRED" - case timedOut = "TIMED_OUT" - case cancelled = "CANCELLED" - case failure = "FAILURE" - case success = "SUCCESS" - case neutral = "NEUTRAL" - case skipped = "SKIPPED" - case startupFailure = "STARTUP_FAILURE" - case stale = "STALE" - } - - struct WorkflowRun: Identifiable, Codable { - let id: String - - let createdAt: Date - let updatedAt: Date - let file: WorkflowRunFile - let resourcePath: URL - let checkSuite: CheckSuite - } - - struct WorkflowRunFile: Identifiable, Codable { - let id: String - - let path: String - } - - struct CheckSuite: Identifiable, Codable { - - let id: String - - let conclusion: CheckConclusionState - let commit: Commit - } - - struct Commit: Identifiable, Codable { - - let id: String - - let oid: String - } - -} From 39edc03ea9d889e993a08537d5f36fef58296e75 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 15:52:44 -1000 Subject: [PATCH 14/16] Update the status correctly --- Builds/Models/ApplicationModel.swift | 123 +++++++++++------- .../BuildsCore/Models/OperationState.swift | 1 - 2 files changed, 79 insertions(+), 45 deletions(-) diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 15c3884..35ebe29 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -447,45 +447,6 @@ class ApplicationModel: NSObject, ObservableObject { func testStuff(workflowIdentifier: WorkflowIdentifier, accessToken: String) async throws -> WorkflowInstance { - enum CheckConclusionState: String, Decodable, StaticSelectable { - - case actionRequired = "ACTION_REQUIRED" - case timedOut = "TIMED_OUT" - case cancelled = "CANCELLED" - case failure = "FAILURE" - case success = "SUCCESS" - case neutral = "NEUTRAL" - case skipped = "SKIPPED" - case startupFailure = "STARTUP_FAILURE" - case stale = "STALE" - - // TODO: This really should set up a decoder to make things easier. - init(from container: any DecodingContainer) throws { - let value = try container.decode(String.self) - // TODO: Needs to throw instead of crash. - self.init(rawValue: value)! - } - - } - - enum CheckSatusState: String, Decodable, StaticSelectable { - - case requested = "REQUESTED" - case queued = "QUEUED" - case inProgress = "IN_PROGRESS" - case completed = "COMPLETED" - case waiting = "WAITING" - case pending = "PENDING" - - // TODO: This really should set up a decoder to make things easier. - init(from container: any DecodingContainer) throws { - let value = try container.decode(String.self) - // TODO: Needs to throw instead of crash. - self.init(rawValue: value)! - } - - } - struct WorkflowRun: StaticSelectableContainer { static let id = Selection("id") @@ -549,8 +510,8 @@ class ApplicationModel: NSObject, ObservableObject { struct CheckSuite: StaticSelectableContainer { static let commit = Selection("commit") - static let conclusion = Selection("conclusion") - static let status = Selection("status") + static let conclusion = Selection("conclusion") + static let status = Selection("status") @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { commit @@ -559,8 +520,8 @@ class ApplicationModel: NSObject, ObservableObject { } let commit: Commit - let conclusion: CheckConclusionState - let status: CheckSatusState + let conclusion: GraphQL.CheckConclusionState + let status: GraphQL.CheckSatusState init(from container: DecodingContainer) throws { self.commit = try container.decode(Self.commit) @@ -623,7 +584,8 @@ class ApplicationModel: NSObject, ObservableObject { annotations: [], createdAt: try workflowResult[workflow][runs][nodes].createdAt, jobs: [], - operationState: .failure, + operationState: OperationState(status: try workflowResult[workflow][runs][nodes].checkSuite.status, + conclusion: try workflowResult[workflow][runs][nodes].checkSuite.conclusion), repositoryURL: nil, sha: try workflowResult[workflow][runs][nodes].checkSuite.commit.oid, title: "Unknown", @@ -639,3 +601,76 @@ class ApplicationModel: NSObject, ObservableObject { } + +struct GraphQL { + + enum CheckConclusionState: String, Decodable, StaticSelectable { + + case actionRequired = "ACTION_REQUIRED" + case timedOut = "TIMED_OUT" + case cancelled = "CANCELLED" + case failure = "FAILURE" + case success = "SUCCESS" + case neutral = "NEUTRAL" + case skipped = "SKIPPED" + case startupFailure = "STARTUP_FAILURE" + case stale = "STALE" + + // TODO: This really should set up a decoder to make things easier. + init(from container: any DecodingContainer) throws { + let value = try container.decode(String.self) + // TODO: Needs to throw instead of crash. + self.init(rawValue: value)! + } + + } + + enum CheckSatusState: String, Decodable, StaticSelectable { + + case requested = "REQUESTED" + case queued = "QUEUED" + case inProgress = "IN_PROGRESS" + case completed = "COMPLETED" + case waiting = "WAITING" + case pending = "PENDING" + + // TODO: This really should set up a decoder to make things easier. + init(from container: any DecodingContainer) throws { + let value = try container.decode(String.self) + // TODO: Needs to throw instead of crash. + self.init(rawValue: value)! + } + + } + +} + +extension OperationState { + + init(status: GraphQL.CheckSatusState?, conclusion: GraphQL.CheckConclusionState?) { + switch status { + case .queued, .requested: + self = .queued + case .waiting, .pending: + self = .waiting + case .inProgress: + self = .inProgress + case .completed: + switch conclusion { + case .success: + self = .success + case .failure, .startupFailure, .timedOut, .actionRequired: + self = .failure + case .cancelled: + self = .cancelled + case .skipped, .neutral, .stale: + self = .skipped + case .none: + self = .unknown + } + case .none: + self = .unknown + } + } + +} diff --git a/BuildsCore/Sources/BuildsCore/Models/OperationState.swift b/BuildsCore/Sources/BuildsCore/Models/OperationState.swift index 881f08a..b7f55f8 100644 --- a/BuildsCore/Sources/BuildsCore/Models/OperationState.swift +++ b/BuildsCore/Sources/BuildsCore/Models/OperationState.swift @@ -143,7 +143,6 @@ public enum OperationState: Codable { case .none: self = .unknown } - } } From e310a8d1803ae9924c5d1df6da9a1127f82b2e37 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 16:00:12 -1000 Subject: [PATCH 15/16] Resilient to errors --- Builds/Models/ApplicationModel.swift | 4 ++-- BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 35ebe29..211f9ef 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -440,8 +440,8 @@ class ApplicationModel: NSObject, ObservableObject { #endif func workflowInstances(_ workflowIdentifiers: [WorkflowIdentifier], accessToken: String) async throws -> [WorkflowInstance] { - return try await workflowIdentifiers.asyncMap { workflowIdentifier in - return try await self.testStuff(workflowIdentifier: workflowIdentifier, accessToken: accessToken) + return await workflowIdentifiers.asyncMap { workflowIdentifier in + return (try? await self.testStuff(workflowIdentifier: workflowIdentifier, accessToken: accessToken)) ?? WorkflowInstance(id: workflowIdentifier) } } diff --git a/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift index 0974925..7efee30 100644 --- a/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift +++ b/BuildsCore/Sources/BuildsCore/GraphQL/GraphQLClient.swift @@ -43,10 +43,10 @@ public struct GraphQLClient { let (data, response) = try await URLSession.shared.data(for: request) try response.checkHTTPStatusCode() - print(String(data: data, encoding: .utf8) ?? "nil") do { return try query.decode(data) } catch { + print("Failed to decode data with error \(error).") print(String(data: data, encoding: .utf8) ?? "nil") throw error } From 3880c1546825b384b9ac8fa3b37f296929e6dd7d Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 28 Apr 2024 16:03:21 -1000 Subject: [PATCH 16/16] Simplify things --- Builds/Models/ApplicationModel.swift | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/Builds/Models/ApplicationModel.swift b/Builds/Models/ApplicationModel.swift index 211f9ef..258e356 100644 --- a/Builds/Models/ApplicationModel.swift +++ b/Builds/Models/ApplicationModel.swift @@ -455,7 +455,6 @@ class ApplicationModel: NSObject, ObservableObject { static let file = Selection("file") static let checkSuite = Selection("checkSuite") static let updatedAt = Selection("updatedAt") - static let runNumber = Selection("runNumber") @SelectionBuilder static func selections() -> [any BuildsCore.Selectable] { id @@ -464,7 +463,6 @@ class ApplicationModel: NSObject, ObservableObject { file checkSuite updatedAt - runNumber } let id: String @@ -473,7 +471,6 @@ class ApplicationModel: NSObject, ObservableObject { let file: WorkflowRunFile let checkSuite: CheckSuite let updatedAt: Date - let runNumber: Int init(from container: DecodingContainer) throws { self.id = try container.decode(Self.id) @@ -482,7 +479,6 @@ class ApplicationModel: NSObject, ObservableObject { self.file = try container.decode(Self.file) self.checkSuite = try container.decode(Self.checkSuite) self.updatedAt = try container.decode(Self.updatedAt) - self.runNumber = try container.decode(Self.runNumber) } } @@ -569,23 +565,13 @@ class ApplicationModel: NSObject, ObservableObject { let client = GraphQLClient(url: URL(string: "https://api.github.com/graphql")!) let workflowResult = try await client.query(workflowQuery, accessToken: accessToken) - print(try workflowResult[workflow][runs][nodes].id) - print(try workflowResult[workflow][name]) - print(try workflowResult[workflow][runs][nodes].event) - print(try workflowResult[workflow][runs][nodes].createdAt) - print(try workflowResult[workflow][runs][nodes].updatedAt) - print(try workflowResult[workflow][runs][nodes].file.path) - print(try workflowResult[workflow][runs][nodes].checkSuite.commit.oid) - print(try workflowResult[workflow][runs][nodes].checkSuite.conclusion) - print(try workflowResult[workflow][runs][nodes].checkSuite.status) - print(try workflowResult[workflow][runs][nodes].runNumber) // TODO: THis probably isn't right. - + let operationState = OperationState(status: try workflowResult[workflow][runs][nodes].checkSuite.status, + conclusion: try workflowResult[workflow][runs][nodes].checkSuite.conclusion) let workflowInstance = WorkflowInstance(id: workflowIdentifier, annotations: [], createdAt: try workflowResult[workflow][runs][nodes].createdAt, jobs: [], - operationState: OperationState(status: try workflowResult[workflow][runs][nodes].checkSuite.status, - conclusion: try workflowResult[workflow][runs][nodes].checkSuite.conclusion), + operationState: operationState, repositoryURL: nil, sha: try workflowResult[workflow][runs][nodes].checkSuite.commit.oid, title: "Unknown",