diff --git a/Sources/MarvelService/Internal/Extensions/TimeInterval+Computed.swift b/Sources/MarvelService/Internal/Extensions/TimeInterval+Computed.swift new file mode 100644 index 00000000..467ff0c2 --- /dev/null +++ b/Sources/MarvelService/Internal/Extensions/TimeInterval+Computed.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) -2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +import struct Foundation.TimeInterval + +extension TimeInterval { + + // MARK: Functions + + /// Converts a time interval to a string value. + /// - Returns: A time interval as a string. + var asString: String { + .init(format: "%f", self) + } + +} diff --git a/Sources/MarvelService/Internal/Use Cases/GenerateHashUseCase.swift b/Sources/MarvelService/Internal/Use Cases/GenerateHashUseCase.swift new file mode 100644 index 00000000..f0803e08 --- /dev/null +++ b/Sources/MarvelService/Internal/Use Cases/GenerateHashUseCase.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) -2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +import CryptoKit + +import struct Foundation.Data +import struct Foundation.TimeInterval + +/// A use case that generates a MD5 hash value, which is required to authenticate any request. +struct GenerateHashUseCase { + + // MARK: Properties + + /// A private key. + private let privateKey: String + + /// A public key. + private let publicKey: String + + // MARK: Initializers + + /// Initializes this use case. + /// - Parameters: + /// - privateKey: A private key. + /// - publicKey: A public key. + init( + privateKey: String, + publicKey: String, + ) { + self.privateKey = privateKey + self.publicKey = publicKey + } + + // MARK: Functions + + /// Generates a MD5 hash value out of a given public key, private key and a timestamp. + /// - Parameter timestamp: A timestamp that changes on a request-by-request basis. + /// - Returns: A MD5 hash generated out of a private key, an public key, and a timestamp. + func callAsFunction( + timestamp: TimeInterval + ) -> String { + let stringToHash = timestamp.asString + self.privateKey + self.publicKey + let dataToHash = Data(stringToHash.utf8) + + return Insecure.MD5 + .hash(data: dataToHash) + .map { String(format: "%02x", $0) } + .joined() + } + +} diff --git a/Sources/MarvelService/Public/Middlewares/AuthMiddleware.swift b/Sources/MarvelService/Public/Middlewares/AuthMiddleware.swift new file mode 100644 index 00000000..1070841c --- /dev/null +++ b/Sources/MarvelService/Public/Middlewares/AuthMiddleware.swift @@ -0,0 +1,104 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +import class OpenAPIRuntime.HTTPBody + +import protocol OpenAPIRuntime.ClientMiddleware + +import struct Foundation.Date +import struct Foundation.TimeInterval +import struct Foundation.URL +import struct Foundation.URLComponents +import struct HTTPTypes.HTTPRequest +import struct HTTPTypes.HTTPResponse + +/// A middleware that attaches the necessary authentication parameters to the path of the request. +public struct AuthMiddleware { + + // MARK: Properties + + /// A use case that generates a MD5 hash value to use as an authentication parameter. + private let hash: GenerateHashUseCase + + /// A Marvel API public key. + private let publicKey: String + + // MARK: Initializers + + /// Initializes this middleware with private and public keys. + /// + /// The middleware attaches the required `apikey`, `ts`, and `hash` parameters to the URI path of the intercepted request. + /// This initializer should be used for server-side applications, as indicated in the [Marvel API documentation](https://developer.marvel.com/documentation/authorization) + /// + /// - Parameters: + /// - privateKey: A Marvel API private key. + /// - publicKey: A Marvel API public key. + public init( + privateKey: String, + publicKey: String + ) { + self.hash = .init( + privateKey: privateKey, + publicKey: publicKey + ) + self.publicKey = publicKey + } + +} + +// MARK: - ClientMiddleware + +extension AuthMiddleware: ClientMiddleware { + + // MARK: Functions + + public func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + guard + let uriPath = request.path, + var urlComponents = URLComponents(string: uriPath) + else { + return try await next(request, body, baseURL) + } + + let queryItems = urlComponents.queryItems ?? [] + let timestamp = Date().timeIntervalSince1970 + + urlComponents.queryItems = queryItems + [ + .init(name: "ts", value: timestamp.asString), + .init(name: "apikey", value: publicKey), + .init(name: "hash", value: hash(timestamp: timestamp)) + ] + + let newPath = if let urlQuery = urlComponents.query { + urlComponents.path + "?" + urlQuery + } else { + urlComponents.path + } + + let newRequest = HTTPRequest( + method: request.method, + scheme: request.scheme, + authority: request.authority, + path: newPath, + headerFields: request.headerFields + ) + + return try await next(newRequest, body, baseURL) + } + +} diff --git a/Tests/MarvelService/Cases/Internal/Extensions/TimeInterval+ComputedTests.swift b/Tests/MarvelService/Cases/Internal/Extensions/TimeInterval+ComputedTests.swift new file mode 100644 index 00000000..3ccdb0fc --- /dev/null +++ b/Tests/MarvelService/Cases/Internal/Extensions/TimeInterval+ComputedTests.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +import Testing + +import struct Foundation.TimeInterval + +@testable import MarvelService + +@Suite("Time Interval computed properties", .tags(.extension)) +struct TimeIntervalComputedTests { + + // MARK: Properties tests + +#if swift(>=6.2) + @Test(arguments: zip( + Input.timestamps, + Output.timestampAsString + )) + func `asString`( + timestamp: TimeInterval, + expects string: String + ) { + assertAsString( + timestamp: timestamp, + expects: string + ) + } +#else + @Test("asString", arguments: zip( + Input.timestamps, + Output.timestampAsString + )) + func asString( + timestamp: TimeInterval, + expects string: String + ) { + assertAsString( + timestamp: timestamp, + expects: string + ) + } +#endif + +} + +// MARK: - Assertions + +private extension TimeIntervalComputedTests { + + // MARK: Functions + + /// Asserts the timestamp to string conversion. + /// - Parameters: + /// - timestamp: A timestamp to convert. + /// - string: An expected timestamp converted to string. + func assertAsString( + timestamp: TimeInterval, + expects string: String + ) { + // GIVEN + // WHEN + let result = timestamp.asString + + // THEN + #expect(result == string) + } + +} + +// MARK: - Constants + +private extension Output { + /// A list of outcomes that are expected from converting timestamps to string. + static let timestampAsString: [String] = [ + "0.000000", + "1000.000000", + "1000000.000000", + "1000000000.000000" + ] +} diff --git a/Tests/MarvelService/Cases/Internal/Use Cases/GenerateHashUseCaseTests.swift b/Tests/MarvelService/Cases/Internal/Use Cases/GenerateHashUseCaseTests.swift new file mode 100644 index 00000000..18f35c7f --- /dev/null +++ b/Tests/MarvelService/Cases/Internal/Use Cases/GenerateHashUseCaseTests.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +import Testing + +import struct Foundation.TimeInterval + +@testable import struct MarvelService.GenerateHashUseCase + +@Suite("Generate Hash Use Case", .tags(.useCase)) +struct GenerateHashUseCaseTests { + + // MARK: Functions + +#if swift(>=6.2) + @Test(arguments: zip( + Input.timestamps, + Output.generatedHashes + )) + func `hash`( + timestamp: TimeInterval, + expects hash: String + ) async throws { + assertHash( + timestamp: timestamp, + expects: hash + ) + } +#else + @Test("hash", arguments: zip( + Input.timestamps, + Output.generatedHashes + )) + func hash( + timestamp: TimeInterval, + expects hash: String + ) async throws { + assertHash( + timestamp: timestamp, + expects: hash + ) + } +#endif + +} + +// MARK: - Assertions + +private extension GenerateHashUseCaseTests { + + // MARK: Functions + + /// Asserts the MD5 hash generated from the use case. + /// - Parameters: + /// - timestamp: A timestamp to use in the hash generation. + /// - hash: An expected MD5 hash string as a result of the use case. + func assertHash( + timestamp: TimeInterval, + expects hash: String + ) { + // GIVEN + let useCase: GenerateHashUseCase = .init( + privateKey: .Key.private, + publicKey: .Key.public + ) + + // WHEN + let result = useCase(timestamp: timestamp) + + // THEN + #expect(result == hash) + } + +} + +// MARK: - Constants + +private extension Output { + /// A list of outcomes that are expected from the hash generation. + static let generatedHashes: [String] = [ + "ef9ca6f930e56fb4f8a109a9003580fe", + "b500748e9f0aabc67ffc640ae9b87695", + "b537f18579112902b7ce046dddad558a", + "00fec88a254d42e3a439d49e14cd60d1" + ] +} diff --git a/Tests/MarvelService/Cases/Public/Middlewares/AuthMiddlewareTests.swift b/Tests/MarvelService/Cases/Public/Middlewares/AuthMiddlewareTests.swift new file mode 100644 index 00000000..5b848957 --- /dev/null +++ b/Tests/MarvelService/Cases/Public/Middlewares/AuthMiddlewareTests.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +import Testing + +import struct Foundation.URL +import struct Foundation.URLComponents +import struct HTTPTypes.HTTPRequest +import struct HTTPTypes.HTTPResponse +import struct MarvelService.AuthMiddleware + +@Suite("Auth Middleware", .tags(.middleware)) +struct AuthMiddlewareTest { + + // MARK: Functions + +#if swift(>=6.2) + @Test(arguments: Input.pathRequests) + func `intercept`(path: String?) async throws { + try await assertIntercept(path: path) + } +#else + @Test("intercept", arguments: Input.pathRequests) + func intercept(path: String?) async throws { + try await assertIntercept(path: path) + } +#endif + +} + +// MARK: - Assertions + +private extension AuthMiddlewareTest { + + // MARK: Functions + + /// Asserts the interception of a request to add authentication parameters in it. + /// - Parameter path: A URI path for a request. + /// - Throws: An error in case + func assertIntercept(path: String?) async throws { + // GIVEN + let baseURL: URL = .baseURL + let request: HTTPRequest = .init(path: path) + + let middleware: AuthMiddleware = .init( + privateKey: .Key.private, + publicKey: .Key.public + ) + + // WHEN + _ = try await confirmation { confirmation in + try await middleware.intercept( + request, + body: nil, + baseURL: baseURL, + operationID: .operationId + ) { request, _, _ in + // THEN + if path != nil { + let pathRequest = try #require(request.path) + let urlComponents = try #require(URLComponents(string: pathRequest)) + let queryItems = try #require(urlComponents.queryItems) + + #expect(queryItems.count >= 3) + #expect(queryItems.contains(where: { $0.name == "ts" })) + #expect(queryItems.contains(where: { $0.name == "apikey" })) + #expect(queryItems.contains(where: { $0.name == "hash" })) + } else { + #expect(request.path == nil) + } + + confirmation() + + return (.init(status: .ok) , nil) + } + } + } + +} + +// MARK: - Helpers + +private extension HTTPRequest { + + // MARK: Initializers + + /// Initializes a HTTP request with a method and a path. + /// - Parameters: + /// - method: The request method. + /// - path: The value of the “:path” pseudo header field. + init( + method: HTTPRequest.Method = .get, + path: String? + ) { + self.init( + method: method, + scheme: nil, + authority: nil, + path: path + ) + } + +} + +// MARK: - Constants + +private extension Input { + /// A list of URI path to a resource for a request. + static let pathRequests: [String?] = [ + nil, + "/path/to/resource", + "/path/to/resource?boolean", + "/path/to/resource?query=value", + "/path/to/resource?query=value&anotherQuery=anotherValue" + ] +} + +private extension String { + /// An operation ID sample. + static let operationId = "SomeOperationId" +} + +private extension URL { + /// A base URL sample. + static let baseURL = URL(string: "https://sample.domain.com")! +} diff --git a/Tests/MarvelService/Types/Extensions/String+Samples.swift b/Tests/MarvelService/Types/Extensions/String+Samples.swift new file mode 100644 index 00000000..3b2c79bc --- /dev/null +++ b/Tests/MarvelService/Types/Extensions/String+Samples.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +extension String { + /// A namespace assigned for Marvel API key samples. + enum Key { + /// A Marvel API private key sample. + static let `private` = "SomePrivateKey" + /// A Marvel API public key sample. + static let `public` = "SomePublicKey" + } +} diff --git a/Tests/MarvelService/Types/Extensions/Tag+Customs.swift b/Tests/MarvelService/Types/Extensions/Tag+Customs.swift new file mode 100644 index 00000000..1565b12e --- /dev/null +++ b/Tests/MarvelService/Types/Extensions/Tag+Customs.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +import Testing + +extension Tag { + + // MARK: Constants + + /// A flag that indicates tests for a type extension. + @Tag static var `extension`: Self + + /// A flag that indicates tests for a middleware type. + @Tag static var middleware: Self + + /// A flag that indicates tests for a use case type. + @Tag static var useCase: Self +} diff --git a/Tests/MarvelService/Types/Namespaces/Input.swift b/Tests/MarvelService/Types/Namespaces/Input.swift new file mode 100644 index 00000000..42ed60c9 --- /dev/null +++ b/Tests/MarvelService/Types/Namespaces/Input.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +import struct Foundation.Date +import struct Foundation.TimeInterval + +/// A namespace assigned for input arguments on test cases. +enum Input { + + // MARK: Constants + + /// A list of timestamps samples. + static let timestamps: [TimeInterval] = [ + Date(timeIntervalSince1970: 0).timeIntervalSince1970, + Date(timeIntervalSince1970: 1_000).timeIntervalSince1970, + Date(timeIntervalSince1970: 1_000_000).timeIntervalSince1970, + Date(timeIntervalSince1970: 1_000_000_000).timeIntervalSince1970, + ] +} diff --git a/Tests/MarvelService/Types/Namespaces/Output.swift b/Tests/MarvelService/Types/Namespaces/Output.swift new file mode 100644 index 00000000..0bc713eb --- /dev/null +++ b/Tests/MarvelService/Types/Namespaces/Output.swift @@ -0,0 +1,14 @@ +//===----------------------------------------------------------------------=== +// +// This source file is part of the MarvelService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the MarvelService project authors +// Licensed under the EUPL 1.2 or later. +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of MarvelService project authors +// +//===----------------------------------------------------------------------=== + +/// A namespace assigned for output arguments on test cases, that are expected results. +enum Output {}