├── .gitignore ├── .swift-version ├── .travis.yml ├── Config.xcconfig ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Wormhole │ ├── AppStoreConnectError.swift │ ├── Client.swift │ ├── EntityType.swift │ ├── Info.plist │ ├── JWTEncoder.swift │ └── Request.swift └── jwt │ ├── jwt.h │ └── module.modulemap └── Tests ├── LinuxMain.swift └── WormholeTests ├── AppStoreConnectErrorTests.swift ├── ClientTests.swift ├── EntityTests.swift ├── Fixtures.swift ├── Fixtures ├── errors.json ├── post_user_invitations.json ├── private.p8 ├── user.json └── users.json ├── Info.plist ├── JWTEncoderTests.swift └── User.swift /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/4bff4a2986af526650f1d329d97047dc1fa87599/Swift.gitignore 2 | 3 | # Xcode 4 | # 5 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 6 | 7 | ## Build generated 8 | build/ 9 | DerivedData/ 10 | 11 | ## Various settings 12 | *.pbxuser 13 | !default.pbxuser 14 | *.mode1v3 15 | !default.mode1v3 16 | *.mode2v3 17 | !default.mode2v3 18 | *.perspectivev3 19 | !default.perspectivev3 20 | xcuserdata/ 21 | 22 | ## Other 23 | *.moved-aside 24 | *.xccheckout 25 | *.xcscmblueprint 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 | # Package.pins 42 | # Package.resolved 43 | .build/ 44 | 45 | # CocoaPods 46 | # 47 | # We recommend against adding the Pods directory to your .gitignore. However 48 | # you should judge for yourself, the pros and cons are mentioned at: 49 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 50 | # 51 | # Pods/ 52 | 53 | # Carthage 54 | # 55 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 56 | # Carthage/Checkouts 57 | 58 | Carthage/Build 59 | 60 | # fastlane 61 | # 62 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 63 | # screenshots whenever they are needed. 64 | # For more information about the recommended setup visit: 65 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 66 | 67 | fastlane/report.xml 68 | fastlane/Preview.html 69 | fastlane/screenshots 70 | fastlane/test_output 71 | 72 | *.xcodeproj 73 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 4.2 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | include: 3 | - env: OS=macOS 4 | before_install: 5 | - export PREFIX=`brew --prefix` 6 | os: osx 7 | osx_image: xcode10 8 | language: objective-c 9 | install: 10 | - brew tap giginet/libjwt https://github.com/giginet/libjwt.git 11 | - brew install giginet/libjwt/libjwt 12 | script: 13 | - swift test -Xcc -I${PREFIX}/include -Xlinker -L${PREFIX}/lib 14 | -------------------------------------------------------------------------------- /Config.xcconfig: -------------------------------------------------------------------------------- 1 | HEADER_SEARCH_PATHS = /usr/local/opt/openssl/include /usr/local/include 2 | LIBRARY_SEARCH_PATHS = /usr/local/opt/openssl/lib /usr/local/lib 3 | OTHER_LDFLAGS = -lssl -lcrypto -ljansson -ljwt 4 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Result", 6 | "repositoryURL": "https://github.com/antitypical/Result.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "8fc088dcf72802801efeecba76ea8fb041fb773d", 10 | "version": "4.0.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.2 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: "Wormhole", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library( 11 | name: "Wormhole", 12 | targets: ["Wormhole"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/antitypical/Result.git", from: "4.0.0"), 16 | ], 17 | targets: [ 18 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 19 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 20 | .systemLibrary(name: "JWT", 21 | path: "./Sources/jwt", 22 | providers: [.brew(["libjwt"]), .apt(["libjwt"])]), 23 | .target( 24 | name: "Wormhole", 25 | dependencies: ["Result", "JWT"]), 26 | .testTarget( 27 | name: "WormholeTests", 28 | dependencies: ["Wormhole"]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wormhole 2 | 3 | [![Build Status](https://travis-ci.org/giginet/Wormhole.svg?branch=master)](https://travis-ci.org/giginet/Wormhole) 4 | 5 | Type safety App Store Connect API client in Swift :rocket: 6 | 7 | :bug: **Wormhole** invites you to the fastest trip to AppStore Connect. 8 | 9 | This library is currently developing. 10 | 11 | :warning: App Store Connect API is currently unavailable. 12 | 13 | ## Features 14 | 15 | - :white_check_mark: Pure Swifty 16 | - :white_check_mark: Type Safed 17 | - :white_check_mark: Swift Package Manager Support 18 | - :construction: Linux Support 19 | - :skull_and_crossbones: Available this summer 20 | 21 | ## Requirements 22 | 23 | - Xcode 10 24 | - libjwt(unstable version) 25 | 26 | ### libjwt 27 | 28 | You need to install unstable version of [libjwt](https://github.com/benmcollins/libjwt). 29 | 30 | ```console 31 | $ brew tap giginet/libjwt https://github.com/giginet/libjwt.git 32 | $ brew install giginet/libjwt/libjwt 33 | ``` 34 | 35 | ## Setup 36 | 37 | ### 1. Generate new project with SwiftPM 38 | 39 | ```console 40 | $ mkdir MyExecutable 41 | $ cd MyExecutable 42 | $ swift package init --type executable 43 | $ swift package tools-version --set 4.2.0 44 | ``` 45 | 46 | ### 2. Add the dependency to your `Package.swift`. 47 | 48 | ```swift 49 | // swift-tools-version:4.2 50 | // The swift-tools-version declares the minimum version of Swift required to build this package. 51 | 52 | import PackageDescription 53 | 54 | let package = Package( 55 | name: "MyExecutable", 56 | dependencies: [ 57 | .package(url: "https://github.com/giginet/Wormhole.git", from: "0.1.0"), 58 | ], 59 | targets: [ 60 | .target( 61 | name: "MyExecutable", 62 | dependencies: ["Wormhole"]), 63 | ] 64 | ) 65 | ``` 66 | 67 | ### 3. Run with SwiftPM 68 | 69 | ```console 70 | $ swift run -Xcc -I/usr/local/include -Xlinker -L/usr/local/lib 71 | ``` 72 | 73 | ## Usage 74 | 75 | ### Initialize API Client 76 | 77 | You can find your issuerID, keyID or private key on App Store Connect. 78 | 79 | ```swift 80 | import Foundation 81 | import Wormhole 82 | import Result 83 | 84 | // Download your private key from App Store Connect 85 | let client = try! Client(p8Path: URL(fileURLWithPath: "/path/to/private_key.p8"), 86 | issuerID: UUID(uuidString: "b91d85c7-b7db-4451-8f3f-9a3c8af9a392")!, 87 | keyID: "100000") 88 | ``` 89 | 90 | ### Define model 91 | 92 | ```swift 93 | enum Role: String, Codable { 94 | case developer = "DEVELOPER" 95 | case marketing = "MARKETING" 96 | } 97 | 98 | struct User: AttributeType { 99 | let firstName: String 100 | let lastName: String 101 | let email: String 102 | let roles: [Role] 103 | } 104 | ``` 105 | 106 | ### Send Get Request 107 | 108 | ```swift 109 | /// Define request model 110 | struct GetUsersRequest: RequestType { 111 | // You can use `SingleContainer` or `CollectionContainer` 112 | typealias Response = CollectionContainer 113 | 114 | // HTTP method(get, post, patch or delete) 115 | let method: HTTPMethod = .get 116 | 117 | // API endpoint 118 | let path = "/users" 119 | 120 | // Request payload. You can use `.void` to send request without any payloads. 121 | let payload: RequestPayload = .void 122 | } 123 | 124 | let request = GetUsersRequest() 125 | client.send(request) { (result: Result, ClientError>) in 126 | switch result { 127 | case .success(let container): 128 | let firstUser: User = container.data.first! 129 | print("Name: \(firstUser.firstName) \(firstUser.lastName)") 130 | print("Email: \(firstUser.email)") 131 | print("Roles: \(firstUser.roles)") 132 | case .failure(let error): 133 | print("Something went wrong") 134 | print(String(describing: error)) 135 | } 136 | } 137 | ``` 138 | 139 | ### Send Patch Request 140 | 141 | ```swift 142 | // UUID of a target user 143 | let uuid = UUID(uuidString: "588ec36e-ba74-11e8-8879-93c782f9ccb3") 144 | 145 | // Define Request model 146 | struct RoleModificationRequest: RequestType { 147 | // Response should indicate a single user. 148 | typealias Response = SingleContainer 149 | let method: HTTPMethod = .patch 150 | var path: String { 151 | return "/users/\(id.uuidString.lowercased())" 152 | } 153 | let id: UUID 154 | let roles: [Role] 155 | 156 | // Payload 157 | var payload: RequestPayload { 158 | return .init(id: id, 159 | type: "users", 160 | attributes: roles) 161 | } 162 | } 163 | 164 | let request = RoleModificationRequest(id: uuid, roles: [.developer, .marketing]) 165 | client.send(request) { result in 166 | switch result { 167 | case .success(let container): 168 | let modifiedUser: User = container.data 169 | print("Name: \(modifiedUser.firstName) \(modifiedUser.lastName)") 170 | print("Email: \(modifiedUser.email)") 171 | print("Roles: \(modifiedUser.roles)") 172 | case .failure(let error): 173 | print("Something went wrong") 174 | print(String(describing: error)) 175 | } 176 | } 177 | ``` 178 | 179 | ## Development 180 | 181 | ### Generate Xcode project 182 | 183 | ```console 184 | $ swift package generate-xcodeproj --xcconfig-overrides Config.xcconfig 185 | $ open ./Wormhole.xcodeproj 186 | ``` 187 | -------------------------------------------------------------------------------- /Sources/Wormhole/AppStoreConnectError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct AppStoreConnectError: Decodable, CustomStringConvertible { 4 | public struct Source: Decodable { 5 | let parameter: String? 6 | } 7 | 8 | public let id: UUID 9 | public let status: String 10 | public let code: String 11 | public let title: String 12 | public let detail: String 13 | public let source: Source? 14 | public var description: String { 15 | return "\(status) \(title): \(detail)" 16 | } 17 | } 18 | 19 | struct ErrorsContainer: Decodable { 20 | let errors: [AppStoreConnectError] 21 | 22 | enum CodingKeys: String, CodingKey { 23 | case errors 24 | } 25 | 26 | init(_ decoder: Decoder) throws { 27 | let container = try decoder.container(keyedBy: CodingKeys.self) 28 | var nested = try container.nestedUnkeyedContainer(forKey: .errors) 29 | errors = try nested.decode([AppStoreConnectError].self) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Wormhole/Client.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Result 3 | 4 | private let baseEndpointURL = URL(string: "https://api.appstoreconnect.apple.com/")! 5 | 6 | public protocol SessionType { 7 | func request(with request: URLRequest, 8 | completion: @escaping (_ data: Data?, _ response: URLResponse?, _ error: Error?) -> Void) 9 | } 10 | 11 | public struct HTTPSession: SessionType { 12 | public init() { } 13 | public func request(with request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { 14 | URLSession.shared.dataTask(with: request, completionHandler: completion).resume() 15 | } 16 | } 17 | 18 | public enum ClientError: Swift.Error { 19 | case invalidRequest 20 | case decodeError(Error?) 21 | case apiError([AppStoreConnectError]) 22 | case unknown 23 | } 24 | 25 | public struct Client { 26 | private var session: SessionType 27 | public typealias Completion = (Result) -> Void 28 | 29 | public enum APIVersion: String { 30 | case v1 31 | } 32 | 33 | private let token: String 34 | private var apiVersion: APIVersion = .v1 35 | private let decoder = JSONDecoder() 36 | 37 | private var baseURL: URL { 38 | return baseEndpointURL.appendingPathComponent(apiVersion.rawValue) 39 | } 40 | 41 | private func buildURLRequest(from request: Request) throws -> URLRequest { 42 | var urlComponent = URLComponents() 43 | urlComponent.queryItems = request.queryItems 44 | guard let url = urlComponent.url(relativeTo: baseURL) else { 45 | throw ClientError.invalidRequest 46 | } 47 | let fullURL = url.appendingPathComponent(request.path) 48 | var urlRequest = URLRequest(url: fullURL) 49 | urlRequest.allHTTPHeaderFields = [ 50 | "Authorization": "Bearer \(token)" 51 | ] 52 | urlRequest.httpMethod = request.method.rawValue.uppercased() 53 | urlRequest.httpBody = request.payload.httpBody 54 | return urlRequest 55 | } 56 | 57 | public init(p8Path: URL, 58 | issuerID: UUID, 59 | keyID: String, 60 | session: SessionType = HTTPSession()) throws { 61 | let encoder = try JWTEncoder(fileURL: p8Path) 62 | let token = try encoder.encode(issuerID: issuerID, keyID: keyID) 63 | self.session = session 64 | self.token = token 65 | } 66 | 67 | public init(p8Data: Data, 68 | issuerID: UUID, 69 | keyID: String, 70 | session: SessionType = HTTPSession()) throws { 71 | let encoder = try JWTEncoder(data: p8Data) 72 | let token = try encoder.encode(issuerID: issuerID, keyID: keyID) 73 | self.session = session 74 | self.token = token 75 | } 76 | 77 | public func send(_ request: Request, 78 | completion: @escaping (Result) -> Void) { 79 | let urlRequest: URLRequest 80 | do { 81 | urlRequest = try buildURLRequest(from: request) 82 | } catch { 83 | let clientError = (error as? ClientError) ?? ClientError.unknown 84 | return completion(Result(error: clientError)) 85 | } 86 | session.request(with: urlRequest) { data, response, error in 87 | let result: Result 88 | if let data = data { 89 | do { 90 | if error == nil { 91 | let response = try self.decoder.decode(Request.Response.self, from: data) 92 | result = .init(value: response) 93 | } else { 94 | let errorContainer = try self.decoder.decode(ErrorsContainer.self, from: data) 95 | result = .init(error: .apiError(errorContainer.errors)) 96 | } 97 | } catch { 98 | result = .init(error: .decodeError(error)) 99 | } 100 | } else { 101 | if let container = Request.Response(from: data) { 102 | result = .init(value: container) 103 | } else { 104 | result = .init(error: .unknown) 105 | } 106 | } 107 | completion(result) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/Wormhole/EntityType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol AttributeType: Codable { } 4 | public struct VoidAttribute: AttributeType { } 5 | 6 | public struct Entity: Decodable { 7 | public let id: UUID? 8 | public let type: String? 9 | public let attributes: Attribute? 10 | } 11 | 12 | public protocol EntityContainerType: Decodable { 13 | associatedtype Attribute: AttributeType 14 | init?(from data: Data?) 15 | } 16 | 17 | public extension EntityContainerType { 18 | public init?(from data: Data?) { 19 | return nil 20 | } 21 | } 22 | 23 | private enum EntityCodingKeys: String, CodingKey { 24 | case data 25 | } 26 | 27 | public struct SingleContainer: Decodable, EntityContainerType { 28 | public let data: Entity 29 | 30 | init(_ decoder: Decoder) throws { 31 | let container = try decoder.container(keyedBy: EntityCodingKeys.self) 32 | var nested = try container.nestedUnkeyedContainer(forKey: .data) 33 | data = try nested.decode(Entity.self) 34 | } 35 | } 36 | 37 | public struct CollectionContainer: Decodable, EntityContainerType { 38 | public let data: [Entity] 39 | 40 | init(_ decoder: Decoder) throws { 41 | let container = try decoder.container(keyedBy: EntityCodingKeys.self) 42 | var nested = try container.nestedUnkeyedContainer(forKey: .data) 43 | data = try nested.decode([Entity].self) 44 | } 45 | } 46 | 47 | public struct VoidContainer: Decodable, EntityContainerType { 48 | public typealias Attribute = VoidAttribute 49 | 50 | init(_ decoder: Decoder) { } 51 | 52 | public init?(from data: Data?) { } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Wormhole/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 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 | -------------------------------------------------------------------------------- /Sources/Wormhole/JWTEncoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JWT 3 | 4 | struct JWTEncoder { 5 | enum Error: Swift.Error { 6 | case keyNotFound 7 | case decodeError 8 | } 9 | 10 | private let privateKey: String 11 | private let expirationInterval: TimeInterval = 20 * 60 12 | 13 | init(fileURL: URL) throws { 14 | if !FileManager.default.fileExists(atPath: fileURL.path) { 15 | throw Error.keyNotFound 16 | } 17 | let privateKey = try String(contentsOf: fileURL) 18 | self.init(privateKey: privateKey) 19 | } 20 | 21 | init(data: Data) throws { 22 | guard let privateKey = String(data: data, encoding: .utf8) else { 23 | throw Error.decodeError 24 | } 25 | self.init(privateKey: privateKey) 26 | } 27 | 28 | init(privateKey: String) { 29 | self.privateKey = privateKey 30 | } 31 | 32 | func encode(issuerID: UUID, keyID: String) throws -> String { 33 | let object = UnsafeMutablePointer.allocate(capacity: MemoryLayout.size) 34 | jwt_new(object) 35 | defer { jwt_free(object.pointee) } 36 | 37 | let keyPointer = convertToCString(privateKey) 38 | defer { keyPointer.deallocate() } 39 | 40 | jwt_set_alg(object.pointee, 41 | JWT_ALG_ES256, 42 | keyPointer, 43 | Int32(privateKey.utf16.count + 1)) 44 | // https://github.com/benmcollins/libjwt/pull/71 45 | jwt_add_header(object.pointee, "kid", keyID) 46 | 47 | jwt_add_grant(object.pointee, "iss", issuerID.uuidString.lowercased()) 48 | let expirationDate = Date().addingTimeInterval(expirationInterval) 49 | jwt_add_grant_int(object.pointee, "exp", Int(expirationDate.timeIntervalSince1970)) 50 | jwt_add_grant(object.pointee, "aud", "appstoreconnect-v1") 51 | 52 | guard let encodedCString = jwt_encode_str(object.pointee) else { 53 | throw Error.decodeError 54 | } 55 | 56 | return String(cString: encodedCString) 57 | } 58 | 59 | private func convertToCString(_ string: String) -> UnsafeMutablePointer { 60 | let result = string.withCString { c -> (Int, UnsafeMutablePointer?) in 61 | let len = Int(strlen(c) + 1) 62 | let dst = strcpy(UnsafeMutablePointer.allocate(capacity: len), c) 63 | return (len, dst) 64 | } 65 | let uint8 = UnsafeMutablePointer.allocate(capacity: result.0) 66 | memcpy(uint8, result.1, result.0) 67 | defer { result.1?.deallocate() } 68 | return uint8 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Wormhole/Request.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol PayloadAttachable { 4 | var httpBody: Data? { get } 5 | } 6 | extension Array: PayloadAttachable where Element: Encodable { } 7 | extension Dictionary: PayloadAttachable where Key: Encodable, Value: Encodable { } 8 | extension PayloadAttachable where Self: Encodable { 9 | public var httpBody: Data? { 10 | let encoder = JSONEncoder() 11 | return try? encoder.encode(self) 12 | } 13 | } 14 | 15 | public struct RequestPayload { 16 | private struct Payload: Encodable { 17 | let id: UUID? 18 | let type: String 19 | let attributes: Attachment 20 | 21 | private enum CodingKeys: CodingKey { 22 | case id 23 | case type 24 | case attributes 25 | } 26 | 27 | func encode(to encoder: Encoder) throws { 28 | var container = encoder.container(keyedBy: CodingKeys.self) 29 | if let id = id { 30 | try container.encode(id, forKey: .id) 31 | } 32 | try container.encode(type, forKey: .type) 33 | var attributeContainer = container.nestedUnkeyedContainer(forKey: .attributes) 34 | try attributeContainer.encode(attributes.httpBody) 35 | } 36 | } 37 | public static let void: RequestPayload = RequestPayload { return nil } 38 | private let makeHTTPBody: () -> Data? 39 | internal var httpBody: Data? { 40 | return makeHTTPBody() 41 | } 42 | } 43 | 44 | public extension RequestPayload { 45 | public init(id: UUID? = nil, type: String, attributes: Attachment) { 46 | makeHTTPBody = { 47 | let encoder = JSONEncoder() 48 | let payload = Payload(id: id, type: type, attributes: attributes) 49 | return try? encoder.encode(payload) 50 | } 51 | } 52 | } 53 | 54 | public enum HTTPMethod: String { 55 | case get 56 | case post 57 | case patch 58 | case delete 59 | } 60 | 61 | public protocol RequestType { 62 | associatedtype Response: EntityContainerType 63 | var method: HTTPMethod { get } 64 | var path: String { get } 65 | var queryItems: [URLQueryItem]? { get } 66 | var payload: RequestPayload { get } 67 | } 68 | 69 | public extension RequestType { 70 | var queryItems: [URLQueryItem]? { 71 | return nil 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/jwt/jwt.h: -------------------------------------------------------------------------------- 1 | #include 2 | -------------------------------------------------------------------------------- /Sources/jwt/module.modulemap: -------------------------------------------------------------------------------- 1 | module JWT [system] { 2 | header "jwt.h" 3 | link "jwt" 4 | export * 5 | } 6 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import WormholeTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += WormholeTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/WormholeTests/AppStoreConnectErrorTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import Wormhole 4 | 5 | final class AppStoreConnectErrorTests: XCTestCase { 6 | func testDecodeError() { 7 | let jsonData = loadFixture(errorResponse) 8 | let decoder = JSONDecoder() 9 | let container = try! decoder.decode(ErrorsContainer.self, from: jsonData) 10 | XCTAssertEqual(container.errors.count, 1) 11 | 12 | let error = container.errors.first! 13 | XCTAssertNotNil(error.id) 14 | XCTAssertEqual(error.title, "A parameter has an invalid value") 15 | XCTAssertEqual(error.status, "400") 16 | XCTAssertEqual(error.detail, "'emaill' is not a valid filter type") 17 | XCTAssertEqual(error.code, "PARAMETER_ERROR.INVALID") 18 | XCTAssertNotNil(error.source) 19 | } 20 | 21 | func testDescription() { 22 | let jsonData = loadFixture(errorResponse) 23 | let decoder = JSONDecoder() 24 | let container = try! decoder.decode(ErrorsContainer.self, from: jsonData) 25 | let error = container.errors.first! 26 | XCTAssertEqual(String(describing: error), "400 A parameter has an invalid value: 'emaill' is not a valid filter type") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/WormholeTests/ClientTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | import Wormhole 4 | import Result 5 | 6 | struct TestingSession: SessionType { 7 | init() { } 8 | 9 | var data: Data? = nil 10 | var response: URLResponse? = nil 11 | var error: Error? = nil 12 | var requestBlock: ((URLRequest) -> Void)? 13 | 14 | func request(with request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { 15 | requestBlock?(request) 16 | completion(data, response, error) 17 | } 18 | } 19 | 20 | final class ClientTests: XCTestCase { 21 | var session: TestingSession! 22 | var client: Client { 23 | let data = loadFixture(privateKey) 24 | return try! Client(p8Data: data, 25 | issuerID: UUID(), 26 | keyID: "999999", 27 | session: session) 28 | } 29 | 30 | func makeResponse(to path: String, statusCode: Int) -> HTTPURLResponse { 31 | let baseURL = URL(string: "https://api.appstoreconnect.apple.com/v1")! 32 | return HTTPURLResponse(url: baseURL.appendingPathComponent(path), 33 | statusCode: statusCode, 34 | httpVersion: nil, 35 | headerFields: nil)! 36 | } 37 | 38 | override func setUp() { 39 | session = TestingSession() 40 | 41 | super.setUp() 42 | } 43 | 44 | func testGet() { 45 | session.data = loadFixture(userResponse) 46 | session.response = makeResponse(to: "/users", statusCode: 200) 47 | struct UsersRequest: RequestType { 48 | typealias Response = SingleContainer 49 | let method: HTTPMethod = .get 50 | let path = "/users" 51 | let payload: RequestPayload = .void 52 | } 53 | 54 | session.requestBlock = { request in 55 | XCTAssertEqual(request.httpMethod, "GET") 56 | XCTAssertEqual(request.url, URL(string: "https://api.appstoreconnect.apple.com/v1/users")!) 57 | XCTAssertNil(request.httpBody) 58 | } 59 | client.send(UsersRequest()) { (result: Result, ClientError>) in 60 | switch result { 61 | case .success(let container): 62 | let user = container.data 63 | XCTAssertEqual(user.attributes?.firstName, "John") 64 | case .failure(_): 65 | XCTFail("Request should be success") 66 | } 67 | } 68 | } 69 | 70 | func testPost() { 71 | struct UserInvitation: AttributeType, PayloadAttachable { 72 | let firstName: String 73 | let lastName: String 74 | let email: String 75 | let roles: [Role] 76 | let allAppsVisible: Bool 77 | } 78 | struct PostUserInvitationRequest: RequestType { 79 | typealias Response = SingleContainer 80 | let method: HTTPMethod = .post 81 | let path = "/userInvitations" 82 | let invitation: UserInvitation 83 | var payload: RequestPayload { 84 | return .init(type: "userInvitations", attributes: invitation) 85 | } 86 | } 87 | let request = PostUserInvitationRequest( 88 | invitation: UserInvitation(firstName: "John", 89 | lastName: "Appleseed", 90 | email: "john-appleseed@mac.com", 91 | roles: [.developer], 92 | allAppsVisible: true) 93 | ) 94 | session.data = loadFixture(postUserInvitations) 95 | session.response = makeResponse(to: "/userInvitations", statusCode: 201) 96 | session.requestBlock = { request in 97 | XCTAssertEqual(request.httpMethod, "POST") 98 | XCTAssertEqual(request.url, URL(string: "https://api.appstoreconnect.apple.com/v1/userInvitations")!) 99 | XCTAssertNotNil(request.httpBody) 100 | let body = try! JSONSerialization.jsonObject(with: request.httpBody!, options: []) as! [String: Any] 101 | XCTAssertEqual(body.count, 2) 102 | XCTAssertEqual(body["type"] as! String, "userInvitations") 103 | XCTAssertNotNil(body["attributes"]) 104 | } 105 | client.send(request) { result in 106 | switch result { 107 | case .success(let createdInvitation): 108 | XCTAssertEqual(createdInvitation.data.attributes?.firstName, "John") 109 | case .failure(_): 110 | XCTFail("Request should be success") 111 | } 112 | } 113 | } 114 | 115 | func testPatch() { 116 | let uuid = UUID() 117 | struct RoleModificationRequest: RequestType { 118 | typealias Response = SingleContainer 119 | let method: HTTPMethod = .patch 120 | var path: String { 121 | return "/users/\(id.uuidString.lowercased())" 122 | } 123 | let id: UUID 124 | let roles: [Role] 125 | var payload: RequestPayload { 126 | return .init(id: id, 127 | type: "users", 128 | attributes: roles) 129 | } 130 | } 131 | let request = RoleModificationRequest(id: uuid, roles: [.developer, .marketing]) 132 | session.data = loadFixture(userResponse) 133 | session.response = makeResponse(to: "/users", statusCode: 200) 134 | session.requestBlock = { request in 135 | XCTAssertEqual(request.httpMethod, "PATCH") 136 | XCTAssertEqual(request.url, URL(string: "https://api.appstoreconnect.apple.com/v1/users/\(uuid.uuidString.lowercased())")!) 137 | XCTAssertNotNil(request.httpBody) 138 | let body = try! JSONSerialization.jsonObject(with: request.httpBody!, options: []) as! [String: Any] 139 | XCTAssertEqual(body.count, 3) 140 | XCTAssertEqual(body["type"] as! String, "users") 141 | XCTAssertEqual(body["id"] as! String, uuid.uuidString) 142 | XCTAssertNotNil(body["attributes"]) 143 | } 144 | client.send(request) { result in 145 | switch result { 146 | case .success(let createdInvitation): 147 | XCTAssertEqual(createdInvitation.data.attributes?.firstName, "John") 148 | XCTAssertEqual(createdInvitation.data.attributes?.roles, [.developer]) 149 | case .failure(_): 150 | XCTFail("Request should be success") 151 | } 152 | } 153 | } 154 | 155 | func testDelete() { 156 | let uuid = UUID() 157 | session.response = makeResponse(to: "/users/\(uuid.uuidString)", statusCode: 204) 158 | struct DeleteUserRequest: RequestType { 159 | typealias Response = VoidContainer 160 | let id: UUID 161 | let method: HTTPMethod = .delete 162 | var path: String { 163 | return "/users/\(id.uuidString.lowercased())" 164 | } 165 | let payload: RequestPayload = .void 166 | } 167 | 168 | session.requestBlock = { request in 169 | XCTAssertEqual(request.httpMethod, "DELETE") 170 | XCTAssertEqual(request.url, URL(string: "https://api.appstoreconnect.apple.com/v1/users/\(uuid.uuidString.lowercased())")!) 171 | XCTAssertNil(request.httpBody) 172 | } 173 | client.send(DeleteUserRequest(id: uuid)) { result in 174 | switch result { 175 | case .success(_): 176 | break 177 | case .failure(_): 178 | XCTFail("Request should be success") 179 | } 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Tests/WormholeTests/EntityTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import Wormhole 4 | 5 | final class EntityTests: XCTestCase { 6 | func testDecodeSingleObject() { 7 | let jsonData = loadFixture(userResponse) 8 | let decoder = JSONDecoder() 9 | let container = try? decoder.decode(SingleContainer.self, from: jsonData) 10 | XCTAssertNotNil(container?.data.id) 11 | XCTAssertEqual(container?.data.attributes?.firstName, "John") 12 | } 13 | 14 | func testDecodeMultipleObjects() { 15 | let jsonData = loadFixture(usersResponse) 16 | let decoder = JSONDecoder() 17 | let container = try? decoder.decode(CollectionContainer.self, from: jsonData) 18 | XCTAssertEqual(container?.data.count, 2) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/WormholeTests/Fixtures.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // SPM currently not supports file fixtures... 4 | 5 | let errorResponse = """ 6 | { 7 | "errors": [ 8 | { 9 | "status": "400", 10 | "id": "b91d85c7-b7db-4451-8f3f-9a3c8af9a392", 11 | "title": "A parameter has an invalid value", 12 | "detail": "'emaill' is not a valid filter type", 13 | "code": "PARAMETER_ERROR.INVALID", 14 | "source": { 15 | "parameter": "filter[emaill]" 16 | } 17 | } 18 | ] 19 | } 20 | """ 21 | 22 | let privateKey = """ 23 | -----BEGIN PRIVATE KEY----- 24 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 25 | OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r 26 | 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G 27 | -----END PRIVATE KEY----- 28 | """ 29 | 30 | let userResponse = """ 31 | { 32 | "data": { 33 | "type": "users", 34 | "id": "b91d85c7-b7db-4451-8f3f-9a3c8af9a392", 35 | "attributes": { 36 | "firstName": "John", 37 | "lastName": "Appleseed", 38 | "email": "john-appleseed@mac.com", 39 | "inviteType": "EMAIL", 40 | "roles": ["DEVELOPER"] 41 | }, 42 | "relationships": {}, 43 | "links": { 44 | "self": ".../v1/betaTesters/4277b871-ce4e-4fc7-9e34" 45 | } 46 | } 47 | } 48 | """ 49 | 50 | let usersResponse = """ 51 | { 52 | "data": [ 53 | { 54 | "type": "users", 55 | "id": "b91d85c7-b7db-4451-8f3f-9a3c8af9a392", 56 | "attributes": { 57 | "firstName": "John", 58 | "lastName": "Appleseed", 59 | "email": "john-appleseed@mac.com", 60 | "inviteType": "EMAIL", 61 | "roles": ["DEVELOPER"] 62 | }, 63 | "relationships": {}, 64 | "links": { 65 | "self": ".../v1/betaTesters/4277b871-ce4e-4fc7-9e34" 66 | } 67 | }, 68 | { 69 | "type": "users", 70 | "id": "093a04ed-b021-42e3-a1df-5d064f05ec3f", 71 | "attributes": { 72 | "firstName": "John", 73 | "lastName": "Appleseed", 74 | "email": "john-appleseed@mac.com", 75 | "inviteType": "EMAIL", 76 | "roles": ["DEVELOPER"] 77 | }, 78 | "relationships": {}, 79 | "links": { 80 | "self": ".../v1/betaTesters/4277b871-ce4e-4fc7-9e34" 81 | } 82 | } 83 | ] 84 | } 85 | """ 86 | 87 | let postUserInvitations = """ 88 | { 89 | "data": { 90 | "type": "userInvitations", 91 | "id": "24e811a2-2ad0-46e4-b632-61fec324ebed", 92 | "attributes": { 93 | "firstName": "John", 94 | "lastName": "Appleseed", 95 | "email": "john-appleseed@mac.com", 96 | "roles": ["DEVELOPER"], 97 | "allAppsVisible": true, 98 | "expirationDate": "2018-06-10T13:15.00" 99 | }, 100 | "links": { 101 | "self": "../v1/userInvitations/24e811a2-2ad0-46e4-b632-61fec324ebed" 102 | } 103 | } 104 | } 105 | """ 106 | 107 | func loadFixture(_ raw: String) -> Data { 108 | return raw.data(using: .utf8)! 109 | } 110 | -------------------------------------------------------------------------------- /Tests/WormholeTests/Fixtures/errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": [ 3 | { 4 | "status": "400", 5 | "id": "b91d85c7-b7db-4451-8f3f-9a3c8af9a392", 6 | "title": "A parameter has an invalid value", 7 | "detail": "'emaill' is not a valid filter type", 8 | "code": "PARAMETER_ERROR.INVALID", 9 | "source": { 10 | "parameter": "filter[emaill]" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /Tests/WormholeTests/Fixtures/post_user_invitations.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "userInvitations", 4 | "id": "24e811a2-2ad0-46e4-b632-61fec324ebed", 5 | "attributes": { 6 | "firstName": "John", 7 | "lastName": "Appleseed", 8 | "email": "john-appleseed@mac.com", 9 | "roles": ["DEVELOPER"], 10 | "allAppsVisible": true, 11 | "expirationDate": "2018-06-10T13:15.00" 12 | }, 13 | "links": { 14 | "self": "../v1/userInvitations/24e811a2-2ad0-46e4-b632-61fec324ebed" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/WormholeTests/Fixtures/private.p8: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 3 | OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r 4 | 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G 5 | -----END PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /Tests/WormholeTests/Fixtures/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "users", 4 | "id": "b91d85c7-b7db-4451-8f3f-9a3c8af9a392", 5 | "attributes": { 6 | "firstName": "John", 7 | "lastName": "Appleseed", 8 | "email": "john-appleseed@mac.com", 9 | "inviteType": "EMAIL" 10 | }, 11 | "relationships": {}, 12 | "links": { 13 | "self": ".../v1/betaTesters/4277b871-ce4e-4fc7-9e34" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/WormholeTests/Fixtures/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "users", 5 | "id": "b91d85c7-b7db-4451-8f3f-9a3c8af9a392", 6 | "attributes": { 7 | "firstName": "John", 8 | "lastName": "Appleseed", 9 | "email": "john-appleseed@mac.com", 10 | "inviteType": "EMAIL" 11 | }, 12 | "relationships": {}, 13 | "links": { 14 | "self": ".../v1/betaTesters/4277b871-ce4e-4fc7-9e34" 15 | } 16 | }, 17 | { 18 | "type": "users", 19 | "id": "093a04ed-b021-42e3-a1df-5d064f05ec3f", 20 | "attributes": { 21 | "firstName": "John", 22 | "lastName": "Appleseed", 23 | "email": "john-appleseed@mac.com", 24 | "inviteType": "EMAIL" 25 | }, 26 | "relationships": {}, 27 | "links": { 28 | "self": ".../v1/betaTesters/4277b871-ce4e-4fc7-9e34" 29 | } 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /Tests/WormholeTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 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 | -------------------------------------------------------------------------------- /Tests/WormholeTests/JWTEncoderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Wormhole 3 | 4 | final class JWTEncoderTests: XCTestCase { 5 | func testEncode() { 6 | let privateKey = """ 7 | -----BEGIN PRIVATE KEY----- 8 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 9 | OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r 10 | 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G 11 | -----END PRIVATE KEY----- 12 | """ 13 | 14 | let encoder = JWTEncoder(privateKey: privateKey) 15 | let encoded = try! encoder.encode(issuerID: UUID(uuidString: "B58A79D0-14D9-4C3C-A6E1-846DF1AAFDEB")!, 16 | keyID: "14241745") 17 | XCTAssertNotNil(encoded) 18 | let components = encoded.split(separator: ".") 19 | let header = components[0] 20 | XCTAssertEqual(header, "eyJraWQiOiIxNDI0MTc0NSIsInR5cCI6IkpXVCIsImFsZyI6IkVTMjU2In0") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/WormholeTests/User.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Wormhole 3 | 4 | enum Role: String, Codable { 5 | case developer = "DEVELOPER" 6 | case marketing = "MARKETING" 7 | } 8 | 9 | struct User: AttributeType, PayloadAttachable { 10 | let firstName: String 11 | let lastName: String 12 | let email: String 13 | let inviteType: String 14 | let httpBody: Data? = nil 15 | let roles: [Role] 16 | } 17 | --------------------------------------------------------------------------------