diff --git a/Apps/Locations/Libraries/Package.swift b/Apps/Locations/Libraries/Package.swift index 7014f0a..ef3cbc4 100644 --- a/Apps/Locations/Libraries/Package.swift +++ b/Apps/Locations/Libraries/Package.swift @@ -36,6 +36,7 @@ let package = Package( .testTarget( name: "LocationsTests", dependencies: [ + "APICore", "Locations" ] ), diff --git a/Apps/Locations/Libraries/Sources/APICore/Protocols/Client.swift b/Apps/Locations/Libraries/Sources/APICore/Protocols/Client.swift new file mode 100644 index 0000000..fec93a5 --- /dev/null +++ b/Apps/Locations/Libraries/Sources/APICore/Protocols/Client.swift @@ -0,0 +1,15 @@ +// +// Client.swift +// APICore +// +// Created by Javier Cicchelli on 10/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +/// This protocol defines a client that will be making the API calls. +public protocol Client { + func request( + endpoint: some Endpoint, + for model: Model.Type + ) async throws -> Model +} diff --git a/Apps/Locations/Libraries/Sources/Locations/Clients/LocationsClient.swift b/Apps/Locations/Libraries/Sources/Locations/Clients/LocationsClient.swift new file mode 100644 index 0000000..9ed37f2 --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Locations/Clients/LocationsClient.swift @@ -0,0 +1,66 @@ +// +// LocationsClient.swift +// Locations +// +// Created by Javier Cicchelli on 10/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import APICore +import Foundation + +struct LocationsClient { + + // MARK: Properties + + private let session: URLSession + private let decoder: JSONDecoder = .init() + private let makeURLRequest: MakeURLRequestUseCase = .init() + + // MARK: Initialisers + + init(configuration: URLSessionConfiguration = .default) { + self.session = .init(configuration: configuration) + } + +} + +// MARK: - Client + +extension LocationsClient: Client { + + // MARK: Functions + + func request( + endpoint: some Endpoint, + for model: Model.Type + ) async throws -> Model { + let urlRequest = try makeURLRequest(endpoint: endpoint) + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LocationsClientError.responseNotReturned + } + + switch httpResponse.statusCode { + case 200: + return try decoder.decode(model, from: data) + case 400...499: + throw LocationsClientError.statusErrorClient + case 500...599: + throw LocationsClientError.statusErrorServer + default: + throw LocationsClientError.statusErrorUnexpected + } + } + +} + +// MARK: - Errors + +public enum LocationsClientError: Error { + case responseNotReturned + case statusErrorClient + case statusErrorServer + case statusErrorUnexpected +} diff --git a/Apps/Locations/Libraries/Sources/Locations/Models/Location.swift b/Apps/Locations/Libraries/Sources/Locations/Models/Location.swift index 86fe12f..52567a0 100644 --- a/Apps/Locations/Libraries/Sources/Locations/Models/Location.swift +++ b/Apps/Locations/Libraries/Sources/Locations/Models/Location.swift @@ -6,10 +6,26 @@ // Copyright © 2023 Röck+Cöde. All rights reserved. // -public struct Location { +public struct Location: Equatable { + + // MARK: Properties + public let name: String? public let latitude: Float public let longitude: Float + + // MARK: Initialisers + + public init( + name: String? = nil, + latitude: Float, + longitude: Float + ) { + self.name = name + self.latitude = latitude + self.longitude = longitude + } + } // MARK: - Decodable diff --git a/Apps/Locations/Libraries/Tests/LocationsTests/Clients/LocationsClientTests.swift b/Apps/Locations/Libraries/Tests/LocationsTests/Clients/LocationsClientTests.swift new file mode 100644 index 0000000..165951f --- /dev/null +++ b/Apps/Locations/Libraries/Tests/LocationsTests/Clients/LocationsClientTests.swift @@ -0,0 +1,164 @@ +// +// LocationsClientTests.swift +// LocationsTests +// +// Created by Javier Cicchelli on 10/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import APICore +import XCTest + +@testable import Locations + +final class LocationsClientTests: XCTestCase { + + // MARK: Properties + + private let makeURLRequest = MakeURLRequestUseCase() + private let sessionConfiguration = { + let configuration = URLSessionConfiguration.default + + configuration.protocolClasses = [MockURLProtocol.self] + + return configuration + }() + + private var client: LocationsClient! + private var url: URL! + private var data: Data! + + // MARK: Setup + + override func setUp() async throws { + client = .init(configuration: sessionConfiguration) + } + + override func tearDown() async throws { + client = nil + } + + // MARK: Tests + + func test_request_withGetLocationsEndpoint_forLocations_whenResponseOK() async throws { + // GIVEN + let endpoint = GetLocationsEndpoint() + + url = try makeURLRequest(endpoint: endpoint).url + data = .locations + + MockURLProtocol.mockData[url] = MockURLResponse( + status: 200, + headers: [:], + data: data + ) + + // WHEN + let result = try await client.request(endpoint: endpoint, for: Locations.self) + + // THEN + XCTAssertEqual(result, Locations(locations: [ + .init( + name: "Amsterdam", + latitude: 52.3547498, + longitude: 4.8339215 + ), + .init( + name: "Mumbai", + latitude: 19.0823998, + longitude: 72.8111468 + ), + .init( + name: "Copenhagen", + latitude: 55.6713442, + longitude: 12.523785 + ), + .init( + latitude: 40.4380638, + longitude: -3.7495758 + ) + ])) + } + + func test_request_withGetLocationsEndpoint_forLocations_whenResponseClientError() async throws { + // GIVEN + let endpoint = GetLocationsEndpoint() + + url = try makeURLRequest(endpoint: endpoint).url + data = .locations + + MockURLProtocol.mockData[url] = MockURLResponse( + status: 404, + headers: [:], + data: data + ) + + // WHEN & THEN + do { + _ = try await client.request(endpoint: endpoint, for: Locations.self) + } catch LocationsClientError.statusErrorClient { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + + func test_request_withGetLocationsEndpoint_forLocations_whenResponseServerError() async throws { + // GIVEN + let endpoint = GetLocationsEndpoint() + + url = try makeURLRequest(endpoint: endpoint).url + data = .locations + + MockURLProtocol.mockData[url] = MockURLResponse( + status: 500, + headers: [:], + data: data + ) + + // WHEN & THEN + do { + _ = try await client.request(endpoint: endpoint, for: Locations.self) + } catch LocationsClientError.statusErrorServer { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + + func test_request_withGetLocationsEndpoint_forLocations_whenResponseUnexpectedError() async throws { + // GIVEN + let endpoint = GetLocationsEndpoint() + + url = try makeURLRequest(endpoint: endpoint).url + data = .locations + + MockURLProtocol.mockData[url] = MockURLResponse( + status: 302, + headers: [:], + data: data + ) + + // WHEN & THEN + do { + _ = try await client.request(endpoint: endpoint, for: Locations.self) + } catch LocationsClientError.statusErrorUnexpected { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + +} + +// MARK: - Models + +private struct Locations: Decodable, Equatable { + public let locations: [Location] +} + +// MARK: - String+Constants + +private extension Data { + static let locations = "{\"locations\":[{\"name\":\"Amsterdam\",\"lat\":52.3547498,\"long\":4.8339215},{\"name\":\"Mumbai\",\"lat\":19.0823998,\"long\":72.8111468},{\"name\":\"Copenhagen\",\"lat\":55.6713442,\"long\":12.523785},{\"lat\":40.4380638,\"long\":-3.7495758}]}".data(using: .utf8) +}