Skip to content

Commit d8d399b

Browse files
Squashed commit of the following:
commit aa41573 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Wed Mar 12 00:08:27 2025 +0100 Update GitHub Actions checkout action to version 4 commit 7acd361 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Wed Mar 12 00:02:29 2025 +0100 Refactor DataTransformer and ResponseDataDeserializer: enhance initializers and enforce usage of default initializer commit 39d8e2b Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 23:26:13 2025 +0100 Add DataTransformer and ResponseDataDeserializer: refactor deserialization protocols and improve data transformation handling commit d727bc1 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 18:47:30 2025 +0100 Refactor FetchRawDataAuthRequest: remove unnecessary whitespace and improve code readability commit 65ec39a Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 18:44:35 2025 +0100 Refactor APIClient: change access level from public final to public commit 2154aad Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 18:42:47 2025 +0100 Refactor APIClient and request protocols: streamline header handling and add support for authenticated requests commit b931f50 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 17:34:24 2025 +0100 Update README.md commit 4821195 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 17:21:51 2025 +0100 Add error handling to MockURLProtocol for missing headers and handlers commit 47f5483 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 17:13:39 2025 +0100 Refactor MockURLHandlerStore: simplify handler type definition and update method signatures for clarity commit e224d0a Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 17:07:01 2025 +0100 Add XML response deserialization test and request struct for APIClient commit 6330daf Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 16:42:45 2025 +0100 Refactor APIClientTests: rename test methods for clarity, add JSON request mocks commit 2f20f5c Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 16:27:32 2025 +0100 Update README.md commit 38b6cd0 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 16:23:47 2025 +0100 Update README.md commit f7c191c Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 16:21:38 2025 +0100 Update README.md commit 045e026 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 16:19:19 2025 +0100 Refactor APIClient and HTTPRequestProtocol: rename variables, remove deprecated extensions, and enhance URL handling commit 39026d1 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 15:54:43 2025 +0100 Update README.md commit 7ea464b Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 14:55:10 2025 +0100 Add support for DriverKit and visionOS commit f5acc47 Author: Artem Kalinovsky <artem.kalinovsky@gmail.com> Date: Tue Mar 11 14:44:20 2025 +0100 Update launch configuration and package structure
1 parent 6920d84 commit d8d399b

23 files changed

+470
-209
lines changed

.github/workflows/swift.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
with:
1515
xcode-version: latest-stable
1616

17-
- uses: actions/checkout@v3
17+
- uses: actions/checkout@v4
1818

1919
- name: Build
2020
run: swift build -v

