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
+ }
+"""