diff --git a/Sources/MarvelService/Internal/Extensions/String+Constants.swift b/Sources/MarvelService/Internal/Extensions/String+Constants.swift new file mode 100644 index 00000000..f7f87042 --- /dev/null +++ b/Sources/MarvelService/Internal/Extensions/String+Constants.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------=== +// +// 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 parameter keys of URI paths. + enum Parameter { + /// A Marvel API key parameter. + static let apiKey = "apikey" + /// A MD5 hash parameter. + static let hash = "hash" + /// A timestamp parameter. + static let timestamp = "ts" + } +} diff --git a/Sources/MarvelService/Public/Middlewares/AuthMiddleware.swift b/Sources/MarvelService/Public/Middlewares/AuthMiddleware.swift index 1070841c..d83ca9fd 100644 --- a/Sources/MarvelService/Public/Middlewares/AuthMiddleware.swift +++ b/Sources/MarvelService/Public/Middlewares/AuthMiddleware.swift @@ -26,13 +26,24 @@ public struct AuthMiddleware { // MARK: Properties + /// A Marvel API key. + private let apiKey: String + /// 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 - + private let hash: GenerateHashUseCase? + // MARK: Initializers + + /// Initializes this middleware with an api key. + /// + /// The middleware attaches the required `apikey` parameter to the URI path of the intercepted request. + /// This initializer should be used for client-side applications, as indicated in the [Marvel API documentation](https://developer.marvel.com/documentation/authorization) + /// + /// - Parameter apiKey: A Marvel API key. + public init(apiKey: String) { + self.apiKey = apiKey + self.hash = nil + } /// Initializes this middleware with private and public keys. /// @@ -46,11 +57,11 @@ public struct AuthMiddleware { privateKey: String, publicKey: String ) { + self.apiKey = publicKey self.hash = .init( privateKey: privateKey, publicKey: publicKey ) - self.publicKey = publicKey } } @@ -68,37 +79,68 @@ extension AuthMiddleware: ClientMiddleware { 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 { + guard let path = request.path else { return try await next(request, body, baseURL) } - let queryItems = urlComponents.queryItems ?? [] - let timestamp = Date().timeIntervalSince1970 + return try await next( + .init( + method: request.method, + scheme: request.scheme, + authority: request.authority, + path: authenticatedPath(path), + headerFields: request.headerFields + ), + body, + baseURL + ) + } + +} - urlComponents.queryItems = queryItems + [ - .init(name: "ts", value: timestamp.asString), - .init(name: "apikey", value: publicKey), - .init(name: "hash", value: hash(timestamp: timestamp)) - ] +// MARK: - Helpers + +private extension AuthMiddleware { + + // MARK: Functions + + /// Adds the necessary authentication parameters to a given request path. + /// - Parameter path: A request path to authenticate. + /// - Returns: A request path with the necessary authentication parameters added. + func authenticatedPath(_ path: String) -> String { + guard var urlComponents = URLComponents(string: path) else { + return path + } - let newPath = if let urlQuery = urlComponents.query { + var queryItems = urlComponents.queryItems ?? [] + + queryItems.append(.init( + name: .Parameter.apiKey, + value: apiKey + )) + + if let hash { + let timestamp = Date().timeIntervalSince1970 + + queryItems.append(contentsOf: [ + .init( + name: .Parameter.hash, + value: hash(timestamp: timestamp) + ), + .init( + name: .Parameter.timestamp, + value: timestamp.asString + ), + ]) + } + + urlComponents.queryItems = queryItems + + return 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/Sources/MarvelService/openapi.yaml b/Sources/MarvelService/openapi.yaml index b01b4231..1da5d1c5 100644 --- a/Sources/MarvelService/openapi.yaml +++ b/Sources/MarvelService/openapi.yaml @@ -20,7 +20,7 @@ info: termsOfService: https://developer.marvel.com/terms version: Cable servers: -- url: https://gateway.marvel.com/ +- url: https://gateway.marvel.com description: Live service tags: - name: characters @@ -5763,13 +5763,10 @@ components: $ref: '#/components/schemas/ComicSummary' ErrorResponse: type: object + description: This is the standard error response type. properties: code: - type: integer - description: The HTTP status code of the returned result. - reason: type: string - description: A reason describing the error. + description: The HTTP status code of the returned result. required: - - code - - reason + - code \ No newline at end of file diff --git a/Tests/MarvelService/Cases/Public/Middlewares/AuthMiddlewareTests.swift b/Tests/MarvelService/Cases/Public/Middlewares/AuthMiddlewareTests.swift index 5b848957..907b23e7 100644 --- a/Tests/MarvelService/Cases/Public/Middlewares/AuthMiddlewareTests.swift +++ b/Tests/MarvelService/Cases/Public/Middlewares/AuthMiddlewareTests.swift @@ -16,7 +16,8 @@ import struct Foundation.URL import struct Foundation.URLComponents import struct HTTPTypes.HTTPRequest import struct HTTPTypes.HTTPResponse -import struct MarvelService.AuthMiddleware + +@testable import MarvelService @Suite("Auth Middleware", .tags(.middleware)) struct AuthMiddlewareTest { @@ -25,13 +26,45 @@ struct AuthMiddlewareTest { #if swift(>=6.2) @Test(arguments: Input.pathRequests) - func `intercept`(path: String?) async throws { - try await assertIntercept(path: path) + func `intercept with API key`( + path: String? + ) async throws { + try await assertIntercept( + path: path, + with: .Key.api + ) + } + + @Test(arguments: Input.pathRequests) + func `intercept with private and public keys`( + path: String? + ) async throws { + try await assertIntercept( + path: path, + with: .Key.public, + and: .Key.private + ) } #else - @Test("intercept", arguments: Input.pathRequests) - func intercept(path: String?) async throws { - try await assertIntercept(path: path) + @Test("intercept with API key", arguments: Input.pathRequests) + func interceptWithAPIKey( + path: String? + ) async throws { + try await assertIntercept( + path: path, + with: .Key.api + ) + } + + @Test("intercept with private and public keys", arguments: Input.pathRequests) + func interceptWithKeys( + path: String? + ) async throws { + try await assertIntercept( + path: path, + with: .Key.public, + and: .Key.private + ) } #endif @@ -45,16 +78,26 @@ private extension AuthMiddlewareTest { /// Asserts the interception of a request to add authentication parameters in it. /// - Parameter path: A URI path for a request. + /// - Parameter apiKey: A Marvel (public) API key. + /// - Parameter privateKey: <#publicKey description#> /// - Throws: An error in case - func assertIntercept(path: String?) async throws { + func assertIntercept( + path: String?, + with apiKey: String, + and privateKey: String? = nil + ) async throws { // GIVEN let baseURL: URL = .baseURL let request: HTTPRequest = .init(path: path) - let middleware: AuthMiddleware = .init( - privateKey: .Key.private, - publicKey: .Key.public - ) + let middleware: AuthMiddleware = if let privateKey { + .init( + privateKey: privateKey, + publicKey: apiKey + ) + } else { + .init(apiKey: apiKey) + } // WHEN _ = try await confirmation { confirmation in @@ -70,10 +113,15 @@ private extension AuthMiddlewareTest { 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" })) + #expect(queryItems.contains(where: { $0.name == .Parameter.apiKey })) + + if privateKey == nil { + #expect(queryItems.count >= 1) + } else { + #expect(queryItems.contains(where: { $0.name == .Parameter.hash })) + #expect(queryItems.contains(where: { $0.name == .Parameter.timestamp })) + #expect(queryItems.count >= 3) + } } else { #expect(request.path == nil) } diff --git a/Tests/MarvelService/Types/Extensions/String+Samples.swift b/Tests/MarvelService/Types/Extensions/String+Samples.swift index 3b2c79bc..eb6f0d73 100644 --- a/Tests/MarvelService/Types/Extensions/String+Samples.swift +++ b/Tests/MarvelService/Types/Extensions/String+Samples.swift @@ -13,6 +13,8 @@ extension String { /// A namespace assigned for Marvel API key samples. enum Key { + /// A Marvel API key sample. + static let api = "SomeAPIKey" /// A Marvel API private key sample. static let `private` = "SomePrivateKey" /// A Marvel API public key sample.