├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── Kite │ ├── APIClient.swift │ ├── Deserializers │ ├── DataTransformer.swift │ ├── JSONDeserializer │ │ ├── JSONDecoderKeypath │ │ │ ├── KeyPathWrapper.swift │ │ │ └── UserInfoKeys.swift │ │ └── JSONDeserializer.swift │ ├── ResponseDataDeserializer.swift │ └── XMLDeserializer │ │ └── XMLDeserializer.swift │ ├── Extensions │ ├── AuthRequestProtocol+Extensions.swift │ ├── HTTPRequestProtocol+Extensions.swift │ ├── JSONDecoder+Extensions.swift │ └── XMLIndexer+Extensions.swift │ ├── HTTPMethod.swift │ └── Protocols │ ├── AuthRequestProtocol.swift │ ├── DataTransformerProtocol.swift │ ├── DeserializeableRequestProtocol.swift │ └── HTTPRequestProtocol.swift └── Tests └── KiteTests ├── APIClientTests └── APIClientTests.swift ├── JSONDeserializerTests └── JSONDeserializerTests.swift ├── KiteTests.xctestplan ├── Mocks ├── MockURLHandlerStore.swift ├── MockURLProtocol.swift ├── Models │ └── TestPerson.swift └── Requests │ ├── FetchRawDataAuthRequest.swift │ ├── FetchRawDataRequest.swift │ ├── FetchSingleTestPersonJSONRequest.swift │ ├── FetchSingleTestPersonXMLRequest.swift │ └── SendMultipartFormDataRequest.swift ├── Stubs ├── BinaryStubs │ └── swift_logo.png ├── JSONStubs │ └── JSONStubs.swift └── XMLStubs │ └── XMLStubs.swift └── XMLDeserializerTests └── XMLDeserializerTests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-latest 12 | steps: 13 | - uses: maxim-lobanov/setup-xcode@v1 14 | with: 15 | xcode-version: latest-stable 16 | 17 | - uses: actions/checkout@v4 18 | 19 | - name: Build 20 | run: swift build -v 21 | - name: Run tests 22 | run: swift test -v 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | .DS_Store 7 | xcuserdata/ 8 | 9 | ## Compatibility with older Xcode versions 10 | *.xcscmblueprint 11 | *.xccheckout 12 | 13 | ## Xcode 3 and earlier (not required starting Xcode 4) 14 | build/ 15 | DerivedData/ 16 | *.moved-aside 17 | *.pbxuser 18 | !default.pbxuser 19 | *.mode1v3 20 | !default.mode1v3 21 | *.mode2v3 22 | !default.mode2v3 23 | *.perspectivev3 24 | !default.perspectivev3 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | 29 | ## App packaging 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | # Swift Package Manager 39 | .build/ 40 | .swiftpm/ 41 | Packages/ 42 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Package", 6 | "type": "lldb", 7 | "request": "launch", 8 | "program": "${workspaceFolder}/.build/debug/Kite", 9 | "args": [], 10 | "cwd": "${workspaceFolder}", 11 | "preLaunchTask": "Build Package", 12 | "stopOnEntry": false 13 | }, 14 | { 15 | "name": "Debug Tests", 16 | "type": "lldb", 17 | "request": "launch", 18 | "program": "${workspaceFolder}/.build/debug/Kite", 19 | "args": [], 20 | "cwd": "${workspaceFolder}", 21 | "preLaunchTask": "Build Package", 22 | "stopOnEntry": false 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Clean Package", 6 | "type": "shell", 7 | "command": "swift package clean", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Resolve SPM Dependencies", 12 | "type": "shell", 13 | "command": "swift package resolve", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "SwiftLint", 18 | "type": "shell", 19 | "command": "swiftlint --fix", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Build Package", 24 | "type": "shell", 25 | "command": "swift build", 26 | "dependsOn": [ 27 | "Clean Package", 28 | "Resolve SPM Dependencies", 29 | "SwiftLint" 30 | ], 31 | "group": { 32 | "kind": "build", 33 | "isDefault": true 34 | }, 35 | "presentation": { 36 | "reveal": "always" 37 | }, 38 | "problemMatcher": [] 39 | }, 40 | { 41 | "label": "Run Tests", 42 | "type": "shell", 43 | "command": "swift test", 44 | "dependsOn": [ 45 | "Clean Package", 46 | "Resolve SPM Dependencies", 47 | "SwiftLint" 48 | ], 49 | "presentation": { 50 | "reveal": "always" 51 | }, 52 | "problemMatcher": [] 53 | } 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Artem Kalinovsky 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 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "bb0eb308bbe6cf094e35108e0d527e6b6c1f329353360692b8401ff4516faac2", 3 | "pins" : [ 4 | { 5 | "identity" : "swxmlhash", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/drmohundro/SWXMLHash.git", 8 | "state" : { 9 | "revision" : "6ae7b83692c4a2ed53ea2cb0e6cda20768800a99", 10 | "version" : "8.1.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 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: "Kite", 8 | platforms: [ 9 | .macOS(.v12), 10 | .iOS(.v15), 11 | .tvOS(.v15), 12 | .watchOS(.v8), 13 | .driverKit(.v19), 14 | .visionOS(.v1) 15 | ], 16 | products: [ 17 | .library( 18 | name: "Kite", 19 | targets: ["Kite"]) 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/drmohundro/SWXMLHash.git", exact: "8.1.0") 23 | ], 24 | targets: [ 25 | .target( 26 | name: "Kite", 27 | dependencies: [ 28 | .product(name: "SWXMLHash", package: "SWXMLHash") 29 | ] 30 | ), 31 | .testTarget( 32 | name: "KiteTests", 33 | dependencies: ["Kite"], 34 | path: "Tests/KiteTests", 35 | exclude: ["KiteTests.xctestplan"], 36 | resources: [ 37 | .process("Stubs/BinaryStubs") 38 | ] 39 | ) 40 | ], 41 | swiftLanguageModes: [.v6] 42 | ) 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![swift workflow](https://github.com/artemkalinovsky/Kite/actions/workflows/swift.yml/badge.svg) 2 | [![Swift 6](https://img.shields.io/badge/Swift-6.0-orange.svg)](https://swift.org) 3 | [![macOS](https://img.shields.io/badge/macOS-12%2B-blue.svg)](https://developer.apple.com/macos/) 4 | [![iOS](https://img.shields.io/badge/iOS-15%2B-blue.svg)](https://developer.apple.com/ios/) 5 | [![tvOS](https://img.shields.io/badge/tvOS-15%2B-blue.svg)](https://developer.apple.com/tvos/) 6 | [![watchOS](https://img.shields.io/badge/watchOS-8%2B-blue.svg)](https://developer.apple.com/watchos/) 7 | [![driverKit](https://img.shields.io/badge/driverKit-19%2B-blue.svg)](https://developer.apple.com/driverkit/) 8 | [![visionOS](https://img.shields.io/badge/visionOS-1%2B-blue.svg)](https://developer.apple.com/visionos/) 9 | 10 | 11 | 12 | 13 | # Kite 14 | 15 | Kite is named after the kite bird, known for its lightness, speed, and agile flight. This Swift Package aims to embody those qualities—offering a lightweight, fast, and flexible networking layer that soars across Apple platforms. 16 | 17 | ### Features: 18 | 19 | * ***Swift Concurrency (async/await)***: Easily manage asynchronous networking operations. 20 | * Lightweight API Client: A simple APIClient class lets you execute requests that conform to HTTPRequestProtocol or DeserializeableRequest. 21 | * JSON & XML Deserialization: Built-in JSONDeserializer and XMLDeserializer types for decoding server responses. 22 | 23 | ## Project Status 24 | 25 | This project is considered production-ready. Contributions—whether pull requests, questions, or suggestions—are always welcome! 😃 26 | 27 | ## Installation 📦 28 | 29 | * #### Swift Package Manager 30 | 31 | You can use Xcode SPM GUI: *File -> Swift Packages -> Add Package Dependency -> Pick "Up to Next Major Version 3.0.0"*. 32 | 33 | Or add the following to your `Package.swift` file: 34 | 35 | ``` swift 36 | .package(url: "https://github.com/artemkalinovsky/Kite.git", from: "3.0.0") 37 | 38 | ``` 39 | 40 | Then specify "Kite" as a dependency of the target in which you wish to use Kite. 41 | 42 | Here's an example `Package.swift`: 43 | 44 | ``` swift 45 | // swift-tools-version:6.0 46 | import PackageDescription 47 | 48 | let package = Package( 49 | name: "MyPackage", 50 | products: [ 51 | .library( 52 | name: "MyPackage", 53 | targets: ["MyPackage"]), 54 | ], 55 | dependencies: [ 56 | .package(url: "https://github.com/artemkalinovsky/Kite.git", from: "3.0.0") 57 | ], 58 | targets: [ 59 | .target( 60 | name: "MyPackage", 61 | dependencies: ["Kite"]) 62 | ] 63 | ) 64 | ``` 65 | ## Usage 🧑‍💻 66 | 67 | Let's suppose we want to fetch a list of users from JSON and the response looks like this: 68 | 69 | ``` json 70 | { 71 | "results":[ 72 | { 73 | "name":{ 74 | "first":"brad", 75 | "last":"gibson" 76 | }, 77 | "email":"brad.gibson@example.com" 78 | } 79 | ] 80 | } 81 | ``` 82 | 83 | * #### Setup 84 | 85 | 1. Create `APIClient` : 86 | 87 | ``` swift 88 | let apiClient = APIClient() 89 | ``` 90 | 91 | 2. Create the Response Model: 92 | 93 | ``` swift 94 | struct User: Decodable { 95 | struct Name: Decodable { 96 | let first: String 97 | let last: String 98 | } 99 | 100 | let name: Name 101 | let email: String 102 | } 103 | ``` 104 | 105 | 3. Create a Request with Endpoint Path and Desired Response Deserializer: 106 | 107 | ``` swift 108 | import Foundation 109 | import Kite 110 | 111 | struct FetchRandomUsersRequest: DeserializeableRequestProtocol { 112 | var baseURL: URL { URL(string: "https://randomuser.me")! } 113 | var path: String {"api"} 114 | 115 | var deserializer: ResponseDataDeserializer<[User]> { 116 | JSONDeserializer.collectionDeserializer(keyPath: "results") 117 | } 118 | } 119 | ``` 120 | 121 | * #### Perform the Request 122 | 123 | ``` swift 124 | Task { 125 | let (users, urlResponse) = try await apiClient.execute(request: FetchRandomUsersRequest()) 126 | } 127 | ``` 128 | 129 | Voilà!🧑‍🎨 130 | 131 | ## Apps using Kite 132 | 133 | - [PinPlace](https://apps.apple.com/ua/app/pinplace/id1571349149) 134 | 135 | ## Credits 👏 136 | 137 | * @0111b for [JSONDecoder-Keypath](https://github.com/0111b/JSONDecoder-Keypath) 138 | * @drmohundro for [SWXMLHash](https://github.com/drmohundro/SWXMLHash) 139 | 140 | ## License 📄 141 | 142 | Kite is released under an MIT license. See [LICENCE](https://github.com/artemkalinovsky/Kite/blob/master/LICENSE) for more information. 143 | -------------------------------------------------------------------------------- /Sources/Kite/APIClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | 4 | public class APIClient: @unchecked Sendable { 5 | private let urlSession: URLSession 6 | 7 | public init(urlSession: URLSession = URLSession.shared) { 8 | self.urlSession = urlSession 9 | } 10 | 11 | public func execute( 12 | request: HTTPRequestProtocol, 13 | deserializer: ResponseDataDeserializer = VoidDeserializer() 14 | ) async throws -> (T, URLResponse) { 15 | guard let url = request.url else { 16 | throw URLError(.badURL) 17 | } 18 | 19 | var urlRequest = URLRequest(url: url) 20 | urlRequest.httpMethod = request.method.rawValue 21 | 22 | for (field, value) in request.headers { 23 | urlRequest.setValue(value, forHTTPHeaderField: field) 24 | } 25 | 26 | if let parameters = request.parameters { 27 | if request.method == .get { 28 | var components = URLComponents(url: url, resolvingAgainstBaseURL: false) 29 | components?.queryItems = parameters.map { key, value in 30 | URLQueryItem(name: key, value: "\(value)") 31 | } 32 | if let newURL = components?.url { 33 | urlRequest.url = newURL 34 | } 35 | } else { 36 | urlRequest.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: []) 37 | urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") 38 | } 39 | } 40 | 41 | if let multipartFormData = request.multipartFormData, !multipartFormData.isEmpty { 42 | let boundary = "Boundary-\(UUID().uuidString)" 43 | urlRequest.setValue( 44 | "multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type" 45 | ) 46 | 47 | var body = Data() 48 | for (key, fileURL) in multipartFormData { 49 | body.append("--\(boundary)\r\n".data(using: .utf8)!) 50 | let filename = fileURL.lastPathComponent 51 | body.append( 52 | "Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)! 53 | ) 54 | let fileExtension = fileURL.pathExtension 55 | let contentType = UTType(filenameExtension: fileExtension)?.preferredMIMEType ?? "application/octet-stream" 56 | body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!) 57 | let fileData = try Data(contentsOf: fileURL) 58 | body.append(fileData) 59 | body.append("\r\n".data(using: .utf8)!) 60 | } 61 | body.append("--\(boundary)--\r\n".data(using: .utf8)!) 62 | urlRequest.httpBody = body 63 | } 64 | 65 | let (data, urlResponse) = try await urlSession.data(for: urlRequest) 66 | return (try await deserializer.deserialize(data: data), urlResponse) 67 | } 68 | 69 | public func execute(request: R) async throws -> (R.ResponseType, URLResponse) { 70 | try await execute(request: request, deserializer: request.deserializer) 71 | } 72 | 73 | public func execute(request: R) async throws -> (R.ResponseType, URLResponse) { 74 | guard let authorizationHeader = request.headers["Authorization"], !authorizationHeader.isEmpty 75 | else { 76 | throw URLError(.userAuthenticationRequired) 77 | } 78 | 79 | return try await execute(request: request, deserializer: request.deserializer) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/Kite/Deserializers/DataTransformer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataTransformer.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct DataTransformer: DataTransformerProtocol { 11 | public let transform: (Data) throws -> O 12 | 13 | public init(transform: @escaping (Data) throws -> O) { 14 | self.transform = transform 15 | } 16 | } 17 | 18 | extension DataTransformer where O == Void { 19 | public init() { 20 | self.transform = { _ in } 21 | } 22 | 23 | @available(*, unavailable, message: "Use the default initializer instead") 24 | public init(transform: @escaping (Data) throws -> O) { 25 | fatalError("This initializer is unavailable. Use the default initializer instead.") 26 | } 27 | } 28 | 29 | extension DataTransformer where O == Data { 30 | public init() { 31 | self.transform = { $0 } 32 | } 33 | 34 | @available(*, unavailable, message: "Use the default initializer instead") 35 | public init(transform: @escaping (Data) throws -> O) { 36 | fatalError("This initializer is unavailable. Use the default initializer instead.") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Kite/Deserializers/JSONDeserializer/JSONDecoderKeypath /KeyPathWrapper.swift: -------------------------------------------------------------------------------- 1 | /// Object which is representing value 2 | final class KeyPathWrapper: Decodable { 3 | 4 | enum KeyPathError: Error { 5 | case `internal` 6 | } 7 | 8 | /// Naive coding key implementation 9 | struct Key: CodingKey { 10 | init?(intValue: Int) { 11 | self.intValue = intValue 12 | stringValue = String(intValue) 13 | } 14 | 15 | init?(stringValue: String) { 16 | self.stringValue = stringValue 17 | intValue = nil 18 | } 19 | 20 | let intValue: Int? 21 | let stringValue: String 22 | } 23 | 24 | typealias KeyedContainer = KeyedDecodingContainer.Key> 25 | 26 | init(from decoder: Decoder) throws { 27 | guard let keyPath = decoder.userInfo[UserInfoKeys.decodingContext] as? [String], 28 | !keyPath.isEmpty 29 | else { throw KeyPathError.internal } 30 | 31 | /// Creates a `Key` from the first keypath element 32 | func getKey(from keyPath: [String]) throws -> Key { 33 | guard let first = keyPath.first, 34 | let key = Key(stringValue: first) 35 | else { throw KeyPathError.internal } 36 | return key 37 | } 38 | 39 | /// Finds nested container and returns it and the key for object 40 | func objectContainer( 41 | for keyPath: [String], 42 | in currentContainer: KeyedContainer, 43 | key currentKey: Key 44 | ) throws -> (KeyedContainer, Key) { 45 | guard !keyPath.isEmpty else { return (currentContainer, currentKey) } 46 | let container = try currentContainer.nestedContainer(keyedBy: Key.self, forKey: currentKey) 47 | let key = try getKey(from: keyPath) 48 | return try objectContainer(for: Array(keyPath.dropFirst()), in: container, key: key) 49 | } 50 | 51 | let rootKey = try getKey(from: keyPath) 52 | let rooTContainer = try decoder.container(keyedBy: Key.self) 53 | 54 | let (keyedContainer, key) = try objectContainer( 55 | for: Array(keyPath.dropFirst()), 56 | in: rooTContainer, 57 | key: rootKey 58 | ) 59 | 60 | object = try keyedContainer.decode(T.self, forKey: key) 61 | } 62 | 63 | let object: T 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Kite/Deserializers/JSONDeserializer/JSONDecoderKeypath /UserInfoKeys.swift: -------------------------------------------------------------------------------- 1 | struct UserInfoKeys { 2 | 3 | public static let decodingContext = CodingUserInfoKey(rawValue: "decodingContext")! 4 | 5 | } 6 | -------------------------------------------------------------------------------- /Sources/Kite/Deserializers/JSONDeserializer/JSONDeserializer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum JSONDeserializerError: Error { 4 | case jsonDeserializableInitFailed(String) 5 | } 6 | 7 | public class JSONDeserializer: ResponseDataDeserializer { 8 | public convenience init() { 9 | self.init( 10 | transformer: DataTransformer( 11 | transform: { data -> T in 12 | let jsonObject = try JSONSerialization.jsonObject(with: data) 13 | guard let object = jsonObject as? T else { 14 | throw JSONDeserializerError.jsonDeserializableInitFailed( 15 | "Wrong result type: \(type(of: jsonObject)). Expected \(T.self)" 16 | ) 17 | } 18 | return object 19 | } 20 | ) 21 | ) 22 | } 23 | } 24 | 25 | extension JSONDeserializer where T: Decodable { 26 | public class func singleObjectDeserializer(keyPath path: String...) -> JSONDeserializer { 27 | JSONDeserializer( 28 | transformer: DataTransformer( 29 | transform: { data in 30 | let jsonDecoder = JSONDecoder() 31 | do { 32 | if path.isEmpty { 33 | return try jsonDecoder.decode(T.self, from: data) 34 | } else { 35 | return try jsonDecoder.decode(T.self, from: data, keyPath: path.joined(separator: ".")) 36 | } 37 | } catch { 38 | throw JSONDeserializerError.jsonDeserializableInitFailed( 39 | "Failed to create \(T.self) object from path \(path)." 40 | ) 41 | } 42 | } 43 | ) 44 | ) 45 | } 46 | 47 | public class func collectionDeserializer(keyPath path: String...) -> JSONDeserializer<[T]> { 48 | JSONDeserializer<[T]>( 49 | transformer: DataTransformer( 50 | transform: { data in 51 | let jsonDecoder = JSONDecoder() 52 | do { 53 | if path.isEmpty { 54 | return try jsonDecoder.decode([T].self, from: data) 55 | } else { 56 | return try jsonDecoder.decode([T].self, from: data, keyPath: path.joined(separator: ".")) 57 | } 58 | } catch { 59 | throw JSONDeserializerError.jsonDeserializableInitFailed( 60 | "Failed to create array of \(T.self) objects." 61 | ) 62 | } 63 | } 64 | ) 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Kite/Deserializers/ResponseDataDeserializer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class ResponseDataDeserializer { 4 | private let transformer: DataTransformer 5 | 6 | public init(transformer: DataTransformer) { 7 | self.transformer = transformer 8 | } 9 | 10 | public func deserialize(data: Data) async throws -> T { 11 | return try transformer.transform(data) 12 | } 13 | } 14 | 15 | public class VoidDeserializer: ResponseDataDeserializer { 16 | public init() { 17 | super.init(transformer: DataTransformer()) 18 | } 19 | 20 | @available(*, unavailable, message: "Use the default initializer instead") 21 | override public init(transformer: DataTransformer = .init()) { 22 | fatalError("This initializer is unavailable. Use the default initializer instead.") 23 | } 24 | } 25 | 26 | public class RawDataDeserializer: ResponseDataDeserializer { 27 | public init() { 28 | super.init(transformer: DataTransformer()) 29 | } 30 | 31 | @available(*, unavailable, message: "Use the default initializer instead") 32 | override public init(transformer: DataTransformer = .init()) { 33 | fatalError("This initializer is unavailable. Use the default initializer instead.") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Kite/Deserializers/XMLDeserializer/XMLDeserializer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SWXMLHash 3 | 4 | public enum XMLDeserializerError: Error { 5 | case xmlDeserializationFailed(String) 6 | } 7 | 8 | public class XMLDeserializer: ResponseDataDeserializer { 9 | public convenience init() { 10 | self.init( 11 | transformer: DataTransformer( 12 | transform: { xmlObject -> T in 13 | if let xmlObject = xmlObject as? T { 14 | return xmlObject 15 | } 16 | throw XMLDeserializerError.xmlDeserializationFailed( 17 | "Wrong result type: \(type(of: xmlObject)). Expected \(T.self)" 18 | ) 19 | } 20 | ) 21 | ) 22 | } 23 | } 24 | 25 | extension XMLDeserializer where T: XMLObjectDeserialization { 26 | public class func singleObjectDeserializer(keyPath path: String...) -> XMLDeserializer { 27 | XMLDeserializer( 28 | transformer: DataTransformer( 29 | transform: { xmlData in 30 | let xml = XMLHash.lazy(xmlData) 31 | return try xml[path].value() 32 | } 33 | ) 34 | ) 35 | } 36 | 37 | public class func collectionDeserializer(keyPath path: String...) -> XMLDeserializer<[T]> { 38 | XMLDeserializer<[T]>( 39 | transformer: DataTransformer( 40 | transform: { xmlData in 41 | let xml = XMLHash.lazy(xmlData) 42 | return try xml[path].value() 43 | } 44 | ) 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Kite/Extensions/AuthRequestProtocol+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthRequestProtocol+Extensions.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | import Foundation 9 | 10 | extension AuthRequestProtocol { 11 | public var accessTokenPrefix: String { 12 | "Bearer" 13 | } 14 | 15 | public var headers: [String: String] { 16 | [ 17 | "Authorization": "\(accessTokenPrefix) \(accessToken)" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Kite/Extensions/HTTPRequestProtocol+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension HTTPRequestProtocol { 4 | public var url: URL? { 5 | baseURL.appendingPathComponent(self.path) 6 | } 7 | 8 | public var path: String { 9 | "" 10 | } 11 | 12 | public var method: HTTPMethod { 13 | .get 14 | } 15 | 16 | public var parameters: [String: Any]? { 17 | nil 18 | } 19 | 20 | public var headers: [String: String] { 21 | [:] 22 | } 23 | 24 | public var multipartFormData: [String: URL]? { 25 | nil 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Kite/Extensions/JSONDecoder+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension JSONDecoder { 4 | 5 | /// Decode value at the keypath of the given type from the given JSON representation 6 | /// 7 | /// - Parameters: 8 | /// - type: The type of the value to decode. 9 | /// - data: The data to decode from. 10 | /// - keyPath: The JSON keypath 11 | /// - keyPathSeparator: Nested keypath separator 12 | /// - Returns: A value of the requested type. 13 | /// - Throws: An error if any value throws an error during decoding. 14 | func decode(_ type: T.Type, 15 | from data: Data, 16 | keyPath: String, 17 | keyPathSeparator separator: String = ".") throws -> T where T: Decodable { 18 | userInfo[UserInfoKeys.decodingContext] = keyPath.components(separatedBy: separator) 19 | return try decode(KeyPathWrapper.self, from: data).object 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Kite/Extensions/XMLIndexer+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SWXMLHash 3 | 4 | extension XMLIndexer { 5 | subscript(keys: [String]) -> XMLIndexer { 6 | keys.reduce(self) { current, key in 7 | current[key] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Kite/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | public enum HTTPMethod: String { 2 | case connect = "CONNECT" 3 | case delete = "DELETE" 4 | case get = "GET" 5 | case head = "HEAD" 6 | case options = "OPTIONS" 7 | case patch = "PATCH" 8 | case post = "POST" 9 | case put = "PUT" 10 | case query = "QUERY" 11 | case trace = "TRACE" 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Kite/Protocols/AuthRequestProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthRequestProtocol.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | public protocol AuthRequestProtocol: HTTPRequestProtocol { 9 | var accessToken: String { get } 10 | var accessTokenPrefix: String { get } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Kite/Protocols/DataTransformerProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol DataTransformerProtocol { 4 | associatedtype Output 5 | var transform: (Data) throws -> Output { get } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Kite/Protocols/DeserializeableRequestProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeserializeableRequestProtocol.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | public protocol DeserializeableRequestProtocol: HTTPRequestProtocol { 9 | associatedtype ResponseType 10 | var deserializer: ResponseDataDeserializer { get } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Kite/Protocols/HTTPRequestProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol HTTPRequestProtocol { 4 | var baseURL: URL { get } 5 | var path: String { get } 6 | var parameters: [String: Any]? { get } 7 | var method: HTTPMethod { get } 8 | var multipartFormData: [String: URL]? { get } 9 | var headers: [String: String] { get } 10 | } 11 | -------------------------------------------------------------------------------- /Tests/KiteTests/APIClientTests/APIClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIClientTests.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 10.03.2025. 6 | // 7 | 8 | import Foundation 9 | import Kite 10 | import Testing 11 | 12 | @Suite("APIClientTests") 13 | struct APIClientTests { 14 | @Test("execute(request:) returns expected raw data") 15 | func testExecuteReturnsExpectedRawData() async throws { 16 | let client = APIClient(urlSession: makeMockSession()) 17 | let expectedData = "Test Data".data(using: .utf8)! 18 | let expectedResponse = HTTPURLResponse( 19 | url: URL(string: "https://example.com/test")!, 20 | statusCode: 200, 21 | httpVersion: nil, 22 | headerFields: nil 23 | )! 24 | 25 | let dummyRequest = FetchRawDataRequest() 26 | 27 | await MockURLHandlerStore.shared.updateRequestHandler(for: dummyRequest.id.uuidString) { 28 | _ in 29 | return (expectedData, expectedResponse) 30 | } 31 | 32 | let (data, _) = try await client.execute( 33 | request: dummyRequest, 34 | deserializer: RawDataDeserializer() 35 | ) 36 | 37 | #expect(data == expectedData) 38 | } 39 | 40 | @Test("execute(request:) handles authenticated request correctly") 41 | func testExecuteHandlesAuthenticatedRequestCorrectly() async throws { 42 | let client = APIClient(urlSession: makeMockSession()) 43 | let expectedData = "Authenticated Data".data(using: .utf8)! 44 | let expectedResponse = HTTPURLResponse( 45 | url: URL(string: "https://example.com/auth")!, 46 | statusCode: 200, 47 | httpVersion: nil, 48 | headerFields: nil 49 | )! 50 | 51 | let dummyRequest = FetchRawDataAuthRequest(accessToken: UUID().uuidString) 52 | 53 | await MockURLHandlerStore.shared.updateRequestHandler(for: dummyRequest.id.uuidString) { 54 | _ in 55 | return (expectedData, expectedResponse) 56 | } 57 | 58 | let (data, _) = try await client.execute( 59 | request: dummyRequest, 60 | deserializer: RawDataDeserializer() 61 | ) 62 | 63 | #expect(data == expectedData) 64 | } 65 | 66 | @Test("execute(request:) deserializes JSON response correctly") 67 | func testExecuteDeserializesJSONResponseCorrectly() async throws { 68 | let client = APIClient(urlSession: makeMockSession()) 69 | let expectedData = JSONStubs.singlePerson.data(using: .utf8)! 70 | let expectedTestPerson = TestPerson.sample 71 | let expectedResponse = HTTPURLResponse( 72 | url: URL(string: "https://example.com/test")!, 73 | statusCode: 200, 74 | httpVersion: nil, 75 | headerFields: nil 76 | )! 77 | 78 | let dummyRequest = FetchSingleTestPersonJSONRequest() 79 | 80 | await MockURLHandlerStore.shared.updateRequestHandler(for: dummyRequest.id.uuidString) { 81 | _ in 82 | return (expectedData, expectedResponse) 83 | } 84 | 85 | let (result, _) = try await client.execute(request: dummyRequest) 86 | #expect(result == expectedTestPerson) 87 | } 88 | 89 | @Test("execute(request:) deserializes XML response correctly") 90 | func testExecuteDeserializesXMLResponseCorrectly() async throws { 91 | let client = APIClient(urlSession: makeMockSession()) 92 | let expectedData = XMLStubs.singlePerson.data(using: .utf8)! 93 | let expectedTestPerson = TestPerson.sample 94 | let expectedResponse = HTTPURLResponse( 95 | url: URL(string: "https://example.com/test")!, 96 | statusCode: 200, 97 | httpVersion: nil, 98 | headerFields: nil 99 | )! 100 | 101 | let dummyRequest = FetchSingleTestPersonXMLRequest() 102 | 103 | await MockURLHandlerStore.shared.updateRequestHandler(for: dummyRequest.id.uuidString) { 104 | _ in 105 | return (expectedData, expectedResponse) 106 | } 107 | 108 | let (result, _) = try await client.execute(request: dummyRequest) 109 | #expect(result == expectedTestPerson) 110 | } 111 | 112 | @Test("execute(request:) handles multipart form data correctly") 113 | func testExecuteHandlesMultipartFormDataCorrectly() async throws { 114 | let client = APIClient(urlSession: makeMockSession()) 115 | let expectedLogoURL = URL(string: "https://example.com/swift_logo.png")! 116 | let expectedData = """ 117 | { 118 | "logo_url": "\(expectedLogoURL.absoluteString)" 119 | } 120 | """ 121 | .data(using: .utf8)! 122 | 123 | let expectedResponse = HTTPURLResponse( 124 | url: URL(string: "https://example.com/upload")!, 125 | statusCode: 200, 126 | httpVersion: nil, 127 | headerFields: nil 128 | )! 129 | 130 | let dummyRequest = SendMultipartFormDataRequest( 131 | accessToken: UUID().uuidString, 132 | multipartFormData: [ 133 | "file": Bundle.module.url(forResource: "swift_logo", withExtension: "png")! 134 | ] 135 | ) 136 | 137 | await MockURLHandlerStore.shared.updateRequestHandler(for: dummyRequest.id.uuidString) { 138 | _ in 139 | return (expectedData, expectedResponse) 140 | } 141 | 142 | let (logoURL, urlResponse) = try await client.execute(request: dummyRequest) 143 | 144 | let httpURLResponse = try #require(urlResponse as? HTTPURLResponse) 145 | #expect(httpURLResponse.statusCode == 200) 146 | #expect(logoURL == expectedLogoURL) 147 | } 148 | } 149 | 150 | extension APIClientTests { 151 | fileprivate func makeMockSession() -> URLSession { 152 | let configuration = URLSessionConfiguration.ephemeral 153 | configuration.protocolClasses = [MockURLProtocol.self] 154 | return URLSession(configuration: configuration) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Tests/KiteTests/JSONDeserializerTests/JSONDeserializerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONDeserializerTests.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 10.03.2025. 6 | // 7 | 8 | import Testing 9 | import Kite 10 | 11 | @Suite("JSONDeserializerTests") 12 | struct JSONDeserializerTests { 13 | @Test("Single object deserializer decodes correctly") 14 | func testSingleObjectDeserializer() async throws { 15 | let data = JSONStubs.singlePerson.data(using: .utf8)! 16 | let deserializer = JSONDeserializer.singleObjectDeserializer() 17 | let person = try await deserializer.deserialize(data: data) 18 | #expect(person == TestPerson(name: "John", age: 30)) 19 | } 20 | 21 | @Test("Collection deserializer decodes correctly") 22 | func testCollectionDeserializer() async throws { 23 | let data = JSONStubs.personCollection.data(using: .utf8)! 24 | let deserializer = JSONDeserializer.collectionDeserializer() 25 | let persons = try await deserializer.deserialize(data: data) 26 | #expect(persons == [ 27 | TestPerson(name: "John", age: 30), 28 | TestPerson(name: "Jane", age: 25) 29 | ]) 30 | } 31 | 32 | @Test("Single object deserializer fails on invalid JSON") 33 | func testSingleObjectDeserializerFailure() async { 34 | let invalidJSON = "Not a JSON".data(using: .utf8)! 35 | let deserializer = JSONDeserializer.singleObjectDeserializer() 36 | await #expect(throws: (any Error).self) { 37 | _ = try await deserializer.deserialize(data: invalidJSON) 38 | } 39 | } 40 | 41 | // MARK: - Tests with keyPath parameter 42 | 43 | @Test("Nested single object deserializer decodes correctly with keyPath") 44 | func testNestedSingleObjectDeserializer() async throws { 45 | let data = JSONStubs.nestedSinglePerson.data(using: .utf8)! 46 | let deserializer = JSONDeserializer.singleObjectDeserializer(keyPath: "response", "person") 47 | let person = try await deserializer.deserialize(data: data) 48 | #expect(person == TestPerson(name: "John", age: 30)) 49 | } 50 | 51 | @Test("Nested collection deserializer decodes correctly with keyPath") 52 | func testNestedCollectionDeserializer() async throws { 53 | let data = JSONStubs.nestedPersonCollection.data(using: .utf8)! 54 | let deserializer = JSONDeserializer.collectionDeserializer(keyPath: "response", "persons") 55 | let persons = try await deserializer.deserialize(data: data) 56 | #expect(persons == [ 57 | TestPerson(name: "John", age: 30), 58 | TestPerson(name: "Jane", age: 25) 59 | ]) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/KiteTests/KiteTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "B4E9ED3A-55EB-4287-BD7D-834D18FD3266", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "testTimeoutsEnabled" : true 13 | }, 14 | "testTargets" : [ 15 | { 16 | "target" : { 17 | "containerPath" : "container:", 18 | "identifier" : "KiteTests", 19 | "name" : "KiteTests" 20 | } 21 | } 22 | ], 23 | "version" : 1 24 | } 25 | -------------------------------------------------------------------------------- /Tests/KiteTests/Mocks/MockURLHandlerStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockURLHandlerStore.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | import Foundation 9 | 10 | final actor MockURLHandlerStore { 11 | private typealias Handler = @Sendable (URLRequest) throws -> (Data, URLResponse) 12 | static let shared = MockURLHandlerStore() 13 | private init() {} 14 | private var handlers: [String: Handler] = [:] 15 | func updateRequestHandler( 16 | for id: String, 17 | requestHandler: @escaping @Sendable (URLRequest) throws -> (Data, URLResponse) 18 | ) { 19 | handlers[id] = requestHandler 20 | } 21 | 22 | func handler(for id: String) -> (@Sendable (URLRequest) throws -> (Data, URLResponse))? { 23 | handlers[id] 24 | } 25 | 26 | func removeHandler(for id: String) { 27 | handlers.removeValue(forKey: id) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/KiteTests/Mocks/MockURLProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockURLProtocol.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | import Foundation 9 | 10 | final class MockURLProtocol: URLProtocol, @unchecked Sendable { 11 | enum Error: Swift.Error { 12 | case missedXTestIDHeader 13 | case missedRequestHandler 14 | } 15 | 16 | override class func canInit(with request: URLRequest) -> Bool { 17 | return true 18 | } 19 | 20 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 21 | return request 22 | } 23 | 24 | override func startLoading() { 25 | Task { 26 | guard let testID = self.request.value(forHTTPHeaderField: "X-Test-ID") else { 27 | throw Error.missedXTestIDHeader 28 | } 29 | guard let handler = await MockURLHandlerStore.shared.handler(for: testID) else { 30 | throw Error.missedRequestHandler 31 | } 32 | do { 33 | let (data, response) = try handler(self.request) 34 | await MainActor.run { [weak self] in 35 | guard let self else { return } 36 | self.client?.urlProtocol( 37 | self, didReceive: response, cacheStoragePolicy: .notAllowed) 38 | self.client?.urlProtocol(self, didLoad: data) 39 | self.client?.urlProtocolDidFinishLoading(self) 40 | } 41 | } catch { 42 | await MainActor.run { [weak self] in 43 | guard let self else { return } 44 | self.client?.urlProtocol(self, didFailWithError: error) 45 | } 46 | } 47 | } 48 | } 49 | 50 | override func stopLoading() {} 51 | } 52 | -------------------------------------------------------------------------------- /Tests/KiteTests/Mocks/Models/TestPerson.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestPerson.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | import SWXMLHash 9 | 10 | struct TestPerson: Codable, Equatable, XMLObjectDeserialization { 11 | let name: String 12 | let age: Int 13 | 14 | static let sample = TestPerson(name: "John", age: 30) 15 | 16 | static func deserialize(_ node: XMLIndexer) throws -> Self { 17 | return try Self( 18 | name: node["name"].value(), 19 | age: node["age"].value() 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/KiteTests/Mocks/Requests/FetchRawDataAuthRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Kite 3 | 4 | struct FetchRawDataAuthRequest: AuthRequestProtocol & DeserializeableRequestProtocol { 5 | let id: UUID 6 | let accessToken: String 7 | var baseURL: URL { URL(string: "https://example.com")! } 8 | var deserializer: ResponseDataDeserializer { 9 | RawDataDeserializer() 10 | } 11 | 12 | var headers: [String: String] { 13 | [ 14 | "X-Test-ID": id.uuidString, 15 | "Authorization": "\(accessTokenPrefix) \(accessToken)" 16 | ] 17 | } 18 | 19 | init(accessToken: String, id: UUID = UUID()) { 20 | self.accessToken = accessToken 21 | self.id = id 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/KiteTests/Mocks/Requests/FetchRawDataRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchRawDataRequest.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | import Foundation 9 | import Kite 10 | 11 | struct FetchRawDataRequest: HTTPRequestProtocol { 12 | var baseURL: URL { URL(string: "https://example.com")! } 13 | var path: String { "test" } 14 | var headers: [String: String] { ["X-Test-ID": id.uuidString] } 15 | 16 | let id = UUID() 17 | } 18 | -------------------------------------------------------------------------------- /Tests/KiteTests/Mocks/Requests/FetchSingleTestPersonJSONRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchSingleTestPersonJSONRequest.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | import Foundation 9 | import Kite 10 | 11 | struct FetchSingleTestPersonJSONRequest: DeserializeableRequestProtocol { 12 | var baseURL: URL { URL(string: "https://example.com")! } 13 | var path: String { "test" } 14 | var headers: [String: String] { ["X-Test-ID": id.uuidString] } 15 | 16 | let id = UUID() 17 | 18 | var deserializer: ResponseDataDeserializer { 19 | JSONDeserializer.singleObjectDeserializer() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/KiteTests/Mocks/Requests/FetchSingleTestPersonXMLRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchSingleTestPersonXMLRequest.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | import Foundation 9 | import Kite 10 | 11 | struct FetchSingleTestPersonXMLRequest: DeserializeableRequestProtocol { 12 | var baseURL: URL { URL(string: "https://example.com")! } 13 | var path: String { "test" } 14 | var headers: [String: String] { ["X-Test-ID": id.uuidString] } 15 | 16 | let id = UUID() 17 | 18 | var deserializer: ResponseDataDeserializer { 19 | XMLDeserializer.singleObjectDeserializer(keyPath: "response", "person") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/KiteTests/Mocks/Requests/SendMultipartFormDataRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Kite 3 | 4 | struct SendMultipartFormDataRequest: AuthRequestProtocol & DeserializeableRequestProtocol { 5 | let id: UUID 6 | let accessToken: String 7 | var baseURL: URL { URL(string: "https://example.com")! } 8 | var path: String { "/upload" } 9 | var method: HTTPMethod { .post } 10 | let multipartFormData: [String: URL]? 11 | 12 | var headers: [String: String] { 13 | [ 14 | "X-Test-ID": id.uuidString, 15 | "Authorization": "\(accessTokenPrefix) \(accessToken)" 16 | ] 17 | } 18 | 19 | var deserializer: ResponseDataDeserializer { 20 | JSONDeserializer.singleObjectDeserializer(keyPath: "logo_url") 21 | } 22 | 23 | init(accessToken: String, multipartFormData: [String: URL], id: UUID = UUID()) { 24 | self.accessToken = accessToken 25 | self.multipartFormData = multipartFormData 26 | self.id = id 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/KiteTests/Stubs/BinaryStubs/swift_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemkalinovsky/Kite/a2a4123f5e1ef238c860439a1a91899590e5e378/Tests/KiteTests/Stubs/BinaryStubs/swift_logo.png -------------------------------------------------------------------------------- /Tests/KiteTests/Stubs/JSONStubs/JSONStubs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONStubs.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | enum JSONStubs { 9 | static let singlePerson = """ 10 | { 11 | "name": "John", 12 | "age": 30 13 | } 14 | """ 15 | 16 | static let personCollection = """ 17 | [ 18 | { "name": "John", "age": 30 }, 19 | { "name": "Jane", "age": 25 } 20 | ] 21 | """ 22 | 23 | static let nestedSinglePerson = """ 24 | { 25 | "response": { 26 | "person": { 27 | "name": "John", 28 | "age": 30 29 | } 30 | } 31 | } 32 | """ 33 | 34 | static let nestedPersonCollection = """ 35 | { 36 | "response": { 37 | "persons": [ 38 | { "name": "John", "age": 30 }, 39 | { "name": "Jane", "age": 25 } 40 | ] 41 | } 42 | } 43 | """ 44 | } 45 | -------------------------------------------------------------------------------- /Tests/KiteTests/Stubs/XMLStubs/XMLStubs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XMLStubs.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 11.03.2025. 6 | // 7 | 8 | enum XMLStubs { 9 | static let singlePerson = """ 10 | 11 | 12 | John 13 | 30 14 | 15 | 16 | """ 17 | 18 | static let personCollection = """ 19 | 20 | 21 | 22 | John 23 | 30 24 | 25 | 26 | Jane 27 | 25 28 | 29 | 30 | 31 | """ 32 | } 33 | -------------------------------------------------------------------------------- /Tests/KiteTests/XMLDeserializerTests/XMLDeserializerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XMLDeserializerTests.swift 3 | // Kite 4 | // 5 | // Created by Artem Kalinovsky on 10.03.2025. 6 | // 7 | 8 | import Testing 9 | import SWXMLHash 10 | import Kite 11 | 12 | @Suite("XMLDeserializerTests") 13 | struct XMLDeserializerTests { 14 | @Test("Single object deserializer decodes correctly") 15 | func testSingleObjectDeserializer() async throws { 16 | let data = XMLStubs.singlePerson.data(using: .utf8)! 17 | let deserializer = XMLDeserializer.singleObjectDeserializer(keyPath: "response", "person") 18 | let person = try await deserializer.deserialize(data: data) 19 | 20 | let expected = TestPerson(name: "John", age: 30) 21 | #expect(person == expected) 22 | } 23 | 24 | @Test("Collection deserializer decodes correctly") 25 | func testCollectionDeserializer() async throws { 26 | let data = XMLStubs.personCollection.data(using: .utf8)! 27 | let deserializer = XMLDeserializer.collectionDeserializer(keyPath: "response", "persons", "person") 28 | let persons = try await deserializer.deserialize(data: data) 29 | let expected = [ 30 | TestPerson(name: "John", age: 30), 31 | TestPerson(name: "Jane", age: 25) 32 | ] 33 | #expect(persons == expected) 34 | } 35 | 36 | @Test("Single object deserializer fails on invalid XML") 37 | func testSingleObjectDeserializerFailure() async { 38 | let invalidXML = "" 39 | let data = invalidXML.data(using: .utf8)! 40 | let deserializer = XMLDeserializer.singleObjectDeserializer(keyPath: "response", "person") 41 | 42 | await #expect(throws: (any Error).self) { 43 | _ = try await deserializer.deserialize(data: data) 44 | } 45 | } 46 | } 47 | --------------------------------------------------------------------------------