[Libraries] Locations (#4)

This PR contains the work that implements the Locations service, which is used to retrieve location data from a remote server.

To give further details on what was done:
- [x] created the `APICore` and `Locations` libraries into the **Libraries** package;
- [x] defined the `Endpoint` and `Client` protocols;
- [x] implemented the `MakeURLRequestUseCase` use case;
- [x] implemented the `MockURLProtocol` protocol and the `MockURLResponse` models;
- [x] implemented the `Location` model;
- [x] implemented the `GetLocationsEndpoint` endpoint;
- [x] implemented the `LocationsClient` client;
- [x] implemented the `LocationsService` service.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#4
This commit is contained in:
Javier Cicchelli 2023-04-10 15:31:22 +00:00
parent 14e39a40ae
commit 6da2e946ce
19 changed files with 880 additions and 30 deletions

View File

@ -1,28 +1,44 @@
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Libraries",
platforms: [
.iOS(.v16)
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "Libraries",
targets: ["Libraries"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
"Locations"
]
),
],
dependencies: [],
targets: [
.target(
name: "Libraries",
dependencies: []),
name: "APICore",
dependencies: []
),
.target(
name: "Locations",
dependencies: [
"APICore"
]
),
.testTarget(
name: "LibrariesTests",
dependencies: ["Libraries"]),
name: "APICoreTests",
dependencies: [
"APICore"
]
),
.testTarget(
name: "LocationsTests",
dependencies: [
"APICore",
"Locations"
]
),
]
)

View File

@ -0,0 +1,63 @@
//
// MockURLProtocol.swift
// APICore
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
/// This class overrides the `URLProtocol` protocol used by the `URLSession` to handle the loading of protocol-specific URL data so it is possible to mock URL response for testing purposes.
public class MockURLProtocol: URLProtocol {
// MARK: Properties
public static var mockData: [URL: MockURLResponse] = [:]
// MARK: Functions
public override class func canInit(with task: URLSessionTask) -> Bool {
true
}
public override class func canInit(with request: URLRequest) -> Bool {
true
}
public override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
public override func startLoading() {
guard
let url = request.url,
let response = Self.mockData[url]
else {
client?.urlProtocolDidFinishLoading(self)
return
}
if let data = response.data {
client?.urlProtocol(self, didLoad: data)
}
if let httpResponse = HTTPURLResponse(
url: url,
statusCode: response.status,
httpVersion: nil,
headerFields: response.headers
) {
client?.urlProtocol(
self,
didReceive: httpResponse,
cacheStoragePolicy: .allowedInMemoryOnly
)
}
client?.urlProtocolDidFinishLoading(self)
}
public override func stopLoading() {}
}

View File

@ -0,0 +1,12 @@
//
// HTTPRequestMethod.swift
// APICore
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
/// Enumeration that represents the available HTTP request methods to use in this library.
public enum HTTPRequestMethod: String {
case get = "GET"
}

View File

@ -0,0 +1,32 @@
//
// MockURLResponse.swift
// APICore
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
/// This model includes the data to be injected into an specific URL at the time of mocking its response.
public struct MockURLResponse {
// MARK: Properties
public let status: Int
public let headers: [String: String]
public let data: Data?
// MARK: Initialisers
public init(
status: Int,
headers: [String : String],
data: Data? = nil
) {
self.status = status
self.headers = headers
self.data = data
}
}

View File

@ -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<Model: Decodable>(
endpoint: some Endpoint,
for model: Model.Type
) async throws -> Model
}

View File

@ -0,0 +1,20 @@
//
// Endpoint.swift
// APICore
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
/// This protocol defines an endpoint to be used in an API call.
public protocol Endpoint {
var scheme: String { get }
var host: String { get }
var port: Int? { get }
var path: String { get }
var method: HTTPRequestMethod { get }
var headers: [String: String] { get }
var body: Data? { get }
}

View File

