├── .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 | 
2 | [](https://swift.org)
3 | [](https://developer.apple.com/macos/)
4 | [](https://developer.apple.com/ios/)
5 | [](https://developer.apple.com/tvos/)
6 | [](https://developer.apple.com/watchos/)
7 | [](https://developer.apple.com/driverkit/)
8 | [](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 |
--------------------------------------------------------------------------------