diff --git a/Libraries/Sources/APIService/Extensions/DateFormatter+Formatters.swift b/Libraries/Sources/APIService/Extensions/DateFormatter+Formatters.swift new file mode 100644 index 0000000..323e44d --- /dev/null +++ b/Libraries/Sources/APIService/Extensions/DateFormatter+Formatters.swift @@ -0,0 +1,27 @@ +// +// DateFormatter+Formatter.swift +// APIServices +// +// Created by Javier Cicchelli on 04/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import Foundation + +extension DateFormatter { + static let iso8601 = { + let dateFormatter = DateFormatter() + + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + + return dateFormatter + }() + + static let isoZulu = { + let dateFormatter = DateFormatter() + + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + + return dateFormatter + }() +} diff --git a/Libraries/Sources/APIService/Models/Item.swift b/Libraries/Sources/APIService/Models/Item.swift index 78b7a24..e607a77 100644 --- a/Libraries/Sources/APIService/Models/Item.swift +++ b/Libraries/Sources/APIService/Models/Item.swift @@ -31,3 +31,7 @@ extension Item: Decodable { case contentType } } + +// MARK: - Equatable + +extension Item: Equatable {} diff --git a/Libraries/Sources/APIService/Models/Me.swift b/Libraries/Sources/APIService/Models/Me.swift index 43b9bcf..35c1b87 100644 --- a/Libraries/Sources/APIService/Models/Me.swift +++ b/Libraries/Sources/APIService/Models/Me.swift @@ -15,3 +15,7 @@ public struct Me { // MARK: - Decodable extension Me: Decodable {} + +// MARK: - Equatable + +extension Me: Equatable {} diff --git a/Libraries/Sources/APIService/Structs/APIClient.swift b/Libraries/Sources/APIService/Structs/APIClient.swift new file mode 100644 index 0000000..47751cd --- /dev/null +++ b/Libraries/Sources/APIService/Structs/APIClient.swift @@ -0,0 +1,113 @@ +// +// APIClient.swift +// APIService +// +// Created by Javier Cicchelli on 04/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import Foundation + +struct APIClient { + private let session: URLSession + private let decoder: JSONDecoder + private let makeURLRequest: MakeURLRequestUseCase +} + +// MARK: - Initialisers + +extension APIClient { + init(configuration: URLSessionConfiguration = .default) { + self.session = .init(configuration: configuration) + self.decoder = .init() + self.makeURLRequest = .init() + } +} + + +// MARK: - Client + +extension APIClient: Client { + func request( + endpoint: some Endpoint, + model: Model.Type + ) async throws -> Model where Model : Decodable { + let urlRequest = try makeURLRequest(endpoint: endpoint) + let (data, response) = try await session.data(for: urlRequest) + + guard + let httpResponse = response as? HTTPURLResponse, + let responseStatus = ResponseStatus(rawValue: httpResponse.statusCode) + else { + throw APIClientError.responseNotReturned + } + + switch responseStatus { + case .ok, + .created: + return try await decode(data, as: model) + case .noContent: + throw APIClientError.notSupported + case .badRequest: + throw APIClientError.itemAlreadyExist + case .notFound: + throw APIClientError.itemDoesNotExist + } + } + + @discardableResult + func request( + endpoint: some Endpoint + ) async throws -> Data { + let urlRequest = try makeURLRequest(endpoint: endpoint) + let (data, response) = try await session.data(for: urlRequest) + + guard + let httpResponse = response as? HTTPURLResponse, + let responseStatus = ResponseStatus(rawValue: httpResponse.statusCode) + else { + throw APIClientError.responseNotReturned + } + + switch responseStatus { + case .ok, + .noContent: + return data + case .created: + throw APIClientError.notSupported + case .badRequest: + throw APIClientError.itemIsNotFile + case .notFound: + throw APIClientError.itemDoesNotExist + } + } +} + +// MARK: - Helpers + +private extension APIClient { + func decode( + _ data: Data, + as model: Model.Type + ) async throws -> Model { + do { + decoder.dateDecodingStrategy = .formatted(.isoZulu) + + return try decoder.decode(model, from: data) + } catch { + decoder.dateDecodingStrategy = .iso8601 + + return try decoder.decode(model, from: data) + } + } +} + +// MARK: - Errors + +public enum APIClientError: Error { + case responseNotReturned + case itemIsNotFile + case itemAlreadyExist + case itemDoesNotExist + case notSupported +} diff --git a/Libraries/Tests/APIServiceTests/Cases/Structs/APIClient+RequestTests.swift b/Libraries/Tests/APIServiceTests/Cases/Structs/APIClient+RequestTests.swift new file mode 100644 index 0000000..00611a3 --- /dev/null +++ b/Libraries/Tests/APIServiceTests/Cases/Structs/APIClient+RequestTests.swift @@ -0,0 +1,337 @@ +// +// APIClient+RequestTests.swift +// APIServiceTests +// +// Created by Javier Cicchelli on 04/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import Foundation +import XCTest + +@testable import APIService + +final class APIClientRequestTests: XCTestCase { + + // MARK: Properties + + private let makeURLRequest = MakeURLRequestUseCase() + private let dateFormatter = DateFormatter.iso8601 + private let sessionConfiguration = { + let configuration = URLSessionConfiguration.default + + configuration.protocolClasses = [MockURLProtocol.self] + + return configuration + }() + + private var client: APIClient! + private var url: URL! + private var data: Data! + + // MARK: Setup + + override func setUp() async throws { + client = .init(configuration: sessionConfiguration) + } + + // MARK: Request cases + + func test_withSomeEndpoint_andSomeModel_whenResponseStatusOk() async throws { + // GIVEN + let endpoint = GetMeEndpoint( + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + data = "{\"firstName\":\"Noel\",\"lastName\":\"Flantier\",\"rootItem\":{\"id\":\"4b8e41fd4a6a89712f15bbf102421b9338cfab11\",\"parentId\":\"\",\"name\":\"dossierTest\",\"isDir\":true,\"modificationDate\":\"2021-11-29T10:57:13Z\"}}\n".data(using: .utf8) + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .ok, + headers: [:], + data: data + ) + + // WHEN + let result = try await client.request(endpoint: endpoint, model: Me.self) + + // THEN + XCTAssertEqual(result, Me( + firstName: "Noel", + lastName: "Flantier", + rootItem: .init( + idParent: "", + id: "4b8e41fd4a6a89712f15bbf102421b9338cfab11", + name: "dossierTest", + isDirectory: true, + lastModifiedAt: dateFormatter.date(from: "2021-11-29T10:57:13Z")!, + size: nil, + contentType: nil + ) + )) + } + + func test_withSomeEndpoint_andSomeModel_whenResponseStatusCreated() async throws { + // GIVEN + let endpoint = GetMeEndpoint( + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + data = "{\"firstName\":\"Noel\",\"lastName\":\"Flantier\",\"rootItem\":{\"id\":\"4b8e41fd4a6a89712f15bbf102421b9338cfab11\",\"parentId\":\"\",\"name\":\"dossierTest\",\"isDir\":true,\"modificationDate\":\"2021-11-29T10:57:13Z\"}}\n".data(using: .utf8) + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .created, + headers: [:], + data: data + ) + + // WHEN + let result = try await client.request(endpoint: endpoint, model: Me.self) + + // THEN + XCTAssertEqual(result, Me( + firstName: "Noel", + lastName: "Flantier", + rootItem: .init( + idParent: "", + id: "4b8e41fd4a6a89712f15bbf102421b9338cfab11", + name: "dossierTest", + isDirectory: true, + lastModifiedAt: dateFormatter.date(from: "2021-11-29T10:57:13Z")!, + size: nil, + contentType: nil + ) + )) + } + + func test_withSomeEndpoint_andSomeModel_whenResponseStatusNoContent() async throws { + // GIVEN + let endpoint = GetMeEndpoint( + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .noContent, + headers: [:], + data: nil + ) + + // WHEN & THEN + do { + _ = try await client.request(endpoint: endpoint, model: Me.self) + } catch APIClientError.notSupported { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + + func test_withSomeEndpoint_andSomeModel_whenResponseStatusBadRequest() async throws { + // GIVEN + let endpoint = GetMeEndpoint( + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .badRequest, + headers: [:], + data: nil + ) + + // WHEN & THEN + do { + _ = try await client.request(endpoint: endpoint, model: Me.self) + } catch APIClientError.itemAlreadyExist { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + + func test_withSomeEndpoint_andSomeModel_whenResponseStatusNotFound() async throws { + // GIVEN + let endpoint = GetMeEndpoint( + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .notFound, + headers: [:], + data: nil + ) + + // WHEN & THEN + do { + _ = try await client.request(endpoint: endpoint, model: Me.self) + } catch APIClientError.itemDoesNotExist { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + + func test_withSomeEndpoint_andSomeModel_whenResponseDataIncorrect() async throws { + // GIVEN + let endpoint = GetMeEndpoint( + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + data = "{\"firstName\":\"Noel\",\"lastName\":\"Flantier\",\"rootItem\":{\"id\":\"4b8e41fd4a6a89712f15bbf102421b9338cfab11\"\"isDir\":true}}\n".data(using: .utf8) + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .ok, + headers: [:], + data: data + ) + + // WHEN & THEN + do { + _ = try await client.request(endpoint: endpoint, model: Me.self) + } catch is DecodingError { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + + func test_withSomeEndpoint_whenResponseStatusOk() async throws { + // GIVEN + let endpoint = GetDataEndpoint( + itemId: UUID().uuidString, + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + data = "This is just some dummy data for testing purposes".data(using: .utf8) + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .ok, + headers: [:], + data: data + ) + + // WHEN + let result = try await client.request(endpoint: endpoint) + + // THEN + XCTAssertEqual(result, data) + } + + func test_withSomeEndpoint_whenResponseStatusCreated() async throws { + // GIVEN + let endpoint = GetDataEndpoint( + itemId: UUID().uuidString, + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .created, + headers: [:], + data: nil + ) + + // WHEN & THEN + do { + try await client.request(endpoint: endpoint) + } catch APIClientError.notSupported { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + + func test_withSomeEndpoint_whenResponseStatusNoContent() async throws { + // GIVEN + let endpoint = GetDataEndpoint( + itemId: UUID().uuidString, + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + data = .init() + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .noContent, + headers: [:], + data: nil + ) + + // WHEN + let result = try await client.request(endpoint: endpoint) + + // THEN + XCTAssertEqual(result, data) + } + + func test_withSomeEndpoint_whenResponseStatusBadRequest() async throws { + // GIVEN + let endpoint = GetDataEndpoint( + itemId: UUID().uuidString, + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .badRequest, + headers: [:], + data: nil + ) + + // WHEN & THEN + do { + try await client.request(endpoint: endpoint) + } catch APIClientError.itemIsNotFile { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + + func test_withSomeEndpoint_whenResponseStatusNotFound() async throws { + // GIVEN + let endpoint = GetDataEndpoint( + itemId: UUID().uuidString, + username: "username", + password: "password" + ) + + url = try makeURLRequest(endpoint: endpoint).url + + MockURLProtocol.mockData[url] = MockURLResponse( + status: .notFound, + headers: [:], + data: nil + ) + + // WHEN & THEN + do { + try await client.request(endpoint: endpoint) + } catch APIClientError.itemDoesNotExist { + XCTAssertTrue(true) + } catch { + XCTAssertTrue(false) + } + } + +}