@ -0,0 +1,54 @@
//
// MakeURLRequestUseCase.swift
// APICore
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
/// This use case generate a url request out of a given endpoint.
public struct MakeURLRequestUseCase {
// MARK: Initialisers
public init() {}
// MARK: Functions
/// Generate a `URLRequest` instance out of a given endpoint that conforms to the `Endpoint` protocol.
/// - Parameter endpoint: An endpoint which is used to generate a `URLRequest` instance from.
/// - Returns: A `URLRequest` instance filled with data provided by the given endpoint.
public func callAsFunction(endpoint: some Endpoint) throws -> URLRequest {
var urlComponents = URLComponents()
urlComponents.scheme = endpoint.scheme
urlComponents.host = endpoint.host
urlComponents.path = endpoint.path
if let port = endpoint.port {
urlComponents.port = port
}
guard let url = urlComponents.url else {
throw MakeURLRequestError.urlNotCreated
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = endpoint.method.rawValue
urlRequest.httpBody = endpoint.body
urlRequest.allHTTPHeaderFields = endpoint.headers
return urlRequest
}
}
// MARK: - Errors
enum MakeURLRequestError: Error {
case urlNotCreated
}

View File

@ -1,6 +0,0 @@
public struct Libraries {
public private(set) var text = "Hello, World!"
public init() {
}
}

View File

@ -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<Model: Decodable>(
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
}

View File

@ -0,0 +1,20 @@
//
// GetLocationsEndpoint.swift
// Locations
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import APICore
import Foundation
struct GetLocationsEndpoint: Endpoint {
let scheme: String = .Scheme.https
let host: String = .Hosts.default
let port: Int? = nil
let path: String = .Paths.getLocations
let method: HTTPRequestMethod = .get
let headers: [String: String] = [:]
let body: Data? = nil
}

View File

@ -0,0 +1,21 @@
//
// String+Constants.swift
// Locations
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
extension String {
enum Scheme {
static let https = "https"
}
enum Hosts {
static let `default` = "raw.githubusercontent.com"
}
enum Paths {
static let getLocations = "/abnamrocoesd/assignment-ios/main/locations.json"
}
}

View File

@ -0,0 +1,39 @@
//
// Location.swift
// Locations (Library)
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
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
extension Location: Decodable {
enum CodingKeys: String, CodingKey {
case name
case latitude = "lat"
case longitude = "long"
}
}

View File

@ -0,0 +1,39 @@
//
// LocationsService.swift
// Locations
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import APICore
import Foundation
public struct LocationsService {
// MARK: Properties
private let client: Client
// MARK: Initialisers
public init(configuration: URLSessionConfiguration = .default) {
self.client = LocationsClient(configuration: configuration)
}
// MARK: Functions
public func getLocations() async throws -> [Location] {
try await client.request(
endpoint: GetLocationsEndpoint(),
for: Locations.self
).locations
}
}
// MARK: - Models
struct Locations: Decodable, Equatable {
public let locations: [Location]
}

View File

@ -0,0 +1,119 @@
//
// MakeURLRequestUseCaseTests.swift
// APICoreTests
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
import XCTest
@testable import APICore
final class MakeURLRequestUseCaseTests: XCTestCase {
// MARK: Properties
private let makeURLRequest = MakeURLRequestUseCase()
// MARK: Test cases
func test_withEndpoint_initialisedByDefault() throws {
// GIVEN
let endpoint = TestEndpoint()
// WHEN
let result = try makeURLRequest(endpoint: endpoint)
// THEN
XCTAssertNotNil(result)
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com/path/to/endpoint")
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
XCTAssertEqual(result.allHTTPHeaderFields, [:])
XCTAssertNil(result.httpBody)
}
func test_withEndpoint_initialisedWithPort() throws {
// GIVEN
let endpoint = TestEndpoint(port: 8080)
// WHEN
let result = try makeURLRequest(endpoint: endpoint)
// THEN
XCTAssertNotNil(result)
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com:8080/path/to/endpoint")
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
XCTAssertEqual(result.allHTTPHeaderFields, [:])
XCTAssertNil(result.httpBody)
}
func test_withEndpoint_initialisedWithHeaders() throws {
// GIVEN
let endpoint = TestEndpoint(headers: [
"aHeader": "aValueForHead",
"someOtherHeader": "someValueForOtherHeader"
])
// WHEN
let result = try makeURLRequest(endpoint: endpoint)
// THEN
XCTAssertNotNil(result)
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com/path/to/endpoint")
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
XCTAssertEqual(result.allHTTPHeaderFields, [
"aHeader": "aValueForHead",
"someOtherHeader": "someValueForOtherHeader"
])
XCTAssertNil(result.httpBody)
}
func test_withEndpoint_initialisedWithBody() throws {
// GIVEN
let data = "This is some data for a body of a request".data(using: .utf8)
let endpoint = TestEndpoint(body: data)
// WHEN
let result = try makeURLRequest(endpoint: endpoint)
// THEN
XCTAssertNotNil(result)
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com/path/to/endpoint")
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
XCTAssertEqual(result.allHTTPHeaderFields, [:])
XCTAssertEqual(result.httpBody, data)
XCTAssertNotNil(data)
}
}
// MARK: - TestEndpoint
private struct TestEndpoint: Endpoint {
// MARK: Properties
let scheme: String = "http"
let host: String = "www.something.com"
let path: String = "/path/to/endpoint"
let method: HTTPRequestMethod = .get
var port: Int?
var headers: [String : String]
var body: Data?
// MARK: Initialisers
init(
port: Int? = nil,
headers: [String : String] = [:],
body: Data? = nil
) {
self.port = port
self.body = body
self.headers = headers
}
}

View File

@ -1,11 +0,0 @@
import XCTest
@testable import Libraries
final class LibrariesTests: XCTestCase {
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
XCTAssertEqual(Libraries().text, "Hello, World!")
}
}

