├── .swift-version ├── autograph.png ├── LinuxMain.swift ├── AutoGraphTests ├── Models │ └── Film.swift ├── Info.plist ├── Data │ ├── Film.json │ ├── Film401.json │ ├── VariableFilm.json │ └── AllFilms.json ├── AuthHandlerTests.swift ├── Requests │ ├── FilmRequest.swift │ ├── AllFilmsRequest.swift │ └── FilmSubscriptionRequest.swift ├── ErrorTests.swift ├── DispatcherTests.swift ├── AlamofireClientTests.swift ├── XCTestManifests.swift ├── Stub.swift ├── ResponseHandlerTests.swift ├── WebSocketClientTests.swift └── AutoGraphTests.swift ├── AutoGraph ├── AutoGraph.h ├── SubscriptionResponseHandler.swift ├── Info.plist ├── WebSocketError.swift ├── Utilities.swift ├── AlamofireClient.swift ├── SubscriptionResponseSerializer.swift ├── Request.swift ├── ResponseHandler.swift ├── Dispatcher.swift ├── SubscriptionRequest.swift ├── AuthHandler.swift ├── Errors.swift ├── AutoGraph.swift └── WebSocketClient.swift ├── QueryBuilder ├── QueryBuilder.h ├── Info.plist └── FoundationExtensions.swift ├── codecov.yml ├── QueryBuilderTests ├── Info.plist ├── FoundationExtensionsTests.swift └── XCTestManifests.swift ├── Package.resolved ├── LICENSE ├── .swiftlint.yml ├── .gitignore ├── Package.swift ├── .github └── workflows │ └── ci.yml └── README.md /.swift-version: -------------------------------------------------------------------------------- 1 | 6.2 2 | -------------------------------------------------------------------------------- /autograph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autograph-hq/AutoGraph/HEAD/autograph.png -------------------------------------------------------------------------------- /LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import AutoGraphTests 4 | import QueryBuilderTests 5 | 6 | var tests = [XCTestCaseEntry]() 7 | tests += AutoGraphTests.__allTests() 8 | tests += QueryBuilderTests.__allTests() 9 | 10 | XCTMain(tests) 11 | -------------------------------------------------------------------------------- /AutoGraphTests/Models/Film.swift: -------------------------------------------------------------------------------- 1 | import AutoGraphQL 2 | import Foundation 3 | import JSONValueRX 4 | 5 | struct Film: Decodable, Equatable { 6 | let remoteId: String 7 | let title: String 8 | let episode: Int 9 | let openingCrawl: String 10 | let director: String 11 | 12 | enum CodingKeys: String, CodingKey { 13 | case remoteId = "id" 14 | case title 15 | case episode = "episodeID" 16 | case openingCrawl 17 | case director 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /AutoGraph/AutoGraph.h: -------------------------------------------------------------------------------- 1 | // 2 | // AutoGraph.h 3 | // AutoGraph 4 | // 5 | // Created by Rex Fenley on 10/14/16. 6 | // Copyright © 2016 Remind. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for AutoGraph. 12 | FOUNDATION_EXPORT double AutoGraphVersionNumber; 13 | 14 | //! Project version string for AutoGraph. 15 | FOUNDATION_EXPORT const unsigned char AutoGraphVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /QueryBuilder/QueryBuilder.h: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder.h 3 | // QueryBuilder 4 | // 5 | // Created by Rex Fenley on 10/18/16. 6 | // Copyright © 2016 Remind. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for QueryBuilder. 12 | FOUNDATION_EXPORT double QueryBuilderVersionNumber; 13 | 14 | //! Project version string for QueryBuilder. 15 | FOUNDATION_EXPORT const unsigned char QueryBuilderVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: true 4 | comment: 5 | behavior: default 6 | layout: header, diff 7 | require_changes: false 8 | coverage: 9 | precision: 2 10 | range: 11 | - 70.0 12 | - 100.0 13 | round: down 14 | status: 15 | default: 16 | threshold: 0% 17 | changes: false 18 | patch: true 19 | project: true 20 | parsers: 21 | gcov: 22 | branch_detection: 23 | conditional: true 24 | loop: true 25 | macro: false 26 | method: false 27 | javascript: 28 | enable_partials: false 29 | ignore: 30 | - ./Pods 31 | - ./QueryBuilderTests 32 | - ./AutoGraphTests 33 | -------------------------------------------------------------------------------- /AutoGraphTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /QueryBuilderTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /AutoGraph/SubscriptionResponseHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct SubscriptionResponseHandler { 4 | public typealias WebSocketCompletionBlock = (Result) -> Void 5 | 6 | private let completion: WebSocketCompletionBlock 7 | 8 | init(completion: @escaping WebSocketCompletionBlock) { 9 | self.completion = completion 10 | } 11 | 12 | public func didReceive(subscriptionResponse: SubscriptionResponse) { 13 | if let error = subscriptionResponse.error { 14 | didReceive(error: error) 15 | } 16 | else if let data = subscriptionResponse.payload { 17 | self.completion(.success(data)) 18 | } 19 | } 20 | 21 | public func didReceive(error: Error) { 22 | self.completion(.failure(error)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /AutoGraphTests/Data/Film.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "film": { 4 | "id": "ZmlsbXM6MQ==", 5 | "title": "A New Hope", 6 | "episodeID": 4, 7 | "director": "George Lucas", 8 | "openingCrawl": "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy...." 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /AutoGraph/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 0.10.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSPrincipalClass 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /QueryBuilder/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSPrincipalClass 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /AutoGraphTests/Data/Film401.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "film": null 4 | }, 5 | "errors": [ 6 | { 7 | "__restql_adapter__": { 8 | "service_response": { 9 | "error": "Unauthenticated", 10 | "error_code": "unauthenticated" 11 | }, 12 | "service_status": 401 13 | }, 14 | "locations": [ 15 | { 16 | "column": 1, 17 | "line": 3 18 | } 19 | ], 20 | "message": "401 - {\"error\":\"Unauthenticated\",\"error_code\":\"unauthenticated\"}", 21 | "path": [ 22 | "film" 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "alamofire", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/Alamofire/Alamofire.git", 7 | "state" : { 8 | "revision" : "b2fa556e4e48cbf06cf8c63def138c98f4b811fa", 9 | "version" : "5.8.0" 10 | } 11 | }, 12 | { 13 | "identity" : "jsonvalue", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/rexmas/JSONValue.git", 16 | "state" : { 17 | "revision" : "8c79a348868726629bfdc20cda6e0855a1abb97f", 18 | "version" : "8.0.0" 19 | } 20 | }, 21 | { 22 | "identity" : "starscream", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/daltoniam/Starscream.git", 25 | "state" : { 26 | "revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a", 27 | "version" : "4.0.8" 28 | } 29 | } 30 | ], 31 | "version" : 2 32 | } 33 | -------------------------------------------------------------------------------- /AutoGraph/WebSocketError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum WebSocketError: Error { 4 | case webSocketNotConnected(subscriptionPayload: String) 5 | case subscriptionRequestBodyFailed(operationName: String) 6 | case subscriptionPayloadFailedSerialization([String : Any], underlyingError: Error?) 7 | 8 | public var localizedDescription: String { 9 | switch self { 10 | case let .webSocketNotConnected(subscriptionPayload): 11 | return "WebSocket is not connected, cannot yet make a subscription: \(subscriptionPayload)" 12 | case let .subscriptionRequestBodyFailed(operationName): 13 | return "Subscription request body failed to serialize for query: \(operationName)" 14 | case let .subscriptionPayloadFailedSerialization(payload, underlyingError): 15 | return "Subscription message payload failed to serialize message string: \(payload) underlying error: \(String(describing: underlyingError))" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Remind 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers to exclude from running 2 | - class_delegate_protocol 3 | - colon 4 | - cyclomatic_complexity 5 | - file_length 6 | - force_try 7 | - function_body_length 8 | - function_parameter_count 9 | - generic_type_name 10 | - identifier_name 11 | - large_tuple 12 | - leading_whitespace 13 | - line_length 14 | - nesting 15 | - opening_brace 16 | - statement_position 17 | - todo 18 | - trailing_whitespace 19 | - type_body_length 20 | - void_return 21 | - weak_delegate 22 | - xctfail_message 23 | opt_in_rules: # some rules are only opt-in 24 | included: # paths to include during linting. `--path` is ignored if present. 25 | - AutoGraph 26 | - QueryBuilder 27 | excluded: # paths to ignore during linting. Takes precedence over `included`. 28 | - Pods 29 | custom_rules: 30 | else_newline: 31 | name: Newline before else 32 | regex: (\}\h*else\s*\{) 33 | message: Else statements should go on a new line 34 | severity: warning 35 | 36 | weak_delegate: 37 | name: Weak delegate 38 | regex: (?<=\n)\h*(var delegate) 39 | message: Delegates should be weak 40 | severity: error 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # Xcode 5 | # 6 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 7 | 8 | ## Build generated 9 | build/ 10 | DerivedData/ 11 | 12 | ## Various settings 13 | *.pbxuser 14 | !default.pbxuser 15 | *.mode1v3 16 | !default.mode1v3 17 | *.mode2v3 18 | !default.mode2v3 19 | *.perspectivev3 20 | !default.perspectivev3 21 | xcuserdata/ 22 | 23 | ## Other 24 | *.moved-aside 25 | *.xcuserstate 26 | 27 | ## Obj-C/Swift specific 28 | *.hmap 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | .build/ 42 | .swiftpm/ 43 | 44 | # Xcode projects generated from SPM 45 | *.xcodeproj 46 | *.xcworkspace 47 | 48 | # Carthage 49 | # 50 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 51 | # Carthage/Checkouts 52 | 53 | Carthage/Build 54 | 55 | # fastlane 56 | # 57 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 58 | # screenshots whenever they are needed. 59 | # For more information about the recommended setup visit: 60 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 61 | 62 | fastlane/report.xml 63 | fastlane/Preview.html 64 | fastlane/screenshots 65 | fastlane/test_output 66 | 67 | # SPM 68 | .build 69 | -------------------------------------------------------------------------------- /AutoGraph/Utilities.swift: -------------------------------------------------------------------------------- 1 | import Alamofire 2 | import Foundation 3 | import JSONValueRX 4 | 5 | public typealias AutoGraphResult = Swift.Result 6 | public typealias ResultIncludingNetworkResponse = AutoGraphResult> 7 | 8 | extension DataResponse { 9 | func extractValue() throws -> Any { 10 | switch self.result { 11 | case .success(let value): 12 | return value 13 | 14 | case .failure(let e): 15 | 16 | let gqlError: AutoGraphError? = { 17 | guard let data = self.data, let json = try? JSONValue.decode(data) else { 18 | return nil 19 | } 20 | 21 | return AutoGraphError(graphQLResponseJSON: json, response: self.response, networkErrorParser: nil) 22 | }() 23 | 24 | throw AutoGraphError.network(error: e, statusCode: self.response?.statusCode ?? -1, response: self.response, underlying: gqlError) 25 | } 26 | } 27 | 28 | func extractJSON(networkErrorParser: @escaping NetworkErrorParser) throws -> JSONValue { 29 | let value = try self.extractValue() 30 | let json = try JSONValue(object: value) 31 | 32 | if let queryError = AutoGraphError(graphQLResponseJSON: json, response: self.response, networkErrorParser: networkErrorParser) { 33 | throw queryError 34 | } 35 | 36 | return json 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /AutoGraphTests/Data/VariableFilm.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "allFilms": { 4 | "films": [ 5 | { 6 | "id": "ZmlsbXM6Ng==", 7 | "title": "Revenge of the Sith", 8 | "episodeID": 3, 9 | "director": "George Lucas", 10 | "openingCrawl": "War! The Republic is crumbling\r\nunder attacks by the ruthless\r\nSith Lord, Count Dooku.\r\nThere are heroes on both sides.\r\nEvil is everywhere.\r\n\r\nIn a stunning move, the\r\nfiendish droid leader, General\r\nGrievous, has swept into the\r\nRepublic capital and kidnapped\r\nChancellor Palpatine, leader of\r\nthe Galactic Senate.\r\n\r\nAs the Separatist Droid Army\r\nattempts to flee the besieged\r\ncapital with their valuable\r\nhostage, two Jedi Knights lead a\r\ndesperate mission to rescue the\r\ncaptive Chancellor...." 11 | } 12 | ] 13 | }, 14 | "node": { 15 | "id": "ZmlsbXM6MQ==", 16 | "title": "A New Hope", 17 | "episodeID": 4, 18 | "director": "George Lucas", 19 | "openingCrawl": "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy...." 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /AutoGraphTests/AuthHandlerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Alamofire 3 | import JSONValueRX 4 | @testable import AutoGraphQL 5 | 6 | class AuthHandlerTests: XCTestCase { 7 | 8 | var subject: AuthHandler! 9 | 10 | override func setUp() { 11 | super.setUp() 12 | 13 | self.subject = AuthHandler(accessToken: "token", refreshToken: "token") 14 | } 15 | 16 | override func tearDown() { 17 | self.subject = nil 18 | 19 | super.tearDown() 20 | } 21 | 22 | func testAdaptsAuthToken() { 23 | self.subject.adapt( 24 | URLRequest(url: URL(string: "localhost")!), 25 | for: Session(interceptor: self.subject), 26 | completion: 27 | { urlRequestResult in 28 | let urlRequest = try! urlRequestResult.get() 29 | XCTAssertEqual(urlRequest.allHTTPHeaderFields!["Authorization"]!, "Bearer token") 30 | }) 31 | } 32 | 33 | func testGetsAuthTokensIfSuccess() { 34 | self.subject.reauthenticated(success: true, accessToken: "a", refreshToken: "b") 35 | XCTAssertEqual(self.subject.accessToken, "a") 36 | XCTAssertEqual(self.subject.refreshToken, "b") 37 | } 38 | 39 | func testDoesNotGetAuthTokensIfFailure() { 40 | XCTAssertNotNil(self.subject.accessToken) 41 | XCTAssertNotNil(self.subject.refreshToken) 42 | self.subject.reauthenticated(success: false, accessToken: "a", refreshToken: "b") 43 | XCTAssertNil(self.subject.accessToken) 44 | XCTAssertNil(self.subject.refreshToken) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /QueryBuilderTests/FoundationExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import AutoGraphQL 3 | 4 | class FoundationExtensionsTests: XCTestCase { 5 | func testArgumentStringJsonEncodes() { 6 | XCTAssertEqual(try! "arg arg".graphQLInputValue(), "\"arg arg\"") 7 | } 8 | 9 | func testNSNullJsonEncodes() { 10 | XCTAssertEqual(try! NSNull().graphQLInputValue(), "null") 11 | } 12 | 13 | func testNSNumberJsonEncodes() { 14 | // 1.1 -> "1.1000000000000001" https://bugs.swift.org/browse/SR-5961 15 | XCTAssertEqual(try! (1.2).graphQLInputValue(), "1.2") 16 | } 17 | } 18 | 19 | class OrderedDictionaryTests: XCTestCase { 20 | func testsMaintainsOrder() { 21 | var orderedDictionary = OrderedDictionary() 22 | orderedDictionary["first"] = "firstThing" 23 | orderedDictionary["second"] = "secondThing" 24 | orderedDictionary["third"] = "thirdThing" 25 | orderedDictionary["forth"] = "forthThing" 26 | 27 | XCTAssertEqual(orderedDictionary.values, ["firstThing", "secondThing", "thirdThing", "forthThing"]) 28 | 29 | orderedDictionary["second"] = nil 30 | orderedDictionary["third"] = nil 31 | orderedDictionary["third"] = nil // Some redundancy to test assigning `nil` twice. 32 | 33 | XCTAssertEqual(orderedDictionary.values, ["firstThing", "forthThing"]) 34 | 35 | orderedDictionary["fifth"] = "fifthThing" 36 | 37 | XCTAssertEqual(orderedDictionary.values, ["firstThing", "forthThing", "fifthThing"]) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "AutoGraph", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13) 11 | ], 12 | products: [ 13 | .library( 14 | name: "AutoGraphQL", 15 | targets: ["AutoGraphQL"] 16 | ) 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0"), 20 | .package(url: "https://github.com/rexmas/JSONValue.git", from: "8.0.0"), 21 | .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.8") 22 | ], 23 | targets: [ 24 | .target( 25 | name: "AutoGraphQL", 26 | dependencies: [ 27 | "Alamofire", 28 | .product(name: "JSONValueRX", package: "JSONValue"), 29 | "Starscream" 30 | ], 31 | path: ".", 32 | exclude: [ 33 | "AutoGraph/Info.plist", 34 | "QueryBuilder/Info.plist", 35 | "AutoGraphTests", 36 | "QueryBuilderTests" 37 | ], 38 | sources: ["AutoGraph", "QueryBuilder"] 39 | ), 40 | .testTarget( 41 | name: "AutoGraphTests", 42 | dependencies: ["AutoGraphQL"], 43 | path: "./AutoGraphTests", 44 | exclude: [ 45 | "Info.plist" 46 | ], 47 | resources: [ 48 | .copy("Data") 49 | ] 50 | ), 51 | .testTarget( 52 | name: "QueryBuilderTests", 53 | dependencies: ["AutoGraphQL"], 54 | path: "./QueryBuilderTests", 55 | exclude: [ 56 | "Info.plist" 57 | ] 58 | ) 59 | ] 60 | ) 61 | 62 | -------------------------------------------------------------------------------- /AutoGraphTests/Requests/FilmRequest.swift: -------------------------------------------------------------------------------- 1 | @testable import AutoGraphQL 2 | import Foundation 3 | import JSONValueRX 4 | 5 | class FilmRequest: Request { 6 | /* 7 | query film { 8 | film(id: "ZmlsbXM6MQ==") { 9 | id 10 | title 11 | episodeID 12 | director 13 | openingCrawl 14 | } 15 | } 16 | */ 17 | 18 | let queryDocument = Operation(type: .query, 19 | name: "film", 20 | selectionSet: [ 21 | Object(name: "film", 22 | alias: nil, 23 | arguments: ["id" : "ZmlsbXM6MQ=="], 24 | selectionSet: [ 25 | "id", 26 | Scalar(name: "title", alias: nil), 27 | Scalar(name: "episodeID", alias: nil), 28 | Scalar(name: "director", alias: nil), 29 | Scalar(name: "openingCrawl", alias: nil)]) 30 | ]) 31 | 32 | let variables: [AnyHashable : Any]? = nil 33 | 34 | let rootKeyPath: String = "data.film" 35 | 36 | public func willSend() throws { } 37 | public func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws { } 38 | public func didFinish(result: AutoGraphResult) throws { } 39 | } 40 | 41 | class FilmStub: Stub { 42 | override var jsonFixtureFile: String? { 43 | get { return "Film" } 44 | set { } 45 | } 46 | 47 | override var urlPath: String? { 48 | get { return "/graphql" } 49 | set { } 50 | } 51 | 52 | override var graphQLQuery: String { 53 | get { 54 | return "query film {\n" + 55 | "film(id: \"ZmlsbXM6MQ==\") {\n" + 56 | "id\n" + 57 | "title\n" + 58 | "episodeID\n" + 59 | "director\n" + 60 | "openingCrawl\n" + 61 | "}\n" + 62 | "}\n" 63 | } 64 | set { } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /AutoGraph/AlamofireClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Alamofire 3 | 4 | struct AutoGraphAlamofireClientError: LocalizedError { 5 | public var errorDescription: String? { 6 | return "Session of AlamofireClient must be initialized with `interceptor` of AuthHandler." 7 | } 8 | } 9 | 10 | open class AlamofireClient: Client { 11 | public let session: Session 12 | public let url: URL 13 | public var httpHeaders: [String : String] 14 | public var authHandler: AuthHandler? { 15 | self.session.interceptor as? AuthHandler 16 | } 17 | public var requestInterceptor: RequestInterceptor? { 18 | self.session.interceptor 19 | } 20 | 21 | public var sessionConfiguration: URLSessionConfiguration { 22 | return self.session.session.configuration 23 | } 24 | 25 | public var authTokens: AuthTokens { 26 | return (accessToken: self.authHandler?.accessToken, 27 | refreshToken: self.authHandler?.refreshToken) 28 | } 29 | 30 | public required init( 31 | url: URL, 32 | httpHeaders: [String : String] = [:], 33 | session: Session) 34 | { 35 | self.url = url 36 | self.httpHeaders = httpHeaders 37 | self.session = session 38 | } 39 | 40 | public convenience init( 41 | url: String, 42 | httpHeaders: [String : String] = [:], 43 | session: Session) 44 | throws 45 | { 46 | self.init(url: try url.asURL(), httpHeaders: httpHeaders, session: session) 47 | } 48 | 49 | public func sendRequest(parameters: [String : Any], completion: @escaping (AFDataResponse) -> ()) { 50 | self.session.request( 51 | self.url, 52 | method: .post, 53 | parameters: parameters, 54 | encoding: JSONEncoding.default, 55 | headers: HTTPHeaders(self.httpHeaders)) 56 | .responseJSON(completionHandler: completion) 57 | } 58 | 59 | public func authenticate(authTokens: AuthTokens) { 60 | self.authHandler?.reauthenticated(success: true, accessToken: authTokens.accessToken, refreshToken: authTokens.refreshToken) 61 | } 62 | 63 | public func cancelAll() { 64 | self.session.session.getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in 65 | dataTasks.forEach { $0.cancel() } 66 | uploadTasks.forEach { $0.cancel() } 67 | downloadTasks.forEach { $0.cancel() } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /AutoGraphTests/Requests/AllFilmsRequest.swift: -------------------------------------------------------------------------------- 1 | @testable import AutoGraphQL 2 | import Foundation 3 | import JSONValueRX 4 | 5 | class AllFilmsRequest: Request { 6 | /* 7 | query allFilms { 8 | film(id: "ZmlsbXM6MQ==") { 9 | id 10 | title 11 | episodeID 12 | director 13 | openingCrawl 14 | } 15 | } 16 | */ 17 | 18 | let queryDocument = Operation(type: .query, 19 | name: "allFilms", 20 | selectionSet: [ 21 | Object(name: "allFilms", 22 | arguments: nil, 23 | selectionSet: [ 24 | Object(name: "films", 25 | alias: nil, 26 | arguments: nil, 27 | selectionSet: [ 28 | "id", 29 | Scalar(name: "title", alias: nil), 30 | Scalar(name: "episodeID", alias: nil), 31 | Scalar(name: "director", alias: nil), 32 | Scalar(name: "openingCrawl", alias: nil)]) 33 | ]) 34 | ]) 35 | 36 | let variables: [AnyHashable : Any]? = nil 37 | 38 | let rootKeyPath: String = "data.allFilms.films" 39 | 40 | public func willSend() throws { } 41 | public func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws { } 42 | public func didFinish(result: AutoGraphResult<[Film]>) throws { } 43 | } 44 | 45 | class AllFilmsStub: Stub { 46 | override var jsonFixtureFile: String? { 47 | get { return "AllFilms" } 48 | set { } 49 | } 50 | 51 | override var urlPath: String? { 52 | get { return "/graphql" } 53 | set { } 54 | } 55 | 56 | override var graphQLQuery: String { 57 | get { 58 | return "query allFilms {\n" + 59 | "allFilms {\n" + 60 | "films {\n" + 61 | "id\n" + 62 | "title\n" + 63 | "episodeID\n" + 64 | "director\n" + 65 | "openingCrawl\n" + 66 | "}\n" + 67 | "}\n" + 68 | "}\n" 69 | 70 | } 71 | set { } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /AutoGraph/SubscriptionResponseSerializer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONValueRX 3 | 4 | public enum ResponseSerializerError: Error { 5 | case failedToConvertTextToData(String) 6 | case webSocketError(String) 7 | 8 | public var localizedDescription: String { 9 | switch self { 10 | case let .failedToConvertTextToData(text): 11 | return "Failed to convert into data: \(text)" 12 | case let .webSocketError(error): 13 | return "Received websocket error event: \(error)" 14 | } 15 | } 16 | } 17 | 18 | public final class SubscriptionResponseSerializer { 19 | private let queue = DispatchQueue(label: "org.autograph.subscription_response_handler", qos: .default) 20 | 21 | func serialize(data: Data) throws -> SubscriptionResponse { 22 | return try JSONDecoder().decode(SubscriptionResponse.self, from: data) 23 | } 24 | 25 | func serialize(text: String) throws -> SubscriptionResponse { 26 | guard let data = text.data(using: .utf8) else { 27 | throw ResponseSerializerError.failedToConvertTextToData(text) 28 | } 29 | 30 | return try self.serialize(data: data) 31 | } 32 | 33 | func serializeFinalObject(data: Data, completion: @escaping (Result) -> Void) { 34 | self.queue.async { 35 | do { 36 | let serializedObject = try JSONDecoder().decode(SerializedObject.self, from: data) 37 | DispatchQueue.main.async { 38 | completion(.success(serializedObject)) 39 | } 40 | } 41 | catch let error { 42 | DispatchQueue.main.async { 43 | completion(.failure(error)) 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | public struct SubscriptionResponse: Decodable { 51 | enum CodingKeys: String, CodingKey { 52 | case id 53 | case payload 54 | case type 55 | } 56 | 57 | enum PayloadCodingKeys: String, CodingKey { 58 | case data 59 | case errors 60 | } 61 | 62 | let id: String 63 | let payload: Data? 64 | let type: GraphQLWSProtocol 65 | let error: AutoGraphError? 66 | 67 | public init(from decoder: Decoder) throws { 68 | let container = try decoder.container(keyedBy: CodingKeys.self) 69 | self.id = try container.decode(String.self, forKey: .id) 70 | self.type = GraphQLWSProtocol(rawValue: try container.decode(String.self, forKey: .type)) ?? .unknownResponse 71 | let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) 72 | self.payload = try payloadContainer.decodeIfPresent(JSONValue.self, forKey: .data)?.encode() 73 | 74 | let payloadJSON = try container.decode(JSONValue.self, forKey: .payload) 75 | self.error = AutoGraphError(graphQLResponseJSON: payloadJSON, response: nil, networkErrorParser: nil) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /AutoGraph/Request.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONValueRX 3 | 4 | /// A `Request` to be sent by AutoGraph. 5 | public protocol Request { 6 | /// The returned type for the request. 7 | associatedtype SerializedObject: Decodable 8 | 9 | associatedtype QueryDocument: GraphQLDocument 10 | associatedtype Variables: GraphQLVariables 11 | 12 | /// The query to be sent to GraphQL. 13 | var queryDocument: QueryDocument { get } 14 | 15 | /// The Operation Name for the query document. 16 | var operationName: String { get } 17 | 18 | /// The variables sent along with the query. 19 | var variables: Variables? { get } 20 | 21 | /// The key path to the result object in the data 22 | var rootKeyPath: String { get } 23 | 24 | /// Called at the moment before the request will be sent from the `Client`. 25 | func willSend() throws 26 | 27 | /// Called as soon as the http request finishes. 28 | func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws 29 | 30 | /// Called right before calling the completion handler for the sent request, i.e. at the end of the lifecycle. 31 | func didFinish(result: AutoGraphResult) throws 32 | } 33 | 34 | extension Request where QueryDocument == Operation { 35 | public var operationName: String { 36 | return self.queryDocument.name 37 | } 38 | } 39 | 40 | /// A weird enum that collects info for a request. 41 | public enum ObjectBinding { 42 | case object(keyPath: String, isRequestIncludingNetworkResponse: Bool, completion: RequestCompletion) 43 | } 44 | 45 | extension Request { 46 | func generateBinding(completion: @escaping RequestCompletion) -> ObjectBinding { 47 | return ObjectBinding.object(keyPath: self.rootKeyPath, isRequestIncludingNetworkResponse: self is IsRequestIncludingNetworking, completion: completion) 48 | } 49 | } 50 | 51 | private protocol IsRequestIncludingNetworking {} 52 | extension RequestIncludingNetworkResponse: IsRequestIncludingNetworking {} 53 | 54 | public struct HTTPResponse: Decodable { 55 | public let urlString: String 56 | public let statusCode: Int 57 | public let headerFields: [String : String] 58 | } 59 | 60 | public struct DataIncludingNetworkResponse: Decodable { 61 | public let value: T 62 | public let json: JSONValue 63 | public let httpResponse: HTTPResponse? 64 | } 65 | 66 | public struct RequestIncludingNetworkResponse: Request { 67 | public typealias SerializedObject = DataIncludingNetworkResponse 68 | public typealias QueryDocument = R.QueryDocument 69 | public typealias Variables = R.Variables 70 | 71 | public let request: R 72 | 73 | public var queryDocument: R.QueryDocument { 74 | return self.request.queryDocument 75 | } 76 | 77 | public var operationName: String { 78 | return self.request.operationName 79 | } 80 | 81 | public var variables: R.Variables? { 82 | return self.request.variables 83 | } 84 | 85 | public var rootKeyPath: String { 86 | return self.request.rootKeyPath 87 | } 88 | 89 | public func willSend() throws { 90 | try self.request.willSend() 91 | } 92 | 93 | public func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws { 94 | try self.request.didFinishRequest(response: response, json: json) 95 | } 96 | 97 | public func didFinish(result: Result, Error>) throws { 98 | try self.request.didFinish(result: { 99 | switch result { 100 | case .success(let data): 101 | return .success(data.value) 102 | case .failure(let error): 103 | return .failure(error) 104 | } 105 | }()) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /AutoGraphTests/ErrorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Alamofire 3 | import JSONValueRX 4 | @testable import AutoGraphQL 5 | 6 | struct MockNetworkError: NetworkError { 7 | let statusCode: Int 8 | let underlyingError: GraphQLError 9 | } 10 | 11 | class ErrorTests: XCTestCase { 12 | func testInvalidResponseLocalizedErrorDoesntCrash() { 13 | let description = AutoGraphError.invalidResponse(response: nil).localizedDescription 14 | XCTAssertGreaterThan(description.count, 0) 15 | } 16 | 17 | func testAutoGraphErrorGraphQLErrorUsesMessages() { 18 | let message1 = "Cannot query field \"d\" on type \"Planet\"." 19 | let message2 = "401 - {\"error\":\"Unauthenticated\",\"error_code\":\"unauthenticated\"}" 20 | let line = 18 21 | let column = 7 22 | let jsonObj: [AnyHashable : Any] = [ 23 | "errors" : [ 24 | [ 25 | "message": message1, 26 | "locations": [ 27 | [ 28 | "line": line, 29 | "column": column 30 | ] 31 | ] 32 | ], 33 | [ 34 | "message": message2, 35 | "locations": [ 36 | [ 37 | "line": line, 38 | "column": column 39 | ] 40 | ] 41 | ] 42 | ] 43 | ] 44 | 45 | let json = try! JSONValue(object: jsonObj) 46 | let error = AutoGraphError(graphQLResponseJSON: json, response: nil, networkErrorParser: nil)! 47 | XCTAssertEqual(error.errorDescription!, "\(message1)\n\(message2)") 48 | XCTAssertEqual(error.localizedDescription, "\(message1)\n\(message2)") 49 | } 50 | 51 | func testGraphQLErrorUsesMessageForLocalizedDescription() { 52 | let message = "Cannot query field \"d\" on type \"Planet\"." 53 | let line = 18 54 | let column = 7 55 | let jsonObj: [AnyHashable : Any] = [ 56 | "message": message, 57 | "locations": [ 58 | [ 59 | "line": line, 60 | "column": column 61 | ] 62 | ] 63 | ] 64 | 65 | let error = GraphQLError(json: try! JSONValue(object: jsonObj)) 66 | XCTAssertEqual(error.errorDescription!, message) 67 | XCTAssertEqual(error.localizedDescription, message) 68 | } 69 | 70 | func testAutoGraphErrorProducesNetworkErrorForNetworkErrorParserMatch() { 71 | let message = "401 - {\"error\":\"Unauthenticated\",\"error_code\":\"unauthenticated\"}" 72 | let line = 18 73 | let column = 7 74 | let jsonObj: [AnyHashable : Any] = [ 75 | "errors" : [ 76 | [ 77 | "message": message, 78 | "locations": [ 79 | [ 80 | "line": line, 81 | "column": column 82 | ] 83 | ] 84 | ] 85 | ] 86 | ] 87 | 88 | let json = try! JSONValue(object: jsonObj) 89 | let error = AutoGraphError(graphQLResponseJSON: json, response: nil, networkErrorParser: { (gqlError) -> NetworkError? in 90 | guard message == gqlError.message else { 91 | return nil 92 | } 93 | 94 | return MockNetworkError(statusCode: 401, underlyingError: gqlError) 95 | }) 96 | 97 | guard 98 | case .some(.network(let baseError, let statusCode, _, let underlying)) = error, 99 | case .some(.graphQL(errors: let underlyingErrors, _)) = underlying, 100 | case let networkError as NetworkError = baseError, 101 | networkError.statusCode == 401, 102 | networkError.underlyingError == underlyingErrors.first, 103 | networkError.statusCode == statusCode 104 | else { 105 | XCTFail() 106 | return 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /AutoGraph/ResponseHandler.swift: -------------------------------------------------------------------------------- 1 | import Alamofire 2 | import Foundation 3 | import JSONValueRX 4 | 5 | open class ResponseHandler { 6 | 7 | public struct ObjectKeyPathError: LocalizedError { 8 | let keyPath: String 9 | public var errorDescription: String? { 10 | return "No object to map found at keyPath '\(self.keyPath)'" 11 | } 12 | } 13 | 14 | private let queue: OperationQueue 15 | private let callbackQueue: OperationQueue 16 | public var networkErrorParser: NetworkErrorParser? 17 | 18 | public init(queue: OperationQueue = OperationQueue(), 19 | callbackQueue: OperationQueue = OperationQueue.main) { 20 | self.queue = queue 21 | self.callbackQueue = callbackQueue 22 | } 23 | 24 | func handle(response: AFDataResponse, 25 | objectBinding: ObjectBinding, 26 | preMappingHook: (HTTPURLResponse?, JSONValue) throws -> ()) { 27 | do { 28 | let json = try response.extractJSON(networkErrorParser: self.networkErrorParser ?? { _ in return nil }) 29 | try preMappingHook(response.response, json) 30 | 31 | self.queue.addOperation { [weak self] in 32 | self?.map(json: json, response: response.response, objectBinding: objectBinding) 33 | } 34 | } 35 | catch let e { 36 | self.fail(error: e, objectBinding: objectBinding) 37 | } 38 | } 39 | 40 | private func map(json: JSONValue, response: HTTPURLResponse?, objectBinding: ObjectBinding) { 41 | do { 42 | switch objectBinding { 43 | case .object(let keyPath, let isRequestIncludingNetworkResponse, let completion): 44 | let decoder = JSONDecoder() 45 | let decodingJSON: JSONValue = try { 46 | guard let objectJson = json[keyPath] else { 47 | throw ObjectKeyPathError(keyPath: keyPath) 48 | } 49 | 50 | switch isRequestIncludingNetworkResponse { 51 | case true: 52 | let httpResponseJson = response.flatMap { 53 | try? JSONValue(dict: [ 54 | "urlString" : $0.url?.absoluteString ?? "", 55 | "statusCode" : $0.statusCode, 56 | "headerFields" : $0.allHeaderFields 57 | ]) 58 | } ?? .null 59 | 60 | let result: [String : JSONValue] = [ 61 | "json" : objectJson, 62 | "value" : objectJson, 63 | "httpResponse" : httpResponseJson 64 | ] 65 | 66 | return .object(result) 67 | case false: 68 | return objectJson 69 | } 70 | }() 71 | let object = try decoder.decode(SerializedObject.self, from: decodingJSON.encode()) 72 | 73 | self.callbackQueue.addOperation { 74 | completion(.success(object)) 75 | } 76 | } 77 | } 78 | catch let e { 79 | self.fail(error: AutoGraphError.mapping(error: e, response: response), objectBinding: objectBinding) 80 | } 81 | } 82 | 83 | // MARK: - Post mapping. 84 | 85 | func fail(error: Error, completion: @escaping RequestCompletion) { 86 | self.callbackQueue.addOperation { 87 | completion(.failure(error)) 88 | } 89 | } 90 | 91 | func fail(error: Error, objectBinding: ObjectBinding) { 92 | switch objectBinding { 93 | case .object(_, _, completion: let completion): 94 | self.fail(error: error, completion: completion) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /AutoGraph/Dispatcher.swift: -------------------------------------------------------------------------------- 1 | import Alamofire 2 | import Foundation 3 | 4 | public protocol RequestSender { 5 | func sendRequest(parameters: [String : Any], completion: @escaping (AFDataResponse) -> ()) 6 | } 7 | 8 | public final class Sendable { 9 | public let queryDocument: GraphQLDocument 10 | public let variables: GraphQLVariables? 11 | public let willSend: (() throws -> ())? 12 | public let dispatcherCompletion: (Sendable) -> (AFDataResponse) -> () 13 | public let dispatcherEarlyFailure: (Sendable) -> (Error) -> () 14 | 15 | public required init(queryDocument: GraphQLDocument, variables: GraphQLVariables?, willSend: (() throws -> ())?, dispatcherCompletion: @escaping (Sendable) -> (AFDataResponse) -> (), dispatcherEarlyFailure: @escaping (Sendable) -> (Error) -> ()) { 16 | self.queryDocument = queryDocument 17 | self.variables = variables 18 | self.willSend = willSend 19 | self.dispatcherCompletion = dispatcherCompletion 20 | self.dispatcherEarlyFailure = dispatcherEarlyFailure 21 | } 22 | 23 | public convenience init(dispatcher: Dispatcher, request: R, objectBindingPromise: @escaping (Sendable) -> ObjectBinding, globalWillSend: ((R) throws -> ())?) { 24 | 25 | let completion: (Sendable) -> (AFDataResponse) -> () = { [weak dispatcher] sendable in 26 | { [weak dispatcher] response in 27 | dispatcher?.responseHandler.handle(response: response, objectBinding: objectBindingPromise(sendable), preMappingHook: request.didFinishRequest) 28 | } 29 | } 30 | 31 | let earlyFailure: (Sendable) -> (Error) -> () = { [weak dispatcher] sendable in 32 | { [weak dispatcher] e in 33 | dispatcher?.responseHandler.fail(error: e, objectBinding: objectBindingPromise(sendable)) 34 | } 35 | } 36 | 37 | let willSend: (() throws -> ())? = { 38 | try globalWillSend?(request) 39 | try request.willSend() 40 | } 41 | 42 | self.init(queryDocument: request.queryDocument, variables: request.variables, willSend: willSend, dispatcherCompletion: completion, dispatcherEarlyFailure: earlyFailure) 43 | } 44 | } 45 | 46 | open class Dispatcher { 47 | public let responseHandler: ResponseHandler 48 | public let requestSender: RequestSender 49 | 50 | public internal(set) var pendingRequests = [Sendable]() 51 | 52 | public internal(set) var paused = false { 53 | didSet { 54 | if !self.paused { 55 | self.pendingRequests.forEach { sendable in 56 | self.send(sendable: sendable) 57 | } 58 | self.pendingRequests.removeAll() 59 | } 60 | } 61 | } 62 | 63 | public required init(requestSender: RequestSender, responseHandler: ResponseHandler) { 64 | self.requestSender = requestSender 65 | self.responseHandler = responseHandler 66 | } 67 | 68 | open func send(sendable: Sendable) { 69 | guard !self.paused else { 70 | self.pendingRequests.append(sendable) 71 | return 72 | } 73 | 74 | do { 75 | try sendable.willSend?() 76 | let query = try sendable.queryDocument.graphQLString() 77 | var parameters: [String : Any] = ["query" : query] 78 | if let variables = try sendable.variables?.graphQLVariablesDictionary() { 79 | parameters["variables"] = variables 80 | } 81 | 82 | if let operation = sendable.queryDocument as? Operation { 83 | parameters["operationName"] = operation.name 84 | } 85 | else if let document = sendable.queryDocument as? Document, let operation = document.operations.first { 86 | parameters["operationName"] = operation.name 87 | } 88 | 89 | self.requestSender.sendRequest(parameters: parameters, completion: sendable.dispatcherCompletion(sendable)) 90 | } 91 | catch let e { 92 | sendable.dispatcherEarlyFailure(sendable)(e) 93 | } 94 | } 95 | 96 | open func cancelAll() { 97 | self.pendingRequests.removeAll() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /AutoGraph/SubscriptionRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol SubscriptionRequestSerializable { 4 | func serializedSubscriptionPayload() throws -> String 5 | } 6 | 7 | public struct SubscriptionRequest: SubscriptionRequestSerializable { 8 | let operationName: String 9 | let request: R 10 | let subscriptionID: SubscriptionID 11 | 12 | init(request: R) throws { 13 | self.operationName = request.operationName 14 | self.request = request 15 | self.subscriptionID = try SubscriptionRequest.generateSubscriptionID(request: request, 16 | operationName: operationName) 17 | } 18 | 19 | public func serializedSubscriptionPayload() throws -> String { 20 | let query = try self.request.queryDocument.graphQLString() 21 | 22 | var body: [String : Any] = [ 23 | "operationName": self.operationName, 24 | "query": query 25 | ] 26 | 27 | if let variables = try self.request.variables?.graphQLVariablesDictionary() { 28 | body["variables"] = variables 29 | } 30 | 31 | let payload: [String : Any] = [ 32 | "payload": body, 33 | "id": self.subscriptionID, 34 | "type": GraphQLWSProtocol.start.rawValue 35 | ] 36 | 37 | let serialized: Data = try { 38 | do { 39 | return try JSONSerialization.data(withJSONObject: payload, options: .fragmentsAllowed) 40 | } 41 | catch let e { 42 | throw WebSocketError.subscriptionPayloadFailedSerialization(payload, underlyingError: e) 43 | } 44 | }() 45 | 46 | // TODO: Do we need to convert this to a string? 47 | guard let serializedString = String(data: serialized, encoding: .utf8) else { 48 | throw WebSocketError.subscriptionPayloadFailedSerialization(payload, underlyingError: nil) 49 | } 50 | 51 | return serializedString 52 | } 53 | 54 | static func generateSubscriptionID(request: Req, operationName: String) throws -> SubscriptionID { 55 | guard let variablesString = try request.variables?.graphQLVariablesDictionary().reduce(into: "", { (result, arg1) in 56 | result += "\(String(describing: arg1.key)):\(String(describing: arg1.value))," 57 | }) 58 | else { 59 | return operationName 60 | } 61 | return "\(operationName):{\(variablesString)}" 62 | } 63 | } 64 | 65 | public enum GraphQLWSProtocol: String { 66 | case connectionInit = "connection_init" // Client -> Server 67 | case connectionTerminate = "connection_terminate" // Client -> Server 68 | case start = "start" // Client -> Server 69 | case stop = "stop" // Client -> Server 70 | 71 | case connectionAck = "connection_ack" // Server -> Client 72 | case connectionError = "connection_error" // Server -> Client 73 | case connectionKeepAlive = "ka" // Server -> Client 74 | case data = "data" // Server -> Client For Subscription Payload 75 | case error = "error" // Server -> Client For Subscription Payload 76 | case complete = "complete" // Server -> Client 77 | case unknownResponse // Server -> Client 78 | 79 | public func serializedSubscriptionPayload(id: String? = nil) throws -> String { 80 | var payload: [String : Any] = [ 81 | "type": self.rawValue 82 | ] 83 | 84 | if let id = id { 85 | payload["id"] = id 86 | } 87 | let serialized: Data = try { 88 | do { 89 | return try JSONSerialization.data(withJSONObject: payload, options: .fragmentsAllowed) 90 | } 91 | catch let e { 92 | throw WebSocketError.subscriptionPayloadFailedSerialization(payload, underlyingError: e) 93 | } 94 | }() 95 | 96 | // TODO: Do we need to convert this to a string? 97 | guard let serializedString = String(data: serialized, encoding: .utf8) else { 98 | throw WebSocketError.subscriptionPayloadFailedSerialization(payload, underlyingError: nil) 99 | } 100 | 101 | return serializedString 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, master, v2] 6 | pull_request: 7 | branches: [main, master, v2] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build-and-test: 12 | name: Build and Test 13 | runs-on: macos-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Select Xcode 20 | run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer 21 | 22 | - name: Show Swift version 23 | run: swift --version 24 | 25 | - name: Show Xcode version 26 | run: xcodebuild -version 27 | 28 | - name: Cache SPM dependencies 29 | uses: actions/cache@v4 30 | with: 31 | path: .build 32 | key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} 33 | restore-keys: | 34 | ${{ runner.os }}-spm- 35 | 36 | - name: Build 37 | run: swift build -c release 38 | 39 | - name: Run tests 40 | run: swift test --parallel 41 | 42 | test-ios-simulator: 43 | name: Test on iOS Simulator 44 | runs-on: macos-latest 45 | strategy: 46 | fail-fast: false 47 | matrix: 48 | include: 49 | - device: "iPhone 17" 50 | os: "26.1" 51 | - device: "iPhone 16" 52 | os: "18.6" 53 | 54 | steps: 55 | - name: Checkout code 56 | uses: actions/checkout@v4 57 | 58 | - name: Show tool versions 59 | run: | 60 | swift --version 61 | xcodebuild -version 62 | 63 | - name: Show available simulators 64 | run: xcrun simctl list devices available 65 | 66 | - name: Cache SPM dependencies 67 | uses: actions/cache@v4 68 | with: 69 | path: .build 70 | key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} 71 | restore-keys: | 72 | ${{ runner.os }}-spm- 73 | 74 | - name: Detect Xcode scheme for Swift package 75 | run: | 76 | set -eo pipefail 77 | 78 | echo "🔍 Detecting testable scheme via xcodebuild -list -json" 79 | 80 | SCHEMES_JSON=$(xcodebuild -list -json 2>/dev/null || true) 81 | 82 | if [ -z "$SCHEMES_JSON" ]; then 83 | echo "❌ xcodebuild -list -json returned no data." 84 | exit 1 85 | fi 86 | 87 | ALL_SCHEMES=$(echo "$SCHEMES_JSON" | jq -r ' 88 | [ 89 | (.project.schemes[]?), 90 | (.workspace.schemes[]?) 91 | ] | unique | .[]' || true) 92 | 93 | if [ -z "$ALL_SCHEMES" ]; then 94 | echo "❌ No schemes found in xcodebuild -list -json output." 95 | echo "$SCHEMES_JSON" 96 | exit 1 97 | fi 98 | 99 | echo "Available schemes:" 100 | echo "$ALL_SCHEMES" 101 | echo 102 | 103 | PKG_NAME=$(basename "$PWD") 104 | echo "Package directory name: $PKG_NAME" 105 | 106 | # 1️⃣ Prefer *-Package 107 | PACKAGE_SCHEME=$(echo "$ALL_SCHEMES" | grep -- '-Package$' | head -n 1 || true) 108 | 109 | # 2️⃣ Else scheme == package name 110 | if [ -z "$PACKAGE_SCHEME" ]; then 111 | PACKAGE_SCHEME=$(echo "$ALL_SCHEMES" | grep -i "^${PKG_NAME}$" | head -n 1 || true) 112 | fi 113 | 114 | # 3️⃣ Else first scheme 115 | if [ -z "$PACKAGE_SCHEME" ]; then 116 | PACKAGE_SCHEME=$(echo "$ALL_SCHEMES" | head -n 1 || true) 117 | fi 118 | 119 | if [ -z "$PACKAGE_SCHEME" ]; then 120 | echo "❌ Could not determine a scheme to use." 121 | exit 1 122 | fi 123 | 124 | echo "✅ Using scheme: $PACKAGE_SCHEME" 125 | echo "PACKAGE_SCHEME=$PACKAGE_SCHEME" >> "$GITHUB_ENV" 126 | 127 | - name: Build for iOS Simulator 128 | run: | 129 | xcodebuild build-for-testing \ 130 | -scheme "$PACKAGE_SCHEME" \ 131 | -destination "platform=iOS Simulator,name=${{ matrix.device }},OS=${{ matrix.os }}" \ 132 | -enableCodeCoverage YES 133 | 134 | - name: Test on iOS Simulator 135 | run: | 136 | xcodebuild test \ 137 | -scheme "$PACKAGE_SCHEME" \ 138 | -destination "platform=iOS Simulator,name=${{ matrix.device }},OS=${{ matrix.os }}" \ 139 | -enableCodeCoverage YES 140 | 141 | lint: 142 | name: SwiftLint 143 | runs-on: macos-14 144 | 145 | steps: 146 | - name: Checkout code 147 | uses: actions/checkout@v4 148 | 149 | - name: Install SwiftLint 150 | run: brew install swiftlint 151 | 152 | - name: Run SwiftLint 153 | run: swiftlint lint --reporter github-actions-logging 154 | continue-on-error: true 155 | -------------------------------------------------------------------------------- /AutoGraphTests/Data/AllFilms.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "allFilms": { 4 | "films": [ 5 | { 6 | "id": "ZmlsbXM6MQ==", 7 | "title": "A New Hope", 8 | "episodeID": 4, 9 | "openingCrawl": "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy....", 10 | "director": "George Lucas" 11 | }, 12 | { 13 | "id": "ZmlsbXM6Mg==", 14 | "title": "The Empire Strikes Back", 15 | "episodeID": 5, 16 | "openingCrawl": "It is a dark time for the\r\nRebellion. Although the Death\r\nStar has been destroyed,\r\nImperial troops have driven the\r\nRebel forces from their hidden\r\nbase and pursued them across\r\nthe galaxy.\r\n\r\nEvading the dreaded Imperial\r\nStarfleet, a group of freedom\r\nfighters led by Luke Skywalker\r\nhas established a new secret\r\nbase on the remote ice world\r\nof Hoth.\r\n\r\nThe evil lord Darth Vader,\r\nobsessed with finding young\r\nSkywalker, has dispatched\r\nthousands of remote probes into\r\nthe far reaches of space....", 17 | "director": "Irvin Kershner" 18 | }, 19 | { 20 | "id": "ZmlsbXM6Mw==", 21 | "title": "Return of the Jedi", 22 | "episodeID": 6, 23 | "openingCrawl": "Luke Skywalker has returned to\r\nhis home planet of Tatooine in\r\nan attempt to rescue his\r\nfriend Han Solo from the\r\nclutches of the vile gangster\r\nJabba the Hutt.\r\n\r\nLittle does Luke know that the\r\nGALACTIC EMPIRE has secretly\r\nbegun construction on a new\r\narmored space station even\r\nmore powerful than the first\r\ndreaded Death Star.\r\n\r\nWhen completed, this ultimate\r\nweapon will spell certain doom\r\nfor the small band of rebels\r\nstruggling to restore freedom\r\nto the galaxy...", 24 | "director": "Richard Marquand" 25 | }, 26 | { 27 | "id": "ZmlsbXM6NA==", 28 | "title": "The Phantom Menace", 29 | "episodeID": 1, 30 | "openingCrawl": "Turmoil has engulfed the\r\nGalactic Republic. The taxation\r\nof trade routes to outlying star\r\nsystems is in dispute.\r\n\r\nHoping to resolve the matter\r\nwith a blockade of deadly\r\nbattleships, the greedy Trade\r\nFederation has stopped all\r\nshipping to the small planet\r\nof Naboo.\r\n\r\nWhile the Congress of the\r\nRepublic endlessly debates\r\nthis alarming chain of events,\r\nthe Supreme Chancellor has\r\nsecretly dispatched two Jedi\r\nKnights, the guardians of\r\npeace and justice in the\r\ngalaxy, to settle the conflict....", 31 | "director": "George Lucas" 32 | }, 33 | { 34 | "id": "ZmlsbXM6NQ==", 35 | "title": "Attack of the Clones", 36 | "episodeID": 2, 37 | "openingCrawl": "There is unrest in the Galactic\r\nSenate. Several thousand solar\r\nsystems have declared their\r\nintentions to leave the Republic.\r\n\r\nThis separatist movement,\r\nunder the leadership of the\r\nmysterious Count Dooku, has\r\nmade it difficult for the limited\r\nnumber of Jedi Knights to maintain \r\npeace and order in the galaxy.\r\n\r\nSenator Amidala, the former\r\nQueen of Naboo, is returning\r\nto the Galactic Senate to vote\r\non the critical issue of creating\r\nan ARMY OF THE REPUBLIC\r\nto assist the overwhelmed\r\nJedi....", 38 | "director": "George Lucas" 39 | }, 40 | { 41 | "id": "ZmlsbXM6Ng==", 42 | "title": "Revenge of the Sith", 43 | "episodeID": 3, 44 | "openingCrawl": "War! The Republic is crumbling\r\nunder attacks by the ruthless\r\nSith Lord, Count Dooku.\r\nThere are heroes on both sides.\r\nEvil is everywhere.\r\n\r\nIn a stunning move, the\r\nfiendish droid leader, General\r\nGrievous, has swept into the\r\nRepublic capital and kidnapped\r\nChancellor Palpatine, leader of\r\nthe Galactic Senate.\r\n\r\nAs the Separatist Droid Army\r\nattempts to flee the besieged\r\ncapital with their valuable\r\nhostage, two Jedi Knights lead a\r\ndesperate mission to rescue the\r\ncaptive Chancellor....", 45 | "director": "George Lucas" 46 | } 47 | ] 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /AutoGraph/AuthHandler.swift: -------------------------------------------------------------------------------- 1 | import Alamofire 2 | import Foundation 3 | 4 | public typealias ReauthenticationRefreshCompletion = (_ succeeded: Bool, _ accessToken: String?, _ refreshToken: String?) -> Void 5 | 6 | internal protocol AuthHandlerDelegate: AnyObject { 7 | func authHandlerBeganReauthentication(_ authHandler: AuthHandler) 8 | func authHandler(_ authHandler: AuthHandler, reauthenticatedSuccessfully: Bool) 9 | } 10 | 11 | public protocol ReauthenticationDelegate: AnyObject { 12 | func autoGraphRequiresReauthentication(accessToken: String?, refreshToken: String?, completion: ReauthenticationRefreshCompletion) 13 | } 14 | 15 | public let Unauthorized401StatusCode = 401 16 | 17 | // NOTE: Currently too coupled to Alamofire, will need to write an adapter and 18 | // move some of this into AlamofireClient eventually. 19 | 20 | public class AuthHandler: RequestInterceptor { 21 | private typealias RequestRetryCompletion = (RetryResult) -> Void 22 | 23 | internal weak var delegate: AuthHandlerDelegate? 24 | public weak var reauthenticationDelegate: ReauthenticationDelegate? 25 | 26 | public fileprivate(set) var accessToken: String? 27 | public fileprivate(set) var refreshToken: String? 28 | public fileprivate(set) var isRefreshing = false 29 | 30 | private let lock = NSRecursiveLock() 31 | private var requestsToRetry: [RequestRetryCompletion] = [] 32 | 33 | public init(accessToken: String? = nil, refreshToken: String? = nil) { 34 | self.accessToken = accessToken 35 | self.refreshToken = refreshToken 36 | } 37 | } 38 | 39 | // MARK: - RequestAdapter 40 | 41 | extension AuthHandler: RequestAdapter { 42 | public func adapt( 43 | _ urlRequest: URLRequest, 44 | for session: Session, 45 | completion: @escaping (Result) -> Void) 46 | { 47 | self.lock.lock() 48 | 49 | var urlRequest = urlRequest 50 | if let accessToken = self.accessToken { 51 | urlRequest.headers.add(.authorization(bearerToken: accessToken)) 52 | } 53 | 54 | self.lock.unlock() 55 | completion(.success(urlRequest)) 56 | } 57 | } 58 | 59 | // MARK: - RequestRetrier 60 | 61 | extension AuthHandler: RequestRetrier { 62 | public func retry( 63 | _ request: Alamofire.Request, 64 | for session: Session, dueTo error: Error, 65 | completion: @escaping (RetryResult) -> Void) 66 | { 67 | self.lock.lock() 68 | defer { 69 | self.lock.unlock() 70 | } 71 | 72 | // Don't retry if we already failed once. 73 | guard request.retryCount == 0 else { 74 | completion(.doNotRetry) 75 | return 76 | } 77 | 78 | // Retry unless it's a 401, in which case, rauth. 79 | guard 80 | case let response as HTTPURLResponse = request.task?.response, 81 | response.statusCode == Unauthorized401StatusCode 82 | else { 83 | completion(.retry) 84 | return 85 | } 86 | 87 | self.requestsToRetry.append(completion) 88 | 89 | self.reauthenticate() 90 | } 91 | 92 | func reauthenticate() { 93 | self.lock.lock() 94 | defer { 95 | self.lock.unlock() 96 | } 97 | 98 | guard !self.isRefreshing else { 99 | return 100 | } 101 | 102 | self.isRefreshing = true 103 | 104 | DispatchQueue.main.async { [weak self] in 105 | guard let strongSelf = self else { return } 106 | 107 | strongSelf.delegate?.authHandlerBeganReauthentication(strongSelf) 108 | 109 | strongSelf.reauthenticationDelegate?.autoGraphRequiresReauthentication( 110 | accessToken: strongSelf.accessToken, 111 | refreshToken: strongSelf.refreshToken) 112 | { [weak self] succeeded, accessToken, refreshToken in 113 | 114 | self?.reauthenticated(success: succeeded, accessToken: accessToken, refreshToken: refreshToken) 115 | } 116 | } 117 | } 118 | 119 | public func reauthenticated(success: Bool, accessToken: String?, refreshToken: String?) { 120 | self.lock.lock() 121 | defer { 122 | self.lock.unlock() 123 | } 124 | 125 | if success { 126 | self.accessToken = accessToken 127 | self.refreshToken = refreshToken 128 | self.requestsToRetry.forEach { $0(.retry) } 129 | } 130 | else { 131 | self.accessToken = nil 132 | self.refreshToken = nil 133 | } 134 | 135 | self.requestsToRetry.removeAll() 136 | 137 | guard self.isRefreshing else { 138 | return 139 | } 140 | 141 | self.isRefreshing = false 142 | self.delegate?.authHandler(self, reauthenticatedSuccessfully: success) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /AutoGraph/Errors.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONValueRX 3 | 4 | /* 5 | GraphQL errors have the following base shape: 6 | 7 | { 8 | "errors": [ 9 | { 10 | "message": "Cannot query field \"d\" on type \"Planet\".", 11 | "locations": [ 12 | { 13 | "line": 18, 14 | "column": 7 15 | } 16 | ] 17 | }, 18 | { 19 | "message": "Fragment \"planet\" is never used.", 20 | "locations": [ 21 | { 22 | "line": 23, 23 | "column": 1 24 | } 25 | ] 26 | } 27 | ] 28 | } 29 | 30 | */ 31 | 32 | public protocol NetworkError: Error { 33 | var statusCode: Int { get } 34 | var underlyingError: GraphQLError { get } 35 | } 36 | public typealias NetworkErrorParser = (_ graphQLError: GraphQLError) -> NetworkError? 37 | 38 | public indirect enum AutoGraphError: LocalizedError { 39 | case graphQL(errors: [GraphQLError], response: HTTPURLResponse?) 40 | case network(error: Error, statusCode: Int, response: HTTPURLResponse?, underlying: AutoGraphError?) 41 | case mapping(error: Error, response: HTTPURLResponse?) 42 | case invalidResponse(response: HTTPURLResponse?) 43 | case subscribeWithMissingWebSocketClient 44 | 45 | // TODO: make a subscriptions friendly version. 46 | 47 | public init?(graphQLResponseJSON: JSONValue, response: HTTPURLResponse?, networkErrorParser: NetworkErrorParser?) { 48 | guard let errorsJSON = graphQLResponseJSON["errors"] else { 49 | return nil 50 | } 51 | 52 | guard case .array(let errorsArray) = errorsJSON else { 53 | self = .invalidResponse(response: response) 54 | return 55 | } 56 | 57 | let errors = errorsArray.compactMap { GraphQLError(json: $0) } 58 | let graphQLError = AutoGraphError.graphQL(errors: errors, response: response) 59 | if let networkError: NetworkError = networkErrorParser.flatMap({ 60 | for error in errors { 61 | if let networkError = $0(error) { 62 | return networkError 63 | } 64 | } 65 | return nil 66 | }) 67 | { 68 | self = .network(error: networkError, statusCode: networkError.statusCode, response: nil, underlying: graphQLError) 69 | } 70 | else { 71 | self = graphQLError 72 | } 73 | } 74 | 75 | public var errorDescription: String? { 76 | switch self { 77 | case .graphQL(let errors, _): 78 | return errors.compactMap { $0.localizedDescription }.joined(separator: "\n") 79 | 80 | case .network(let error, let statusCode, _, let underlying): 81 | return "Network Failure - \(statusCode): " + error.localizedDescription + "\n" + (underlying?.localizedDescription ?? "") 82 | 83 | case .mapping(let error, _): 84 | return "Mapping Failure: " + error.localizedDescription 85 | 86 | case .invalidResponse: 87 | return "Invalid Response" 88 | 89 | case .subscribeWithMissingWebSocketClient: 90 | return "Attempting to subscribe to a subscription but AutoGraph was not initialized with a WebSocketClient. Please initialize with a WebSocketClient." 91 | } 92 | } 93 | } 94 | 95 | public struct GraphQLError: LocalizedError, Equatable { 96 | 97 | public struct Location: CustomStringConvertible, Equatable { 98 | public let line: Int 99 | public let column: Int 100 | 101 | public var description: String { 102 | return "line: \(line), column: \(column)" 103 | } 104 | 105 | init?(json: JSONValue) { 106 | guard case .some(.number(let line)) = json["line"] else { 107 | return nil 108 | } 109 | 110 | guard case .some(.number(let column)) = json["column"] else { 111 | return nil 112 | } 113 | 114 | self.line = line.asNSNumber.intValue 115 | self.column = column.asNSNumber.intValue 116 | } 117 | } 118 | 119 | public let message: String 120 | public let locations: [Location] 121 | public let jsonPayload: JSONValue 122 | 123 | public var errorDescription: String? { 124 | return self.message 125 | } 126 | 127 | init(json: JSONValue) { 128 | self.jsonPayload = json 129 | self.message = { 130 | guard case .some(.string(let message)) = json["message"] else { 131 | return "" 132 | } 133 | return message 134 | }() 135 | 136 | self.locations = { 137 | guard case .some(.array(let locations)) = json["locations"] else { 138 | return [] 139 | } 140 | return locations.compactMap { Location(json: $0) } 141 | }() 142 | } 143 | 144 | public static func == (lhs: GraphQLError, rhs: GraphQLError) -> Bool { 145 | return lhs.message == rhs.message && lhs.locations == rhs.locations 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /AutoGraphTests/DispatcherTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Alamofire 3 | @testable import AutoGraphQL 4 | 5 | class DispatcherTests: XCTestCase { 6 | 7 | var subject: Dispatcher! 8 | var mockRequestSender: MockRequestSender! 9 | 10 | class MockRequestSender: RequestSender { 11 | 12 | var expectation: Bool = false 13 | 14 | var testSendRequest: ((_ parameters: [String : Any], _ completion: @escaping (AFDataResponse) -> ()) -> Bool)? 15 | 16 | func sendRequest(parameters: [String : Any], completion: @escaping (AFDataResponse) -> ()) { 17 | self.expectation = testSendRequest?(parameters, completion) ?? false 18 | } 19 | } 20 | 21 | class MockQueue: OperationQueue, @unchecked Swift.Sendable { 22 | override func addOperation(_ op: Foundation.Operation) { 23 | op.start() 24 | } 25 | } 26 | 27 | override func setUp() { 28 | super.setUp() 29 | 30 | self.mockRequestSender = MockRequestSender() 31 | self.subject = Dispatcher(requestSender: self.mockRequestSender, responseHandler: ResponseHandler(queue: MockQueue(), callbackQueue: MockQueue())) 32 | } 33 | 34 | override func tearDown() { 35 | self.subject = nil 36 | self.mockRequestSender = nil 37 | super.tearDown() 38 | } 39 | 40 | func testForwardsRequestToSender() { 41 | let request = FilmRequest() 42 | let sendable = Sendable(dispatcher: self.subject, request: request, objectBindingPromise: { _ in request.generateBinding(completion: { _ in }) }, globalWillSend: { _ in }) 43 | 44 | self.mockRequestSender.testSendRequest = { params, completion in 45 | return (params as! [String : String] == [ 46 | "query" : try! request.queryDocument.graphQLString(), 47 | "operationName" : "film" 48 | ]) 49 | } 50 | 51 | XCTAssertFalse(self.mockRequestSender.expectation) 52 | self.subject.send(sendable: sendable) 53 | XCTAssertTrue(self.mockRequestSender.expectation) 54 | } 55 | 56 | func testHoldsRequestsWhenPaused() { 57 | let request = FilmRequest() 58 | let sendable = Sendable(dispatcher: self.subject, request: request, objectBindingPromise: { _ in request.generateBinding(completion: { _ in }) }, globalWillSend: { _ in }) 59 | 60 | XCTAssertEqual(self.subject.pendingRequests.count, 0) 61 | self.subject.paused = true 62 | self.subject.send(sendable: sendable) 63 | XCTAssertEqual(self.subject.pendingRequests.count, 1) 64 | } 65 | 66 | func testClearsRequestsOnCancel() { 67 | let request = FilmRequest() 68 | let sendable = Sendable(dispatcher: self.subject, request: request, objectBindingPromise: { _ in request.generateBinding(completion: { _ in }) }, globalWillSend: { _ in }) 69 | 70 | self.subject.paused = true 71 | self.subject.send(sendable: sendable) 72 | XCTAssertEqual(self.subject.pendingRequests.count, 1) 73 | self.subject.cancelAll() 74 | XCTAssertEqual(self.subject.pendingRequests.count, 0) 75 | } 76 | 77 | func testForwardsAndClearsPendingRequestsOnUnpause() { 78 | let request = FilmRequest() 79 | let sendable = Sendable(dispatcher: self.subject, request: request, objectBindingPromise: { _ in request.generateBinding(completion: { _ in }) }, globalWillSend: { _ in }) 80 | 81 | self.mockRequestSender.testSendRequest = { params, completion in 82 | return (params as! [String : String] == [ 83 | "query" : try! request.queryDocument.graphQLString(), 84 | "operationName" : "film" 85 | ]) 86 | } 87 | 88 | self.subject.paused = true 89 | self.subject.send(sendable: sendable) 90 | 91 | XCTAssertEqual(self.subject.pendingRequests.count, 1) 92 | XCTAssertFalse(self.mockRequestSender.expectation) 93 | 94 | self.subject.paused = false 95 | 96 | XCTAssertEqual(self.subject.pendingRequests.count, 0) 97 | XCTAssertTrue(self.mockRequestSender.expectation) 98 | } 99 | 100 | class BadRequest: AutoGraphQL.Request { 101 | struct BadQuery: GraphQLDocument { 102 | func graphQLString() throws -> String { 103 | throw NSError(domain: "error", code: -1, userInfo: nil) 104 | } 105 | } 106 | 107 | typealias SerializedObject = Film 108 | let queryDocument = BadQuery() 109 | var operationName = "Bad" 110 | let variables: [AnyHashable : Any]? = nil 111 | let rootKeyPath: String = "" 112 | } 113 | 114 | func testFailureReturnsToCaller() { 115 | let request = BadRequest() 116 | var called = false 117 | let objectBinding = request.generateBinding { result in 118 | guard case .failure(_) = result else { 119 | XCTFail() 120 | return 121 | } 122 | called = true 123 | } 124 | 125 | let sendable = Sendable(dispatcher: self.subject, request: request, objectBindingPromise: { _ in objectBinding }, globalWillSend: { _ in }) 126 | 127 | self.subject.send(sendable: sendable) 128 | XCTAssertTrue(called) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /AutoGraphTests/AlamofireClientTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Alamofire 3 | import JSONValueRX 4 | @testable import AutoGraphQL 5 | 6 | class MockDataRequest: DataRequest { 7 | @discardableResult 8 | override public func resume() -> Self { 9 | // no-op 10 | return self 11 | } 12 | } 13 | 14 | class AlamofireClientTests: XCTestCase { 15 | 16 | var subject: AlamofireClient! 17 | 18 | override func setUp() { 19 | super.setUp() 20 | 21 | self.subject = try! AlamofireClient(url: "localhost", session: Session(interceptor: AuthHandler())) 22 | } 23 | 24 | override func tearDown() { 25 | self.subject = nil 26 | 27 | super.tearDown() 28 | } 29 | 30 | func testSetsAuthHandlerOnSession() { 31 | let authHandler = self.subject.authHandler 32 | XCTAssertEqual(ObjectIdentifier(self.subject.session.interceptor as! AuthHandler), ObjectIdentifier(authHandler!)) 33 | } 34 | 35 | func testAuthenticatingSetsTokens() { 36 | var tokens = self.subject.authTokens 37 | XCTAssertNil(tokens.accessToken) 38 | XCTAssertNil(tokens.refreshToken) 39 | 40 | self.subject.authenticate(authTokens: ("access", "refresh")) 41 | tokens = self.subject.authTokens 42 | XCTAssertEqual(tokens.accessToken, "access") 43 | XCTAssertEqual(tokens.refreshToken, "refresh") 44 | } 45 | 46 | func testAuthHandlerDelegateCallsBackWhenAuthenticatingDirectlyIfRefreshing() { 47 | class MockAuthHandlerDelegate: AuthHandlerDelegate { 48 | var called = false 49 | func authHandlerBeganReauthentication(_ authHandler: AuthHandler) { } 50 | func authHandler(_ authHandler: AuthHandler, reauthenticatedSuccessfully: Bool) { 51 | called = reauthenticatedSuccessfully 52 | } 53 | } 54 | 55 | class Mock401Request: MockDataRequest { 56 | class Mock401Task: URLSessionTask, @unchecked Swift.Sendable { 57 | class Mock401Response: HTTPURLResponse, @unchecked Swift.Sendable { 58 | override var statusCode: Int { 59 | return 401 60 | } 61 | } 62 | override var response: URLResponse? { 63 | return Mock401Response(url: URL(string: "www.google.com")!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil) 64 | } 65 | } 66 | 67 | override var task: URLSessionTask? { 68 | // TODO: Fix deprecation https://stackoverflow.com/questions/68609049/mocking-urlsessiondatatask-without-using-init 69 | return Mock401Task() 70 | } 71 | } 72 | 73 | let delegate = MockAuthHandlerDelegate() 74 | self.subject.authHandler!.delegate = delegate 75 | 76 | XCTAssertFalse(self.subject.authHandler!.isRefreshing) 77 | 78 | self.subject.authenticate(authTokens: ("access", "refresh")) 79 | 80 | XCTAssertFalse(delegate.called) 81 | 82 | let request = Mock401Request(convertible: URLRequest(url: URL(string: "www.google.com")!), 83 | underlyingQueue: self.subject.session.rootQueue, 84 | serializationQueue: self.subject.session.serializationQueue, 85 | eventMonitor: self.subject.session.eventMonitor, 86 | interceptor: self.subject.session.interceptor, 87 | delegate: self.subject.session) 88 | 89 | self.subject.authHandler?.retry(request, for: self.subject.session, dueTo: NSError(domain: "", code: 0, userInfo: nil), completion: { _ in }) 90 | 91 | XCTAssertTrue(self.subject.authHandler!.isRefreshing) 92 | self.subject.authenticate(authTokens: ("access", "refresh")) 93 | XCTAssertTrue(delegate.called) 94 | } 95 | 96 | func testForwardsSendRequestToAlamofireAndRespectsHeaders() { 97 | 98 | class MockSession: Session { 99 | var success = false 100 | 101 | override func request(_ convertible: URLConvertible, method: HTTPMethod = .get, parameters: Parameters? = nil, encoding: ParameterEncoding = URLEncoding.default, headers: HTTPHeaders? = nil, interceptor: RequestInterceptor? = nil, requestModifier: Session.RequestModifier? = nil) -> DataRequest { 102 | 103 | success = 104 | (convertible as! URL == URL(string: "localhost")!) 105 | && (method == .post) 106 | && (parameters! as! [String : String] == ["cool" : "param"]) 107 | && (encoding is JSONEncoding) 108 | && (headers!.dictionary == ["dumb" : "header"]) 109 | 110 | let request = MockDataRequest(convertible: try! URLRequest(url: convertible, 111 | method: method, 112 | headers: headers), 113 | underlyingQueue: self.rootQueue, 114 | serializationQueue: self.serializationQueue, 115 | eventMonitor: self.eventMonitor, 116 | interceptor: self.interceptor, 117 | delegate: self) 118 | 119 | return request 120 | } 121 | } 122 | 123 | let session = MockSession(startRequestsImmediately: false, interceptor: AuthHandler()) 124 | self.subject = try! AlamofireClient(url: "localhost", session: session) 125 | self.subject.httpHeaders["dumb"] = "header" 126 | self.subject.sendRequest(parameters: ["cool" : "param"], completion: { _ in }) 127 | 128 | XCTAssertTrue(session.success) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /AutoGraphTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | #if !canImport(ObjectiveC) 2 | import XCTest 3 | 4 | extension AlamofireClientTests { 5 | // DO NOT MODIFY: This is autogenerated, use: 6 | // `swift test --generate-linuxmain` 7 | // to regenerate. 8 | static let __allTests__AlamofireClientTests = [ 9 | ("testAuthenticatingSetsTokens", testAuthenticatingSetsTokens), 10 | ("testAuthHandlerDelegateCallsBackWhenAuthenticatingDirectlyIfRefreshing", testAuthHandlerDelegateCallsBackWhenAuthenticatingDirectlyIfRefreshing), 11 | ("testForwardsSendRequestToAlamofireAndRespectsHeaders", testForwardsSendRequestToAlamofireAndRespectsHeaders), 12 | ("testSetsAuthHandlerOnSession", testSetsAuthHandlerOnSession), 13 | ] 14 | } 15 | 16 | extension AuthHandlerTests { 17 | // DO NOT MODIFY: This is autogenerated, use: 18 | // `swift test --generate-linuxmain` 19 | // to regenerate. 20 | static let __allTests__AuthHandlerTests = [ 21 | ("testAdaptsAuthToken", testAdaptsAuthToken), 22 | ("testDoesNotGetAuthTokensIfFailure", testDoesNotGetAuthTokensIfFailure), 23 | ("testGetsAuthTokensIfSuccess", testGetsAuthTokensIfSuccess), 24 | ] 25 | } 26 | 27 | extension AutoGraphTests { 28 | // DO NOT MODIFY: This is autogenerated, use: 29 | // `swift test --generate-linuxmain` 30 | // to regenerate. 31 | static let __allTests__AutoGraphTests = [ 32 | ("testArrayObjectSerialization", testArrayObjectSerialization), 33 | ("testArraySubscription", testArraySubscription), 34 | ("testAuthHandlerBeganReauthenticationPausesDispatcher", testAuthHandlerBeganReauthenticationPausesDispatcher), 35 | ("testAuthHandlerReauthenticatedSuccessfullyUnpausesDispatcher", testAuthHandlerReauthenticatedSuccessfullyUnpausesDispatcher), 36 | ("testAuthHandlerReauthenticatedUnsuccessfullyCancelsAll", testAuthHandlerReauthenticatedUnsuccessfullyCancelsAll), 37 | ("testCancelAllCancelsDispatcherAndClient", testCancelAllCancelsDispatcherAndClient), 38 | ("testFunctional401Request", testFunctional401Request), 39 | ("testFunctional401RequestNotHandled", testFunctional401RequestNotHandled), 40 | ("testFunctionalGlobalLifeCycle", testFunctionalGlobalLifeCycle), 41 | ("testFunctionalLifeCycle", testFunctionalLifeCycle), 42 | ("testFunctionalSingleFilmRequest", testFunctionalSingleFilmRequest), 43 | ("testRequestIncludingNetworking", testRequestIncludingNetworking), 44 | ("testSubscription", testSubscription), 45 | ("testTriggeringReauthenticationPausesSystem", testTriggeringReauthenticationPausesSystem), 46 | ] 47 | } 48 | 49 | extension DispatcherTests { 50 | // DO NOT MODIFY: This is autogenerated, use: 51 | // `swift test --generate-linuxmain` 52 | // to regenerate. 53 | static let __allTests__DispatcherTests = [ 54 | ("testClearsRequestsOnCancel", testClearsRequestsOnCancel), 55 | ("testFailureReturnsToCaller", testFailureReturnsToCaller), 56 | ("testForwardsAndClearsPendingRequestsOnUnpause", testForwardsAndClearsPendingRequestsOnUnpause), 57 | ("testForwardsRequestToSender", testForwardsRequestToSender), 58 | ("testHoldsRequestsWhenPaused", testHoldsRequestsWhenPaused), 59 | ] 60 | } 61 | 62 | extension ErrorTests { 63 | // DO NOT MODIFY: This is autogenerated, use: 64 | // `swift test --generate-linuxmain` 65 | // to regenerate. 66 | static let __allTests__ErrorTests = [ 67 | ("testAutoGraphErrorGraphQLErrorUsesMessages", testAutoGraphErrorGraphQLErrorUsesMessages), 68 | ("testAutoGraphErrorProducesNetworkErrorForNetworkErrorParserMatch", testAutoGraphErrorProducesNetworkErrorForNetworkErrorParserMatch), 69 | ("testGraphQLErrorUsesMessageForLocalizedDescription", testGraphQLErrorUsesMessageForLocalizedDescription), 70 | ("testInvalidResponseLocalizedErrorDoesntCrash", testInvalidResponseLocalizedErrorDoesntCrash), 71 | ] 72 | } 73 | 74 | extension ResponseHandlerTests { 75 | // DO NOT MODIFY: This is autogenerated, use: 76 | // `swift test --generate-linuxmain` 77 | // to regenerate. 78 | static let __allTests__ResponseHandlerTests = [ 79 | ("testErrorsJsonReturnsGraphQLError", testErrorsJsonReturnsGraphQLError), 80 | ("testMappingErrorReturnsMappingError", testMappingErrorReturnsMappingError), 81 | ("testNetworkErrorReturnsNetworkError", testNetworkErrorReturnsNetworkError), 82 | ("testPreMappingHookCalledBeforeMapping", testPreMappingHookCalledBeforeMapping), 83 | ("testResponseReturnedFromGraphQLError", testResponseReturnedFromGraphQLError), 84 | ("testResponseReturnedFromInvalidResponseError", testResponseReturnedFromInvalidResponseError), 85 | ("testResponseReturnedFromMappingError", testResponseReturnedFromMappingError), 86 | ("testResponseReturnedFromNetworkError", testResponseReturnedFromNetworkError), 87 | ] 88 | } 89 | 90 | extension WebSocketClientTests { 91 | // DO NOT MODIFY: This is autogenerated, use: 92 | // `swift test --generate-linuxmain` 93 | // to regenerate. 94 | static let __allTests__WebSocketClientTests = [ 95 | ("testConnectionOccursOnReconnectAttemptTwo", testConnectionOccursOnReconnectAttemptTwo), 96 | ("testDisconnectEventReconnects", testDisconnectEventReconnects), 97 | ("testGenerateSubscriptionID", testGenerateSubscriptionID), 98 | ("testReconnectIsNotCalledIfFullDisconnect", testReconnectIsNotCalledIfFullDisconnect), 99 | ("testSendSubscriptionCorrectlyDecodesResponse", testSendSubscriptionCorrectlyDecodesResponse), 100 | ("testSendSubscriptionResponseHandlerIsCalledOnSuccess", testSendSubscriptionResponseHandlerIsCalledOnSuccess), 101 | ("testSubscribeQueuesAndSendsSubscriptionAfterConnectionFinishes", testSubscribeQueuesAndSendsSubscriptionAfterConnectionFinishes), 102 | ("testSubscriptionsGetRequeued", testSubscriptionsGetRequeued), 103 | ("testThreeReconnectAttemptsAndDelayTimeIncreaseEachAttempt", testThreeReconnectAttemptsAndDelayTimeIncreaseEachAttempt), 104 | ("testUnsubscribeAllRemovesSubscriptions", testUnsubscribeAllRemovesSubscriptions), 105 | ("testUnsubscribeRemovesSubscriptions", testUnsubscribeRemovesSubscriptions), 106 | ("testWebSocketClientDelegatDidRecieveError", testWebSocketClientDelegatDidRecieveError), 107 | ("testWebSocketClientDelegateDidReceiveEventGetsCalled", testWebSocketClientDelegateDidReceiveEventGetsCalled), 108 | ] 109 | } 110 | 111 | public func __allTests() -> [XCTestCaseEntry] { 112 | return [ 113 | testCase(AlamofireClientTests.__allTests__AlamofireClientTests), 114 | testCase(AuthHandlerTests.__allTests__AuthHandlerTests), 115 | testCase(AutoGraphTests.__allTests__AutoGraphTests), 116 | testCase(DispatcherTests.__allTests__DispatcherTests), 117 | testCase(ErrorTests.__allTests__ErrorTests), 118 | testCase(ResponseHandlerTests.__allTests__ResponseHandlerTests), 119 | testCase(WebSocketClientTests.__allTests__WebSocketClientTests), 120 | ] 121 | } 122 | #endif 123 | -------------------------------------------------------------------------------- /QueryBuilderTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | #if !canImport(ObjectiveC) 2 | import XCTest 3 | 4 | extension DocumentTests { 5 | // DO NOT MODIFY: This is autogenerated, use: 6 | // `swift test --generate-linuxmain` 7 | // to regenerate. 8 | static let __allTests__DocumentTests = [ 9 | ("testGraphQLStringWithObjectFieldsFragments", testGraphQLStringWithObjectFieldsFragments), 10 | ] 11 | } 12 | 13 | extension FieldTests { 14 | // DO NOT MODIFY: This is autogenerated, use: 15 | // `swift test --generate-linuxmain` 16 | // to regenerate. 17 | static let __allTests__FieldTests = [ 18 | ("testSerializeAlias", testSerializeAlias), 19 | ] 20 | } 21 | 22 | extension FoundationExtensionsTests { 23 | // DO NOT MODIFY: This is autogenerated, use: 24 | // `swift test --generate-linuxmain` 25 | // to regenerate. 26 | static let __allTests__FoundationExtensionsTests = [ 27 | ("testArgumentStringJsonEncodes", testArgumentStringJsonEncodes), 28 | ("testNSNullJsonEncodes", testNSNullJsonEncodes), 29 | ("testNSNumberJsonEncodes", testNSNumberJsonEncodes), 30 | ] 31 | } 32 | 33 | extension FragmentDefinitionTests { 34 | // DO NOT MODIFY: This is autogenerated, use: 35 | // `swift test --generate-linuxmain` 36 | // to regenerate. 37 | static let __allTests__FragmentDefinitionTests = [ 38 | ("testFragmentNamedOnIsNil", testFragmentNamedOnIsNil), 39 | ("testGraphQLStringWithDirectives", testGraphQLStringWithDirectives), 40 | ("testGraphQLStringWithFragments", testGraphQLStringWithFragments), 41 | ("testGraphQLStringWithObjectFields", testGraphQLStringWithObjectFields), 42 | ("testGraphQLStringWithScalarFields", testGraphQLStringWithScalarFields), 43 | ("testSelectionSetName", testSelectionSetName), 44 | ("testWithoutSelectionSetIsNil", testWithoutSelectionSetIsNil), 45 | ] 46 | } 47 | 48 | extension InlineFragmentTests { 49 | // DO NOT MODIFY: This is autogenerated, use: 50 | // `swift test --generate-linuxmain` 51 | // to regenerate. 52 | static let __allTests__InlineFragmentTests = [ 53 | ("testInlineFragment", testInlineFragment), 54 | ("testObjectWithInlineFragment", testObjectWithInlineFragment), 55 | ("testSelectionSetName", testSelectionSetName), 56 | ] 57 | } 58 | 59 | extension InputValueTests { 60 | // DO NOT MODIFY: This is autogenerated, use: 61 | // `swift test --generate-linuxmain` 62 | // to regenerate. 63 | static let __allTests__InputValueTests = [ 64 | ("testArrayInputValue", testArrayInputValue), 65 | ("testBoolInputValue", testBoolInputValue), 66 | ("testDictionaryInputValue", testDictionaryInputValue), 67 | ("testDoubleInputValue", testDoubleInputValue), 68 | ("testEmptyArrayInputValue", testEmptyArrayInputValue), 69 | ("testEmptyDictionaryInputValue", testEmptyDictionaryInputValue), 70 | ("testEnumInputValue", testEnumInputValue), 71 | ("testIDInputValue", testIDInputValue), 72 | ("testIntInputValue", testIntInputValue), 73 | ("testNonNullInputValue", testNonNullInputValue), 74 | ("testNSNullInputValue", testNSNullInputValue), 75 | ("testVariableInputValue", testVariableInputValue), 76 | ] 77 | } 78 | 79 | extension ObjectTests { 80 | // DO NOT MODIFY: This is autogenerated, use: 81 | // `swift test --generate-linuxmain` 82 | // to regenerate. 83 | static let __allTests__ObjectTests = [ 84 | ("testGraphQLStringWithAlias", testGraphQLStringWithAlias), 85 | ("testGraphQLStringWithObjectFields", testGraphQLStringWithObjectFields), 86 | ("testGraphQLStringWithoutAlias", testGraphQLStringWithoutAlias), 87 | ("testGraphQLStringWithScalarFields", testGraphQLStringWithScalarFields), 88 | ("testThrowsIfNoFieldsOrFragments", testThrowsIfNoFieldsOrFragments), 89 | ] 90 | } 91 | 92 | extension OperationTests { 93 | // DO NOT MODIFY: This is autogenerated, use: 94 | // `swift test --generate-linuxmain` 95 | // to regenerate. 96 | static let __allTests__OperationTests = [ 97 | ("testDirectives", testDirectives), 98 | ("testInitializersOnSelectionTypeArray", testInitializersOnSelectionTypeArray), 99 | ("testMutationForms", testMutationForms), 100 | ("testQueryForms", testQueryForms), 101 | ("testSelectionSet", testSelectionSet), 102 | ("testSubscriptionForms", testSubscriptionForms), 103 | ("testVariableDefinitions", testVariableDefinitions), 104 | ("testVariableVariablesWithDefaultValuesFail", testVariableVariablesWithDefaultValuesFail), 105 | ] 106 | } 107 | 108 | extension OrderedDictionaryTests { 109 | // DO NOT MODIFY: This is autogenerated, use: 110 | // `swift test --generate-linuxmain` 111 | // to regenerate. 112 | static let __allTests__OrderedDictionaryTests = [ 113 | ("testsMaintainsOrder", testsMaintainsOrder), 114 | ] 115 | } 116 | 117 | extension ScalarTests { 118 | // DO NOT MODIFY: This is autogenerated, use: 119 | // `swift test --generate-linuxmain` 120 | // to regenerate. 121 | static let __allTests__ScalarTests = [ 122 | ("testGraphQLStringAsLiteral", testGraphQLStringAsLiteral), 123 | ("testGraphQLStringWithAlias", testGraphQLStringWithAlias), 124 | ("testGraphQLStringWithoutAlias", testGraphQLStringWithoutAlias), 125 | ] 126 | } 127 | 128 | extension SelectionSetTests { 129 | // DO NOT MODIFY: This is autogenerated, use: 130 | // `swift test --generate-linuxmain` 131 | // to regenerate. 132 | static let __allTests__SelectionSetTests = [ 133 | ("testGraphQLString", testGraphQLString), 134 | ("testKey", testKey), 135 | ("testKind", testKind), 136 | ("testMergingFragmentSpreads", testMergingFragmentSpreads), 137 | ("testMergingScalars", testMergingScalars), 138 | ("testMergingSelections", testMergingSelections), 139 | ("testMergingSelectionsOfSameKeyButDifferentTypeFails", testMergingSelectionsOfSameKeyButDifferentTypeFails), 140 | ("testSelectionSetName", testSelectionSetName), 141 | ("testSerializedSelections", testSerializedSelections), 142 | ] 143 | } 144 | 145 | extension VariableTest { 146 | // DO NOT MODIFY: This is autogenerated, use: 147 | // `swift test --generate-linuxmain` 148 | // to regenerate. 149 | static let __allTests__VariableTest = [ 150 | ("testVariableInputValue", testVariableInputValue), 151 | ("testVariableTypeThrows", testVariableTypeThrows), 152 | ] 153 | } 154 | 155 | public func __allTests() -> [XCTestCaseEntry] { 156 | return [ 157 | testCase(DocumentTests.__allTests__DocumentTests), 158 | testCase(FieldTests.__allTests__FieldTests), 159 | testCase(FoundationExtensionsTests.__allTests__FoundationExtensionsTests), 160 | testCase(FragmentDefinitionTests.__allTests__FragmentDefinitionTests), 161 | testCase(InlineFragmentTests.__allTests__InlineFragmentTests), 162 | testCase(InputValueTests.__allTests__InputValueTests), 163 | testCase(ObjectTests.__allTests__ObjectTests), 164 | testCase(OperationTests.__allTests__OperationTests), 165 | testCase(OrderedDictionaryTests.__allTests__OrderedDictionaryTests), 166 | testCase(ScalarTests.__allTests__ScalarTests), 167 | testCase(SelectionSetTests.__allTests__SelectionSetTests), 168 | testCase(VariableTest.__allTests__VariableTest), 169 | ] 170 | } 171 | #endif 172 | -------------------------------------------------------------------------------- /AutoGraphTests/Stub.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | fileprivate var AllStubs = [Stub]() 4 | 5 | extension Array where Element == Stub { 6 | func find(_ request: URLRequest ) -> Data? { 7 | for stub in self { 8 | if stub.verify(request: request) { 9 | return stub.responseData 10 | } 11 | } 12 | return nil 13 | } 14 | } 15 | 16 | class Stub { 17 | static func clearAll() { 18 | AllStubs.removeAll() 19 | } 20 | 21 | var json: Any { 22 | return try! JSONSerialization.jsonObject(with: self.jsonData) 23 | } 24 | 25 | var jsonData: Data { 26 | precondition(self.jsonFixtureFile != nil, "Stub is missing jsonFixtureFile: \(self)") 27 | 28 | // Use Bundle.module for SPM resources 29 | guard let url = Bundle.module.url(forResource: self.jsonFixtureFile, withExtension: "json", subdirectory: "Data") else { 30 | fatalError("Missing test fixture \(self.jsonFixtureFile!).json in Bundle.module/Data") 31 | } 32 | 33 | return try! Data(contentsOf: url) 34 | } 35 | 36 | var graphQLQuery: String = "" 37 | var variables: [AnyHashable : Any]? = nil 38 | 39 | var httpMethod: String? 40 | var urlPath: String? 41 | var expectedResponseCode = 200 42 | var urlQueryString: String? 43 | var additionalHeaders = [String : Any]() 44 | var jsonFixtureFile: String? 45 | 46 | var responseData: Data? { 47 | let data = try! JSONSerialization.data(withJSONObject: self.json, options: JSONSerialization.WritingOptions(rawValue: 0)) 48 | return data 49 | } 50 | 51 | let requestTime: TimeInterval = { 52 | return 0.01 53 | }() 54 | 55 | let responseTime: TimeInterval = { 56 | return 0.00 57 | }() 58 | 59 | func verify(request: URLRequest) -> Bool { 60 | if let httpMethod = self.httpMethod { 61 | if request.httpMethod != httpMethod { 62 | return false 63 | } 64 | } 65 | 66 | guard let url = request.url, url.relativePath == self.urlPath || url.absoluteString == self.urlPath else { 67 | return false 68 | } 69 | 70 | let body = request.httpBodyStream!.readfully() 71 | let jsonBody = try? JSONSerialization.jsonObject(with: body, options: JSONSerialization.ReadingOptions(rawValue: 0)) 72 | let query = (jsonBody as! [String : Any])["query"] as! String 73 | guard query.condensedWhitespace == self.graphQLQuery.condensedWhitespace else { 74 | return false 75 | } 76 | 77 | if case let variables as NSDictionary = self.variables { 78 | guard case let otherVariables as NSDictionary = (jsonBody as! [AnyHashable : Any])["variables"] else { 79 | return false 80 | } 81 | 82 | guard otherVariables == variables else { 83 | return false 84 | } 85 | } 86 | 87 | if let urlQueryString = self.urlQueryString { 88 | if urlQueryString != url.query { 89 | return false 90 | } 91 | } 92 | 93 | return true 94 | } 95 | 96 | func registerStub() { 97 | AllStubs.append(self) 98 | } 99 | 100 | var responseHeaders: [String : Any] { 101 | var defaultHeaders: [String : Any] = [ 102 | "Cache-Control" : "max-age=0, private, must-revalidate", 103 | "Content-Type" : "application/json" 104 | ] 105 | self.additionalHeaders.forEach { key, value in defaultHeaders[key] = value } 106 | 107 | return defaultHeaders 108 | } 109 | 110 | } 111 | 112 | extension String { 113 | var condensedWhitespace: String { 114 | let components = self.components(separatedBy: NSCharacterSet.whitespacesAndNewlines) 115 | return components.filter { !$0.isEmpty }.joined(separator: " ") 116 | } 117 | } 118 | 119 | extension InputStream { 120 | func readfully() -> Data { 121 | var result = Data() 122 | var buffer = [UInt8](repeating: 0, count: 4096) 123 | 124 | self.open() 125 | defer { self.close() } 126 | 127 | var amount = 0 128 | repeat { 129 | amount = read(&buffer, maxLength: buffer.count) 130 | if amount > 0 { 131 | result.append(buffer, count: amount) 132 | } 133 | } while amount > 0 134 | 135 | return result 136 | } 137 | } 138 | 139 | class MockURLProtocol: URLProtocol { 140 | private let cannedHeaders = ["Content-Type" : "application/json; charset=utf-8"] 141 | 142 | // MARK: Properties 143 | private struct PropertyKeys { 144 | static let handledByForwarderURLProtocol = "HandledByProxyURLProtocol" 145 | } 146 | 147 | static func sessionConfiguration() -> URLSessionConfiguration { 148 | let config = URLSessionConfiguration.ephemeral 149 | config.protocolClasses = [MockURLProtocol.self] 150 | // Commented out Alamofire test stuff that we may want later. 151 | // config.httpAdditionalHeaders = SessionManager.defaultHTTPHeaders 152 | config.httpAdditionalHeaders = ["Session-Configuration-Header": "foo"] 153 | return config 154 | } 155 | 156 | static func session() -> URLSession { 157 | return Foundation.URLSession(configuration: self.sessionConfiguration()) 158 | } 159 | 160 | // MARK: Class Request Methods 161 | override class func canInit(with request: URLRequest) -> Bool { 162 | return URLProtocol.property(forKey: PropertyKeys.handledByForwarderURLProtocol, in: request) == nil 163 | } 164 | 165 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 166 | return request 167 | // Commented out Alamofire test stuff that we may want later. 168 | // guard let headers = request.allHTTPHeaderFields else { return request } 169 | // do { 170 | // return try URLEncoding.default.encode(request, with: headers) 171 | // } catch { 172 | // return request 173 | // } 174 | } 175 | 176 | override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool { 177 | return false 178 | } 179 | 180 | // MARK: Loading Methods 181 | override func startLoading() { 182 | defer { client?.urlProtocolDidFinishLoading(self) } 183 | let url = request.url! 184 | 185 | guard let data = AllStubs.find(request) else { 186 | let response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: cannedHeaders)! 187 | client?.urlProtocol(self, 188 | didReceive: response, 189 | cacheStoragePolicy: URLCache.StoragePolicy.notAllowed) 190 | let data = try! JSONSerialization.data(withJSONObject: ["status": 404, "description": "no stub for request: \(request)"], options: JSONSerialization.WritingOptions(rawValue: 0)) 191 | client?.urlProtocol(self, didLoad: data) 192 | return 193 | } 194 | 195 | let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: cannedHeaders)! 196 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: URLCache.StoragePolicy.notAllowed) 197 | client?.urlProtocol(self, didLoad: data) 198 | } 199 | 200 | override func stopLoading() { 201 | } 202 | } 203 | 204 | // MARK: URLSessionDelegate extension 205 | extension MockURLProtocol: URLSessionDelegate { 206 | func URLSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceiveData data: Data) { 207 | client?.urlProtocol(self, didLoad: data) 208 | } 209 | 210 | func URLSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 211 | if let response = task.response { 212 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 213 | } 214 | client?.urlProtocolDidFinishLoading(self) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /AutoGraph/AutoGraph.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Alamofire 3 | 4 | public protocol Cancellable { 5 | func cancelAll() 6 | } 7 | 8 | public typealias AuthTokens = (accessToken: String?, refreshToken: String?) 9 | 10 | public protocol Client: RequestSender, Cancellable { 11 | var url: URL { get } 12 | var authHandler: AuthHandler? { get } 13 | var sessionConfiguration: URLSessionConfiguration { get } 14 | } 15 | 16 | public typealias RequestCompletion = (_ result: AutoGraphResult) -> () 17 | 18 | open class GlobalLifeCycle { 19 | open func willSend(request: R) throws { } 20 | open func didFinish(result: AutoGraphResult) throws { } 21 | } 22 | 23 | open class AutoGraph { 24 | public var url: URL { 25 | return self.client.url 26 | } 27 | 28 | public var authHandler: AuthHandler? { 29 | return self.client.authHandler 30 | } 31 | 32 | public var networkErrorParser: NetworkErrorParser? { 33 | get { 34 | return self.dispatcher.responseHandler.networkErrorParser 35 | } 36 | set { 37 | self.dispatcher.responseHandler.networkErrorParser = newValue 38 | } 39 | } 40 | 41 | public let client: Client 42 | public var webSocketClient: WebSocketClient? 43 | public let dispatcher: Dispatcher 44 | public var lifeCycle: GlobalLifeCycle? 45 | 46 | public static let localHost = "http://localhost:8080/graphql" 47 | 48 | public required init( 49 | client: Client, 50 | webSocketClient: WebSocketClient? = nil 51 | ) 52 | { 53 | self.client = client 54 | self.webSocketClient = webSocketClient 55 | self.dispatcher = Dispatcher(requestSender: client, responseHandler: ResponseHandler()) 56 | self.client.authHandler?.delegate = self 57 | } 58 | 59 | public init(client: Client, webSocketClient: WebSocketClient?, dispatcher: Dispatcher) { 60 | self.client = client 61 | self.webSocketClient = webSocketClient 62 | self.dispatcher = dispatcher 63 | self.client.authHandler?.delegate = self 64 | } 65 | 66 | // For Testing. 67 | internal convenience init() throws { 68 | guard let url = URL(string: AutoGraph.localHost) else { 69 | struct URLMissingError: Error { 70 | let urlString: String 71 | } 72 | throw URLMissingError(urlString: AutoGraph.localHost) 73 | } 74 | let client = AlamofireClient(url: url, 75 | session: Alamofire.Session(interceptor: AuthHandler())) 76 | let dispatcher = Dispatcher(requestSender: client, responseHandler: ResponseHandler()) 77 | let webSocketClient = try WebSocketClient(url: URL(string: AutoGraph.localHost)!) 78 | self.init(client: client, webSocketClient: webSocketClient, dispatcher: dispatcher) 79 | } 80 | 81 | open func send(_ request: R, completion: @escaping RequestCompletion) { 82 | let objectBindingPromise = { sendable in 83 | return request.generateBinding { [weak self] result in 84 | self?.complete(result: result, sendable: sendable, requestDidFinish: request.didFinish, completion: completion) 85 | } 86 | } 87 | 88 | let sendable = Sendable(dispatcher: self.dispatcher, request: request, objectBindingPromise: objectBindingPromise) { [weak self] request in 89 | try self?.lifeCycle?.willSend(request: request) 90 | } 91 | 92 | self.dispatcher.send(sendable: sendable) 93 | } 94 | 95 | open func send(includingNetworkResponse request: R, completion: @escaping (_ result: ResultIncludingNetworkResponse) -> ()) { 96 | let requestIncludingJSON = RequestIncludingNetworkResponse(request: request) 97 | self.send(requestIncludingJSON, completion: completion) 98 | } 99 | 100 | /// Subscribe to a subscription. Returns a handler to the Subscription which can be used to unsubscribe. A `nil` return value implies 101 | /// immediate failure, in which case, please check the error received in the completion block. 102 | /// 103 | /// It will form a websocket connection if one doesn't exist already. 104 | open func subscribe(_ request: R, completion: @escaping RequestCompletion) -> Subscriber? { 105 | guard let webSocketClient = self.webSocketClient else { 106 | completion(.failure(AutoGraphError.subscribeWithMissingWebSocketClient)) 107 | return nil 108 | } 109 | 110 | do { 111 | let request = try SubscriptionRequest(request: request) 112 | let responseHandler = SubscriptionResponseHandler { (result) in 113 | switch result { 114 | case .success(let data): 115 | self.webSocketClient?.subscriptionSerializer.serializeFinalObject(data: data, completion: completion) 116 | case .failure(let error): 117 | completion(.failure(error)) 118 | } 119 | } 120 | 121 | return webSocketClient.subscribe(request: request, responseHandler: responseHandler) 122 | } 123 | catch let error { 124 | completion(.failure(error)) 125 | return nil 126 | } 127 | } 128 | 129 | open func unsubscribeAll(request: R) throws { 130 | let request = try SubscriptionRequest(request: request) 131 | try self.webSocketClient?.unsubscribeAll(request: request) 132 | } 133 | 134 | open func unsubscribe(subscriber: Subscriber) throws { 135 | try self.webSocketClient?.unsubscribe(subscriber: subscriber) 136 | } 137 | 138 | open func disconnectAndRetryWebSocket() { 139 | self.webSocketClient?.disconnect() 140 | } 141 | 142 | private func complete(result: AutoGraphResult, sendable: Sendable, requestDidFinish: (AutoGraphResult) throws -> (), completion: @escaping RequestCompletion) { 143 | 144 | do { 145 | try self.raiseAuthenticationError(from: result) 146 | } 147 | catch { 148 | self.triggerReauthentication() 149 | self.dispatcher.paused = true 150 | self.dispatcher.send(sendable: sendable) 151 | return 152 | } 153 | 154 | do { 155 | try requestDidFinish(result) 156 | try self.lifeCycle?.didFinish(result: result) 157 | completion(result) 158 | } 159 | catch let e { 160 | completion(.failure(e)) 161 | } 162 | } 163 | 164 | private func raiseAuthenticationError(from result: AutoGraphResult) throws { 165 | guard 166 | case .failure(let error) = result, 167 | case let autoGraphError as AutoGraphError = error, 168 | case let .network(error: _, statusCode: code, response: _, underlying: _) = autoGraphError, 169 | code == Unauthorized401StatusCode 170 | else { 171 | return 172 | } 173 | 174 | throw error 175 | } 176 | 177 | public func triggerReauthentication() { 178 | self.authHandler?.reauthenticate() 179 | } 180 | 181 | public func cancelAll() { 182 | self.dispatcher.cancelAll() 183 | self.client.cancelAll() 184 | } 185 | 186 | open func reset() { 187 | self.cancelAll() 188 | self.dispatcher.paused = false 189 | self.webSocketClient?.disconnect() 190 | } 191 | } 192 | 193 | extension AutoGraph: AuthHandlerDelegate { 194 | func authHandlerBeganReauthentication(_ authHandler: AuthHandler) { 195 | self.dispatcher.paused = true 196 | } 197 | 198 | func authHandler(_ authHandler: AuthHandler, reauthenticatedSuccessfully: Bool) { 199 | guard reauthenticatedSuccessfully else { 200 | self.cancelAll() 201 | return 202 | } 203 | 204 | self.dispatcher.paused = false 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /QueryBuilder/FoundationExtensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONValueRX 3 | 4 | enum QueryBuilderError: LocalizedError { 5 | case incorrectArgumentKey(key: Any) 6 | case incorrectArgumentValue(value: Any) 7 | case incorrectInputType(message: String) 8 | case missingFields(selectionSetName: String) 9 | case selectionMergeFailure(selection1: Selection, selection2: Selection) 10 | 11 | public var errorDescription: String? { 12 | switch self { 13 | case .incorrectArgumentValue(let value): 14 | return "value \(value) is not an `InputValue`" 15 | case .incorrectArgumentKey(let key): 16 | return "key \(key) is not a `String`" 17 | case .incorrectInputType(message: let message): 18 | return message 19 | case .missingFields(selectionSetName: let selectionSetName): 20 | return "Selection Set on \(selectionSetName) must have either `fields` or `fragments` or both" 21 | case .selectionMergeFailure(selection1: let selection1, selection2: let selection2): 22 | return "Cannot merge selection \(selection1.selectionSetDebugName) of kind \(selection1.kind) with selection \(selection2.selectionSetDebugName) of kind \(selection2.kind)" 23 | } 24 | } 25 | } 26 | 27 | public struct OrderedDictionary { 28 | public var dictionary = [Key : Value]() 29 | public var keys = [Key]() 30 | public var values: [Value] { 31 | return self.keys.compactMap { dictionary[$0] } 32 | } 33 | 34 | public var count: Int { 35 | assert(keys.count == dictionary.count, "Keys and values array out of sync") 36 | return self.keys.count 37 | } 38 | 39 | public init() {} 40 | public init(_ dictionary: [Key : Value]) { 41 | for (key, value) in dictionary { 42 | self.keys.append(key) 43 | self.dictionary[key] = value 44 | } 45 | } 46 | 47 | public subscript(index: Int) -> Value? { 48 | get { 49 | let key = self.keys[index] 50 | return self.dictionary[key] 51 | } 52 | set(newValue) { 53 | let key = self.keys[index] 54 | if newValue != nil { 55 | self.dictionary[key] = newValue 56 | } 57 | else { 58 | self.dictionary.removeValue(forKey: key) 59 | self.keys.remove(at: index) 60 | } 61 | } 62 | } 63 | 64 | public subscript(key: Key) -> Value? { 65 | get { 66 | return self.dictionary[key] 67 | } 68 | set(newValue) { 69 | if let newValue = newValue { 70 | if self.dictionary.updateValue(newValue, forKey: key) == nil { 71 | self.keys.append(key) 72 | } 73 | } 74 | else { 75 | if self.dictionary.removeValue(forKey: key) != nil { 76 | guard let index = self.keys.firstIndex(of: key) else { 77 | fatalError("OrderedDictionary attempted to remove value for key \"\(key)\" that has no index.") 78 | } 79 | self.keys.remove(at: index) 80 | } 81 | } 82 | } 83 | } 84 | 85 | public mutating func insert(contentsOf contents: OrderedDictionary) throws { 86 | for key in contents.keys { 87 | self[key] = contents.dictionary[key] 88 | } 89 | } 90 | 91 | public mutating func removeValue(forKey key: Key) -> Value? { 92 | let value = self[key] 93 | self[key] = nil 94 | return value 95 | } 96 | 97 | public func map(_ transform: ((key: Key, value: Value)) throws -> T) rethrows -> [T] { 98 | var output = [T]() 99 | for key in keys { 100 | if let value = self.dictionary[key] { 101 | output.append(try transform((key, value))) 102 | } 103 | } 104 | return output 105 | } 106 | 107 | var description: String { 108 | var result = "{\n" 109 | for i in 0.. InputType { 119 | return .scalar(.string) 120 | } 121 | 122 | public func graphQLString() throws -> String { 123 | return self.name 124 | } 125 | 126 | public var name: String { 127 | return self 128 | } 129 | 130 | public func graphQLInputValue() throws -> String { 131 | return try self.jsonEncodedString() 132 | } 133 | 134 | // TODO: At somepoint we can "verifyNoWhitespace" and throw an error instead. 135 | var withoutWhitespace: String { 136 | return self.replace(" ", with: "") 137 | } 138 | 139 | private func replace(_ string: String, with replacement: String) -> String { 140 | return self.replacingOccurrences(of: string, with: replacement, options: NSString.CompareOptions.literal, range: nil) 141 | } 142 | } 143 | 144 | extension Int: InputValue { 145 | public static func inputType() throws -> InputType { 146 | return .scalar(.int) 147 | } 148 | 149 | public func graphQLInputValue() throws -> String { 150 | return try self.jsonEncodedString() 151 | } 152 | } 153 | 154 | extension UInt: InputValue { 155 | public static func inputType() throws -> InputType { 156 | return .scalar(.int) 157 | } 158 | 159 | public func graphQLInputValue() throws -> String { 160 | return try self.jsonEncodedString() 161 | } 162 | 163 | public func jsonEncodedString() throws -> String { 164 | let jsonNumber: JSONNumber = { 165 | guard let i = Int64(exactly: self) else { 166 | return .fraction(Double(self)) 167 | } 168 | return .int(i) 169 | }() 170 | return try JSONValue.number(jsonNumber).encodeAsString() 171 | } 172 | } 173 | 174 | extension Double: InputValue { 175 | public static func inputType() throws -> InputType { 176 | return .scalar(.float) 177 | } 178 | 179 | public func graphQLInputValue() throws -> String { 180 | return try self.jsonEncodedString() 181 | } 182 | } 183 | 184 | extension Bool: InputValue { 185 | public static func inputType() throws -> InputType { 186 | return .scalar(.boolean) 187 | } 188 | 189 | public func graphQLInputValue() throws -> String { 190 | return try self.jsonEncodedString() 191 | } 192 | 193 | public func jsonEncodedString() throws -> String { 194 | return try JSONValue.bool(self).encodeAsString() 195 | } 196 | } 197 | 198 | extension Float: InputValue { 199 | public static func inputType() throws -> InputType { 200 | return .scalar(.float) 201 | } 202 | 203 | public func graphQLInputValue() throws -> String { 204 | return try self.jsonEncodedString() 205 | } 206 | 207 | public func jsonEncodedString() throws -> String { 208 | return try JSONValue.number(.fraction(Double(self))).encodeAsString() 209 | } 210 | } 211 | 212 | extension NSNull: InputValue { 213 | public static func inputType() throws -> InputType { 214 | return .scalar(.null) 215 | } 216 | 217 | public func graphQLInputValue() throws -> String { 218 | return try self.jsonEncodedString() 219 | } 220 | 221 | public func jsonEncodedString() throws -> String { 222 | return try JSONValue.null.encodeAsString() 223 | } 224 | } 225 | 226 | extension NSNumber: InputValue { 227 | public static func inputType() throws -> InputType { 228 | return .scalar(.float) 229 | } 230 | 231 | public func graphQLInputValue() throws -> String { 232 | return try self.jsonEncodedString() 233 | } 234 | 235 | public func jsonEncodedString() throws -> String { 236 | return try JSONValue.number(self.asJSONNumber).encodeAsString() 237 | } 238 | } 239 | 240 | // This can be cleaned up once conditional conformances are added to the language https://github.com/apple/swift-evolution/blob/master/proposals/0143-conditional-conformances.md . 241 | extension Array: InputValue { 242 | public static func inputType() throws -> InputType { 243 | guard case let elementType as InputValue.Type = Element.self else { 244 | throw QueryBuilderError.incorrectArgumentValue(value: Element.self) 245 | } 246 | 247 | return .list(try elementType.inputType()) 248 | } 249 | 250 | public func graphQLInputValue() throws -> String { 251 | let values: [String] = try self.map { 252 | guard case let value as InputValue = $0 else { 253 | throw QueryBuilderError.incorrectArgumentValue(value: $0) 254 | } 255 | return try value.graphQLInputValue() 256 | } 257 | 258 | return "[" + values.joined(separator: ", ") + "]" 259 | } 260 | } 261 | 262 | extension Dictionary: InputValue { 263 | public static func inputType() throws -> InputType { 264 | throw QueryBuilderError.incorrectInputType(message: "Dictionary does not have a defined `InputType`, please use `InputObjectType` instead") 265 | } 266 | 267 | public func graphQLInputValue() throws -> String { 268 | let inputs: [String] = try self.map { 269 | guard case let key as String = $0 else { 270 | throw QueryBuilderError.incorrectArgumentKey(key: $0) 271 | } 272 | guard case let value as InputValue = $1 else { 273 | throw QueryBuilderError.incorrectArgumentValue(value: $1) 274 | } 275 | return key + ": " + (try value.graphQLInputValue()) 276 | } 277 | 278 | return "{" + inputs.joined(separator: ", ") + "}" 279 | } 280 | } 281 | 282 | extension Dictionary: GraphQLVariables { 283 | public func graphQLVariablesDictionary() throws -> [AnyHashable : Any] { 284 | return self 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /AutoGraphTests/ResponseHandlerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Alamofire 3 | import JSONValueRX 4 | @testable import AutoGraphQL 5 | 6 | class ResponseHandlerTests: XCTestCase { 7 | 8 | class MockQueue: OperationQueue, @unchecked Swift.Sendable { 9 | override func addOperation(_ op: Foundation.Operation) { 10 | op.start() 11 | } 12 | } 13 | 14 | var subject: ResponseHandler! 15 | 16 | override func setUp() { 17 | super.setUp() 18 | 19 | self.subject = ResponseHandler(queue: MockQueue(), callbackQueue: MockQueue()) 20 | } 21 | 22 | override func tearDown() { 23 | self.subject = nil 24 | 25 | super.tearDown() 26 | } 27 | 28 | func testErrorsJsonReturnsGraphQLError() { 29 | let message = "Cannot query field \"d\" on type \"Planet\"." 30 | let line = 18 31 | let column = 7 32 | 33 | let result = Result.success([ 34 | "dumb" : "data", 35 | "errors": [ 36 | [ 37 | "message": message, 38 | "locations": [ 39 | [ 40 | "line": line, 41 | "column": column 42 | ] 43 | ] 44 | ] 45 | ] 46 | ] as Any) 47 | 48 | let response = AFDataResponse(request: nil, response: nil, data: nil, metrics: nil, serializationDuration: 0.0, result: result) 49 | 50 | var called = false 51 | 52 | self.subject.handle(response: response, objectBinding: FilmRequest().generateBinding { result in 53 | called = true 54 | 55 | guard case .failure(let error as AutoGraphError) = result else { 56 | XCTFail("`result` should be an `AutoGraphError`") 57 | return 58 | } 59 | 60 | guard case .graphQL(errors: let errors, _) = error else { 61 | XCTFail("`error` should be an `.graphQL` error") 62 | return 63 | } 64 | 65 | XCTAssertEqual(errors.count, 1) 66 | 67 | let gqlError = errors[0] 68 | XCTAssertEqual(gqlError.message, message) 69 | XCTAssertEqual(gqlError.locations.count, 1) 70 | 71 | let location = gqlError.locations[0] 72 | XCTAssertEqual(location.line, line) 73 | XCTAssertEqual(location.column, column) 74 | }, preMappingHook: { (_, _) in }) 75 | 76 | XCTAssertTrue(called) 77 | } 78 | 79 | func testNetworkErrorReturnsNetworkError() { 80 | let result = Result.failure(AFError.sessionTaskFailed(error: NSError(domain: "", code: 0, userInfo: nil))) 81 | let response = AFDataResponse(request: nil, response: nil, data: nil, metrics: nil, serializationDuration: 0.0, result: result) 82 | 83 | var called = false 84 | 85 | self.subject.handle(response: response, objectBinding: FilmRequest().generateBinding { result in 86 | called = true 87 | 88 | guard case .failure(let error as AutoGraphError) = result else { 89 | XCTFail("`result` should be an `AutoGraphError`") 90 | return 91 | } 92 | 93 | guard case .network = error else { 94 | XCTFail("`error` should be an `.network` error") 95 | return 96 | } 97 | }, preMappingHook: { (_, _) in }) 98 | 99 | XCTAssertTrue(called) 100 | } 101 | 102 | func testMappingErrorReturnsMappingError() { 103 | class FilmBadRequest: FilmRequest { 104 | } 105 | 106 | let result = Result.success([ "dumb" : "data" ] as Any) 107 | let response = AFDataResponse(request: nil, response: nil, data: nil, metrics: nil, serializationDuration: 0.0, result: result) 108 | 109 | var called = false 110 | 111 | self.subject.handle(response: response, objectBinding: FilmBadRequest().generateBinding { result in 112 | called = true 113 | 114 | guard case .failure(let error as AutoGraphError) = result else { 115 | XCTFail("`result` should be an `AutoGraphError`") 116 | return 117 | } 118 | 119 | guard case .mapping(error: _) = error else { 120 | XCTFail("`error` should be an `.mapping` error") 121 | return 122 | } 123 | }, preMappingHook: { (_, _) in }) 124 | 125 | XCTAssertTrue(called) 126 | } 127 | 128 | func testPreMappingHookCalledBeforeMapping() { 129 | class MockRequest: FilmRequest { 130 | var called = false 131 | override func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws { 132 | called = true 133 | } 134 | } 135 | 136 | let result = Result.success([ "dumb" : "data" ] as Any) 137 | let response = AFDataResponse(request: nil, response: nil, data: nil, metrics: nil, serializationDuration: 0.0, result: result) 138 | let request = MockRequest() 139 | 140 | self.subject.handle(response: response, objectBinding: request.generateBinding(completion: { _ in }), preMappingHook: request.didFinishRequest) 141 | 142 | XCTAssertTrue(request.called) 143 | } 144 | 145 | func testResponseReturnedFromNetworkError() { 146 | let httpResponse = HTTPURLResponse(url: URL(string: "www.test.com")!, statusCode: 400, httpVersion: nil, headerFields: ["request_id" : "1234"]) 147 | let result = Result.failure(AFError.sessionTaskFailed(error: NSError(domain: "", code: 0, userInfo: nil))) 148 | let response = AFDataResponse(request: nil, response: httpResponse, data: nil, metrics: nil, serializationDuration: 0.0, result: result) 149 | 150 | var requestId: String? 151 | 152 | self.subject.handle(response: response, objectBinding: FilmRequest().generateBinding { result in 153 | guard case .failure(let error as AutoGraphError) = result else { 154 | XCTFail("`result` should be an `AutoGraphError`") 155 | return 156 | } 157 | 158 | guard case .network(_,_,let response, _) = error else { 159 | XCTFail("`error` should be an `.network` error") 160 | return 161 | } 162 | 163 | requestId = response?.allHeaderFields["request_id"] as? String 164 | }, preMappingHook: { (_, _) in }) 165 | 166 | XCTAssertNotNil(requestId) 167 | XCTAssertEqual(requestId, "1234") 168 | } 169 | 170 | func testResponseReturnedFromMappingError() { 171 | class FilmBadRequest: FilmRequest { 172 | } 173 | 174 | let httpResponse = HTTPURLResponse(url: URL(string: "www.test.com")!, statusCode: 400, httpVersion: nil, headerFields: ["request_id" : "1234"]) 175 | let result = Result.success([ "dumb" : "data" ] as Any) 176 | let response = AFDataResponse(request: nil, response: httpResponse, data: nil, metrics: nil, serializationDuration: 0.0, result: result) 177 | 178 | var requestId: String? 179 | 180 | self.subject.handle(response: response, objectBinding: FilmBadRequest().generateBinding { result in 181 | guard case .failure(let error as AutoGraphError) = result else { 182 | XCTFail("`result` should be an `AutoGraphError`") 183 | return 184 | } 185 | 186 | guard case .mapping(error: _, let response) = error else { 187 | XCTFail("`error` should be an `.mapping` error") 188 | return 189 | } 190 | 191 | requestId = response?.allHeaderFields["request_id"] as? String 192 | }, preMappingHook: { (_, _) in }) 193 | 194 | XCTAssertNotNil(requestId) 195 | XCTAssertEqual(requestId, "1234") 196 | } 197 | 198 | func testResponseReturnedFromGraphQLError() { 199 | let message = "Cannot query field \"d\" on type \"Planet\"." 200 | let line = 18 201 | let column = 7 202 | 203 | let result = Result.success([ 204 | "dumb" : "data", 205 | "errors": [ 206 | [ 207 | "message": message, 208 | "locations": [ 209 | [ 210 | "line": line, 211 | "column": column 212 | ] 213 | ] 214 | ] 215 | ] 216 | ] as Any) 217 | 218 | let httpResponse = HTTPURLResponse(url: URL(string: "www.test.com")!, statusCode: 400, httpVersion: nil, headerFields: ["request_id" : "1234"]) 219 | let response = AFDataResponse(request: nil, response: httpResponse, data: nil, metrics: nil, serializationDuration: 0.0, result: result) 220 | 221 | var requestId: String? 222 | 223 | self.subject.handle(response: response, objectBinding: FilmRequest().generateBinding { result in 224 | guard case .failure(let error as AutoGraphError) = result else { 225 | XCTFail("`result` should be an `AutoGraphError`") 226 | return 227 | } 228 | 229 | guard case .graphQL(errors: _, let response) = error else { 230 | XCTFail("`error` should be an `.graphQL` error") 231 | return 232 | } 233 | 234 | requestId = response?.allHeaderFields["request_id"] as? String 235 | }, preMappingHook: { (_, _) in }) 236 | 237 | XCTAssertNotNil(requestId) 238 | XCTAssertEqual(requestId, "1234") 239 | } 240 | 241 | func testResponseReturnedFromInvalidResponseError() { 242 | let result = Result.success([ 243 | "dumb" : "data", 244 | "errors": "Invalid", 245 | ] as Any) 246 | 247 | let httpResponse = HTTPURLResponse(url: URL(string: "www.test.com")!, statusCode: 200, httpVersion: nil, headerFields: ["request_id" : "1234"]) 248 | let response = AFDataResponse(request: nil, response: httpResponse, data: nil, metrics: nil, serializationDuration: 0.0, result: result) 249 | 250 | var requestId: String? 251 | 252 | self.subject.handle(response: response, objectBinding: FilmRequest().generateBinding { result in 253 | guard case .failure(let error as AutoGraphError) = result else { 254 | XCTFail("`result` should be an `AutoGraphError`") 255 | return 256 | } 257 | 258 | guard case .invalidResponse(let response) = error else { 259 | XCTFail("`error` should be an `.network` error") 260 | return 261 | } 262 | 263 | requestId = response?.allHeaderFields["request_id"] as? String 264 | }, preMappingHook: { (_, _) in }) 265 | 266 | XCTAssertNotNil(requestId) 267 | XCTAssertEqual(requestId, "1234") 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /AutoGraphTests/Requests/FilmSubscriptionRequest.swift: -------------------------------------------------------------------------------- 1 | @testable import AutoGraphQL 2 | import Foundation 3 | import JSONValueRX 4 | 5 | class FilmSubscriptionRequest: Request { 6 | /* 7 | query film { 8 | film(id: "ZmlsbXM6MQ==") { 9 | id 10 | title 11 | episodeID 12 | director 13 | openingCrawl 14 | } 15 | } 16 | */ 17 | 18 | let operationName: String 19 | let queryDocument = Operation(type: .subscription, 20 | name: "film", 21 | selectionSet: [ 22 | Object(name: "film", 23 | alias: nil, 24 | arguments: ["id" : "ZmlsbXM6MQ=="], 25 | selectionSet: [ 26 | "id", 27 | Scalar(name: "title", alias: nil), 28 | Scalar(name: "episodeID", alias: nil), 29 | Scalar(name: "director", alias: nil), 30 | Scalar(name: "openingCrawl", alias: nil)]) 31 | ]) 32 | 33 | let variables: [AnyHashable : Any]? = nil 34 | 35 | let rootKeyPath: String = "data.film" 36 | 37 | init(operationName: String = "film") { 38 | self.operationName = operationName 39 | } 40 | 41 | public func willSend() throws { } 42 | public func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws { } 43 | public func didFinish(result: AutoGraphResult) throws { } 44 | 45 | static let jsonResponse: [String: Any] = [ 46 | "type": "data", 47 | "id": "film", 48 | "payload": [ 49 | "data": [ 50 | "id": "ZmlsbXM6MQ==", 51 | "title": "A New Hope", 52 | "episodeID": 4, 53 | "director": "George Lucas", 54 | "openingCrawl": "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy...." 55 | ] 56 | ] 57 | ] 58 | } 59 | 60 | class FilmSubscriptionRequestWithVariables: Request { 61 | /* 62 | query film { 63 | film(id: "ZmlsbXM6MQ==") { 64 | id 65 | title 66 | episodeID 67 | director 68 | openingCrawl 69 | } 70 | } 71 | */ 72 | 73 | let queryDocument = Operation(type: .subscription, 74 | name: "film", 75 | selectionSet: [ 76 | Object(name: "film", 77 | alias: nil, 78 | arguments: ["id" : "ZmlsbXM6MQ=="], 79 | selectionSet: [ 80 | "id", 81 | Scalar(name: "title", alias: nil), 82 | Scalar(name: "episodeID", alias: nil), 83 | Scalar(name: "director", alias: nil), 84 | Scalar(name: "openingCrawl", alias: nil)]) 85 | ]) 86 | 87 | let variables: [AnyHashable : Any]? = [ 88 | "id": "ZmlsbXM6MQ==" 89 | ] 90 | 91 | let rootKeyPath: String = "data.film" 92 | 93 | public func willSend() throws { } 94 | public func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws { } 95 | public func didFinish(result: AutoGraphResult) throws { } 96 | 97 | static let jsonResponse: [String: Any] = [ 98 | "type": "data", 99 | "id": "film", 100 | "payload": [ 101 | "data": [ 102 | "id": "ZmlsbXM6MQ==", 103 | "title": "A New Hope", 104 | "episodeID": 4, 105 | "director": "George Lucas", 106 | "openingCrawl": "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy...." 107 | ] 108 | ] 109 | ] 110 | } 111 | 112 | class AllFilmsSubscriptionRequest: Request { 113 | /* 114 | query allFilms { 115 | film(id: "ZmlsbXM6MQ==") { 116 | id 117 | title 118 | episodeID 119 | director 120 | openingCrawl 121 | } 122 | } 123 | */ 124 | 125 | let queryDocument = Operation(type: .subscription, 126 | name: "allFilms", 127 | selectionSet: [ 128 | Object(name: "allFilms", 129 | arguments: nil, 130 | selectionSet: [ 131 | Object(name: "films", 132 | alias: nil, 133 | arguments: nil, 134 | selectionSet: [ 135 | "id", 136 | Scalar(name: "title", alias: nil), 137 | Scalar(name: "episodeID", alias: nil), 138 | Scalar(name: "director", alias: nil), 139 | Scalar(name: "openingCrawl", alias: nil)]) 140 | ]) 141 | ]) 142 | 143 | let variables: [AnyHashable : Any]? = nil 144 | 145 | // TODO: this isn't even used on subscriptions. consider moving this into an extensive protocol and out of Request. 146 | let rootKeyPath: String = "data.allFilms" 147 | 148 | struct Data: Decodable { 149 | struct AllFilms: Decodable { 150 | let films: [Film] 151 | } 152 | let allFilms: AllFilms 153 | } 154 | 155 | public func willSend() throws { } 156 | public func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws { } 157 | public func didFinish(result: AutoGraphResult) throws { } 158 | 159 | static let jsonResponse: [String : Any] = [ 160 | "type": "data", 161 | "id": "allFilms", 162 | "payload": [ 163 | "data": [ 164 | "allFilms": [ 165 | "films": [ 166 | [ 167 | "id": "ZmlsbXM6MQ==", 168 | "title": "A New Hope", 169 | "episodeID": 4, 170 | "openingCrawl": "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy....", 171 | "director": "George Lucas" 172 | ], 173 | [ 174 | "id": "ZmlsbXM6Mg==", 175 | "title": "The Empire Strikes Back", 176 | "episodeID": 5, 177 | "openingCrawl": "It is a dark time for the\r\nRebellion. Although the Death\r\nStar has been destroyed,\r\nImperial troops have driven the\r\nRebel forces from their hidden\r\nbase and pursued them across\r\nthe galaxy.\r\n\r\nEvading the dreaded Imperial\r\nStarfleet, a group of freedom\r\nfighters led by Luke Skywalker\r\nhas established a new secret\r\nbase on the remote ice world\r\nof Hoth.\r\n\r\nThe evil lord Darth Vader,\r\nobsessed with finding young\r\nSkywalker, has dispatched\r\nthousands of remote probes into\r\nthe far reaches of space....", 178 | "director": "Irvin Kershner" 179 | ], 180 | [ 181 | "id": "ZmlsbXM6Mw==", 182 | "title": "Return of the Jedi", 183 | "episodeID": 6, 184 | "openingCrawl": "Luke Skywalker has returned to\r\nhis home planet of Tatooine in\r\nan attempt to rescue his\r\nfriend Han Solo from the\r\nclutches of the vile gangster\r\nJabba the Hutt.\r\n\r\nLittle does Luke know that the\r\nGALACTIC EMPIRE has secretly\r\nbegun construction on a new\r\narmored space station even\r\nmore powerful than the first\r\ndreaded Death Star.\r\n\r\nWhen completed, this ultimate\r\nweapon will spell certain doom\r\nfor the small band of rebels\r\nstruggling to restore freedom\r\nto the galaxy...", 185 | "director": "Richard Marquand" 186 | ], 187 | [ 188 | "id": "ZmlsbXM6NA==", 189 | "title": "The Phantom Menace", 190 | "episodeID": 1, 191 | "openingCrawl": "Turmoil has engulfed the\r\nGalactic Republic. The taxation\r\nof trade routes to outlying star\r\nsystems is in dispute.\r\n\r\nHoping to resolve the matter\r\nwith a blockade of deadly\r\nbattleships, the greedy Trade\r\nFederation has stopped all\r\nshipping to the small planet\r\nof Naboo.\r\n\r\nWhile the Congress of the\r\nRepublic endlessly debates\r\nthis alarming chain of events,\r\nthe Supreme Chancellor has\r\nsecretly dispatched two Jedi\r\nKnights, the guardians of\r\npeace and justice in the\r\ngalaxy, to settle the conflict....", 192 | "director": "George Lucas" 193 | ], 194 | [ 195 | "id": "ZmlsbXM6NQ==", 196 | "title": "Attack of the Clones", 197 | "episodeID": 2, 198 | "openingCrawl": "There is unrest in the Galactic\r\nSenate. Several thousand solar\r\nsystems have declared their\r\nintentions to leave the Republic.\r\n\r\nThis separatist movement,\r\nunder the leadership of the\r\nmysterious Count Dooku, has\r\nmade it difficult for the limited\r\nnumber of Jedi Knights to maintain \r\npeace and order in the galaxy.\r\n\r\nSenator Amidala, the former\r\nQueen of Naboo, is returning\r\nto the Galactic Senate to vote\r\non the critical issue of creating\r\nan ARMY OF THE REPUBLIC\r\nto assist the overwhelmed\r\nJedi....", 199 | "director": "George Lucas" 200 | ], 201 | [ 202 | "id": "ZmlsbXM6Ng==", 203 | "title": "Revenge of the Sith", 204 | "episodeID": 3, 205 | "openingCrawl": "War! The Republic is crumbling\r\nunder attacks by the ruthless\r\nSith Lord, Count Dooku.\r\nThere are heroes on both sides.\r\nEvil is everywhere.\r\n\r\nIn a stunning move, the\r\nfiendish droid leader, General\r\nGrievous, has swept into the\r\nRepublic capital and kidnapped\r\nChancellor Palpatine, leader of\r\nthe Galactic Senate.\r\n\r\nAs the Separatist Droid Army\r\nattempts to flee the besieged\r\ncapital with their valuable\r\nhostage, two Jedi Knights lead a\r\ndesperate mission to rescue the\r\ncaptive Chancellor....", 206 | "director": "George Lucas" 207 | ] 208 | ] 209 | ] 210 | ] 211 | ] 212 | ] 213 | 214 | } 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![AutoGraph](https://github.com/remind101/AutoGraph/blob/master/autograph.png)](https://github.com/remind101/AutoGraph/blob/master/autograph.png) 2 | 3 | [![CI](https://github.com/autograph-hq/AutoGraph/actions/workflows/ci.yml/badge.svg)](https://github.com/autograph-hq/AutoGraph/actions/workflows/ci.yml) 4 | [![Swift](https://img.shields.io/badge/Swift-5.9-orange.svg)](https://swift.org) 5 | [![Platform](https://img.shields.io/badge/platform-iOS%20%7C%20macOS-lightgrey.svg)](https://github.com/autograph-hq/AutoGraph) 6 | 7 | The Swiftest way to GraphQL 8 | 9 | - [Features](#features) 10 | - [Usage](#usage) 11 | - [Contributing](#contributing) 12 | - [License](#license) 13 | 14 | ## Features 15 | 16 | - [x] ⚡️ [Code Generation](#code-generation) 17 | - [x] 🔨 [Query Builder](#query-builder) 18 | - [x] 📩 [Subscriptions](#subscriptions) 19 | - [x] ⛑ [Type safe Mapping](#decodable-for-type-safe-models) 20 | - [x] 🆒 [Type safe JSON](#jsonvalue-for-type-safe-json) 21 | - [x] 🐙 [Threading](#threading) 22 | - [x] 🌐 [Network Library](#network-library) 23 | 24 | AutoGraph is a Swift client framework for making requests using GraphQL and mapping the responses to strongly typed models. Models may be represented by any `Decodable` type. AutoGraph relies heavily on Swift's type safety to drive it, leading to safer, compile time checked code. 25 | 26 | ## Requirements 27 | 28 | - Swift 5.9+ 29 | - Xcode 15.0+ 30 | 31 | ### Version History 32 | 33 | - Swift 5.9 iOS 13 - use version `0.18.0` (last version with CocoaPods support) 34 | - Swift 5.3.2 iOS 11 - use version `0.15.1` 35 | - Swift 5.2 iOS 10 - use version `0.14.7` 36 | - Swift 5.1.3 iOS 10 - use version `0.11.1` 37 | - Swift 5.0 iOS 8 - use version `0.10.0` 38 | - Swift 5.0 pre Decodable - use version `0.8.0` 39 | - Swift 4.2+ - use version `0.7.0` 40 | - Swift 4.1.2 - use version `0.5.1` 41 | 42 | ### Platforms 43 | 44 | - [x] iOS 13.0+ 45 | - [x] macOS 10.15+ 46 | - [ ] Linux 47 | 48 | ## Installation 49 | 50 | ### Swift Package Manager (SPM) 51 | 52 | AutoGraph is available through Swift Package Manager. 53 | 54 | Add AutoGraph to your `Package.swift` dependencies: 55 | 56 | ```swift 57 | dependencies: [ 58 | .package(url: "https://github.com/autograph-hq/AutoGraph.git", from: "1.0.0") 59 | ] 60 | ``` 61 | 62 | Then add it to your target dependencies: 63 | 64 | ```swift 65 | targets: [ 66 | .target( 67 | name: "YourTarget", 68 | dependencies: [ 69 | .product(name: "AutoGraphQL", package: "AutoGraph") 70 | ] 71 | ) 72 | ] 73 | ``` 74 | 75 | ### Using Xcode 76 | 77 | In Xcode, go to **File → Add Package Dependencies** and enter: 78 | 79 | ``` 80 | https://github.com/autograph-hq/AutoGraph.git 81 | ``` 82 | 83 | ## Code Generation 84 | 85 | AutoGraph offers powerful code generation through [AutoGraphCodeGen](https://github.com/remind101/AutoGraphCodeGen), which transforms GraphQL schemas into native Swift types. This eliminates boilerplate and ensures type safety. 86 | 87 | Unlike GraphQL clients that use dictionaries under the hood, AutoGraph generates [zero-cost-abstraction](https://boats.gitlab.io/blog/post/zero-cost-abstractions/) structs - giving you the convenience of high-level abstractions with the performance of hand-written code. 88 | 89 | ## Storage and Caching 90 | 91 | AutoGraph follows a lightweight, flexible philosophy by intentionally decoupling storage and caching from core functionality. This separation empowers you to: 92 | 93 | - Implement exactly the persistence strategy your app requires 94 | - Integrate with your existing storage infrastructure 95 | - Optimize caching based on your specific performance needs 96 | - Avoid unnecessary dependencies and overhead 97 | 98 | Simply use AutoGraph to handle GraphQL codegen and networking, then connect the response data to your preferred storage solution—whether that's Realm, Core Data, SQLite, or a custom in-memory cache. 99 | 100 | While AutoGraph remains focused on its core GraphQL capabilities, we may explore companion libraries for common storage patterns in the future. These would be offered as optional dependencies to maintain AutoGraph's lightweight foundation. 101 | 102 | ## Query Builder 103 | 104 | AutoGraph includes a GraphQL query builder to construct queries in a type safe manner. However, using the query builder is not required; any object which inherits `GraphQLQuery` can act as a query. `String` inherits this by default. 105 | 106 | Query Example 107 | 108 | ```swift 109 | Raw GraphQL AutoGraph 110 | ----------- --------- 111 | query MyCoolQuery { AutoGraphQL.Operation(type: .query, name: "MyCoolQuery", fields: [ 112 | user { Object(name: "user", fields: [ 113 | favorite_authors { Object(name: "favorite_authors", fields: [, 114 | uuid "uuid", 115 | name "name" 116 | } ]), 117 | uuid "uuid", 118 | signature "signature" 119 | } ]) 120 | } 121 | ``` 122 | 123 | Mutation Example 124 | 125 | ```swift 126 | Raw GraphQL 127 | ----------- 128 | mutation MyCoolMutation { 129 | updateFavoriteAuthor(uuid: "long_id", input: { name: "My Cool Name" }) 130 | { 131 | favorite_author { 132 | uuid 133 | name 134 | } 135 | } 136 | } 137 | 138 | AutoGraph 139 | --------- 140 | AutoGraphQL.Operation(type: .mutation, name: "MyCoolMutation", fields: [ 141 | Object( 142 | name: "updateFavoriteAuthor", 143 | arguments: [ // Continues "updateFavoriteAuthor". 144 | "uuid" : "long_id", 145 | "input" : [ 146 | "name" : "My Cool Class" 147 | ] 148 | ], 149 | fields: [ 150 | Object( 151 | name: "favorite_author", 152 | fields: [ 153 | "uuid", 154 | "name" 155 | ]) 156 | ] 157 | ]) 158 | ``` 159 | 160 | ### Supports 161 | 162 | - [x] [Query Document](https://facebook.github.io/graphql/#sec-Language.Query-Document) 163 | - [x] [Operations](https://facebook.github.io/graphql/#sec-Language.Operations) 164 | - [x] [Mutations](http://graphql.org/learn/queries/#mutations) 165 | - [x] [Subscriptions](http://spec.graphql.org/June2018/#sec-Subscription-Operation-Definitions) 166 | - [x] [Selection Sets](https://facebook.github.io/graphql/#sec-Selection-Sets) 167 | - [x] [Fields](https://facebook.github.io/graphql/#sec-Language.Fields) 168 | - [x] [Arguments](https://facebook.github.io/graphql/#sec-Language.Arguments) 169 | - [x] [Aliases](https://facebook.github.io/graphql/#sec-Field-Alias) 170 | - [x] [Variables](https://facebook.github.io/graphql/#sec-Language.Variables) 171 | - [x] [Input Values](https://facebook.github.io/graphql/#sec-Input-Values) 172 | - [x] [List Values](https://facebook.github.io/graphql/#sec-List-Value) 173 | - [x] [Input Object Values](https://facebook.github.io/graphql/#sec-Input-Object-Values) 174 | - [x] [Fragments](https://facebook.github.io/graphql/#sec-Language.Fragments) 175 | - [x] Fragment Spread 176 | - [x] Fragment Definition 177 | - [x] [Inline Fragments](https://facebook.github.io/graphql/#sec-Inline-Fragments) 178 | - [x] [Directives](https://facebook.github.io/graphql/#sec-Language.Directives) 179 | 180 | ## Subscriptions 181 | 182 | AutoGraph now supports subscriptions using the `graphql-ws` protocol. This is this same protocol that Apollo GraphQL server uses, meaning subscriptions will work with Apollo server. 183 | 184 | ``` 185 | let url = URL(string: "wss.mygraphql.com/subscriptions")! 186 | let webSocketClient = try WebSocketClient(url: url) 187 | webSocketClient.delegate = self // Allows the user to inspect errors and events as they arrive. 188 | 189 | let client = try AlamofireClient(url: AutoGraph.localHost, 190 | session: Session(configuration: MockURLProtocol.sessionConfiguration(), 191 | interceptor: AuthHandler())) 192 | let autoGraph = AutoGraph(client: client, webSocketClient: webSocketClient) 193 | 194 | let request = FilmSubscriptionRequest() 195 | let subscriber = self.subject.subscribe(request) { (result) in 196 | switch result { 197 | case .success(let object): // Handle new object. 198 | case .failure(let error): // Handle error 199 | } 200 | } 201 | 202 | // Sometime later... 203 | try autoGraph.unsubscribe(subscriber: subscriber!) 204 | ``` 205 | 206 | ## Decodable for type safe Models 207 | 208 | AutoGraph relies entirely on [Decodable](https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types) for mapping GraphQL JSON responses to data models. It's as easy as conforming the model to `Decodable`! 209 | 210 | ## JSONValue for type safe JSON 211 | 212 | Behind the scenes AutoGraph uses [JSONValue](https://github.com/rexmas/JSONValue.git) for type safe JSON. Feel free to import it for your own needs. 213 | 214 | ## Threading 215 | 216 | AutoGraph performs all network requests and mapping off of the main thread. Since a `Request` will eventually return whole models back to the caller on the main thread, it's important to consider thread safety with the model types being used. For this reason, using immutable `struct` types as models is recommended. 217 | 218 | ## Network Library 219 | 220 | AutoGraph currently relies on [Alamofire](https://github.com/Alamofire/Alamofire) for networking. However this isn't a hard requirement. Pull requests for this are encouraged! 221 | 222 | ## Usage: 223 | 224 | ### Request Protocol 225 | 226 | 1. Create a class that conforms to the Request protocol. You can also extend an existing class to conform to this protocol. Request is a base protocol used for GraphQL requests sent through AutoGraph. It provides the following parameters. 227 | 1. `queryDocument` - The query being sent. You may use the Query Builder or a String. 228 | 2. `variables` - The variables to be sent with the query. A `Dictionary` is accepted. 229 | 3. `rootKeyPath` - Defines where to start mapping data from. Empty string (`""`) will map from the root of the JSON. 230 | 4. An `associatedtype SerializedObject: Decodable` must be provided to tell AutoGraph what data model to decode to. 231 | 5. A number of methods to inform the Request of its point in the life cycle. 232 | 233 | ```swift 234 | class FilmRequest: Request { 235 | /* 236 | query film { 237 | film(id: "ZmlsbXM6MQ==") { 238 | id 239 | title 240 | episodeID 241 | director 242 | openingCrawl 243 | } 244 | } 245 | */ 246 | 247 | let query = Operation(type: .query, 248 | name: "film", 249 | fields: [ 250 | Object(name: "film", 251 | alias: nil, 252 | arguments: ["id" : "ZmlsbXM6MQ=="], 253 | fields: [ 254 | "id", // May use string literal or Scalar. 255 | Scalar(name: "title", alias: nil), 256 | Scalar(name: "episodeID", alias: nil), 257 | Scalar(name: "director", alias: nil), 258 | Scalar(name: "openingCrawl", alias: nil)]) 259 | ]) 260 | 261 | let variables: [AnyHashable : Any]? = nil 262 | 263 | let rootKeyPath: String = "data.film" 264 | 265 | public func willSend() throws { } 266 | public func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws { } 267 | public func didFinish(result: Result) throws { } 268 | } 269 | ``` 270 | 271 | ### Sending 272 | 273 | #### Swift 274 | 275 | 1. Call send on AutoGraph 276 | 1. `autoGraph.send(request, completion: { [weak self] result in ... }` 277 | 2. Handle the response 278 | 1. result is a generic `Result` enum with success and failure cases. 279 | 280 | #### Objective-C 281 | 282 | Sending via Objective-C isn't directly possible because of AutoGraph's use of `associatedtype` and generics. It is possible to build a bridge(s) from Swift into Objective-C to send requests. 283 | 284 | ## Development 285 | 286 | ### Building and Testing 287 | 288 | AutoGraph uses Swift Package Manager for dependency management and building. 289 | 290 | ```bash 291 | # Build the project 292 | swift build 293 | 294 | # Run tests 295 | swift test 296 | 297 | # Generate an Xcode project (optional) 298 | swift package generate-xcodeproj 299 | 300 | # Or open Package.swift directly in Xcode 11+ 301 | open Package.swift 302 | ``` 303 | 304 | ### Requirements for Development 305 | 306 | - Swift 5.9+ 307 | - Xcode 15.0+ (for iOS development) 308 | - macOS 10.15+ or iOS 13.0+ 309 | 310 | ## Contributing 311 | 312 | - Open an issue if you run into any problems. 313 | 314 | ### Pull Requests are welcome! 315 | 316 | - Open an issue describing the feature add or problem being solved. An admin will respond ASAP to discuss the addition. 317 | - You may begin working immediately if you so please, by adding an issue it helps inform others of what is already being worked on and facilitates discussion. 318 | - Fork the project and submit a pull request. Please include tests for new code and an explanation of the problem being solved. An admin will review your code and approve it before merging. 319 | - All CI checks must pass before merging 320 | 321 | ## License 322 | 323 | The MIT License (MIT) 324 | 325 | Copyright (c) 2017-Present Remind101 326 | 327 | Permission is hereby granted, free of charge, to any person obtaining a copy 328 | of this software and associated documentation files (the "Software"), to deal 329 | in the Software without restriction, including without limitation the rights 330 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 331 | copies of the Software, and to permit persons to whom the Software is 332 | furnished to do so, subject to the following conditions: 333 | 334 | The above copyright notice and this permission notice shall be included in all 335 | copies or substantial portions of the Software. 336 | 337 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 338 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 339 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 340 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 341 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 342 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 343 | SOFTWARE. 344 | -------------------------------------------------------------------------------- /AutoGraph/WebSocketClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Starscream 3 | import Alamofire 4 | import JSONValueRX 5 | 6 | public typealias WebSocketConnected = (Result) -> Void 7 | 8 | public protocol WebSocketClientDelegate: AnyObject { 9 | func didReceive(event: WebSocketEvent) 10 | func didReceive(error: Error) 11 | func didChangeConnection(state: WebSocketClient.State) 12 | } 13 | 14 | private let kAttemptReconnectCount = 3 15 | 16 | /// The unique key for a subscription request. A combination of OperationName + variables. 17 | public typealias SubscriptionID = String 18 | 19 | public struct Subscriber: Hashable { 20 | let uuid = UUID() // Disambiguates between multiple different subscribers of same subscription. 21 | let subscriptionID: SubscriptionID 22 | let serializableRequest: SubscriptionRequestSerializable // Needed on reconnect. 23 | 24 | init(subscriptionID: String, serializableRequest: SubscriptionRequestSerializable) { 25 | self.subscriptionID = subscriptionID 26 | self.serializableRequest = serializableRequest 27 | } 28 | 29 | public static func == (lhs: Subscriber, rhs: Subscriber) -> Bool { 30 | return lhs.uuid == rhs.uuid 31 | } 32 | 33 | public func hash(into hasher: inout Hasher) { 34 | hasher.combine(self.uuid) 35 | } 36 | } 37 | 38 | open class WebSocketClient { 39 | public enum State: Equatable { 40 | case connecting 41 | case connected(receivedAck: Bool) 42 | case reconnecting 43 | case disconnected 44 | } 45 | 46 | // Reference type because it's used as a mutable dictionary within a dictionary. 47 | final class SubscriptionSet: Sequence { 48 | var set: [Subscriber : SubscriptionResponseHandler] 49 | 50 | init(set: [Subscriber : SubscriptionResponseHandler]) { 51 | self.set = set 52 | } 53 | 54 | func makeIterator() -> Dictionary.Iterator { 55 | return set.makeIterator() 56 | } 57 | } 58 | 59 | public var webSocket: WebSocket 60 | public weak var delegate: WebSocketClientDelegate? 61 | public private(set) var state: State = .disconnected { 62 | didSet { 63 | self.delegate?.didChangeConnection(state: state) 64 | } 65 | } 66 | 67 | public let subscriptionSerializer = SubscriptionResponseSerializer() 68 | internal var queuedSubscriptions = [Subscriber : WebSocketConnected]() 69 | internal var subscriptions = [SubscriptionID: SubscriptionSet]() 70 | internal var attemptReconnectCount = kAttemptReconnectCount 71 | internal var connectionAckWorkItem: DispatchWorkItem? 72 | internal var viabilityTimeoutWorkItem: DispatchWorkItem? 73 | 74 | public init(url: URL) throws { 75 | let request = try WebSocketClient.connectionRequest(url: url) 76 | self.webSocket = WebSocket(request: request) 77 | self.webSocket.delegate = self 78 | } 79 | 80 | deinit { 81 | self.webSocket.forceDisconnect() 82 | self.webSocket.delegate = nil 83 | } 84 | 85 | public func authenticate(token: String?, headers: [String: String]?) { 86 | var headers = headers ?? [:] 87 | if let token = token { 88 | headers["Authorization"] = "Bearer \(token)" 89 | } 90 | 91 | headers.forEach { (key, value) in 92 | self.webSocket.request.setValue(value, forHTTPHeaderField: key) 93 | } 94 | } 95 | 96 | public func setValue(_ value: String?, forHTTPHeaderField field: String) { 97 | self.webSocket.request.setValue(value, forHTTPHeaderField: field) 98 | } 99 | 100 | private var fullDisconnect = false 101 | public func disconnect() { 102 | self.fullDisconnect = true 103 | 104 | self.cancelViabilityTimeout() 105 | self.disconnectAndPossiblyReconnect(force: true) 106 | } 107 | 108 | public func disconnectAndPossiblyReconnect(force: Bool = false) { 109 | guard self.state != .disconnected, !force else { 110 | return 111 | } 112 | 113 | // TODO: Possible return something to the user if this fails? 114 | if let payload = try? GraphQLWSProtocol.connectionTerminate.serializedSubscriptionPayload() { 115 | self.write(payload) 116 | } 117 | 118 | self.webSocket.disconnect() 119 | } 120 | 121 | /// Subscribe to a Subscription, will automatically connect a websocket if it is not connected or disconnected. 122 | public func subscribe(request: SubscriptionRequest, responseHandler: SubscriptionResponseHandler) -> Subscriber { 123 | // If we already have a subscription for that key then just add the subscriber to the set for that key with a callback. 124 | // Otherwise if connected send subscription and add to subscriber set. 125 | // Otherwise queue it and it will be added after connecting. 126 | 127 | let subscriber = Subscriber(subscriptionID: request.subscriptionID, serializableRequest: request) 128 | let connectionCompletionBlock: WebSocketConnected = self.connectionCompletionBlock(subscriber: subscriber, responseHandler: responseHandler) 129 | 130 | switch self.state { 131 | case .connected: 132 | connectionCompletionBlock(.success(())) 133 | case .connecting, .reconnecting: 134 | self.queuedSubscriptions[subscriber] = connectionCompletionBlock 135 | case .disconnected: 136 | self.queuedSubscriptions[subscriber] = connectionCompletionBlock 137 | 138 | self.performConnect() 139 | } 140 | 141 | return subscriber 142 | } 143 | 144 | func connectionCompletionBlock(subscriber: Subscriber, responseHandler: SubscriptionResponseHandler) -> WebSocketConnected { 145 | return { [weak self] (_) in 146 | guard let self = self else { return } 147 | 148 | // If we already have a subscription for that key then just add the subscriber to the set for that key with a callback. 149 | // Otherwise if connected send subscription and add to subscriber set. 150 | 151 | if let subscriptionSet = self.subscriptions[subscriber.subscriptionID] { 152 | subscriptionSet.set[subscriber] = responseHandler 153 | } 154 | else { 155 | self.subscriptions[subscriber.subscriptionID] = SubscriptionSet(set: [subscriber : responseHandler]) 156 | do { 157 | try self.sendSubscription(request: subscriber.serializableRequest) 158 | } 159 | catch let e { 160 | responseHandler.didReceive(error: e) 161 | } 162 | } 163 | } 164 | } 165 | 166 | public func unsubscribeAll(request: SubscriptionRequest) throws { 167 | let id = request.subscriptionID 168 | self.queuedSubscriptions = self.queuedSubscriptions.filter { (key, _) -> Bool in 169 | return key.subscriptionID == id 170 | } 171 | 172 | if let subscriber = self.subscriptions.removeValue(forKey: id)?.set.first?.key { 173 | try self.unsubscribe(subscriber: subscriber) 174 | } 175 | } 176 | 177 | public func unsubscribe(subscriber: Subscriber) throws { 178 | // Write the unsubscribe, and only remove our subscriptions for that subscriber. 179 | self.queuedSubscriptions.removeValue(forKey: subscriber) 180 | self.subscriptions.removeValue(forKey: subscriber.subscriptionID) 181 | 182 | let stopPayload = try GraphQLWSProtocol.stop.serializedSubscriptionPayload(id: subscriber.subscriptionID) 183 | self.write(stopPayload) 184 | } 185 | 186 | func write(_ message: String) { 187 | self.webSocket.write(string: message, completion: nil) 188 | } 189 | 190 | func sendSubscription(request: SubscriptionRequestSerializable) throws { 191 | let subscriptionPayload = try request.serializedSubscriptionPayload() 192 | guard case .connected(receivedAck: true) = self.state else { 193 | throw WebSocketError.webSocketNotConnected(subscriptionPayload: subscriptionPayload) 194 | } 195 | self.write(subscriptionPayload) 196 | self.resendSubscriptionIfNoConnectionAck() 197 | } 198 | 199 | /// Attempts to reconnect and re-subscribe with multiplied backoff up to 30 seconds. Returns the delay. 200 | func reconnect() -> DispatchTimeInterval? { 201 | guard self.state != .reconnecting, !self.fullDisconnect else { return nil } 202 | if self.attemptReconnectCount > 0 { 203 | self.attemptReconnectCount -= 1 204 | } 205 | 206 | self.cancelViabilityTimeout() 207 | self.state = .reconnecting 208 | 209 | let delayInSeconds = DispatchTimeInterval.seconds(min(abs(kAttemptReconnectCount - self.attemptReconnectCount) * 10, 30)) 210 | DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds) { [weak self] in 211 | guard let self = self else { return } 212 | self.performReconnect() 213 | } 214 | return delayInSeconds 215 | } 216 | 217 | func didConnect() throws { 218 | self.state = .connected(receivedAck: false) 219 | self.attemptReconnectCount = kAttemptReconnectCount 220 | 221 | let connectedPayload = try GraphQLWSProtocol.connectionInit.serializedSubscriptionPayload() 222 | self.write(connectedPayload) 223 | } 224 | 225 | /// Takes all subscriptions and puts them back on the queue, used for reconnection. 226 | func requeueAllSubscribers() { 227 | for (_, subscriptionSet) in self.subscriptions { 228 | for (subscriber, subscriptionResponseHandler) in subscriptionSet { 229 | self.queuedSubscriptions[subscriber] = connectionCompletionBlock(subscriber: subscriber, responseHandler: subscriptionResponseHandler) 230 | } 231 | } 232 | self.subscriptions.removeAll(keepingCapacity: true) 233 | } 234 | 235 | private func reset() { 236 | self.disconnect() 237 | self.subscriptions.removeAll() 238 | self.queuedSubscriptions.removeAll() 239 | self.attemptReconnectCount = kAttemptReconnectCount 240 | self.state = .disconnected 241 | } 242 | 243 | func cancelViabilityTimeout() { 244 | self.viabilityTimeoutWorkItem?.cancel() 245 | self.viabilityTimeoutWorkItem = nil 246 | } 247 | 248 | private func resendSubscriptionIfNoConnectionAck() { 249 | self.connectionAckWorkItem?.cancel() 250 | self.connectionAckWorkItem = nil 251 | 252 | guard case let .connected(receivedAck) = self.state, receivedAck == false else { 253 | return 254 | } 255 | 256 | let connectionAckWorkItem = DispatchWorkItem { [weak self] in 257 | self?.performReconnect() 258 | } 259 | 260 | self.connectionAckWorkItem = connectionAckWorkItem 261 | DispatchQueue.main.asyncAfter(deadline: .now() + 60, execute: connectionAckWorkItem) 262 | } 263 | 264 | private func subscribeQueuedSubscriptions() { 265 | let queuedSubscriptions = self.queuedSubscriptions 266 | self.queuedSubscriptions.removeAll() 267 | queuedSubscriptions.forEach { (_, connected: WebSocketConnected) in 268 | connected(.success(())) 269 | } 270 | } 271 | 272 | private func performConnect() { 273 | self.state = .connecting 274 | self.webSocket.connect() 275 | } 276 | 277 | private func performReconnect() { 278 | // Requeue all so they don't get error callbacks on disconnect and they get re-subscribed on connect. 279 | self.requeueAllSubscribers() 280 | self.disconnectAndPossiblyReconnect() 281 | 282 | self.performConnect() 283 | } 284 | } 285 | 286 | // MARK: - Class Method 287 | 288 | extension WebSocketClient { 289 | class func connectionRequest(url: URL) throws -> URLRequest { 290 | var defaultHeders = [String: String]() 291 | defaultHeders["Sec-WebSocket-Protocol"] = "graphql-ws" 292 | defaultHeders["Origin"] = url.absoluteString 293 | 294 | return try URLRequest(url: url, method: .get, headers: HTTPHeaders(defaultHeders)) 295 | } 296 | } 297 | 298 | // MARK: - WebSocketDelegate 299 | 300 | extension WebSocketClient: WebSocketDelegate { 301 | // This is called on the Starscream callback queue, which defaults to Main. 302 | public func didReceive(event: Starscream.WebSocketEvent, client: any Starscream.WebSocketClient) { 303 | self.delegate?.didReceive(event: event) 304 | do { 305 | switch event { 306 | case .disconnected: 307 | self.state = .disconnected 308 | if !self.fullDisconnect { 309 | _ = self.reconnect() 310 | } 311 | self.fullDisconnect = false 312 | case .cancelled: 313 | _ = self.reconnect() 314 | case .binary(let data): 315 | let subscriptionResponse = try self.subscriptionSerializer.serialize(data: data) 316 | self.didReceive(subscriptionResponse: subscriptionResponse) 317 | case let .text(text): 318 | try self.processWebSocketTextEvent(text: text) 319 | case .connected: 320 | try self.didConnect() 321 | case let .reconnectSuggested(shouldReconnect): 322 | if shouldReconnect { 323 | _ = self.reconnect() 324 | } 325 | case let .viabilityChanged(isViable): 326 | if !isViable { 327 | // Connection is experiencing issues but not yet disconnected. 328 | // We'll set a timer to check if viability returns. 329 | self.viabilityTimeoutWorkItem?.cancel() 330 | 331 | let timeoutWorkItem = DispatchWorkItem { [weak self] in 332 | guard let self = self else { return } 333 | // If we're still in this handler, connection viability didn't recover within time. 334 | // Force a reconnect. 335 | _ = self.reconnect() 336 | } 337 | self.viabilityTimeoutWorkItem = timeoutWorkItem 338 | DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeoutWorkItem) 339 | } 340 | else { 341 | self.viabilityTimeoutWorkItem?.cancel() 342 | self.viabilityTimeoutWorkItem = nil 343 | } 344 | case let .error(error): 345 | if let error = error { 346 | self.delegate?.didReceive(error: error) 347 | } 348 | _ = self.reconnect() 349 | case .ping, .pong: // We just ignore these for now. 350 | break 351 | case .peerClosed: 352 | _ = self.reconnect() 353 | } 354 | } 355 | catch let error { 356 | self.delegate?.didReceive(error: error) 357 | } 358 | } 359 | 360 | func processWebSocketTextEvent(text: String) throws { 361 | guard let data = text.data(using: .utf8) else { 362 | throw ResponseSerializerError.failedToConvertTextToData(text) 363 | } 364 | 365 | let json = try JSONValue.decode(data) 366 | guard case let .string(typeValue) = json["type"] else { 367 | return 368 | } 369 | 370 | let graphQLWSProtocol = GraphQLWSProtocol(rawValue: typeValue) 371 | switch graphQLWSProtocol { 372 | case .connectionAck: 373 | self.state = .connected(receivedAck: true) 374 | self.connectionAckWorkItem?.cancel() 375 | self.connectionAckWorkItem = nil 376 | self.subscribeQueuedSubscriptions() 377 | case .connectionKeepAlive: 378 | self.subscribeQueuedSubscriptions() 379 | case .data: 380 | let subscriptionResponse = try self.subscriptionSerializer.serialize(text: text) 381 | self.didReceive(subscriptionResponse: subscriptionResponse) 382 | case .error: 383 | throw ResponseSerializerError.webSocketError(text) 384 | case .complete: 385 | guard case let .string(id) = json["id"] else { 386 | return 387 | } 388 | 389 | self.subscriptions.removeValue(forKey: id) 390 | self.queuedSubscriptions = self.queuedSubscriptions.filter { $0.key.subscriptionID == id } 391 | case .unknownResponse, 392 | .connectionInit, 393 | .connectionTerminate, 394 | .connectionError, 395 | .start, 396 | .stop, 397 | .none: 398 | break 399 | } 400 | } 401 | 402 | func didReceive(subscriptionResponse: SubscriptionResponse) { 403 | let id = subscriptionResponse.id 404 | guard let subscriptionSet = self.subscriptions[id] else { 405 | print("WARNING: Recieved a subscription response for a subscription that AutoGraph is no longer subscribed to. SubscriptionID: \(subscriptionResponse.id)") 406 | return 407 | } 408 | subscriptionSet.forEach { (_, value: SubscriptionResponseHandler) in 409 | value.didReceive(subscriptionResponse: subscriptionResponse) 410 | } 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /AutoGraphTests/WebSocketClientTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Starscream 3 | @testable import AutoGraphQL 4 | import JSONValueRX 5 | 6 | private let kDelay = 0.5 7 | 8 | class WebSocketClientTests: XCTestCase { 9 | var subject: MockWebSocketClient! 10 | var webSocket: MockWebSocket! 11 | var webSocketDelegate: MockWebSocketClientDelegate! 12 | 13 | override func setUp() { 14 | super.setUp() 15 | let url = URL(string: "localhost")! 16 | self.webSocket = MockWebSocket(request: URLRequest(url: url)) 17 | webSocket.jsonResponse = FilmSubscriptionRequest.jsonResponse // TODO: This isn't obvious, refactor. 18 | self.subject = try! MockWebSocketClient(url: url, webSocket: self.webSocket) 19 | self.webSocketDelegate = MockWebSocketClientDelegate() 20 | self.subject.delegate = self.webSocketDelegate 21 | } 22 | 23 | override func tearDown() { 24 | self.subject = nil 25 | self.webSocket = nil 26 | self.webSocketDelegate = nil 27 | super.tearDown() 28 | } 29 | 30 | func testSendSubscriptionResponseHandlerIsCalledOnSuccess() { 31 | var called = false 32 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 33 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { result in 34 | 35 | guard case .success(_) = result else { 36 | XCTFail() 37 | return 38 | } 39 | 40 | called = true 41 | })) 42 | 43 | waitFor(delay: kDelay) 44 | self.webSocket.sendSubscriptionChange() 45 | XCTAssertTrue(called) 46 | } 47 | 48 | 49 | func testSendSubscriptionCorrectlyDecodesResponse() { 50 | var film: Film? 51 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 52 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { result in 53 | 54 | guard case let .success(data) = result else { 55 | XCTFail() 56 | return 57 | } 58 | 59 | let completion: (Result) -> Void = { result in 60 | guard case let .success(serializedObject) = result else { 61 | XCTFail() 62 | return 63 | } 64 | 65 | film = serializedObject 66 | } 67 | 68 | self.subject.subscriptionSerializer.serializeFinalObject(data: data, completion: completion) 69 | })) 70 | 71 | waitFor(delay: kDelay) 72 | self.webSocket.sendSubscriptionChange() 73 | waitFor(delay: kDelay) 74 | XCTAssertNotNil(film) 75 | XCTAssertEqual(film?.remoteId, "ZmlsbXM6MQ==") 76 | } 77 | 78 | func testUnsubscribeRemovesSubscriptions() { 79 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 80 | let subscriber = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 81 | 82 | XCTAssertTrue(self.subject.subscriptions["film"]!.contains(where: {$0.key == subscriber })) 83 | 84 | try! self.subject.unsubscribe(subscriber: subscriber) 85 | 86 | XCTAssertEqual(self.subject.subscriptions.count, 0) 87 | } 88 | 89 | 90 | func testUnsubscribeAllRemovesSubscriptions() { 91 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 92 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 93 | 94 | let request2 = try! SubscriptionRequest(request: FilmSubscriptionRequest(operationName: "film2")) 95 | _ = self.subject.subscribe(request: request2, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 96 | 97 | try! self.subject.unsubscribeAll(request: request) 98 | 99 | XCTAssertEqual(self.subject.subscriptions.count, 1) 100 | 101 | try! self.subject.unsubscribeAll(request: request2) 102 | 103 | XCTAssertEqual(self.subject.subscriptions.count, 0) 104 | } 105 | 106 | func testSubscriptionsGetRequeued() { 107 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 108 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 109 | 110 | let request2 = try! SubscriptionRequest(request: FilmSubscriptionRequest(operationName: "film2")) 111 | _ = self.subject.subscribe(request: request2, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 112 | 113 | XCTAssertTrue(self.subject.queuedSubscriptions.count == 0) 114 | self.subject.requeueAllSubscribers() 115 | 116 | XCTAssertTrue(self.subject.queuedSubscriptions.count == 2) 117 | XCTAssertEqual(self.subject.subscriptions.count, 0) 118 | } 119 | 120 | func testDisconnectEventReconnects() { 121 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 122 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 123 | 124 | self.subject.didReceive(event: WebSocketEvent.disconnected("", 0), client: self.webSocket) 125 | XCTAssertTrue(self.subject.reconnectCalled) 126 | } 127 | 128 | 129 | func testReconnectIsNotCalledIfFullDisconnect() { 130 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 131 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 132 | 133 | self.subject.disconnect() 134 | 135 | XCTAssertFalse(self.subject.reconnectCalled) 136 | } 137 | 138 | func testWebSocketClientDelegateDidReceiveEventGetsCalled() { 139 | self.subject.ignoreConnection = true 140 | let connectionEvent = WebSocketEvent.connected([:]) 141 | self.subject.didReceive(event: connectionEvent, client: self.webSocket) 142 | 143 | XCTAssertEqual(self.webSocketDelegate.event, connectionEvent) 144 | 145 | let disconnectEvent = WebSocketEvent.disconnected("stop", 0) 146 | self.subject.didReceive(event: disconnectEvent, client: self.webSocket) 147 | 148 | XCTAssertEqual(self.webSocketDelegate.event, disconnectEvent) 149 | 150 | let textEvent = WebSocketEvent.text("hello") 151 | self.subject.didReceive(event: textEvent, client: self.webSocket) 152 | 153 | XCTAssertEqual(self.webSocketDelegate.event, textEvent) 154 | } 155 | 156 | func testWebSocketClientDelegateDidRecieveError() { 157 | self.subject.didReceive(event: WebSocketEvent.error(TestError()), client: self.webSocket) 158 | 159 | XCTAssertNotNil(self.webSocketDelegate.error) 160 | } 161 | 162 | func testSubscribeQueuesAndSendsSubscriptionAfterConnectionFinishes() { 163 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 164 | let subscriber = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 165 | 166 | XCTAssertTrue(self.subject.subscriptions["film"]!.contains(where: {$0.key == subscriber })) 167 | 168 | let request2 = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 169 | var subscriptionNotCalled = true 170 | let subscriber2 = self.subject.subscribe(request: request2, responseHandler: SubscriptionResponseHandler(completion: { _ in 171 | subscriptionNotCalled = false 172 | })) 173 | 174 | waitFor(delay: kDelay) 175 | XCTAssertTrue(subscriptionNotCalled) 176 | XCTAssertEqual(self.subject.subscriptions.count, 1) 177 | XCTAssertTrue(self.subject.subscriptions["film"]!.contains(where: {$0.key == subscriber2 })) 178 | } 179 | 180 | func testThreeReconnectAttemptsAndDelayTimeIncreaseEachAttempt() { 181 | self.webSocket.enableReconnectLoop = true 182 | self.subject.didReceive(event: .disconnected("", 0), client: self.webSocket) 183 | let delayTime1 = self.subject.reconnectTime 184 | guard case let .seconds(seconds) = delayTime1 else { 185 | XCTFail() 186 | return 187 | } 188 | 189 | XCTAssertTrue(self.subject.reconnectCalled) 190 | XCTAssertEqual(self.subject.attemptReconnectCount, 2) 191 | XCTAssertEqual(seconds, 10) 192 | 193 | waitFor(delay: Double(seconds + 1)) 194 | 195 | let delayTime2 = self.subject.reconnectTime 196 | guard case let .seconds(seconds2) = delayTime2 else { 197 | XCTFail() 198 | return 199 | } 200 | 201 | XCTAssertTrue(self.subject.reconnectCalled) 202 | XCTAssertEqual(self.subject.attemptReconnectCount, 1) 203 | XCTAssertEqual(seconds2, 20) 204 | 205 | waitFor(delay: Double(seconds2 + 1)) 206 | 207 | let delayTime3 = self.subject.reconnectTime 208 | guard case let .seconds(seconds3) = delayTime3 else { 209 | XCTFail() 210 | return 211 | } 212 | 213 | XCTAssertTrue(self.subject.reconnectCalled) 214 | XCTAssertEqual(self.subject.attemptReconnectCount, 0) 215 | XCTAssertEqual(seconds3, 30) 216 | } 217 | 218 | func testConnectionOccursOnReconnectAttemptTwo() { 219 | self.subject.didReceive(event: WebSocketEvent.error(TestError()), client: self.webSocket) 220 | let delayTime1 = self.subject.reconnectTime 221 | guard case let .seconds(seconds) = delayTime1 else { 222 | XCTFail() 223 | return 224 | } 225 | 226 | XCTAssertTrue(self.subject.reconnectCalled) 227 | XCTAssertEqual(self.subject.attemptReconnectCount, 2) 228 | 229 | waitFor(delay: Double(seconds + 1)) 230 | 231 | XCTAssertTrue(self.webSocket.isConnected) 232 | } 233 | 234 | func testPeerClosedEventReconnects() { 235 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 236 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 237 | 238 | self.subject.reconnectCalled = false 239 | self.subject.didReceive(event: WebSocketEvent.peerClosed, client: self.webSocket) 240 | 241 | XCTAssertTrue(self.subject.reconnectCalled) 242 | } 243 | 244 | func testViabilityChangedTriggersReconnectAfterTimeout() { 245 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 246 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 247 | 248 | self.subject.reconnectCalled = false 249 | self.subject.didReceive(event: WebSocketEvent.viabilityChanged(false), client: self.webSocket) 250 | 251 | XCTAssertFalse(self.subject.reconnectCalled) 252 | 253 | waitFor(delay: 5.5) 254 | XCTAssertTrue(self.subject.reconnectCalled) 255 | } 256 | 257 | func testViabilityChangedDoesNotReconnectIfViabilityReturns() { 258 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 259 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 260 | 261 | self.subject.reconnectCalled = false 262 | self.subject.didReceive(event: WebSocketEvent.viabilityChanged(false), client: self.webSocket) 263 | 264 | waitFor(delay: 1) 265 | self.subject.didReceive(event: WebSocketEvent.viabilityChanged(true), client: self.webSocket) 266 | 267 | waitFor(delay: 5) 268 | XCTAssertFalse(self.subject.reconnectCalled) 269 | } 270 | 271 | func testReconnectCancelsViabilityTimeout() { 272 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 273 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 274 | 275 | self.subject.reconnectCalled = false 276 | self.subject.cancelViabilityTimeoutCalled = false 277 | 278 | self.subject.didReceive(event: WebSocketEvent.viabilityChanged(false), client: self.webSocket) 279 | XCTAssertNotNil(self.subject.viabilityTimeoutWorkItem) 280 | 281 | _ = self.subject.reconnect() 282 | 283 | XCTAssertTrue(self.subject.cancelViabilityTimeoutCalled) 284 | XCTAssertNil(self.subject.viabilityTimeoutWorkItem) 285 | XCTAssertTrue(self.subject.reconnectCalled) 286 | } 287 | 288 | func testDisconnectCancelsViabilityTimeout() { 289 | let request = try! SubscriptionRequest(request: FilmSubscriptionRequest()) 290 | _ = self.subject.subscribe(request: request, responseHandler: SubscriptionResponseHandler(completion: { _ in })) 291 | 292 | self.subject.cancelViabilityTimeoutCalled = false 293 | 294 | self.subject.didReceive(event: WebSocketEvent.viabilityChanged(false), client: self.webSocket) 295 | XCTAssertNotNil(self.subject.viabilityTimeoutWorkItem) 296 | 297 | self.subject.disconnect() 298 | 299 | XCTAssertTrue(self.subject.cancelViabilityTimeoutCalled) 300 | XCTAssertNil(self.subject.viabilityTimeoutWorkItem) 301 | } 302 | 303 | func testGenerateSubscriptionID() throws { 304 | var id = try SubscriptionRequest.generateSubscriptionID(request: FilmSubscriptionRequest(), operationName: "film") 305 | XCTAssertEqual(id, "film") 306 | id = try SubscriptionRequest.generateSubscriptionID(request: FilmSubscriptionRequestWithVariables(), operationName: "film") 307 | XCTAssertEqual(id, "film:{id:ZmlsbXM6MQ==,}") 308 | } 309 | 310 | func waitFor(delay: TimeInterval) { 311 | let expectation = self.expectation(description: "wait") 312 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 313 | expectation.fulfill() 314 | } 315 | 316 | self.waitForExpectations(timeout: delay + 1.0, handler: { error in 317 | if let error = error { 318 | print(error) 319 | } 320 | }) 321 | } 322 | } 323 | 324 | class MockWebSocketClientDelegate: WebSocketClientDelegate { 325 | var error: Error? 326 | var event: WebSocketEvent? 327 | var state: AutoGraphQL.WebSocketClient.State? 328 | 329 | func didReceive(error: Error) { 330 | self.error = error 331 | } 332 | 333 | func didReceive(event: WebSocketEvent) { 334 | self.event = event 335 | } 336 | 337 | func didChangeConnection(state: AutoGraphQL.WebSocketClient.State) { 338 | self.state = state 339 | } 340 | } 341 | 342 | class MockWebSocketClient: AutoGraphQL.WebSocketClient { 343 | var subscriptionPayload: String? 344 | var reconnectCalled = false 345 | var reconnectTime: DispatchTimeInterval? 346 | var ignoreConnection = false 347 | var cancelViabilityTimeoutCalled = false 348 | 349 | init(url: URL, webSocket: MockWebSocket) throws { 350 | try super.init(url: url) 351 | let request = try AutoGraphQL.WebSocketClient.connectionRequest(url: url) 352 | self.webSocket = webSocket 353 | self.webSocket.request = request 354 | self.webSocket.delegate = self 355 | } 356 | 357 | override func sendSubscription(request: SubscriptionRequestSerializable) throws { 358 | self.subscriptionPayload = try! request.serializedSubscriptionPayload() 359 | 360 | guard case let .connected(receivedAck) = self.state, receivedAck == true else { 361 | throw WebSocketError.webSocketNotConnected(subscriptionPayload: self.subscriptionPayload!) 362 | } 363 | 364 | self.write(self.subscriptionPayload!) 365 | } 366 | 367 | override func reconnect() -> DispatchTimeInterval? { 368 | self.reconnectCalled = true 369 | self.reconnectTime = super.reconnect() 370 | 371 | return reconnectTime 372 | } 373 | 374 | override func cancelViabilityTimeout() { 375 | self.cancelViabilityTimeoutCalled = true 376 | super.cancelViabilityTimeout() 377 | } 378 | 379 | override func didConnect() throws { 380 | if !self.ignoreConnection { 381 | self.reconnectCalled = false 382 | try super.didConnect() 383 | } 384 | } 385 | } 386 | 387 | struct TestError: Error {} 388 | 389 | class MockWebSocket: Starscream.WebSocket { 390 | var subscriptionRequest: String? 391 | var isConnected = false 392 | var enableReconnectLoop = false 393 | var jsonResponse: [String : Any]! 394 | 395 | override func connect() { 396 | if enableReconnectLoop { 397 | self.isConnected = false 398 | } 399 | else { 400 | self.isConnected = true 401 | self.didReceive(event: WebSocketEvent.connected([:])) 402 | self.write(string: "{\"type\": \"connection_ack\"}", completion: nil) 403 | } 404 | } 405 | 406 | override func disconnect(closeCode: UInt16 = CloseCode.normal.rawValue) { 407 | self.didReceive(event: WebSocketEvent.disconnected("disconnect", 0)) 408 | self.isConnected = false 409 | } 410 | 411 | override func write(string: String, completion: (() -> ())?) { 412 | self.subscriptionRequest = string 413 | self.didReceive(event: WebSocketEvent.text(string)) 414 | } 415 | 416 | override func didReceive(event: WebSocketEvent) { 417 | self.delegate?.didReceive(event: event, client: self) 418 | } 419 | 420 | func sendSubscriptionChange() { 421 | self.write(string: self.createResponseString(), completion: nil) 422 | } 423 | 424 | func createResponseString() -> String { 425 | return try! String(data: JSONValue(dict: self.jsonResponse).encode(), encoding: .utf8)! 426 | } 427 | } 428 | 429 | extension WebSocketEvent: Equatable { 430 | public static func ==(lhs: WebSocketEvent, rhs: WebSocketEvent) -> Bool { 431 | switch (lhs, rhs) { 432 | case let (.connected(lhsHeader), .connected(rhsHeader)): 433 | return lhsHeader == rhsHeader 434 | case let (.disconnected(lhsReason, lhsCode), .disconnected(rhsReason, rhsCode)): 435 | return lhsReason == rhsReason && lhsCode == rhsCode 436 | case let (.text(lhsText), .text(rhsText)): 437 | return lhsText == rhsText 438 | default: 439 | return false 440 | } 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /AutoGraphTests/AutoGraphTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Alamofire 3 | import JSONValueRX 4 | @testable import AutoGraphQL 5 | 6 | public extension AutoGraphQL.Request { 7 | func willSend() throws { } 8 | func didFinishRequest(response: HTTPURLResponse?, json: JSONValue) throws { } 9 | func didFinish(result: AutoGraphResult) throws { } 10 | } 11 | 12 | class FilmRequestWithLifeCycle: FilmRequest { 13 | var willSendCalled = false 14 | override func willSend() throws { 15 | willSendCalled = true 16 | } 17 | 18 | var didFinishCalled = false 19 | override func didFinish(result: AutoGraphResult) throws { 20 | didFinishCalled = true 21 | } 22 | } 23 | 24 | private let kDelay = 0.5 25 | 26 | class AutoGraphTests: XCTestCase { 27 | 28 | class MockDispatcher: Dispatcher { 29 | var cancelCalled = false 30 | override func cancelAll() { 31 | cancelCalled = true 32 | } 33 | } 34 | 35 | class MockClient: Client { 36 | public var sessionConfiguration: URLSessionConfiguration = URLSessionConfiguration.default 37 | public var authTokens: AuthTokens = ("", "") 38 | public var authHandler: AuthHandler? = AuthHandler(accessToken: nil, refreshToken: nil) 39 | public var url: URL = URL(string: "localhost")! 40 | 41 | var cancelCalled = false 42 | func cancelAll() { 43 | cancelCalled = true 44 | } 45 | 46 | func sendRequest(parameters: [String : Any], completion: @escaping (AFDataResponse) -> ()) { } 47 | } 48 | 49 | var subject: AutoGraph! 50 | 51 | override func setUp() { 52 | super.setUp() 53 | 54 | let client = try! AlamofireClient(url: AutoGraph.localHost, 55 | session: Session(configuration: MockURLProtocol.sessionConfiguration(), interceptor: AuthHandler())) 56 | self.subject = AutoGraph(client: client) 57 | } 58 | 59 | override func tearDown() { 60 | self.subject = nil 61 | Stub.clearAll() 62 | 63 | super.tearDown() 64 | } 65 | 66 | func testFunctionalSingleFilmRequest() { 67 | let stub = FilmStub() 68 | stub.registerStub() 69 | 70 | var called = false 71 | self.subject.send(FilmRequest()) { result in 72 | called = true 73 | 74 | guard case .success(_) = result else { 75 | XCTFail() 76 | return 77 | } 78 | } 79 | 80 | waitFor(delay: kDelay) 81 | XCTAssertTrue(called) 82 | } 83 | 84 | func testFunctional401Request() { 85 | class Film401Stub: FilmStub { 86 | override var jsonFixtureFile: String? { 87 | get { return "Film401" } 88 | set { } 89 | } 90 | } 91 | 92 | self.subject.networkErrorParser = { gqlError in 93 | guard gqlError.message == "401 - {\"error\":\"Unauthenticated\",\"error_code\":\"unauthenticated\"}" else { 94 | return nil 95 | } 96 | return MockNetworkError(statusCode: 401, underlyingError: gqlError) 97 | } 98 | 99 | let stub = Film401Stub() 100 | stub.registerStub() 101 | 102 | let request = FilmRequest() 103 | 104 | var called = false 105 | self.subject.send(request) { result in 106 | called = true 107 | 108 | XCTFail() 109 | } 110 | 111 | waitFor(delay: kDelay) 112 | XCTAssertFalse(called) 113 | 114 | XCTAssertTrue(self.subject.dispatcher.paused) 115 | XCTAssertTrue(self.subject.authHandler!.isRefreshing) 116 | XCTAssertTrue(( try! self.subject.dispatcher.pendingRequests.first!.queryDocument.graphQLString()) == (try! request.queryDocument.graphQLString())) 117 | } 118 | 119 | func testFunctional401RequestNotHandled() { 120 | class Film401Stub: FilmStub { 121 | override var jsonFixtureFile: String? { 122 | get { return "Film401" } 123 | set { } 124 | } 125 | } 126 | 127 | self.subject.networkErrorParser = { gqlError in 128 | guard gqlError.message == "401 - {\"error\":\"Unauthenticated\",\"error_code\":\"unauthenticated\"}" else { 129 | return nil 130 | } 131 | // Not passing back a 401 status code so won't be registered as an auth error. 132 | return MockNetworkError(statusCode: -1, underlyingError: gqlError) 133 | } 134 | 135 | let stub = Film401Stub() 136 | stub.registerStub() 137 | 138 | let request = FilmRequest() 139 | 140 | var called = false 141 | self.subject.send(request) { result in 142 | called = true 143 | 144 | guard 145 | case .failure(let failureError) = result, 146 | case let error as AutoGraphError = failureError, 147 | case .network(let baseError, let statusCode, _, let underlying) = error, 148 | case .some(.graphQL(errors: let underlyingErrors, _)) = underlying, 149 | case let networkError as NetworkError = baseError, 150 | networkError.statusCode == -1, 151 | networkError.underlyingError == underlyingErrors.first, 152 | networkError.statusCode == statusCode 153 | else { 154 | XCTFail() 155 | return 156 | } 157 | } 158 | 159 | waitFor(delay: kDelay) 160 | XCTAssertTrue(called) 161 | 162 | XCTAssertFalse(self.subject.dispatcher.paused) 163 | XCTAssertFalse(self.subject.authHandler!.isRefreshing) 164 | XCTAssertEqual(self.subject.dispatcher.pendingRequests.count, 0) 165 | } 166 | 167 | func testFunctionalLifeCycle() { 168 | let stub = FilmStub() 169 | stub.registerStub() 170 | 171 | let request = FilmRequestWithLifeCycle() 172 | self.subject.send(request, completion: { _ in }) 173 | 174 | waitFor(delay: kDelay) 175 | XCTAssertTrue(request.willSendCalled) 176 | XCTAssertTrue(request.didFinishCalled) 177 | } 178 | 179 | func testFunctionalGlobalLifeCycle() { 180 | class GlobalLifeCycleMock: GlobalLifeCycle { 181 | var willSendCalled = false 182 | override func willSend(request: R) throws { 183 | self.willSendCalled = request is FilmRequest 184 | } 185 | 186 | var didFinishCalled = false 187 | override func didFinish(result: AutoGraphResult) throws { 188 | guard case .success(let value) = result else { 189 | return 190 | } 191 | self.didFinishCalled = value is Film 192 | } 193 | } 194 | 195 | let lifeCycle = GlobalLifeCycleMock() 196 | self.subject.lifeCycle = lifeCycle 197 | 198 | let stub = FilmStub() 199 | stub.registerStub() 200 | 201 | let request = FilmRequest() 202 | self.subject.send(request, completion: { _ in }) 203 | 204 | waitFor(delay: kDelay) 205 | XCTAssertTrue(lifeCycle.willSendCalled) 206 | XCTAssertTrue(lifeCycle.didFinishCalled) 207 | } 208 | 209 | func testSubscription() throws { 210 | let url = URL(string: "localhost")! 211 | let webSocket = MockWebSocket(request: URLRequest(url: url)) 212 | webSocket.jsonResponse = FilmSubscriptionRequest.jsonResponse 213 | let webSocketClient = try! MockWebSocketClient(url: url, webSocket: webSocket) 214 | let webSocketDelegate = MockWebSocketClientDelegate() 215 | webSocketClient.delegate = webSocketDelegate 216 | 217 | let client = try! AlamofireClient(url: AutoGraph.localHost, 218 | session: Session(configuration: MockURLProtocol.sessionConfiguration(), 219 | interceptor: AuthHandler())) 220 | self.subject = AutoGraph(client: client, webSocketClient: webSocketClient) 221 | 222 | var called = false 223 | let request = FilmSubscriptionRequest() 224 | let subscriber = self.subject.subscribe(request) { (result) in 225 | guard case .success(_) = result else { 226 | XCTFail() 227 | return 228 | } 229 | 230 | called = true 231 | } 232 | 233 | XCTAssertNotNil(subscriber) 234 | 235 | waitFor(delay: kDelay) 236 | webSocket.sendSubscriptionChange() 237 | waitFor(delay: kDelay) 238 | 239 | XCTAssertTrue(called) 240 | XCTAssertEqual(webSocketClient.subscriptions.count, 1) 241 | 242 | try self.subject.unsubscribe(subscriber: subscriber!) 243 | 244 | XCTAssertEqual(webSocketClient.subscriptions.count, 0) 245 | } 246 | 247 | func testArraySubscription() throws { 248 | let url = URL(string: "localhost")! 249 | let webSocket = MockWebSocket(request: URLRequest(url: url)) 250 | webSocket.jsonResponse = AllFilmsSubscriptionRequest.jsonResponse 251 | let webSocketClient = try! MockWebSocketClient(url: url, webSocket: webSocket) 252 | let webSocketDelegate = MockWebSocketClientDelegate() 253 | webSocketClient.delegate = webSocketDelegate 254 | 255 | let client = try! AlamofireClient(url: AutoGraph.localHost, 256 | session: Session(configuration: MockURLProtocol.sessionConfiguration(), 257 | interceptor: AuthHandler())) 258 | self.subject = AutoGraph(client: client, webSocketClient: webSocketClient) 259 | 260 | var called = false 261 | let request = AllFilmsSubscriptionRequest() 262 | let subscriber = self.subject.subscribe(request) { (result) in 263 | guard case .success(_) = result else { 264 | XCTFail() 265 | return 266 | } 267 | 268 | called = true 269 | } 270 | 271 | XCTAssertNotNil(subscriber) 272 | 273 | waitFor(delay: kDelay) 274 | webSocket.sendSubscriptionChange() 275 | waitFor(delay: kDelay) 276 | 277 | XCTAssertTrue(called) 278 | XCTAssertEqual(webSocketClient.subscriptions.count, 1) 279 | 280 | try self.subject.unsubscribe(subscriber: subscriber!) 281 | 282 | XCTAssertEqual(webSocketClient.subscriptions.count, 0) 283 | } 284 | 285 | func testArrayObjectSerialization() { 286 | 287 | class GlobalLifeCycleMock: GlobalLifeCycle { 288 | var gotArray = false 289 | override func didFinish(result: AutoGraphResult) throws { 290 | guard case .success(let value) = result else { 291 | return 292 | } 293 | self.gotArray = value is [Film] 294 | } 295 | } 296 | 297 | let lifeCycle = GlobalLifeCycleMock() 298 | self.subject.lifeCycle = lifeCycle 299 | 300 | let stub = AllFilmsStub() 301 | stub.registerStub() 302 | 303 | let request = AllFilmsRequest() 304 | self.subject.send(request, completion: { _ in }) 305 | 306 | waitFor(delay: kDelay) 307 | XCTAssertTrue(lifeCycle.gotArray) 308 | } 309 | 310 | func testRequestIncludingNetworking() { 311 | let stub = AllFilmsStub() 312 | stub.registerStub() 313 | let request = AllFilmsRequest() 314 | 315 | var called = false 316 | self.subject.send(includingNetworkResponse: request) { result in 317 | called = true 318 | guard case .success(let data) = result else { 319 | XCTFail() 320 | return 321 | } 322 | 323 | let json = try! JSONValue(object: stub.json) 324 | XCTAssertEqual(data.json, json[request.rootKeyPath]) 325 | XCTAssertEqual(data.value, [Film(remoteId: "ZmlsbXM6MQ==", title: "A New Hope", episode: 4, openingCrawl: "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire\'s\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire\'s\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy....", director: "George Lucas"), Film(remoteId: "ZmlsbXM6Mg==", title: "The Empire Strikes Back", episode: 5, openingCrawl: "It is a dark time for the\r\nRebellion. Although the Death\r\nStar has been destroyed,\r\nImperial troops have driven the\r\nRebel forces from their hidden\r\nbase and pursued them across\r\nthe galaxy.\r\n\r\nEvading the dreaded Imperial\r\nStarfleet, a group of freedom\r\nfighters led by Luke Skywalker\r\nhas established a new secret\r\nbase on the remote ice world\r\nof Hoth.\r\n\r\nThe evil lord Darth Vader,\r\nobsessed with finding young\r\nSkywalker, has dispatched\r\nthousands of remote probes into\r\nthe far reaches of space....", director: "Irvin Kershner"), Film(remoteId: "ZmlsbXM6Mw==", title: "Return of the Jedi", episode: 6, openingCrawl: "Luke Skywalker has returned to\r\nhis home planet of Tatooine in\r\nan attempt to rescue his\r\nfriend Han Solo from the\r\nclutches of the vile gangster\r\nJabba the Hutt.\r\n\r\nLittle does Luke know that the\r\nGALACTIC EMPIRE has secretly\r\nbegun construction on a new\r\narmored space station even\r\nmore powerful than the first\r\ndreaded Death Star.\r\n\r\nWhen completed, this ultimate\r\nweapon will spell certain doom\r\nfor the small band of rebels\r\nstruggling to restore freedom\r\nto the galaxy...", director: "Richard Marquand"), Film(remoteId: "ZmlsbXM6NA==", title: "The Phantom Menace", episode: 1, openingCrawl: "Turmoil has engulfed the\r\nGalactic Republic. The taxation\r\nof trade routes to outlying star\r\nsystems is in dispute.\r\n\r\nHoping to resolve the matter\r\nwith a blockade of deadly\r\nbattleships, the greedy Trade\r\nFederation has stopped all\r\nshipping to the small planet\r\nof Naboo.\r\n\r\nWhile the Congress of the\r\nRepublic endlessly debates\r\nthis alarming chain of events,\r\nthe Supreme Chancellor has\r\nsecretly dispatched two Jedi\r\nKnights, the guardians of\r\npeace and justice in the\r\ngalaxy, to settle the conflict....", director: "George Lucas"), Film(remoteId: "ZmlsbXM6NQ==", title: "Attack of the Clones", episode: 2, openingCrawl: "There is unrest in the Galactic\r\nSenate. Several thousand solar\r\nsystems have declared their\r\nintentions to leave the Republic.\r\n\r\nThis separatist movement,\r\nunder the leadership of the\r\nmysterious Count Dooku, has\r\nmade it difficult for the limited\r\nnumber of Jedi Knights to maintain \r\npeace and order in the galaxy.\r\n\r\nSenator Amidala, the former\r\nQueen of Naboo, is returning\r\nto the Galactic Senate to vote\r\non the critical issue of creating\r\nan ARMY OF THE REPUBLIC\r\nto assist the overwhelmed\r\nJedi....", director: "George Lucas"), Film(remoteId: "ZmlsbXM6Ng==", title: "Revenge of the Sith", episode: 3, openingCrawl: "War! The Republic is crumbling\r\nunder attacks by the ruthless\r\nSith Lord, Count Dooku.\r\nThere are heroes on both sides.\r\nEvil is everywhere.\r\n\r\nIn a stunning move, the\r\nfiendish droid leader, General\r\nGrievous, has swept into the\r\nRepublic capital and kidnapped\r\nChancellor Palpatine, leader of\r\nthe Galactic Senate.\r\n\r\nAs the Separatist Droid Army\r\nattempts to flee the besieged\r\ncapital with their valuable\r\nhostage, two Jedi Knights lead a\r\ndesperate mission to rescue the\r\ncaptive Chancellor....", director: "George Lucas")]) 326 | XCTAssertEqual(data.httpResponse?.urlString, "http://localhost:8080/graphql") 327 | XCTAssertEqual(data.httpResponse?.statusCode, 200) 328 | XCTAssertEqual(data.httpResponse?.headerFields["Content-Type"], "application/json; charset=utf-8") 329 | } 330 | 331 | waitFor(delay: kDelay) 332 | XCTAssertTrue(called) 333 | } 334 | 335 | func testCancelAllCancelsDispatcherAndClient() { 336 | class MockWebSocketClient: WebSocketClient { 337 | var disconnected = false 338 | 339 | override func disconnect() { 340 | self.disconnected = true 341 | } 342 | } 343 | 344 | let mockClient = MockClient() 345 | let mockDispatcher = MockDispatcher(requestSender: mockClient, responseHandler: ResponseHandler()) 346 | let mockWebClient = try? MockWebSocketClient(url: URL(string: "localhost")!) 347 | self.subject = AutoGraph(client: mockClient, webSocketClient: mockWebClient, dispatcher: mockDispatcher) 348 | 349 | self.subject.cancelAll() 350 | 351 | XCTAssertTrue(mockClient.cancelCalled) 352 | XCTAssertTrue(mockDispatcher.cancelCalled) 353 | } 354 | 355 | func testAuthHandlerBeganReauthenticationPausesDispatcher() { 356 | XCTAssertFalse(self.subject.dispatcher.paused) 357 | self.subject.authHandlerBeganReauthentication(AuthHandler(accessToken: nil, refreshToken: nil)) 358 | XCTAssertTrue(self.subject.dispatcher.paused) 359 | } 360 | 361 | func testAuthHandlerReauthenticatedSuccessfullyUnpausesDispatcher() { 362 | self.subject.authHandlerBeganReauthentication(AuthHandler(accessToken: nil, refreshToken: nil)) 363 | XCTAssertTrue(self.subject.dispatcher.paused) 364 | self.subject.authHandler(AuthHandler(accessToken: nil, refreshToken: nil), reauthenticatedSuccessfully: true) 365 | XCTAssertFalse(self.subject.dispatcher.paused) 366 | } 367 | 368 | func testAuthHandlerReauthenticatedUnsuccessfullyCancelsAll() { 369 | class MockWebSocketClient: WebSocketClient { 370 | var disconnected = false 371 | 372 | override func disconnect() { 373 | self.disconnected = true 374 | } 375 | } 376 | 377 | let mockClient = MockClient() 378 | let mockDispatcher = MockDispatcher(requestSender: mockClient, responseHandler: ResponseHandler()) 379 | let mockWebClient = try? MockWebSocketClient(url: URL(string: "localhost")!) 380 | self.subject = AutoGraph(client: mockClient, webSocketClient: mockWebClient, dispatcher: mockDispatcher) 381 | 382 | self.subject.authHandlerBeganReauthentication(AuthHandler(accessToken: nil, refreshToken: nil)) 383 | XCTAssertTrue(self.subject.dispatcher.paused) 384 | self.subject.authHandler(AuthHandler(accessToken: nil, refreshToken: nil), reauthenticatedSuccessfully: false) 385 | XCTAssertTrue(self.subject.dispatcher.paused) 386 | 387 | XCTAssertTrue(mockClient.cancelCalled) 388 | XCTAssertTrue(mockDispatcher.cancelCalled) 389 | } 390 | 391 | func testTriggeringReauthenticationPausesSystem() { 392 | self.subject.triggerReauthentication() 393 | self.waitFor(delay: 0.01) 394 | XCTAssertTrue(self.subject.dispatcher.paused) 395 | XCTAssertTrue(self.subject.authHandler!.isRefreshing) 396 | } 397 | 398 | func waitFor(delay: TimeInterval) { 399 | let expectation = self.expectation(description: "wait") 400 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 401 | expectation.fulfill() 402 | } 403 | 404 | self.waitForExpectations(timeout: delay + 1.0, handler: { error in 405 | if let error = error { 406 | print(error) 407 | } 408 | }) 409 | } 410 | } 411 | --------------------------------------------------------------------------------