diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2592bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + library: + runs-on: macos-13 + strategy: + matrix: + xcode: + - '15.2' + - '15.1' + - '15.0' + - '14.3.1' + + steps: + - uses: actions/checkout@v4 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Skip macro validation + run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES + - name: Run tests + run: swift test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..330d167 --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5de93f9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 doxuto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..eead657 --- /dev/null +++ b/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "APIClient", + platforms: [.iOS(.v13), .macOS(.v10_15)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "APIClient", + targets: ["APIClient"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "APIClient"), + .testTarget( + name: "APIClientTests", + dependencies: ["APIClient"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2cb45c --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +[![CI](https://github.com/doxuto/APIClient/actions/workflows/ci.yml/badge.svg)](https://github.com/doxuto/APIClient/actions/workflows/ci.yml) +[![Swift](https://img.shields.io/badge/Swift-5.5_|_5.6_|_5.7_|_5.8_|_5.9-red)](https://img.shields.io/badge/Swift-5.5_5.6_5.7_5.8_5.9-red) +[![Platforms](https://img.shields.io/badge/Platforms-macOS_|_iOS-red)](https://img.shields.io/badge/Platforms-macOS_iOS-red) +### README.md + +```markdown +# APIClient + +## Description +A simply networking layer in Swift that allows you to easily make HTTP requests and handle response data. + +## Installation +To use the APIClient module in your Swift project, include this package as a dependency in your Package.swift file. + +```swift +dependencies: [ + .package(url: "https://github.com/doxuto/APIClient.git", from: "1.0.0") +] +``` + +## Usage +1. Import the APIClient module in your Swift files where you need to make API requests. + +```swift +import APIClient + +struct UserEndpoint: Endpoint { + var url: URL = URL(string: "https://httpbin.org/get")! + var requestMethod: RequestMethod = RequestMethod.get + var headers: [String : String]? = nil + var parameters: [String : String]? = nil + var timeoutInterval: TimeInterval = 60 +} + +let apiClient = APIClient( + validator: DefaultValidator(), + urlSession: URLSession.shared, + jsonDecoder: JSONDecoder() +) + +let endpoint = UserEndpoint() +let user: User = try await apiClient.request(endpoint: endpoint) +``` + +Or you can use Combine's `Publisher` type to handle API responses. +``` + func fetchUser() -> AnyPublisher { + let endpoint = UserEndpoint() + return apiClient.request(endpoint: endpoint) + } + +``` + +2. If you want to customize the `Validator` of APIClient, you can do that in the contructor `APIClient` +``` +struct CustomizedValidator: Validator { + func validate(for response: HTTPURLResponse) -> Bool { + let statusCode = response.statusCode + return statusCode == 200 + } +} + +let apiClient = APIClient( + validator: CustomizedValidator(), + urlSession: URLSession.shared, + jsonDecoder: JSONDecoder() +) + +let endpoint = UserEndpoint() +let user: User = try await apiClient.request(endpoint: endpoint) + +``` + + +## Dependencies +- No external dependencies required. + +## Structure +- **APIClient:** Main module for handling API requests. +- **APIClientTests:** Unit tests for the APIClient module. + +## Contribution +Feel free to contribute by forking the repository and submitting pull requests. + +## License +This package is released under the MIT License. See LICENSE file for more details. +``` diff --git a/Sources/APIClient/APIClient.swift b/Sources/APIClient/APIClient.swift new file mode 100644 index 0000000..cd67f8e --- /dev/null +++ b/Sources/APIClient/APIClient.swift @@ -0,0 +1,65 @@ +// +// APIClient.swift +// +// +// Created by doxuto on 05/03/2024. +// + +import Foundation +import Combine + +public struct APIClient { + private let validator: any Validator + private let urlSession: URLSession + private let jsonDecoder: JSONDecoder + + public init(validator: any Validator, urlSession: URLSession, jsonDecoder: JSONDecoder) { + self.validator = validator + self.urlSession = urlSession + self.jsonDecoder = jsonDecoder + } + + public func request(endpoint: any Endpoint) async throws -> Data { + let urlRequest = endpoint.toRequest + let data: Data + let urlResponse: URLResponse + do { + let response = try await urlSession.data(for: urlRequest) + data = response.0 + urlResponse = response.1 as! HTTPURLResponse + } catch { + throw ApiError.underlying(error) + } + + guard try validator.validate(for: urlResponse as! HTTPURLResponse) else { + throw ApiError.invalid + } + return data + } + + public func request(endpoint: any Endpoint) async throws -> T { + let data = try await self.request(endpoint: endpoint) + do { + let result = try jsonDecoder.decode(T.self, from: data) + return result + } catch { + throw ApiError.parseError(error) + } + } + + public func request(endpoint: any Endpoint) -> AnyPublisher { + urlSession.dataTaskPublisher(for: endpoint.toRequest) + .tryMap { try process(data: $0, response: $1) } + .eraseToAnyPublisher() + } + + private func process(data: Data, response: URLResponse) throws -> T { + let httpResponse = response as! HTTPURLResponse + guard try validator.validate(for: httpResponse) else { throw ApiError.invalid } + do { + return try jsonDecoder.decode(T.self, from: data) + } catch { + throw ApiError.parseError(error) + } + } +} diff --git a/Sources/APIClient/Models/ApiError.swift b/Sources/APIClient/Models/ApiError.swift new file mode 100644 index 0000000..60344d3 --- /dev/null +++ b/Sources/APIClient/Models/ApiError.swift @@ -0,0 +1,15 @@ +// +// ApiError.swift +// +// +// Created by doxuto on 05/03/2024. +// + +import Foundation + +public enum ApiError: Error, Sendable { + case invalid + case badRequest + case underlying(Error) + case parseError(Error) +} diff --git a/Sources/APIClient/Models/DefaultValidator.swift b/Sources/APIClient/Models/DefaultValidator.swift new file mode 100644 index 0000000..6f04755 --- /dev/null +++ b/Sources/APIClient/Models/DefaultValidator.swift @@ -0,0 +1,16 @@ +// +// DefaultValidator.swift +// +// +// Created by doxuto on 05/03/2024. +// + +import Foundation + +struct DefaultValidator: Validator { + let filteredStatusCodes = 200...299 + func validate(for response: HTTPURLResponse) -> Bool { + let statusCode = response.statusCode + return filteredStatusCodes.contains(statusCode) + } +} diff --git a/Sources/APIClient/Models/Endpoint.swift b/Sources/APIClient/Models/Endpoint.swift new file mode 100644 index 0000000..a7c4b57 --- /dev/null +++ b/Sources/APIClient/Models/Endpoint.swift @@ -0,0 +1,31 @@ +// +// Endpoint.swift +// +// +// Created by doxuto on 05/03/2024. +// + +import Foundation + +public protocol Endpoint { + var url: URL { get } + var requestMethod: RequestMethod { get } + var headers: [String: String]? { get } + var timeoutInterval: TimeInterval { get } + var parameters: [String: String]? { get } +} + +public extension Endpoint { + var timeoutInterval: TimeInterval { 60 } +} + +public extension Endpoint { + var toRequest: URLRequest { + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = requestMethod.rawValue + urlRequest.allHTTPHeaderFields = headers + urlRequest.timeoutInterval = timeoutInterval + urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: parameters as Any, options: .fragmentsAllowed) + return urlRequest + } +} diff --git a/Sources/APIClient/Models/RequestMethod.swift b/Sources/APIClient/Models/RequestMethod.swift new file mode 100644 index 0000000..b6c5066 --- /dev/null +++ b/Sources/APIClient/Models/RequestMethod.swift @@ -0,0 +1,20 @@ +// +// RequestMethod.swift +// +// +// Created by doxuto on 05/03/2024. +// + +import Foundation + +public enum RequestMethod: String, Sendable { + case options = "OPTIONS" + case get = "GET" + case head = "HEAD" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" + case trace = "TRACE" + case connect = "CONNECT" +} diff --git a/Sources/APIClient/Models/Validator.swift b/Sources/APIClient/Models/Validator.swift new file mode 100644 index 0000000..8c07762 --- /dev/null +++ b/Sources/APIClient/Models/Validator.swift @@ -0,0 +1,12 @@ +// +// Validator.swift +// +// +// Created by doxuto on 05/03/2024. +// + +import Foundation + +public protocol Validator { + func validate(for response: HTTPURLResponse) throws -> Bool +} diff --git a/Tests/APIClientTests/APIClientTests.swift b/Tests/APIClientTests/APIClientTests.swift new file mode 100644 index 0000000..b246df2 --- /dev/null +++ b/Tests/APIClientTests/APIClientTests.swift @@ -0,0 +1,208 @@ +import Combine +import XCTest +@testable import APIClient + +final class APIClientTests: XCTestCase { + lazy var dataFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z" + return dateFormatter + }() + + lazy var jsonDecoder: JSONDecoder = { + let jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .formatted(self.dataFormatter) + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + return jsonDecoder + }() + + lazy var urlSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + + let urlSession = URLSession(configuration: configuration) + return urlSession + }() + + var apiClient: APIClient! + var cancelables = Set() + + override func setUp() async throws { + apiClient = .init( + validator: DefaultValidator(), + urlSession: urlSession, + jsonDecoder: jsonDecoder + ) + cancelables = [] + } + + func test_fetchUser_success_with_data_response_success() async throws { + let endpoint = UserEndpoint() + let response = HTTPURLResponse( + url: endpoint.url, + statusCode: 200, + httpVersion: nil, + headerFields: endpoint.headers + ) + + MockURLProtocol.mockURLs[endpoint.url] = (error: nil, data: userJson.data(using: .utf8), response: response) + + let userData = try await apiClient.request(endpoint: endpoint) + let user = try jsonDecoder.decode(User.self, from: userData) + XCTAssertEqual(user.email, "test@test.com") + } + + func test_fetchUser_success_with_invalid_statusCode() async throws { + let endpoint = UserEndpoint() + let response = HTTPURLResponse( + url: endpoint.url, + statusCode: 404, + httpVersion: nil, + headerFields: endpoint.headers + ) + + MockURLProtocol.mockURLs[endpoint.url] = (error: nil, data: userJson.data(using: .utf8), response: response) + + do { + let _ = try await apiClient.request(endpoint: endpoint) + } catch { + XCTAssertEqual(error as? ApiError, ApiError.invalid) + } + } + + func test_fetchUser_with_decode_success() async throws { + let endpoint = UserEndpoint() + let response = HTTPURLResponse( + url: endpoint.url, + statusCode: 200, + httpVersion: nil, + headerFields: endpoint.headers + ) + + MockURLProtocol.mockURLs[endpoint.url] = (error: nil, data: userJson.data(using: .utf8), response: response) + + let user: User = try await apiClient.request(endpoint: endpoint) + XCTAssertEqual(user.email, "test@test.com") + } + + func test_fetchUser_with_decode_invalid_statusCode() async throws { + let endpoint = UserEndpoint() + let response = HTTPURLResponse( + url: endpoint.url, + statusCode: 404, + httpVersion: nil, + headerFields: endpoint.headers + ) + + MockURLProtocol.mockURLs[endpoint.url] = (error: nil, data: userJson.data(using: .utf8), response: response) + + do { + let _: User = try await apiClient.request(endpoint: endpoint) + } catch { + XCTAssertEqual(error as? ApiError, ApiError.invalid) + } + } + + func test_fetchUser_with_decode_failure() async throws { + let endpoint = UserEndpoint() + let response = HTTPURLResponse( + url: endpoint.url, + statusCode: 200, + httpVersion: nil, + headerFields: endpoint.headers + ) + MockURLProtocol.mockURLs[endpoint.url] = (error: nil, data: userJsonWithTimestamp.data(using: .utf8), response: response) + + do { + let _: User = try await apiClient.request(endpoint: endpoint) + } catch { + XCTAssertEqual(error as? ApiError, ApiError.parseError(error)) + } + } + + func test_fetchUser_use_publisher_with_decode_success() { + let endpoint = UserEndpoint() + let response = HTTPURLResponse( + url: endpoint.url, + statusCode: 200, + httpVersion: nil, + headerFields: endpoint.headers + ) + + MockURLProtocol.mockURLs[endpoint.url] = (error: nil, data: userJson.data(using: .utf8), response: response) + + let expectation = XCTestExpectation(description: "Receive a user after fetch data") + var user: User? + + let userPublisher: AnyPublisher = apiClient.request(endpoint: endpoint) + + userPublisher.sink( + receiveCompletion: { _ in + }, + receiveValue: { value in + user = value + expectation.fulfill() + }) + .store(in: &cancelables) + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(user?.email, "test@test.com") + } + + func test_fetchUser_use_publisher_with_decode_failure() { + let endpoint = UserEndpoint() + let response = HTTPURLResponse( + url: endpoint.url, + statusCode: 200, + httpVersion: nil, + headerFields: endpoint.headers + ) + + MockURLProtocol.mockURLs[endpoint.url] = (error: nil, data: userJsonWithTimestamp.data(using: .utf8), response: response) + + let expectation = XCTestExpectation(description: "Receive an error") + var error: ApiError? + + let userPublisher: AnyPublisher = apiClient.request(endpoint: endpoint) + + userPublisher.sink( + receiveCompletion: { completion in + if case .failure(let failure) = completion { + error = failure as? ApiError + expectation.fulfill() + } + }, + receiveValue: { _ in + }) + .store(in: &cancelables) + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(error, ApiError.parseError(NSError())) + } +} + +extension ApiError: Equatable { + public static func == (lhs: ApiError, rhs: ApiError) -> Bool { + switch (lhs, rhs) { + case (.invalid, .invalid): + return true + case (.badRequest, .badRequest): + return true + case (.parseError, .parseError): + return true + case (.underlying, .underlying): + return true + default: + return false + } + } +} + +struct UserEndpoint: Endpoint { + var url: URL = URL(string: "https://httpbin.org/get")! + var requestMethod: RequestMethod = RequestMethod.get + var headers: [String : String]? = nil + var parameters: [String : String]? = nil + var timeoutInterval: TimeInterval = 60 +} diff --git a/Tests/APIClientTests/MockURLProtocol.swift b/Tests/APIClientTests/MockURLProtocol.swift new file mode 100644 index 0000000..a7f081b --- /dev/null +++ b/Tests/APIClientTests/MockURLProtocol.swift @@ -0,0 +1,52 @@ +// +// MockURLProtocol.swift +// +// +// Created by doxuto on 06/03/2024. +// + +import Foundation + +class MockURLProtocol: URLProtocol { + /// Dictionary maps URLs to tuples of error, data, and response + static var mockURLs = [URL?: (error: Error?, data: Data?, response: HTTPURLResponse?)]() + + override class func canInit(with request: URLRequest) -> Bool { + // Handle all types of requests + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + // Required to be implemented here. Just return what is passed + return request + } + + override func startLoading() { + if let url = request.url { + if let (error, data, response) = Self.mockURLs[url] { + + // We have a mock response specified so return it. + if let responseStrong = response { + self.client?.urlProtocol(self, didReceive: responseStrong, cacheStoragePolicy: .notAllowed) + } + + // We have mocked data specified so return it. + if let dataStrong = data { + self.client?.urlProtocol(self, didLoad: dataStrong) + } + + // We have a mocked error so return it. + if let errorStrong = error { + self.client?.urlProtocol(self, didFailWithError: errorStrong) + } + } + } + + // Send the signal that we are done returning our mock response + self.client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { + // Required to be implemented. Do nothing here. + } +} diff --git a/Tests/APIClientTests/User.swift b/Tests/APIClientTests/User.swift new file mode 100644 index 0000000..bd07a81 --- /dev/null +++ b/Tests/APIClientTests/User.swift @@ -0,0 +1,60 @@ +// +// User.swift +// +// +// Created by doxuto on 06/03/2024. +// + +import Foundation + +struct User: Decodable { + let userId: String + let email: String + let name: String + let givenName: String + let familyName: String + let nickname: String + let lastIp: String + let loginsCount: Int + let createdAt: Date + let updatedAt: Date + let lastLogin: Date + let emailVerified: Bool +} + + +let userJson = +""" +{ +"user_id": "583c3ac3f38e84297c002546", +"email": "test@test.com", +"name": "test@test.com", +"given_name": "Hello", +"family_name": "Test", +"nickname": "test", +"last_ip": "94.121.163.63", +"logins_count": 15, +"created_at": "2016-11-28T14:10:11.338Z", +"updated_at": "2016-12-02T01:17:29.310Z", +"last_login": "2016-12-02T01:17:29.310Z", +"email_verified": true + } +""" + +let userJsonWithTimestamp = +""" +{ +"user_id": "583c3ac3f38e84297c002546", +"email": "test@test.com", +"name": "test@test.com", +"given_name": "Hello", +"family_name": "Test", +"nickname": "test", +"last_ip": "94.121.163.63", +"logins_count": 15, +"created_at": "1600000000000", +"updated_at": "1600000000000", +"last_login": "1600000000000", +"email_verified": true + } +"""