README.md

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,121 @@
11
![swift workflow](https://github.com/artemkalinovsky/Kite/actions/workflows/swift.yml/badge.svg)
22

3-
🚧 WIP 🚧
4-
I'm still fine-tuning the code—hang tight! 🔧⚙️
5-
More updates coming soon! 🚀
3+
# Kite
64

7-
## Apps using 📱
5+
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.
6+
7+
### Features:
8+
9+
* ***Swift Concurrency (async/await)***: Easily manage asynchronous networking operations.
10+
* Lightweight API Client: A simple APIClient class lets you execute requests that conform to HTTPRequestProtocol or DeserializeableRequest.
11+
* JSON & XML Deserialization: Built-in JSONDeserializer and XMLDeserializer types for decoding server responses.
12+
13+
## Project Status
14+
15+
This project is considered production-ready. Contributions—whether pull requests, questions, or suggestions—are always welcome! 😃
16+
17+
## Installation 📦
18+
19+
* #### Swift Package Manager
20+
21+
You can use Xcode SPM GUI: *File -> Swift Packages -> Add Package Dependency -> Pick "Up to Next Major Version 3.0.0"*.
22+
23+
Or add the following to your `Package.swift` file:
24+
25+
``` swift
26+
.package(url: "https://github.com/artemkalinovsky/Kite.git", from: "3.0.0")
27+
28+
```
29+
30+
and then specify `"Kite"` as a dependency of the Target in which you wish to use Legatus.
31+
Here's an example `PackageDescription` :
32+
33+
``` swift
34+
// swift-tools-version:6.0
35+
import PackageDescription
36+
37+
let package = Package(
38+
name: "MyPackage",
39+
products: [
40+
.library(
41+
name: "MyPackage",
42+
targets: ["MyPackage"]),
43+
],
44+
dependencies: [
45+
.package(url: "https://github.com/artemkalinovsky/Kite.git", from: "3.0.0")
46+
],
47+
targets: [
48+
.target(
49+
name: "MyPackage",
50+
dependencies: ["Kite"])
51+
]
52+
)
53+
```
54+
## Usage 🧑‍💻
55+
56+
Let's suppose we want to fetch list of users from JSON and response is look like this:
57+
58+
``` json
59+
{
60+
"results":[
61+
{
62+
"name":{
63+
"first":"brad",
64+
"last":"gibson"
65+
},
66+
"email":"brad.gibson@example.com"
67+
}
68+
]
69+
}
70+
```
71+
72+
* #### Setup
73+
74+
1. Create `APIClient` :
75+
76+
``` swift
77+
let apiClient = APIClient()
78+
```
79+
80+
2. Create response model:
81+
82+
``` swift
83+
struct User: Decodable {
84+
struct Name: Decodable {
85+
let first: String
86+
let last: String
87+
}
88+
89+
let name: Name
90+
let email: String
91+
}
92+
```
93+
94+
3. Create request with endpoint path and desired reponse deserializer:
95+
96+
``` swift
97+
import Foundation
98+
import Kite
99+
100+
struct FetchRandomUsersRequest: DeserializeableRequest {
101+
var baseURL: URL { URL(string: "https://randomuser.me")! }
102+
var path: String {"api"}
103+
104+
var deserializer: ResponseDeserializer<[User]> {
105+
JSONDeserializer<User>.collectionDeserializer(keyPath: "results")
106+
}
107+
}
108+
```
109+
110+
* #### Perfrom created request
111+
112+
``` swift
113+
let users = try await apiClient.execute(request: FetchRandomUsersRequest())
114+
```
115+
116+
Voilà!🧑‍🎨
117+
118+
## Apps using Kite
8119

9120
- [PinPlace](https://apps.apple.com/ua/app/pinplace/id1571349149)
10121

Sources/Kite/APIClient.swift

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import Foundation
22

3-
public final class APIClient {
4-
private let urlSesion: URLSession
3+
public class APIClient {
4+
private let urlSession: URLSession
55

6-
public init(urlSesion: URLSession = URLSession.shared) {
7-
self.urlSesion = urlSesion
6+
public init(urlSession: URLSession = URLSession.shared) {
7+
self.urlSession = urlSession
88
}
99

10-
public func execute<T>(request: HTTPRequestProtocol, deserializer: ResponseDeserializer<T>) async throws -> T {
11-
guard let url = URL(string: request.fullPath ?? request.path) else {
10+
public func execute<T>(request: HTTPRequestProtocol, deserializer: ResponseDataDeserializer<T> = VoidDeserializer()) async throws -> T {
11+
guard let url = request.url else {
1212
throw URLError(.badURL)
1313
}
14+
1415
var urlRequest = URLRequest(url: url)
1516
urlRequest.httpMethod = request.method.rawValue
1617

17-
let headers = try request.headers()
18-
for (field, value) in headers {
18+
for (field, value) in request.headers {
1919
urlRequest.setValue(value, forHTTPHeaderField: field)
2020
}
2121

@@ -38,12 +38,20 @@ public final class APIClient {
3838
throw URLError(.unsupportedURL)
3939
}
4040

41-
let (data, _) = try await urlSesion.data(for: urlRequest)
41+
let (data, _) = try await urlSession.data(for: urlRequest)
4242

4343
return try await deserializer.deserialize(data: data)
4444
}
4545

4646
public func execute<R: DeserializeableRequest>(request: R) async throws -> R.ResponseType {
4747
try await execute(request: request, deserializer: request.deserializer)
4848
}
49+
50+
public func execute<R: AuthRequestProtocol & DeserializeableRequest>(request: R) async throws -> R.ResponseType {
51+
guard let authorizationHeader = request.headers["Authorization"], !authorizationHeader.isEmpty else {
52+
throw URLError(.userAuthenticationRequired)
53+
}
54+
55+
return try await execute(request: request, deserializer: request.deserializer)
56+
}
4957
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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<O>: 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+
}

Sources/Kite/Deserializers/JSONDeserializer/JSONDeserializer.swift

Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,52 +4,64 @@ public enum JSONDeserializerError: Error {
44
case jsonDeserializableInitFailed(String)
55
}
66

7-
open class JSONDeserializer<T>: ResponseDeserializer<T> {
7+
public class JSONDeserializer<T>: ResponseDataDeserializer<T> {
88
public convenience init() {
9-
self.init(transform: { data -> T in
10-
let jsonObject = try JSONSerialization.jsonObject(with: data)
11-
guard let object = jsonObject as? T else {
12-
throw JSONDeserializerError.jsonDeserializableInitFailed(
13-
"Wrong result type: \(type(of: jsonObject)). Expected \(T.self)"
14-
)
15-
}
16-
return object
17-
})
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+
)
1822
}
1923
}
2024

2125
extension JSONDeserializer where T: Decodable {
2226
public class func singleObjectDeserializer(keyPath path: String...) -> JSONDeserializer<T> {
23-
return JSONDeserializer<T>(transform: { data in
24-
let jsonDecoder = JSONDecoder()
25-
do {
26-
if path.isEmpty {
27-
return try jsonDecoder.decode(T.self, from: data)
28-
} else {
29-
return try jsonDecoder.decode(T.self, from: data, keyPath: path.joined(separator: "."))
27+
JSONDeserializer<T>(
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+
}
3042
}
31-
} catch {
32-
throw JSONDeserializerError.jsonDeserializableInitFailed(
33-
"Failed to create \(T.self) object from path \(path)."
34-
)
35-
}
36-
})
43+
)
44+
)
3745
}
3846

3947
public class func collectionDeserializer(keyPath path: String...) -> JSONDeserializer<[T]> {
40-
return JSONDeserializer<[T]>(transform: { data in
41-
let jsonDecoder = JSONDecoder()
42-
do {
43-
if path.isEmpty {
44-
return try jsonDecoder.decode([T].self, from: data)
45-
} else {
46-
return try jsonDecoder.decode([T].self, from: data, keyPath: path.joined(separator: "."))
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+
}
4763
}
48-
} catch {
49-
throw JSONDeserializerError.jsonDeserializableInitFailed(
50-
"Failed to create array of \(T.self) objects."
51-
)
52-
}
53-
})
64+
)
65+
)
5466
}
5567
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import Foundation
2+
3+
public class ResponseDataDeserializer<T> {
4+
private let transformer: DataTransformer<T>
5+
6+
public init(transformer: DataTransformer<T>) {
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<Void> {
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<Void> = .init()) {
22+
fatalError("This initializer is unavailable. Use the default initializer instead.")
23+
}
24+
}
25+
26+
public class RawDataDeserializer: ResponseDataDeserializer<Data> {
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<Data> = .init()) {
33+
fatalError("This initializer is unavailable. Use the default initializer instead.")
34+
}
35+
}

Sources/Kite/Deserializers/ResponseDeserializer.swift

Lines changed: 0 additions & 26 deletions
This file was deleted.

0 commit comments

Comments
 (0)