View File

@ -0,0 +1,152 @@
//
// 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 = .Responses.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 = .Responses.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 = .Responses.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 = .Responses.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)
}
}
}

View File

@ -0,0 +1,32 @@
//
// GetLocationsEndpointTests.swift
// LocationsTests
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import XCTest
@testable import Locations
final class GetLocationsEndpointTests: XCTestCase {
// MARK: Tests
func test_init() {
// GIVEN
// WHEN
let endpoint = GetLocationsEndpoint()
// THEN
XCTAssertNotNil(endpoint)
XCTAssertEqual(endpoint.scheme, .Scheme.https)
XCTAssertEqual(endpoint.host, .Hosts.default)
XCTAssertNil(endpoint.port)
XCTAssertEqual(endpoint.path, .Paths.getLocations)
XCTAssertTrue(endpoint.headers.isEmpty)
XCTAssertNil(endpoint.body)
}
}

View File

@ -0,0 +1,15 @@
//
// Data+Constants.swift
// LocationsTests
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
extension Data {
enum Responses {
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)
}
}

View File

@ -0,0 +1,152 @@
//
// LocationsServiceTests.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 LocationsServiceTests: XCTestCase {
// MARK: Properties
private let makeURLRequest = MakeURLRequestUseCase()
private let sessionConfiguration = {
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [MockURLProtocol.self]
return configuration
}()
private var service: LocationsService!
private var url: URL!
private var data: Data!
// MARK: Setup
override func setUp() async throws {
service = .init(configuration: sessionConfiguration)
}
override func tearDown() async throws {
service = nil
}
// MARK: Tests
func test_getLocations_whenResponseOK() async throws {
// GIVEN
let endpoint = GetLocationsEndpoint()
url = try makeURLRequest(endpoint: endpoint).url
data = .Responses.locations
MockURLProtocol.mockData[url] = MockURLResponse(
status: 200,
headers: [:],
data: data
)
// WHEN
let result = try await service.getLocations()
// THEN
XCTAssertEqual(result, [
.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_getLocations_whenResponseClientError() async throws {
// GIVEN
let endpoint = GetLocationsEndpoint()
url = try makeURLRequest(endpoint: endpoint).url
data = .Responses.locations
MockURLProtocol.mockData[url] = MockURLResponse(
status: 404,
headers: [:],
data: data
)
// WHEN & THEN
do {
_ = try await service.getLocations()
} catch LocationsClientError.statusErrorClient {
XCTAssertTrue(true)
} catch {
XCTAssertTrue(false)
}
}
func test_getLocations_whenResponseServerError() async throws {
// GIVEN
let endpoint = GetLocationsEndpoint()
url = try makeURLRequest(endpoint: endpoint).url
data = .Responses.locations
MockURLProtocol.mockData[url] = MockURLResponse(
status: 500,
headers: [:],
data: data
)
// WHEN & THEN
do {
_ = try await service.getLocations()
} catch LocationsClientError.statusErrorServer {
XCTAssertTrue(true)
} catch {
XCTAssertTrue(false)
}
}
func test_getLocations_whenResponseUnexpectedError() async throws {
// GIVEN
let endpoint = GetLocationsEndpoint()
url = try makeURLRequest(endpoint: endpoint).url
data = .Responses.locations
MockURLProtocol.mockData[url] = MockURLResponse(
status: 302,
headers: [:],
data: data
)
// WHEN & THEN
do {
_ = try await service.getLocations()
} catch LocationsClientError.statusErrorUnexpected {
XCTAssertTrue(true)
} catch {
XCTAssertTrue(false)
}
}
}