From e6d05cb9cc88e10a82f72b10e11d103ecf35c36e Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 10 Oct 2025 17:59:30 +0200 Subject: [PATCH] Implemented the AuthMiddleware type in the library target. --- .../Extensions/String+Constants.swift | 36 +++ .../Public/Middlewares/AuthMiddleware.swift | 160 +++++++++++ .../Middlewares/AuthMiddlewareTests.swift | 269 ++++++++++++++++++ .../Types/Extensions/Tag+Customs.swift | 24 ++ .../DiscogsService/Types/Samples/Input.swift | 16 ++ .../DiscogsService/Types/Samples/Output.swift | 16 ++ 6 files changed, 521 insertions(+) create mode 100644 Sources/DiscogsService/Internal/Extensions/String+Constants.swift create mode 100644 Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift create mode 100644 Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift create mode 100644 Tests/DiscogsService/Types/Extensions/Tag+Customs.swift create mode 100644 Tests/DiscogsService/Types/Samples/Input.swift create mode 100644 Tests/DiscogsService/Types/Samples/Output.swift diff --git a/Sources/DiscogsService/Internal/Extensions/String+Constants.swift b/Sources/DiscogsService/Internal/Extensions/String+Constants.swift new file mode 100644 index 000000000..3d2af2654 --- /dev/null +++ b/Sources/DiscogsService/Internal/Extensions/String+Constants.swift @@ -0,0 +1,36 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the DiscogsService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of DiscogsService project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +extension String { + /// An empty string. + static let empty = "" + + /// A namespaces assigned for the names of parameters. + enum Parameter { + /// A name for the consumer key. + static let key = "key" + /// A name for the consumer secret. + static let secret = "secret" + /// A name for the user token. + static let token = "token" + } + /// A namespaces assigned for the formats of string values. + enum Format { + /// A format for the consumer authentication header. + static let authConsumer = "Discogs \(String.Parameter.key)=%@, \(String.Parameter.secret)=%@" + /// A format for the user authentication header. + static let authUser = "Discogs \(String.Parameter.token)=%@" + } +} + diff --git a/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift b/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift new file mode 100644 index 000000000..4b05a7273 --- /dev/null +++ b/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift @@ -0,0 +1,160 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the DiscogsService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of DiscogsService project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +import class OpenAPIRuntime.HTTPBody + +import protocol OpenAPIRuntime.ClientMiddleware + +import struct Foundation.URL +import struct Foundation.URLComponents +import struct Foundation.URLQueryItem +import struct HTTPTypes.HTTPFields +import struct HTTPTypes.HTTPRequest +import struct HTTPTypes.HTTPResponse + +/// A middleware that attaches any defined authentication credentials into the requests for the service. +/// +/// Please refer to the [Discogs documentation](https://www.discogs.com/developers#page:authentication) for further information. +public struct AuthMiddleware { + + // MARK: Properties + + /// A representation of an authentication method to use to authenticate requests. + private let method: AuthMethod + + /// A representation of a transport option to send credentials in requests. + private let transport: AuthTransport + + // MARK: Initializers + + /// Initializes this middleware. + /// - Parameters: + /// - method: A representation of an authentication method to use to authenticate requests. + /// - transport: A representation of a transport option to send credentials in requests. + public init( + method: AuthMethod = .none, + transport: AuthTransport + ) { + self.method = method + self.transport = transport + } + +} + +// 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 method != .none else { + return try await next(request, body, baseURL) + } + + let headerFields = if transport == .onHeader { + authenticateHeader(request.headerFields) + } else { + request.headerFields + } + + let path = if transport == .onQuery { + authenticatePath(request.path) + } else { + request.path + } + + return try await next( + .init( + method: request.method, + scheme: request.scheme, + authority: request.authority, + path: path, + headerFields: headerFields + ), + body, + baseURL + ) + } + +} + +// MARK: - Helpers + +private extension AuthMiddleware { + + // MARK: Functions + + /// Adds an authorization header to the existing header fields. + /// - Parameter fields: A set of header fields to update. + /// - Returns: An updated set of header fields. + func authenticateHeader(_ fields: HTTPFields) -> HTTPFields { + var fields = fields + + let authorization: String = switch method { + case let .consumer(key, secret): .init(format: .Format.authConsumer, key, secret) + case let .user(token): .init(format: .Format.authUser, token) + default: .empty + } + + fields.append(.init( + name: .authorization, + value: authorization + )) + + return fields + } + + /// Adds the authentication parameters to the query of a path + /// - Parameter path: A request path to authenticate. + /// - Returns: An updated request path including the authentication parameters. + func authenticatePath(_ path: String?) -> String? { + guard + let path, + var urlComponents = URLComponents(string: path) + else { + return path + } + + let authItems: [URLQueryItem] = switch method { + case let .consumer(key, secret): [ + .init(name: .Parameter.key, value: key), + .init(name: .Parameter.secret, value: secret) + ] + case let .user(token): [ + .init(name: .Parameter.token, value: token) + ] + default: [] + } + + var queryItems = urlComponents.queryItems ?? [] + + queryItems.append(contentsOf: authItems) + + urlComponents.queryItems = queryItems + + return if let urlQuery = urlComponents.query { + urlComponents.path + "?" + urlQuery + } else { + urlComponents.path + } + } + +} diff --git a/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift b/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift new file mode 100644 index 000000000..a076128cf --- /dev/null +++ b/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift @@ -0,0 +1,269 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the DiscogsService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of DiscogsService project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +import struct Foundation.URL +import struct Foundation.URLComponents +import struct HTTPTypes.HTTPFields +import struct HTTPTypes.HTTPRequest +import struct HTTPTypes.HTTPResponse + +import Testing + +@testable import DiscogsService + +@Suite("Auth Middleware", .tags(.middleware)) +struct AuthMiddlewareTests { + + // MARK: Functions tests + +#if swift(>=6.2) + @Test(arguments: Input.authMethods) + func `intercept with authorization on header`( + _ authMethod: AuthMethod + ) async throws { + try await assertIntercept( + authMethod: authMethod, + authTransport: .onHeader, + path: "/some/path/to/resource" + ) + } + + @Test(arguments: Input.authMethods) + func `intercept with authorization on query`( + _ authMethod: AuthMethod + ) async throws { + try await assertIntercept( + authMethod: authMethod, + authTransport: .onQuery, + path: "/some/path/to/resource" + ) + } + + @Test(arguments: Input.authMethods) + func `intercept with authorization on header when headers populated`( + _ authMethod: AuthMethod + ) async throws { + try await assertIntercept( + authMethod: authMethod, + authTransport: .onHeader, + path: "/some/path/to/resource", + headerFields: [.accept: "*/*"] + ) + } + + @Test(arguments: Input.authMethods) + func `intercept with authorization on query when query is populated`( + _ authMethod: AuthMethod + ) async throws { + try await assertIntercept( + authMethod: authMethod, + authTransport: .onQuery, + path: "/some/path/to/resource?key=value" + ) + } +#else + @Test("intercept with authorization on header", arguments: Input.authMethods) + func intercept_withAuthOnHeader( + _ authMethod: AuthMethod + ) async throws { + try await assertIntercept( + authMethod: authMethod, + authTransport: .onHeader, + path: "/some/path/to/resource" + ) + } + + @Test("intercept with authorization on query", arguments: Input.authMethods) + func intercept_withAuthOnQuery( + _ authMethod: AuthMethod + ) async throws { + try await assertIntercept( + authMethod: authMethod, + authTransport: .onQuery, + path: "/some/path/to/resource" + ) + } + + @Test( + "intercept with authorization on header when headers are populated", + arguments: Input.authMethods + ) + func intercept_withAuthOnHeader_whenHeadersPopulated( + _ authMethod: AuthMethod + ) async throws { + try await assertIntercept( + authMethod: authMethod, + authTransport: .onHeader, + path: "/some/path/to/resource", + headerFields: [.accept: "*/*"] + ) + } + + @Test( + "intercept with authorization on query when query is populated", + arguments: Input.authMethods + ) + func intercept_withAuthOnQuery_whenQueryPopulated( + _ authMethod: AuthMethod + ) async throws { + try await assertIntercept( + authMethod: authMethod, + authTransport: .onQuery, + path: "/some/path/to/resource?key=value" + ) + } +#endif + + +} + +// MARK: - Assertions + +private extension AuthMiddlewareTests { + + // MARK: Functions + + /// Asserts the interception of a request to add its authentication. + /// - Parameters: + /// - authMethod: A representation of an authentication method. + /// - authTransport: A representation of an authentication transport. + /// - path: A URI path for a request. + /// - headerFields: A set of header fields for a request. + func assertIntercept( + authMethod: AuthMethod, + authTransport: AuthTransport, + path: String, + headerFields: HTTPFields = [:], + ) async throws { + // GIVEN + let middleware = AuthMiddleware( + method: authMethod, + transport: authTransport + ) + let request = HTTPRequest( + path: path, + headerFields: headerFields + ) + + // WHEN + _ = try await confirmation { confirmation in + try await middleware.intercept( + request, + body: nil, + baseURL: .baseURL, + operationID: .operationId + ) { request, _, _ in + // THEN + switch (authMethod, authTransport) { + case let (.consumer(key, secret), .onHeader): + #expect(request.path == path) + #expect(request.headerFields != headerFields) + #expect(request.headerFields[.authorization] == "Discogs key=\(key), secret=\(secret)") + case (.consumer, .onQuery): + #expect(request.path != path) + try assertAuthInPath(request.path, authMethod) + #expect(request.headerFields == headerFields) + case let (.user(token), .onHeader): + #expect(request.path == path) + #expect(request.headerFields != headerFields) + #expect(request.headerFields[.authorization] == "Discogs token=\(token)") + case (.user, .onQuery): + #expect(request.path != path) + try assertAuthInPath(request.path, authMethod) + #expect(request.headerFields == headerFields) + case (.none, _): + #expect(request.path == path) + #expect(request.headerFields == headerFields) + } + + confirmation() + + return (.init(status: .ok) , nil) + } + } + } + + /// Asserts a request path to contain authentication parameters in its query. + /// - Parameters: + /// - path: A request path + /// - authMethod: A representation of an authentication method. + func assertAuthInPath( + _ path: String?, + _ authMethod: AuthMethod + ) throws { + let pathRequest = try #require(path) + let urlComponents = try #require(URLComponents(string: pathRequest)) + let queryItems = try #require(urlComponents.queryItems) + + switch authMethod { + case .consumer: + #expect(queryItems.count >= 2) + #expect(queryItems.contains(where: { $0.name == .Parameter.key })) + #expect(queryItems.contains(where: { $0.name == .Parameter.secret })) + case .user: + #expect(queryItems.count >= 1) + #expect(queryItems.contains(where: { $0.name == .Parameter.token })) + case .none: break + } + } + +} + +// MARK: - Helpers + +private extension HTTPRequest { + + // MARK: Initializers + + /// Initializes a HTTP request conveniently. + /// - Parameters: + /// - method: A request method. + /// - path: A value of the “:path” pseudo header field. + /// - headerFields: A dictionary of request header fields. + init( + method: HTTPRequest.Method = .get, + path: String?, + headerFields: HTTPFields = [:] + ) { + self.init( + method: method, + scheme: nil, + authority: nil, + path: path, + headerFields: headerFields + ) + } + +} + +// MARK: - Constants + +private extension Input { + /// A list of authentication methods for a request. + static let authMethods: [AuthMethod] = [ + .consumer(key: "SomeKey", secret: "SomeSecret"), + .user(token: "SomeToken"), + .none + ] +} + +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/DiscogsService/Types/Extensions/Tag+Customs.swift b/Tests/DiscogsService/Types/Extensions/Tag+Customs.swift new file mode 100644 index 000000000..a4c60ab30 --- /dev/null +++ b/Tests/DiscogsService/Types/Extensions/Tag+Customs.swift @@ -0,0 +1,24 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the DiscogsService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of DiscogsService project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +import Testing + +extension Tag { + + // MARK: Constants + + /// A tag that indicates tests for a middleware type. + @Tag static var middleware: Self + +} diff --git a/Tests/DiscogsService/Types/Samples/Input.swift b/Tests/DiscogsService/Types/Samples/Input.swift new file mode 100644 index 000000000..b530636bc --- /dev/null +++ b/Tests/DiscogsService/Types/Samples/Input.swift @@ -0,0 +1,16 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the DiscogsService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of DiscogsService project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +/// A namespace assigned for input arguments on test cases. +enum Input {} diff --git a/Tests/DiscogsService/Types/Samples/Output.swift b/Tests/DiscogsService/Types/Samples/Output.swift new file mode 100644 index 000000000..d69c7a15d --- /dev/null +++ b/Tests/DiscogsService/Types/Samples/Output.swift @@ -0,0 +1,16 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the DiscogsService open source project +// +// Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of DiscogsService project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +/// A namespace assigned for output arguments on test cases, that are expected results. +enum Output {}