Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit 3315456

Browse files
committed
Document changes & update Usage section in README
1 parent ab921d7 commit 3315456

6 files changed

Lines changed: 195 additions & 99 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,22 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a
55

66
## [Unreleased]
77
### Added
8-
- None.
8+
- New `ApiProvider` type encapsulating different request methods with support for plugins.
9+
- New asynchronous `performRequest` methods with completion callbacks. ([#2](https://github.com/Flinesoft/Microya/issues/2))
10+
- New `Plugin` class to subclass for integrating into the networking logic via callbacks.
11+
- New pre-implemented plugins: `HttpBasicAuth`, `ProgressIndicator`, `RequestLogger`, `ResponseLogger`.
12+
- New `EmptyResponseBody` type to use whenever the body is expected to be empty or should be ignored.
913
### Changed
10-
- None.
14+
- Renamed `JsonApi` protocol to `Endpoint`.
15+
- Renamed `Method` to `HttpMethod` and added `body: Data` to the cases `post` and `patch`.
16+
- Moved the `request` method from `JsonApi` to the new `ApiProvider` & renamed to `performRequestAndWait`.
17+
- Generally improved the cases in `JsonApiError` & renamed the type to `ApiError`.
18+
- Moved CI from [Bitrise](https://www.bitrise.io/) to [GitHub Actions](https://github.com/Flinesoft/Microya/actions).
1119
### Deprecated
1220
- None.
1321
### Removed
14-
- None.
22+
- The `bodyData: Data?` requirement on `JsonApi` (bodies are not part of `HttpMethod`, see above).
23+
- Installation is no longer possible via [CocoaPods](https://github.com/CocoaPods/CocoaPods) or [Carthage](https://github.com/Carthage/Carthage). Please use [SwiftPM](https://github.com/apple/swift-package-manager) instead.
1524
### Fixed
1625
- None.
1726
### Security

README.md

Lines changed: 162 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -80,21 +80,20 @@ enum Language: String {
8080
}
8181
```
8282

83-
### Step 2: Making your Api `JsonApi` compliant
83+
### Step 2: Making your Api `Endpoint` compliant
8484

85-
Add an extension for your Api `enum` that makes it `JsonApi` compliant, which means you need to add implementations for the following protocol:
85+
Add an extension for your Api `enum` that makes it `Endpoint` compliant, which means you need to add implementations for the following protocol:
8686

8787
```Swift
88-
protocol JsonApi {
88+
public protocol Endpoint {
89+
associatedtype ClientErrorType: Decodable
8990
var decoder: JSONDecoder { get }
9091
var encoder: JSONEncoder { get }
91-
9292
var baseUrl: URL { get }
9393
var headers: [String: String] { get }
94-
var path: String { get }
95-
var method: Method { get }
96-
var queryParameters: [(key: String, value: String)] { get }
97-
var bodyData: Data? { get }
94+
var subpath: String { get }
95+
var method: HttpMethod { get }
96+
var queryParameters: [String: String] { get }
9897
}
9998
```
10099

@@ -104,7 +103,9 @@ Use `switch` statements over `self` to differentiate between the cases (if neede
104103
<summary>Toggle me to see an example</summary>
105104

106105
```Swift
107-
extension MicrosoftTranslatorApi: JsonApi {
106+
extension MicrosoftTranslatorEndpoint: Endpoint {
107+
typealias ClientErrorType = EmptyResponseType
108+
108109
var decoder: JSONDecoder {
109110
return JSONDecoder()
110111
}
@@ -116,66 +117,53 @@ extension MicrosoftTranslatorApi: JsonApi {
116117
var baseUrl: URL {
117118
return URL(string: "https://api.cognitive.microsofttranslator.com")!
118119
}
119-
120-
var path: String {
120+
121+
var headers: [String: String] {
121122
switch self {
122123
case .languages:
123-
return "/languages"
124+
return [:]
124125

125126
case .translate:
126-
return "/translate"
127+
return [
128+
"Ocp-Apim-Subscription-Key": "<SECRET>",
129+
"Content-Type": "application/json"
130+
]
127131
}
128132
}
129133

130-
var method: Method {
134+
var subpath: String {
131135
switch self {
132136
case .languages:
133-
return .get
137+
return "/languages"
134138

135139
case .translate:
136-
return .post
140+
return "/translate"
137141
}
138142
}
139143

140-
var queryParameters: [(key: String, value: String)] {
141-
var urlParameters: [(String, String)] = [(key: "api-version", value: "3.0")]
142-
144+
var method: HttpMethod {
143145
switch self {
144146
case .languages:
145-
break
146-
147-
case let .translate(_, sourceLanguage, targetLanguages, _):
148-
urlParameters.append((key: "from", value: sourceLanguage.rawValue))
147+
return .get
149148

150-
for targetLanguage in targetLanguages {
151-
urlParameters.append((key: "to", value: targetLanguage.rawValue))
152-
}
149+
case let .translate(texts, _, _):
150+
return .post(try! encoder.encode(texts))
153151
}
154-
155-
return urlParameters
156152
}
157153

158-
var bodyData: Data? {
159-
switch self {
160-
case .translate:
161-
return nil // no request data needed
154+
var queryParameters: [String: String] {
155+
var queryParameters: [String: String] = ["api-version": "3.0"]
162156

163-
case let .translate(texts, _, _, _):
164-
return try! encoder.encode(texts)
165-
}
166-
}
167-
168-
var headers: [String: String] {
169157
switch self {
170158
case .languages:
171-
return [:]
159+
break
172160

173-
case .translate:
174-
return [
175-
"Ocp-Apim-Subscription-Key": "<SECRET>",
176-
"Content-Type": "application/json"
177-
]
161+
case let .translate(_, sourceLanguage, targetLanguages, _):
162+
queryParameters["from"] = sourceLanguage.rawValue
163+
queryParameters["to"] = targetLanguages.map { $0.rawValue }.joined(by: ",")
178164
}
165+
166+
return queryParameters
179167
}
180168
}
181169
```
@@ -184,36 +172,158 @@ extension MicrosoftTranslatorApi: JsonApi {
184172

185173
### Step 3: Calling your API endpoint with the Result type
186174

187-
Call an API endpoint providing a `Decodable` type of the expected result (if any) by using this method pre-implemented in the `JsonApi` protocol:
175+
Call an API endpoint providing a `Decodable` type of the expected result (if any) by using one of the methods pre-implemented in the `ApiProvider` type:
188176

189177
```Swift
190-
func request<ResultType: Decodable>(type: ResultType.Type) -> Result<ResultType, JsonApiError>
178+
/// Performs the asynchornous request for the chosen endpoint and calls the completion closure with the result.
179+
performRequest<T: Decodable>(
180+
on endpoint: EndpointType,
181+
decodeBodyTo: T,
182+
completion: @escaping (Result<T, ApiError<ClientErrorType>>) -> Void
183+
)
184+
185+
/// Performs the request for the chosen endpoint synchronously (waits for the result) and returns the result.
186+
public func performRequestAndWait<ResultType: Decodable>(
187+
on endpoint: EndpointType,
188+
decodeBodyTo bodyType: ResultType.Type
189+
)
190+
```
191+
192+
There's also extra methods for endpoints where you don't expect a response body:
193+
194+
```swift
195+
/// Performs the asynchronous request for the chosen write-only endpoint and calls the completion closure with the result.
196+
performRequest(on endpoint: EndpointType, completion: @escaping (Result<T, ApiError<ClientErrorType>>) -> Void)
197+
198+
/// Performs the request for the chosen write-only endpoint synchronously (waits for the result).
199+
performRequestAndWait(on endpoint: EndpointType) -> Result<EmptyBodyResponse, ApiError<ClientErrorType>>
191200
```
192201

193-
For example:
202+
The `EmptyBodyResponse` returned here is just an empty type, so you can just ignore it.
203+
204+
Here's a full example of a call you could make with Mircoya:
194205

195206
```Swift
196-
let endpoint = MicrosoftTranslatorApi.translate(texts: ["Test"], from: .english, to: [.german, .japanese, .turkish])
207+
let provider = ApiProvider<MicrosoftTranslatorEndpoint>()
208+
let endpoint = MicrosoftTranslatorEndpoint.translate(texts: ["Test"], from: .english, to: [.german, .japanese, .turkish])
209+
210+
provider.performRequest(on: endpoint, decodeBodyTo: [String: String].self) { result in
211+
switch result {
212+
case let .success(translationsByLanguage):
213+
// use the already decoded `[String: String]` result
197214

198-
switch endpoint.request(type: [String: String].self) {
215+
case let .failure(apiError):
216+
// error handling
217+
}
218+
}
219+
220+
// OR, if you prefere a synchronous call, use the `AndWait` variant
221+
222+
switch provider.performRequestAndWait(on: endpoint, decodeBodyTo: [String: String].self) {
199223
case let .success(translationsByLanguage):
200224
// use the already decoded `[String: String]` result
201225

202-
case let .failure(error):
226+
case let .failure(apiError):
203227
// error handling
204228
}
205229
```
206230

207231
Note that you can also use the throwing `get()` function of Swift 5's `Result` type instead of using a `switch` statement:
208232

209233
```Swift
210-
let endpoint = MicrosoftTranslatorApi.translate(texts: ["Test"], from: .english, to: [.german, .japanese, .turkish])
211-
let translationsByLanguage = try endpoint.request(type: [String: String].self).get()
234+
provider.performRequest(on: endpoint, decodeBodyTo: [String: String].self) { result in
235+
let translationsByLanguage = try result.get()
236+
// use the already decoded `[String: String]` result
237+
}
238+
239+
// OR, if you prefere a synchronous call, use the `AndWait` variant
240+
241+
let translationsByLanguage = try provider.performRequestAndWait(on: endpoint, decodeBodyTo: [String: String].self).get()
212242
// use the already decoded `[String: String]` result
213243
```
214244

215245
There's even useful functional methods defined on the `Result` type like `map()`, `flatMap()` or `mapError()` and `flatMapError()`. See the "Transforming Result" section in [this](https://www.hackingwithswift.com/articles/161/how-to-use-result-in-swift) article for more information.
216246

247+
### Plugins
248+
249+
The initializer of `ApiProvider` accepts an array of `Plugin` objects. You can implement your own plugins or use one of the existing ones in the [Plugins](https://github.com/Flinesoft/Microya/tree/main/Sources/Microya/Plugins) directory. Here's are the callbacks a custom `Plugin` subclass can override:
250+
251+
```swift
252+
/// Called to modify a request before sending.
253+
modifyRequest(_ request: inout URLRequest, endpoint: EndpointType)
254+
255+
/// Called immediately before a request is sent.
256+
willPerformRequest(_ request: URLRequest, endpoint: EndpointType)
257+
258+
/// Called after a response has been received & decoded, but before calling the completion handler.
259+
didPerformRequest<ResultType: Decodable>(
260+
urlSessionResult: (data: Data?, response: URLResponse?, error: Error?),
261+
typedResult: Result<ResultType, ApiError<EndpointType.ClientErrorType>>,
262+
endpoint: EndpointType
263+
)
264+
```
265+
266+
<details>
267+
<summary>Toggle me to see a full custom plugin example</summary>
268+
269+
Here's a possible implementation of a `RequestResponseLoggerPlugin` that logs using `print`:
270+
271+
```swift
272+
class RequestResponseLoggerPlugin<EndpointType: Endpoint>: Plugin<EndpointType> {
273+
override func willPerformRequest(_ request: URLRequest, endpoint: EndpointType) {
274+
print("Endpoint: \(endpoint), Request: \(request)")
275+
}
276+
277+
override func didPerformRequest<ResultType: Decodable>(
278+
urlSessionResult: ApiProvider<EndpointType>.URLSessionResult,
279+
typedResult: ApiProvider<EndpointType>.TypedResult<ResultType>,
280+
endpoint: EndpointType
281+
) {
282+
print("Endpoint: \(endpoint), URLSession result: \(urlSessionResult), Typed result: \(typedResult)")
283+
}
284+
}
285+
286+
```
287+
288+
</details>
289+
290+
291+
292+
### Shortcuts
293+
294+
`Endpoint` provides default implementations for most of its required methods, namely:
295+
296+
```swift
297+
public var decoder: JSONDecoder { JSONDecoder() }
298+
299+
public var encoder: JSONEncoder { JSONEncoder() }
300+
301+
public var plugins: [Plugin<Self>] { [] }
302+
303+
public var headers: [String: String] {
304+
[
305+
"Content-Type": "application/json",
306+
"Accept": "application/json",
307+
"Accept-Language": Locale.current.languageCode ?? "en"
308+
]
309+
}
310+
311+
public var queryParameters: [String: String] { [:] }
312+
```
313+
314+
So technically, the `Endpoint` type only requires you to specify the following 4 things:
315+
316+
```swift
317+
protocol Endpoint {
318+
associatedtype ClientErrorType: Decodable
319+
var baseUrl: URL { get }
320+
var subpath: String { get }
321+
var method: HttpMethod { get }
322+
}
323+
```
324+
325+
This can be a time (/ code) saver for simple APIs you want to access.
326+
You can also use `EmptyBodyResponse` type for `ClientErrorType` to ignore the client error body structure.
217327

218328
## Donation
219329

Sources/Microya/Core/ApiError.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public enum ApiError<ClientErrorType: Decodable>: Error {
1515
case responseDataConversionFailed(type: String, error: Error)
1616

1717
/// The request was sent and the server responded, but the server reports that something is wrong with the request.
18-
case clientError(statusCode: Int, clientError: ClientErrorType)
18+
case clientError(statusCode: Int, clientError: ClientErrorType?)
1919

2020
/// The request was sent and the server responded, but there seems to be an error which needs to be fixed on the server.
2121
case serverError(statusCode: Int)

Sources/Microya/Core/ApiProvider.swift

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ open class ApiProvider<EndpointType: Endpoint> {
1515
public let plugins: [Plugin<EndpointType>]
1616

1717
/// Initializes a new API provider with the given plugins applied to every request.
18-
public init(plugins: [Plugin<EndpointType>]) {
18+
public init(plugins: [Plugin<EndpointType>] = []) {
1919
self.plugins = plugins
2020
}
2121

22-
/// Performs the asynchornous request for the chosen write-only endpoint and calls the completion closure with the result.
22+
/// Performs the asynchronous request for the chosen write-only endpoint and calls the completion closure with the result.
2323
/// Returns a `EmptyBodyResponse` on success.
2424
///
2525
/// - WARNING: Do not use this if you expect a body response, use `performRequest(decodeBodyTo:complation:)` instead.
@@ -36,7 +36,7 @@ open class ApiProvider<EndpointType: Endpoint> {
3636
self.performRequestAndWait(on: endpoint, decodeBodyTo: EmptyBodyResponse.self)
3737
}
3838

39-
/// Performs the asynchornous request for the chosen endpoint and calls the completion closure with the result.
39+
/// Performs the asynchronous request for the chosen endpoint and calls the completion closure with the result.
4040
/// Specify the expected result type as the `Decodable` generic type.
4141
public func performRequest<ResultType: Decodable>(
4242
on endpoint: EndpointType,
@@ -128,27 +128,27 @@ open class ApiProvider<EndpointType: Endpoint> {
128128
}
129129

130130
case 400 ..< 500:
131-
guard let data = urlSessionResult.data else {
132-
return .failure(ApiError<EndpointType.ClientErrorType>.noDataInResponse(statusCode: httpResponse.statusCode))
133-
}
134-
135-
do {
136-
let clientError = try endpoint.decoder.decode(EndpointType.ClientErrorType.self, from: data)
131+
if ResultType.self == EmptyBodyResponse.self {
137132
return .failure(
138133
ApiError<EndpointType.ClientErrorType>.clientError(
139134
statusCode: httpResponse.statusCode,
140-
clientError: clientError
141-
)
142-
)
143-
} catch {
144-
return .failure(
145-
ApiError<EndpointType.ClientErrorType>.responseDataConversionFailed(
146-
type: String(describing: EndpointType.ClientErrorType.self),
147-
error: error
135+
clientError: nil
148136
)
149137
)
150138
}
151139

140+
guard let data = urlSessionResult.data else {
141+
return .failure(ApiError<EndpointType.ClientErrorType>.noDataInResponse(statusCode: httpResponse.statusCode))
142+
}
143+
144+
let clientError = try? endpoint.decoder.decode(EndpointType.ClientErrorType.self, from: data)
145+
return .failure(
146+
ApiError<EndpointType.ClientErrorType>.clientError(
147+
statusCode: httpResponse.statusCode,
148+
clientError: clientError
149+
)
150+
)
151+
152152
case 500 ..< 600:
153153
return .failure(
154154
ApiError<EndpointType.ClientErrorType>.serverError(statusCode: httpResponse.statusCode)

0 commit comments

Comments
 (0)