diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..6df9b48 --- /dev/null +++ b/Package.resolved @@ -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 +} diff --git a/Package.swift b/Package.swift index 7ed4db2..26063c4 100644 --- a/Package.swift +++ b/Package.swift @@ -32,7 +32,8 @@ let package = Package( .testTarget( name: "AmiiboServiceTests", dependencies: [ - "AmiiboService" + "AmiiboService", + .product(name: "SwiftLibs", package: "swift-libs") ], path: "Tests" ), diff --git a/Sources/Clients/AmiiboClient.swift b/Sources/Clients/AmiiboClient.swift new file mode 100644 index 0000000..170b829 --- /dev/null +++ b/Sources/Clients/AmiiboClient.swift @@ -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( + 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) + } + } + +} diff --git a/Sources/Endpoints/GetAmiiboEndpoint.swift b/Sources/Endpoints/GetAmiiboEndpoint.swift index fb3046f..5d2a320 100644 --- a/Sources/Endpoints/GetAmiiboEndpoint.swift +++ b/Sources/Endpoints/GetAmiiboEndpoint.swift @@ -7,10 +7,17 @@ struct GetAmiiboEndpoint: Endpoint { let scheme: String = .Scheme.https let host: String = .Host.amiiboApi - let port: Int? + let port: Int? = nil let path: String = .Path.type + let parameters: Parameters let method: HTTPRequestMethod = .get let headers: [String : String] = [:] - let body: Data? + let body: Data? = nil + + // MARK: Initialisers + + init(parameters: Parameters) { + self.parameters = parameters + } } diff --git a/Sources/Endpoints/GetCharacterEndpoint.swift b/Sources/Endpoints/GetCharacterEndpoint.swift index 8b08a7b..3646091 100644 --- a/Sources/Endpoints/GetCharacterEndpoint.swift +++ b/Sources/Endpoints/GetCharacterEndpoint.swift @@ -7,10 +7,17 @@ struct GetCharacterEndpoint: Endpoint { let scheme: String = .Scheme.https let host: String = .Host.amiiboApi - let port: Int? + let port: Int? = nil let path: String = .Path.character + let parameters: Parameters let method: HTTPRequestMethod = .get let headers: [String : String] = [:] - let body: Data? + let body: Data? = nil + + // MARK: Initialisers + + init(parameters: Parameters) { + self.parameters = parameters + } } diff --git a/Sources/Endpoints/GetGameSeriesEndpoint.swift b/Sources/Endpoints/GetGameSeriesEndpoint.swift index 033ba65..d347d25 100644 --- a/Sources/Endpoints/GetGameSeriesEndpoint.swift +++ b/Sources/Endpoints/GetGameSeriesEndpoint.swift @@ -7,10 +7,17 @@ struct GetGameSeriesEndpoint: Endpoint { let scheme: String = .Scheme.https let host: String = .Host.amiiboApi - let port: Int? + let port: Int? = nil let path: String = .Path.gameSeries + let parameters: Parameters let method: HTTPRequestMethod = .get let headers: [String : String] = [:] - let body: Data? + let body: Data? = nil + // MARK: Initialisers + + init(parameters: Parameters) { + self.parameters = parameters + } + } diff --git a/Sources/Endpoints/GetLastUpdatedEndpoint.swift b/Sources/Endpoints/GetLastUpdatedEndpoint.swift index 1d7d0a2..70fc19f 100644 --- a/Sources/Endpoints/GetLastUpdatedEndpoint.swift +++ b/Sources/Endpoints/GetLastUpdatedEndpoint.swift @@ -7,10 +7,11 @@ struct GetLastUpdatedEndpoint: Endpoint { let scheme: String = .Scheme.https let host: String = .Host.amiiboApi - let port: Int? + let port: Int? = nil let path: String = .Path.lastUpdated + let parameters: Parameters = [:] let method: HTTPRequestMethod = .get let headers: [String : String] = [:] - let body: Data? + let body: Data? = nil } diff --git a/Sources/Endpoints/GetSeriesEndpoint.swift b/Sources/Endpoints/GetSeriesEndpoint.swift index f791566..0b6a91d 100644 --- a/Sources/Endpoints/GetSeriesEndpoint.swift +++ b/Sources/Endpoints/GetSeriesEndpoint.swift @@ -7,10 +7,17 @@ struct GetSeriesEndpoint: Endpoint { let scheme: String = .Scheme.https let host: String = .Host.amiiboApi - let port: Int? + let port: Int? = nil let path: String = .Path.series + let parameters: Parameters let method: HTTPRequestMethod = .get let headers: [String : String] = [:] - let body: Data? + let body: Data? = nil + + // MARK: Initialisers + + init(parameters: Parameters) { + self.parameters = parameters + } } diff --git a/Sources/Endpoints/GetTypeEndpoint.swift b/Sources/Endpoints/GetTypeEndpoint.swift index 677f0d2..99d4d4a 100644 --- a/Sources/Endpoints/GetTypeEndpoint.swift +++ b/Sources/Endpoints/GetTypeEndpoint.swift @@ -7,10 +7,17 @@ struct GetTypeEndpoint: Endpoint { let scheme: String = .Scheme.https let host: String = .Host.amiiboApi - let port: Int? + let port: Int? = nil let path: String = .Path.type + let parameters: Parameters let method: HTTPRequestMethod = .get let headers: [String : String] = [:] - let body: Data? + let body: Data? = nil + + // MARK: Initialisers + + init(parameters: Parameters) { + self.parameters = parameters + } } diff --git a/Sources/Errors/AmiiboClientError.swift b/Sources/Errors/AmiiboClientError.swift new file mode 100644 index 0000000..6e49f0c --- /dev/null +++ b/Sources/Errors/AmiiboClientError.swift @@ -0,0 +1,4 @@ +public enum AmiiboClientError: Error { + case responseCode(Int) + case responseCodeNotFound +} diff --git a/Sources/Extensions/DateFormatter+Formatter.swift b/Sources/Extensions/DateFormatter+Formatter.swift new file mode 100644 index 0000000..e209df5 --- /dev/null +++ b/Sources/Extensions/DateFormatter+Formatter.swift @@ -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" + } +} diff --git a/Tests/Clients/AmiiboClientTests.swift b/Tests/Clients/AmiiboClientTests.swift new file mode 100644 index 0000000..1c20243 --- /dev/null +++ b/Tests/Clients/AmiiboClientTests.swift @@ -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) + } + } + +} diff --git a/Tests/Helpers/Data+Seed.swift b/Tests/Helpers/Data+Seed.swift new file mode 100644 index 0000000..91b4c80 --- /dev/null +++ b/Tests/Helpers/Data+Seed.swift @@ -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) + } +} diff --git a/Tests/Helpers/MockURLRequest+Init.swift b/Tests/Helpers/MockURLRequest+Init.swift new file mode 100644 index 0000000..25d5abc --- /dev/null +++ b/Tests/Helpers/MockURLRequest+Init.swift @@ -0,0 +1,12 @@ +import Communications +import Foundation + +extension MockURLRequest { + + // MARK: Initialisers + + init(url: URL) { + self.init(method: .get, url: url) + } + +} diff --git a/Tests/Helpers/TestEndpoint.swift b/Tests/Helpers/TestEndpoint.swift new file mode 100644 index 0000000..c3f0755 --- /dev/null +++ b/Tests/Helpers/TestEndpoint.swift @@ -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 + +} diff --git a/Tests/Helpers/TestModel.swift b/Tests/Helpers/TestModel.swift new file mode 100644 index 0000000..c05a0f7 --- /dev/null +++ b/Tests/Helpers/TestModel.swift @@ -0,0 +1,5 @@ +import Foundation + +struct TestModel: Decodable { + let timestamp: Date? +}