[Feature] Client (#3)
This PR contains the work done to implement the `AmiiboClient` struct that conforms to the `Client` protocol coming from the **Communications** library of the [SwiftLibs package](https://github.com/rock-n-code/swift-libs) To provide further detailks about the work done: - [x] added the `parameters` property to all the existing endpoints; - [x] implemented the `AmiiboClient` client and the `AmiiboClientError` error; - [x] implemented some static formatters in the `DateFormatter+Formatter` extension; - [x] updated the `SwiftLibs` package to its latest version. Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Reviewed-on: #3
This commit is contained in:
parent
e12fce9ddc
commit
68acf82f9d
14
Package.resolved
Normal file
14
Package.resolved
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-libs",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/rock-n-code/swift-libs.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2ba3e33a0e8c0ffc2b69efa906b03364a9d53a51",
|
||||||
|
"version" : "0.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
@ -32,7 +32,8 @@ let package = Package(
|
|||||||
.testTarget(
|
.testTarget(
|
||||||
name: "AmiiboServiceTests",
|
name: "AmiiboServiceTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"AmiiboService"
|
"AmiiboService",
|
||||||
|
.product(name: "SwiftLibs", package: "swift-libs")
|
||||||
],
|
],
|
||||||
path: "Tests"
|
path: "Tests"
|
||||||
),
|
),
|
||||||
|
66
Sources/Clients/AmiiboClient.swift
Normal file
66
Sources/Clients/AmiiboClient.swift
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import Communications
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AmiiboClient {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
private let makeURLRequest = MakeURLRequestUseCase()
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
init(configuration: URLSessionConfiguration) {
|
||||||
|
session = .init(configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
func setDateDecodingStrategy(_ dateDecodingStrategy: JSONDecoder.DateDecodingStrategy) {
|
||||||
|
decoder.dateDecodingStrategy = dateDecodingStrategy
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client
|
||||||
|
|
||||||
|
extension AmiiboClient: Client {
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
func request<Model>(
|
||||||
|
endpoint: some Endpoint,
|
||||||
|
as model: Model.Type
|
||||||
|
) async throws -> Model where Model : Decodable {
|
||||||
|
let urlRequest = try makeURLRequest(endpoint: endpoint)
|
||||||
|
let (data, response) = try await session.data(for: urlRequest)
|
||||||
|
|
||||||
|
try check(response)
|
||||||
|
|
||||||
|
return try decoder.decode(model, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension AmiiboClient {
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
func check(_ response: URLResponse) throws {
|
||||||
|
guard
|
||||||
|
let urlResponse = response as? HTTPURLResponse,
|
||||||
|
let responseCode = HTTPResponseCode(rawValue: urlResponse.statusCode)
|
||||||
|
else {
|
||||||
|
throw AmiiboClientError.responseCodeNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
guard responseCode == .ok else {
|
||||||
|
throw AmiiboClientError.responseCode(responseCode.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -7,10 +7,17 @@ struct GetAmiiboEndpoint: Endpoint {
|
|||||||
|
|
||||||
let scheme: String = .Scheme.https
|
let scheme: String = .Scheme.https
|
||||||
let host: String = .Host.amiiboApi
|
let host: String = .Host.amiiboApi
|
||||||
let port: Int?
|
let port: Int? = nil
|
||||||
let path: String = .Path.type
|
let path: String = .Path.type
|
||||||
|
let parameters: Parameters
|
||||||
let method: HTTPRequestMethod = .get
|
let method: HTTPRequestMethod = .get
|
||||||
let headers: [String : String] = [:]
|
let headers: [String : String] = [:]
|
||||||
let body: Data?
|
let body: Data? = nil
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
init(parameters: Parameters) {
|
||||||
|
self.parameters = parameters
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -7,10 +7,17 @@ struct GetCharacterEndpoint: Endpoint {
|
|||||||
|
|
||||||
let scheme: String = .Scheme.https
|
let scheme: String = .Scheme.https
|
||||||
let host: String = .Host.amiiboApi
|
let host: String = .Host.amiiboApi
|
||||||
let port: Int?
|
let port: Int? = nil
|
||||||
let path: String = .Path.character
|
let path: String = .Path.character
|
||||||
|
let parameters: Parameters
|
||||||
let method: HTTPRequestMethod = .get
|
let method: HTTPRequestMethod = .get
|
||||||
let headers: [String : String] = [:]
|
let headers: [String : String] = [:]
|
||||||
let body: Data?
|
let body: Data? = nil
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
init(parameters: Parameters) {
|
||||||
|
self.parameters = parameters
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -7,10 +7,17 @@ struct GetGameSeriesEndpoint: Endpoint {
|
|||||||
|
|
||||||
let scheme: String = .Scheme.https
|
let scheme: String = .Scheme.https
|
||||||
let host: String = .Host.amiiboApi
|
let host: String = .Host.amiiboApi
|
||||||
let port: Int?
|
let port: Int? = nil
|
||||||
let path: String = .Path.gameSeries
|
let path: String = .Path.gameSeries
|
||||||
|
let parameters: Parameters
|
||||||
let method: HTTPRequestMethod = .get
|
let method: HTTPRequestMethod = .get
|
||||||
let headers: [String : String] = [:]
|
let headers: [String : String] = [:]
|
||||||
let body: Data?
|
let body: Data? = nil
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
init(parameters: Parameters) {
|
||||||
|
self.parameters = parameters
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -7,10 +7,11 @@ struct GetLastUpdatedEndpoint: Endpoint {
|
|||||||
|
|
||||||
let scheme: String = .Scheme.https
|
let scheme: String = .Scheme.https
|
||||||
let host: String = .Host.amiiboApi
|
let host: String = .Host.amiiboApi
|
||||||
let port: Int?
|
let port: Int? = nil
|
||||||
let path: String = .Path.lastUpdated
|
let path: String = .Path.lastUpdated
|
||||||
|
let parameters: Parameters = [:]
|
||||||
let method: HTTPRequestMethod = .get
|
let method: HTTPRequestMethod = .get
|
||||||
let headers: [String : String] = [:]
|
let headers: [String : String] = [:]
|
||||||
let body: Data?
|
let body: Data? = nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -7,10 +7,17 @@ struct GetSeriesEndpoint: Endpoint {
|
|||||||
|
|
||||||
let scheme: String = .Scheme.https
|
let scheme: String = .Scheme.https
|
||||||
let host: String = .Host.amiiboApi
|
let host: String = .Host.amiiboApi
|
||||||
let port: Int?
|
let port: Int? = nil
|
||||||
let path: String = .Path.series
|
let path: String = .Path.series
|
||||||
|
let parameters: Parameters
|
||||||
let method: HTTPRequestMethod = .get
|
let method: HTTPRequestMethod = .get
|
||||||
let headers: [String : String] = [:]
|
let headers: [String : String] = [:]
|
||||||
let body: Data?
|
let body: Data? = nil
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
init(parameters: Parameters) {
|
||||||
|
self.parameters = parameters
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -7,10 +7,17 @@ struct GetTypeEndpoint: Endpoint {
|
|||||||
|
|
||||||
let scheme: String = .Scheme.https
|
let scheme: String = .Scheme.https
|
||||||
let host: String = .Host.amiiboApi
|
let host: String = .Host.amiiboApi
|
||||||
let port: Int?
|
let port: Int? = nil
|
||||||
let path: String = .Path.type
|
let path: String = .Path.type
|
||||||
|
let parameters: Parameters
|
||||||
let method: HTTPRequestMethod = .get
|
let method: HTTPRequestMethod = .get
|
||||||
let headers: [String : String] = [:]
|
let headers: [String : String] = [:]
|
||||||
let body: Data?
|
let body: Data? = nil
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
init(parameters: Parameters) {
|
||||||
|
self.parameters = parameters
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
4
Sources/Errors/AmiiboClientError.swift
Normal file
4
Sources/Errors/AmiiboClientError.swift
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
public enum AmiiboClientError: Error {
|
||||||
|
case responseCode(Int)
|
||||||
|
case responseCodeNotFound
|
||||||
|
}
|
35
Sources/Extensions/DateFormatter+Formatter.swift
Normal file
35
Sources/Extensions/DateFormatter+Formatter.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import Core
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension DateFormatter {
|
||||||
|
|
||||||
|
// MARK: Formatters
|
||||||
|
|
||||||
|
static let dateOnly = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
|
||||||
|
formatter.timeZone = .gmt
|
||||||
|
formatter.dateFormat = .Format.yearMonthDay
|
||||||
|
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let dateAndTime = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
|
||||||
|
formatter.timeZone = .gmt
|
||||||
|
formatter.dateFormat = .Format.dateAndTimeWithMicroseconds
|
||||||
|
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - String+Format
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
enum Format {
|
||||||
|
static let yearMonthDay = "yyyy-MM-dd"
|
||||||
|
static let dateAndTimeWithMicroseconds = "yyyy-MM-dd'T'HH:mm:ss.SSS"
|
||||||
|
}
|
||||||
|
}
|
145
Tests/Clients/AmiiboClientTests.swift
Normal file
145
Tests/Clients/AmiiboClientTests.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import Communications
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import AmiiboService
|
||||||
|
|
||||||
|
final class AmiiboClientTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
private let configuration: URLSessionConfiguration = {
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
|
||||||
|
configuration.protocolClasses = [MockURLProtocol.self]
|
||||||
|
|
||||||
|
return configuration
|
||||||
|
}()
|
||||||
|
private let makeURLRequest = MakeURLRequestUseCase()
|
||||||
|
private let endpoint = TestEndpoint()
|
||||||
|
|
||||||
|
private var client: AmiiboClient!
|
||||||
|
|
||||||
|
// MARK: Setup
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
client = .init(configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
client = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Tests
|
||||||
|
|
||||||
|
func test_request_withEndpointAndModel_whenDataDoesMatchModel() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let url = try XCTUnwrap(try makeURLRequest(endpoint: endpoint).url)
|
||||||
|
|
||||||
|
MockURLProtocol.mockData[.init(url: url)] = .init(
|
||||||
|
status: .ok,
|
||||||
|
data: .Seed.dataWithoutTimestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
let model = try await client.request(
|
||||||
|
endpoint: endpoint,
|
||||||
|
as: TestModel.self
|
||||||
|
)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
XCTAssertNotNil(model)
|
||||||
|
XCTAssertNil(model.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_request_withEndpointAndModel_whenDataDoesMatchModel_andDateDecodingStrategy() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let url = try XCTUnwrap(try makeURLRequest(endpoint: endpoint).url)
|
||||||
|
|
||||||
|
MockURLProtocol.mockData[.init(url: url)] = .init(
|
||||||
|
status: .ok,
|
||||||
|
data: .Seed.dataWithDateAndTime
|
||||||
|
)
|
||||||
|
|
||||||
|
client.setDateDecodingStrategy(.formatted(.dateAndTime))
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
let model = try await client.request(
|
||||||
|
endpoint: endpoint,
|
||||||
|
as: TestModel.self
|
||||||
|
)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
XCTAssertNotNil(model)
|
||||||
|
XCTAssertNotNil(model.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_request_withEndpointAndModel_whenDataDoesNotMatchModel() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let url = try XCTUnwrap(try makeURLRequest(endpoint: endpoint).url)
|
||||||
|
|
||||||
|
MockURLProtocol.mockData[.init(url: url)] = .init(
|
||||||
|
status: .ok,
|
||||||
|
data: .Seed.dataUnrelated
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN & THEN
|
||||||
|
do {
|
||||||
|
let _ = try await client.request(
|
||||||
|
endpoint: endpoint,
|
||||||
|
as: TestModel.self
|
||||||
|
)
|
||||||
|
} catch is DecodingError {
|
||||||
|
XCTAssertTrue(true)
|
||||||
|
} catch {
|
||||||
|
XCTAssertTrue(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_request_withEndpointAndModel_whenDateDecodingStrategyNotCorrectlySet() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let url = try XCTUnwrap(try makeURLRequest(endpoint: endpoint).url)
|
||||||
|
|
||||||
|
MockURLProtocol.mockData[.init(url: url)] = .init(
|
||||||
|
status: .ok,
|
||||||
|
data: .Seed.dataWithDateAndTime
|
||||||
|
)
|
||||||
|
|
||||||
|
client.setDateDecodingStrategy(.formatted(.dateOnly))
|
||||||
|
|
||||||
|
// WHEN & THEN
|
||||||
|
do {
|
||||||
|
let _ = try await client.request(
|
||||||
|
endpoint: endpoint,
|
||||||
|
as: TestModel.self
|
||||||
|
)
|
||||||
|
} catch is DecodingError {
|
||||||
|
XCTAssertTrue(true)
|
||||||
|
} catch {
|
||||||
|
XCTAssertTrue(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_request_withEndpointAndModel_whenResponseCodeIsNotOK() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let url = try XCTUnwrap(try makeURLRequest(endpoint: endpoint).url)
|
||||||
|
|
||||||
|
MockURLProtocol.mockData[.init(url: url)] = .init(
|
||||||
|
status: .notFound,
|
||||||
|
data: .Seed.dataWithoutTimestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN & THEN
|
||||||
|
do {
|
||||||
|
let _ = try await client.request(
|
||||||
|
endpoint: endpoint,
|
||||||
|
as: TestModel.self
|
||||||
|
)
|
||||||
|
} catch is AmiiboClientError {
|
||||||
|
XCTAssertTrue(true)
|
||||||
|
} catch {
|
||||||
|
XCTAssertTrue(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
10
Tests/Helpers/Data+Seed.swift
Normal file
10
Tests/Helpers/Data+Seed.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
enum Seed {
|
||||||
|
static let dataUnrelated = "{\"something\":\"Something goes in here...\"}".data(using: .utf8)
|
||||||
|
static let dataWithoutTimestamp = "{\"timestamp\":null}".data(using: .utf8)
|
||||||
|
static let dataWithDateOnly = "{\"timestamp\":\"2023-03-23\"}".data(using: .utf8)
|
||||||
|
static let dataWithDateAndTime = "{\"timestamp\":\"2023-03-23T13:11:20.382254\"}".data(using: .utf8)
|
||||||
|
}
|
||||||
|
}
|
12
Tests/Helpers/MockURLRequest+Init.swift
Normal file
12
Tests/Helpers/MockURLRequest+Init.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Communications
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension MockURLRequest {
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
init(url: URL) {
|
||||||
|
self.init(method: .get, url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
17
Tests/Helpers/TestEndpoint.swift
Normal file
17
Tests/Helpers/TestEndpoint.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Communications
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TestEndpoint: Endpoint {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
let scheme: String = "http"
|
||||||
|
let host: String = "www.something.com"
|
||||||
|
let port: Int? = nil
|
||||||
|
let path: String = "/path/to/endpoint"
|
||||||
|
let parameters: Parameters = [:]
|
||||||
|
let method: HTTPRequestMethod = .get
|
||||||
|
let headers: [String : String] = [:]
|
||||||
|
let body: Data? = nil
|
||||||
|
|
||||||
|
}
|
5
Tests/Helpers/TestModel.swift
Normal file
5
Tests/Helpers/TestModel.swift
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TestModel: Decodable {
|
||||||
|
let timestamp: Date?
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user