diff --git a/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift b/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift index bc805abb5..4afa66676 100644 --- a/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift +++ b/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift @@ -43,6 +43,7 @@ public struct AuthMiddleware { /// - 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. + /// - Throws: An error of type ``InputValidationError`` in case an input failed any validation. public init( method: AuthMethod = .none, transport: AuthTransport diff --git a/Sources/DiscogsService/Public/Middlewares/UserAgentMiddleware.swift b/Sources/DiscogsService/Public/Middlewares/UserAgentMiddleware.swift new file mode 100644 index 000000000..3737ae69c --- /dev/null +++ b/Sources/DiscogsService/Public/Middlewares/UserAgentMiddleware.swift @@ -0,0 +1,109 @@ +// ===----------------------------------------------------------------------=== +// +// 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 HTTPTypes.HTTPField +import struct HTTPTypes.HTTPFields +import struct HTTPTypes.HTTPRequest +import struct HTTPTypes.HTTPResponse + +/// A middleware that attaches the user agent header into the requests to the service. +/// +/// Please refer to the [Discogs documentation](https://www.discogs.com/developers/#page:home,header:home-general-information) for further information. +public struct UserAgentMiddleware { + + // MARK: Properties + + /// A formatted value for the user agent header. + let agentField: HTTPField + + // MARK: Initializers + + /// Initializes this middleware. + /// - Parameter product: A product from which the user agent will be generated from. + /// - Throws: An error of type ``InputValidationError`` in case an input failed any validation. + public init(product: Product) throws { + let agentName = ValidateInputUseCase(rules: .notNil, .notEmpty, .camelCase) + let agentVersion = ValidateInputUseCase(rules: .notNil, .notEmpty, .semanticVersion) + let agentURL = ValidateInputUseCase(rules: .notNil, .notEmpty, .url) + + try agentName(product.name) + try agentVersion(product.version) + try agentURL(product.url) + + self.agentField = .init( + name: .userAgent, + value: .init(format: .Format.userAgent, product.name, product.version, product.url) + ) + } + +} + +// MARK: - ClientMiddleware + +extension UserAgentMiddleware: 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?) { + return try await next( + .init( + method: request.method, + scheme: request.scheme, + authority: request.authority, + path: request.path, + headerFields: userAgentHeader(request.headerFields) + ), + body, + baseURL + ) + } + +} + +// MARK: - Helpers + +private extension UserAgentMiddleware { + + // MARK: Functions + + /// Adds a user agent header to the existing header fields. + /// - Parameter fields: A set of header fields to update. + /// - Returns: An updated set of header fields including the user agent header. + func userAgentHeader(_ fields: HTTPFields) -> HTTPFields { + var fields = fields + + fields.append(agentField) + + return fields + } + +} + +// MARK: - Constants + +private extension String.Format { + /// A format for the user agent header. + static let userAgent = "%@/%@ +%@" +} diff --git a/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift b/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift index d7abfa385..46bc4a9ba 100644 --- a/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift +++ b/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift @@ -15,7 +15,6 @@ import struct Foundation.URL import struct Foundation.URLComponents import struct Foundation.URLQueryItem -import struct HTTPTypes.HTTPField import struct HTTPTypes.HTTPFields import struct HTTPTypes.HTTPRequest import struct HTTPTypes.HTTPResponse @@ -28,6 +27,7 @@ import Testing struct AuthMiddlewareTests { // MARK: Initializers tests + #if swift(>=6.2) @Test(arguments: Input.authMethods) func `initialize`( @@ -273,11 +273,11 @@ private extension AuthMiddlewareTests { } } - /// Asserts the error throwing (if justified) during the initialization of the middleware. + /// Asserts the error throwing (if justified) during the initialization of a middleware. /// - Parameters: /// - authMethod: A representation of an authentication method. /// - authTransport: A representation of an authentication transport. - /// - error: An expected error of type ``InputValidationError`` during the initialization of the middleware. + /// - error: An expected error of type ``InputValidationError`` during the initialization of a middleware. func assertInitThrows( authMethod: AuthMethod, authTransport: AuthTransport, @@ -331,8 +331,8 @@ private extension AuthMiddlewareTests { try await middleware.intercept( request, body: nil, - baseURL: .baseURL, - operationID: .operationId + baseURL: .Sample.baseURL, + operationID: .Sample.operationId ) { request, _, _ in // THEN switch (authMethod, authTransport) { @@ -430,31 +430,6 @@ private extension AuthMiddlewareTests { } -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 { @@ -464,7 +439,7 @@ private extension Input { .user(token: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStT"), .none ] - /// A list of authentication methods to tests for the initialization throw test cases. + /// A list of authentication methods to use in the initialization throw test cases. static let authMethodsThrows: [AuthMethod] = authMethods + [ .consumer(key: .empty, secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"), .consumer(key: "aAbBcCdDeEfFgGhHiI", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"), @@ -487,13 +462,3 @@ private extension Output { /// A list of expected boolean flags coming from the should authenticate test cases. static let authMethodsShouldAuthenticate: [Bool] = [true, true, false] } - -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/Cases/Public/Middlewares/UserAgentMiddlewareTests.swift b/Tests/DiscogsService/Cases/Public/Middlewares/UserAgentMiddlewareTests.swift index a4c0b9a47..20b6d8ff7 100644 --- a/Tests/DiscogsService/Cases/Public/Middlewares/UserAgentMiddlewareTests.swift +++ b/Tests/DiscogsService/Cases/Public/Middlewares/UserAgentMiddlewareTests.swift @@ -12,14 +12,215 @@ // // ===----------------------------------------------------------------------=== -import DiscogsService +import struct HTTPTypes.HTTPField +import struct HTTPTypes.HTTPFields +import struct HTTPTypes.HTTPRequest + import Testing +@testable import DiscogsService + @Suite("User Agent Middleware", .tags(.middleware)) struct UserAgentMiddlewareTests { - -// @Test func <#test function name#>() async throws { -// // Write your test here and use APIs like `#expect(...)` to check expected conditions. -// } - + + // MARK: Initializers tests + +#if swift(>=6.2) + @Test(arguments: Input.userAgents) + func `initialize`( + product: Product + ) throws { + try assertInit(product: product) + } + + @Test(arguments: zip( + Input.userAgentsThrows, + Output.userAgentsThrows + )) + func `initialize throws`( + product: Product, + expect error: InputValidationError? + ) { + assertInitThrows( + product: product, + expects: error + ) + } +#else + @Test("initialize", arguments: Input.userAgents) + func initialize( + product: Product + ) throws { + try assertInit(product: product) + } + + @Test("initialize throws", arguments: zip( + Input.userAgentsThrows, + Output.userAgentsThrows + )) + func initializeThrows( + product: Product, + expect error: InputValidationError? + ) { + assertInitThrows( + product: product, + expects: error + ) + } +#endif + + // MARK: Functions tests + +#if swift(>=6.2) + @Test(arguments: Input.userAgents) + func `intercept with user agent on headers`( + product: Product + ) async throws { + try await assertIntercept(product: product) + } + + @Test(arguments: Input.userAgents) + func `intercept with user agent on headers when headers are populated`( + product: Product + ) async throws { + try await assertIntercept( + product: product, + headerFields: [.accept: "*/*"] + ) + } +#else + @Test("intercept with user agent on headers", arguments: Input.userAgents) + func intercept_withUserAgentOnHeaders( + product: Product + ) async throws { + try await assertIntercept(product: product) + } + + @Test("intercept with user agent on headers when headers are populated", arguments: Input.userAgents) + func intercept_withUserAgentOnHeaders_whenHeadersPopulated( + product: Product + ) async throws { + try await assertIntercept( + product: product, + headerFields: [.accept: "*/*"] + ) + } +#endif + +} + +// MARK: - Assertions + +private extension UserAgentMiddlewareTests { + + // MARK: Functions + + /// Asserts the initialization of the middleware , especially the assignments of its properties. + /// - Parameter product: A product to initialize a middleware. + /// - Throws: an error of type ``InputValidationError`` in case of an unexpected error occurs while running test cases. + func assertInit( + product: Product + ) throws { + // GIVEN + // WHEN + let middleware = try UserAgentMiddleware(product: product) + + // THEN + #expect(middleware.agentField == .init( + name: .userAgent, + value: "\(product.name)/\(product.version) +\(product.url)" + )) + } + + /// Asserts the error throwing (if justified) during the initialization of the middleware. + /// - Parameters: + /// - product: A product to initialize a middleware. + /// - error: An expected error of type ``InputValidationError`` during the initialization of a middleware. + func assertInitThrows( + product: Product, + expects error: InputValidationError? + ) { + // GIVEN + // WHEN + // THEN + if let error { + #expect(throws: error) { + try UserAgentMiddleware(product: product) + } + } else { + #expect(throws: Never.self) { + try UserAgentMiddleware(product: product) + } + } + } + + /// Asserts the interception of a request to add the user agent in its header. + /// - Parameters: + /// - product: A product to initialize a middleware. + /// - path: A URI path for a request. + /// - headerFields: A set of header fields for a request. + func assertIntercept( + product: Product, + path: String? = nil, + headerFields: HTTPFields = [:] + ) async throws { + // GIVEN + let middleware = try UserAgentMiddleware(product: product) + let request = HTTPRequest( + path: path, + headerFields: headerFields + ) + + // WHEN + _ = try await confirmation { confirmation in + try await middleware.intercept( + request, + body: nil, + baseURL: .Sample.baseURL, + operationID: .Sample.operationId + ) { request, _, _ in + // THEN + #expect(request.path == path) + #expect(request.headerFields != headerFields) + #expect(request.headerFields.count == headerFields.count + 1) + #expect(request.headerFields.contains(where: { $0.name == .userAgent })) + + confirmation() + + return (.init(status: .ok) , nil) + } + } + } + +} + +// MARK: - Constants + +private extension Input { + /// A list of products to successfully initialize user agent middleware instances. + static let userAgents: [Product] = [ + .init(name: "SomeApp", version: "0.0.1", url: "http://www.some.app"), + .init(name: "SomeOther4pp", version: "1.2.3-b1", url: "https://some-other.app"), + .init(name: "Yet4notherApp", version: "0.8.8+alpha", url: "https://yet.another.app") + ] + /// A list of products to use in the initialization throw test cases. + static let userAgentsThrows: [Product] = userAgents + [ + .init(name: "Some App", version: "0.0.1", url: "http://www.some.app"), + .init(name: "Some-App", version: "0.0.1", url: "http://www.some.app"), + .init(name: .empty, version: "0.0.1", url: "http://www.some.app"), + .init(name: "SomeApp", version: "v0.0.1", url: "http://www.some.app"), + .init(name: "SomeApp", version: "0.1", url: "http://www.some.app"), + .init(name: "SomeApp", version: .empty, url: "http://www.some.app"), + .init(name: "SomeApp", version: "0.0.1", url: "www.some.app"), + .init(name: "SomeApp", version: "0.0.1", url: "some.app"), + .init(name: "SomeApp", version: "0.0.1", url: .empty), + .init(name: "Some App", version: "v0.0.1", url: "www.some.app"), + .init(name: "SomeApp", version: "v0.0.1", url: "www.some.app"), + .init(name: "Some App", version: "0.0.1", url: "www.some.app"), + ] +} + +private extension Output { + /// A list of expected input validation errors (if thrown) coming from the initialization throw test cases. + static let userAgentsThrows: [InputValidationError?] = [nil, nil, nil, .inputNotCamelCase, .inputNotCamelCase, .inputIsEmpty, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputIsEmpty, .inputNotURL, .inputNotURL, .inputIsEmpty, .inputNotCamelCase, .inputNotSemanticVersion, .inputNotCamelCase] } diff --git a/Tests/DiscogsService/Types/Extensions/HTTPRequests+Inits.swift b/Tests/DiscogsService/Types/Extensions/HTTPRequests+Inits.swift new file mode 100644 index 000000000..c4d426db8 --- /dev/null +++ b/Tests/DiscogsService/Types/Extensions/HTTPRequests+Inits.swift @@ -0,0 +1,41 @@ +// ===----------------------------------------------------------------------=== +// +// 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 HTTPTypes.HTTPFields +import struct HTTPTypes.HTTPRequest + +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 + ) + } + +} diff --git a/Tests/DiscogsService/Types/Samples/String+Samples.swift b/Tests/DiscogsService/Types/Samples/String+Samples.swift new file mode 100644 index 000000000..e356dec43 --- /dev/null +++ b/Tests/DiscogsService/Types/Samples/String+Samples.swift @@ -0,0 +1,21 @@ +// ===----------------------------------------------------------------------=== +// +// 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 { + /// A namespace assigned for string samples on test cases. + enum Sample { + /// An operation ID sample. + static let operationId = "SomeOperationId" + } +} diff --git a/Tests/DiscogsService/Types/Samples/URL+Samples.swift b/Tests/DiscogsService/Types/Samples/URL+Samples.swift new file mode 100644 index 000000000..96821f923 --- /dev/null +++ b/Tests/DiscogsService/Types/Samples/URL+Samples.swift @@ -0,0 +1,23 @@ +// ===----------------------------------------------------------------------=== +// +// 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 Foundation + +extension URL { + /// A namespace assigned for URL samples on test cases. + enum Sample { + /// A base URL sample. + static let baseURL = URL(string: "https://sample.domain.com")! + } +}