From 25fdc1fabd9e06793488aa5679553d7115e5dca2 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 11 Oct 2025 09:59:35 +0200 Subject: [PATCH 01/11] Implemented a Product model in the library target. --- .../Public/Models/Product.swift | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 Sources/DiscogsService/Public/Models/Product.swift diff --git a/Sources/DiscogsService/Public/Models/Product.swift b/Sources/DiscogsService/Public/Models/Product.swift new file mode 100644 index 000000000..b52d52b59 --- /dev/null +++ b/Sources/DiscogsService/Public/Models/Product.swift @@ -0,0 +1,48 @@ +// ===----------------------------------------------------------------------=== +// +// 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 + +/// A type that represents a product that uses the ``Client`` client. +public struct Product: Sendable { + + // MARK: Properties + + /// A camel-cased name of a product. + let name: String + + /// A URI link related to a product. + let url: String + + /// A semantic version of a product. + let version: String + + // MARK: Initializers + + /// Initializes this model. + /// - Parameters: + /// - name: A camel-cased name of a product. + /// - version: A URI link related to a product. + /// - url: A semantic version of a product. + public init( + name: String, + version: String, + url: String + ) { + self.name = name + self.url = url + self.version = version + } + +} -- 2.52.0 From db688c553d2c640cb9099c7e688b287517b53d4b Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 13:13:09 +0200 Subject: [PATCH 02/11] Added the "userAgent" format constant to the String+Constants extension in the library extension. --- .../DiscogsService/Internal/Extensions/String+Constants.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/DiscogsService/Internal/Extensions/String+Constants.swift b/Sources/DiscogsService/Internal/Extensions/String+Constants.swift index 3d2af2654..852fa6a4c 100644 --- a/Sources/DiscogsService/Internal/Extensions/String+Constants.swift +++ b/Sources/DiscogsService/Internal/Extensions/String+Constants.swift @@ -31,6 +31,8 @@ extension String { static let authConsumer = "Discogs \(String.Parameter.key)=%@, \(String.Parameter.secret)=%@" /// A format for the user authentication header. static let authUser = "Discogs \(String.Parameter.token)=%@" + /// A format for the user agent header. + static let userAgent = "%@/%@ +%@" } } -- 2.52.0 From 8c68ae9417bba8910548df3eead8d422a02e1ac9 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 13:24:42 +0200 Subject: [PATCH 03/11] Defined the InputValidationRule protocol in the library target. --- .../Errors/InputValidationError.swift | 21 ++++++++++++ .../Protocols/InputValidationRule.swift | 34 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 Sources/DiscogsService/Internal/Errors/InputValidationError.swift create mode 100644 Sources/DiscogsService/Internal/Protocols/InputValidationRule.swift diff --git a/Sources/DiscogsService/Internal/Errors/InputValidationError.swift b/Sources/DiscogsService/Internal/Errors/InputValidationError.swift new file mode 100644 index 000000000..e4299b690 --- /dev/null +++ b/Sources/DiscogsService/Internal/Errors/InputValidationError.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 +// +// ===----------------------------------------------------------------------=== + +/// A representation of all the possible validation error that could be thrown while validating an input. +enum InputValidationError: Error { + /// An input is empty. + case inputIsEmpty + /// An input is nil. + case inputIsNil +} diff --git a/Sources/DiscogsService/Internal/Protocols/InputValidationRule.swift b/Sources/DiscogsService/Internal/Protocols/InputValidationRule.swift new file mode 100644 index 000000000..e82ad8953 --- /dev/null +++ b/Sources/DiscogsService/Internal/Protocols/InputValidationRule.swift @@ -0,0 +1,34 @@ +// ===----------------------------------------------------------------------=== +// +// 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 protocol that defines an input validation rule to be applied to an input by the ``ValidateInputUseCase`` use case. +protocol InputValidationRule { + + // MARK: Functions + +#if swift(>=6.0) + /// Validates a given input against a validation rule. + /// - Parameter input: An input to be validated. + /// - Returns: A flag that indicates whether an input has been validated or not. + /// - Throws: An error of type ``InputValidationError`` in case a given input failed a validation. + @discardableResult func validate(_ input: String?) throws(InputValidationError) -> Bool +#else + /// Validates a given input against a validation rule. + /// - Parameter input: An input to be validated. + /// - Returns: A flag that indicates whether an input has been validated or not. + /// - Throws: An error of type ``InputValidationError`` in case a given input failed a validation. + @discardableResult func validate(_ input: String?) throws -> Bool +#endif + +} -- 2.52.0 From 630f8a03f715bd5b6f7c7e8c179900ae95af78b3 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 13:57:54 +0200 Subject: [PATCH 04/11] Implemented the NotEmptyValidationRule type in the library target. --- .../NotEmptyValidationRule.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift diff --git a/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift b/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift new file mode 100644 index 000000000..dbcbfbfea --- /dev/null +++ b/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift @@ -0,0 +1,38 @@ +// ===----------------------------------------------------------------------=== +// +// 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 validation rule type that checks whether an input is empty or not. +struct NotEmptyValidationRule: InputValidationRule { + + // MARK: Functions + + func validate(_ input: String?) throws -> Bool { + guard let input else { + return false + } + guard !input.isEmpty else { + throw InputValidationError.inputIsEmpty + } + + return true + } + +} + +// MARK: - Constants + +extension InputValidationRule where Self == NotEmptyValidationRule { + /// A validation rule that checks whether an input is empty or not. + static var notEmpty: Self { .init() } +} -- 2.52.0 From 2677bd8de55b1202121181315c05ac4707441baa Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 13:58:06 +0200 Subject: [PATCH 05/11] Implemented the NotNilValidationRule type in the library target. --- .../NotEmptyValidationRule.swift | 25 ++++++++ .../NotNilValidationRule.swift | 60 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 Sources/DiscogsService/Internal/Validation Rules/NotNilValidationRule.swift diff --git a/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift b/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift index dbcbfbfea..c27491872 100644 --- a/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift +++ b/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift @@ -17,7 +17,32 @@ struct NotEmptyValidationRule: InputValidationRule { // MARK: Functions +#if swift(>=6.0) + func validate(_ input: String?) throws(InputValidationError) -> Bool { + try validate(input: input) + } +#else func validate(_ input: String?) throws -> Bool { + try validate(input: input) + } +#endif + +} + +// MARK: - Helpers + +private extension NotEmptyValidationRule { + + // MARK: Functions + + /// Validates a given input. + /// + /// > note: This helper function would not be necessary when support for *Swift 5.10* is discontinued. + /// + /// - Parameter input: An input to be validated. + /// - Returns: A flag that indicates whether a given input has been validated or not. + /// - Throws: An error of type ``InputValidatorError`` in case the validation failed. + func validate(input: String?) throws -> Bool { guard let input else { return false } diff --git a/Sources/DiscogsService/Internal/Validation Rules/NotNilValidationRule.swift b/Sources/DiscogsService/Internal/Validation Rules/NotNilValidationRule.swift new file mode 100644 index 000000000..1b9472e0d --- /dev/null +++ b/Sources/DiscogsService/Internal/Validation Rules/NotNilValidationRule.swift @@ -0,0 +1,60 @@ +// ===----------------------------------------------------------------------=== +// +// 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 validation rule type that checks whether an input is nil or not. +struct NotNilValidationRule: InputValidationRule { + + // MARK: Functions + +#if swift(>=6.0) + func validate(_ input: String?) throws(InputValidationError) -> Bool { + try validate(input: input) + } +#else + func validate(_ input: String?) throws -> Bool { + try validate(input: input) + } +#endif + +} + +// MARK: - Helpers + +private extension NotNilValidationRule { + + // MARK: Functions + + /// Validates a given input. + /// + /// > note: This helper function would not be necessary when support for *Swift 5.10* is discontinued. + /// + /// - Parameter input: An input to be validated. + /// - Returns: A flag that indicates whether a given input has been validated or not. + /// - Throws: An error of type ``InputValidatorError`` in case the validation failed. + func validate(input: String?) throws -> Bool { + guard let input else { + throw InputValidationError.inputIsNil + } + + return true + } + +} + +// MARK: - Constants + +extension InputValidationRule where Self == NotNilValidationRule { + /// A validation rule that checks whether an input is nil or not. + static var notNil: Self { .init() } +} -- 2.52.0 From c00c348f0289d5123856daba970fdf70c25adfde Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 14:34:28 +0200 Subject: [PATCH 06/11] Implemented the ValidateInputUseCase use case in the library target. --- .../Use Cases/ValidateInputUseCase.swift | 53 ++++++++++ .../Use Cases/ValidateInputUesCaseTests.swift | 97 +++++++++++++++++++ .../UserAgentMiddlewareTests.swift | 25 +++++ 3 files changed, 175 insertions(+) create mode 100644 Sources/DiscogsService/Internal/Use Cases/ValidateInputUseCase.swift create mode 100644 Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift create mode 100644 Tests/DiscogsService/Cases/Public/Middlewares/UserAgentMiddlewareTests.swift diff --git a/Sources/DiscogsService/Internal/Use Cases/ValidateInputUseCase.swift b/Sources/DiscogsService/Internal/Use Cases/ValidateInputUseCase.swift new file mode 100644 index 000000000..85186c92d --- /dev/null +++ b/Sources/DiscogsService/Internal/Use Cases/ValidateInputUseCase.swift @@ -0,0 +1,53 @@ +// ===----------------------------------------------------------------------=== +// +// 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 use case that validates an input against a set of validation rules. +struct ValidateInputUseCase { + + // MARK: Properties + + /// A list of validation rules to match an input against. + private let rules: [any InputValidationRule] + + // MARK: Initializers + + /// Initializes this use case. + /// - Parameter rules: A list of validation rules to match an input against. + init(rules: any InputValidationRule...) { + self.rules = rules + } + + // MARK: Functions + +#if swift(>=6.0) + /// Validates an input against a set of validation rules. + /// - Parameter input: An input to be validated against a set of rules, if any. + /// - Throws: An error of type ``InputValidationError`` in case an input failed any validation. + func callAsFunction(_ input: String?) throws(InputValidationError) { + for rule in rules { + try rule.validate(input) + } + } +#else + /// Validates an input against a set of validation rules. + /// - Parameter input: An input to be validated against a set of rules, if any. + /// - Throws: An error of type ``InputValidationError`` in case an input failed any validation. + func callAsFunction(_ input: String?) throws { + for rule in rules { + try rule.validate(input) + } + } +#endif + +} diff --git a/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift b/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift new file mode 100644 index 000000000..631fd12ff --- /dev/null +++ b/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift @@ -0,0 +1,97 @@ +// ===----------------------------------------------------------------------=== +// +// 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 + +@testable import DiscogsService + +@Suite("Validate Input Use Cases") +struct ValidateInputUseCaseTests { + + // MARK: Functions + +#if swift(>=6.2) + @Test(arguments: zip( + Input.inputsToValidate, + Output.inputsToValidate + )) func `validate`( + input: String?, + expects error: InputValidationError? + ) async throws { + try assertValidate( + input: input, + expects: error + ) + } +#else + @Test("validate", arguments: zip( + Input.inputsToValidate, + Output.inputsToValidate + )) func validate( + input: String?, + expects error: InputValidationError? + ) async throws { + try assertValidate( + input: input, + expects: error + ) + } +#endif + +} + +// MARK: - Assertions + +private extension ValidateInputUseCaseTests { + + // MARK: Functions + + /// Asserts an input validation of a ``ValidateInputUseCase`` use case. + /// - Parameters: + /// - input: An input to validate, if any. + /// - error: An expected error, if any. + /// - Throws: An error of type ``InputValidationError`` in case of an unexpected test case scenario. + func assertValidate( + input: String?, + expects error: InputValidationError? + ) throws { + // GIVEN + let validate = ValidateInputUseCase(rules: .notNil, .notEmpty) + + // WHEN + // THEN + if let error { + #expect(throws: error) { + try validate(input) + } + } else { + #expect(throws: Never.self) { + try validate(input) + } + } + } + +} + +// MARK: - Constants + +private extension Input { + /// A list of inputs to validate against a set of validation rules. + static let inputsToValidate: [String?] = [nil, .empty, "SomeInput"] +} + +private extension Output { + /// A list of expected input validation errors to be thrown (if necessary). + static let inputsToValidate: [InputValidationError?] = [.inputIsNil, .inputIsEmpty, nil] +} diff --git a/Tests/DiscogsService/Cases/Public/Middlewares/UserAgentMiddlewareTests.swift b/Tests/DiscogsService/Cases/Public/Middlewares/UserAgentMiddlewareTests.swift new file mode 100644 index 000000000..a4c0b9a47 --- /dev/null +++ b/Tests/DiscogsService/Cases/Public/Middlewares/UserAgentMiddlewareTests.swift @@ -0,0 +1,25 @@ +// ===----------------------------------------------------------------------=== +// +// 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 DiscogsService +import Testing + +@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. +// } + +} -- 2.52.0 From f32d24b26bb2529f67231a6282a100e850aac27f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 17:37:52 +0200 Subject: [PATCH 07/11] Implemented the SecureValidationRule type in the library target. --- .../Errors/InputValidationError.swift | 6 + .../Extensions/String+Constants.swift | 4 +- .../SecureValidationRule.swift | 135 ++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 Sources/DiscogsService/Internal/Validation Rules/SecureValidationRule.swift diff --git a/Sources/DiscogsService/Internal/Errors/InputValidationError.swift b/Sources/DiscogsService/Internal/Errors/InputValidationError.swift index e4299b690..45901a584 100644 --- a/Sources/DiscogsService/Internal/Errors/InputValidationError.swift +++ b/Sources/DiscogsService/Internal/Errors/InputValidationError.swift @@ -18,4 +18,10 @@ enum InputValidationError: Error { case inputIsEmpty /// An input is nil. case inputIsNil + /// An input does not comply with the consumer key requirements. + case inputNotConsumerKey + /// An input does not comply with the consumer secret requirements. + case inputNotConsumerSecret + /// An input does not comply with the user token requirements. + case inputNotUserToken } diff --git a/Sources/DiscogsService/Internal/Extensions/String+Constants.swift b/Sources/DiscogsService/Internal/Extensions/String+Constants.swift index 852fa6a4c..20e7bde4c 100644 --- a/Sources/DiscogsService/Internal/Extensions/String+Constants.swift +++ b/Sources/DiscogsService/Internal/Extensions/String+Constants.swift @@ -34,5 +34,7 @@ extension String { /// A format for the user agent header. static let userAgent = "%@/%@ +%@" } + + /// A namespaces assigned for the formats of regular expression patterns. + enum Pattern {} } - diff --git a/Sources/DiscogsService/Internal/Validation Rules/SecureValidationRule.swift b/Sources/DiscogsService/Internal/Validation Rules/SecureValidationRule.swift new file mode 100644 index 000000000..e19873465 --- /dev/null +++ b/Sources/DiscogsService/Internal/Validation Rules/SecureValidationRule.swift @@ -0,0 +1,135 @@ +// ===----------------------------------------------------------------------=== +// +// 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 + +/// A validation rule type that checks whether an input is secure or not. +struct SecureValidationRule: InputValidationRule { + + // MARK: Properties + + /// A representation of the available security input types. + private let inputType: SecurityInput + + // MARK: Initializers + + /// Initializes this validation rule. + /// - Parameter inputType: A representation of the available security input types. + init(inputType: SecurityInput) { + self.inputType = inputType + } + + // MARK: Functions + +#if swift(>=6.0) + func validate(_ input: String?) throws(InputValidationError) -> Bool { + try validate(input: input) + } +#else + func validate(_ input: String?) throws -> Bool { + try validate(input: input) + } +#endif + +} + +// MARK: - Definitions + +extension InputValidationRule where Self == SecureValidationRule { + + // MARK: Functions + + /// A validation rule that checks whether an input is secure or not. + /// - Parameter securityInput: A representation of the security input type to validate + /// - Returns: A validation rule that has been configured and it is ready to use. + static func secure(_ securityInput: SecurityInput) -> Self { + .init(inputType: securityInput) + } + +} + +// MARK: - Enumerations + +/// A representation of all the possible security input types, based on their respective character length expectations. +enum SecurityInput: Int { + /// A consumer key is 20 characters long. + case consumerKey = 20 + /// A consumer key is 32 characters long. + case consumerSecret = 32 + /// A consumer key is 40 characters long. + case userToken = 40 +} + +// MARK: - Helpers + +private extension SecureValidationRule { + + // MARK: Functions + + /// Checks if a given input is valid, + /// - Parameter input: An input to validate. + /// - Returns: A flag that indicates whether a given input is valid or not. + func isValid(_ input: String) -> Bool { + let regexPattern: String = .init(format: .Pattern.securityInput, inputType.rawValue) + + do { + if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 6.0, *) { + let securityInput = try Regex(regexPattern) + let matches = input.matches(of: securityInput) + + return !matches.isEmpty + } else { + let securityInput = try NSRegularExpression(pattern: regexPattern) + let matches = securityInput.matches( + in: input, + range: .init(location: 0, length: input.count) + ) + + return !matches.isEmpty + } + } catch { + return false + } + } + + /// Validates a given input. + /// + /// > note: This helper function would not be necessary when support for *Swift 5.10* is discontinued. + /// + /// - Parameter input: An input to be validated. + /// - Returns: A flag that indicates whether a given input has been validated or not. + /// - Throws: An error of type ``InputValidatorError`` in case the validation failed. + func validate(input: String?) throws -> Bool { + guard let input else { + return false + } + guard isValid(input) else { + switch inputType { + case .consumerKey: throw InputValidationError.inputNotConsumerKey + case .consumerSecret: throw InputValidationError.inputNotConsumerSecret + case .userToken: throw InputValidationError.inputNotUserToken + } + } + + return true + } + +} + +// MARK: - Constants + +private extension String.Pattern { + /// A regular expression pattern to match the security inputs against. + static let securityInput = "^([a-z]|[A-Z]){%@}$" +} -- 2.52.0 From a47a3a464bf588aa77ce8ba776277a7187ab3603 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 17:38:35 +0200 Subject: [PATCH 08/11] Fixed a warning message for the NotNilValidationRule type in the library target. --- .../NotEmptyValidationRule.swift | 18 ++++++++++------- .../NotNilValidationRule.swift | 20 +++++++++++-------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift b/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift index c27491872..7b464f291 100644 --- a/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift +++ b/Sources/DiscogsService/Internal/Validation Rules/NotEmptyValidationRule.swift @@ -29,6 +29,17 @@ struct NotEmptyValidationRule: InputValidationRule { } +// MARK: - Definitions + +extension InputValidationRule where Self == NotEmptyValidationRule { + + // MARK: Constants + + /// A validation rule that checks whether an input is empty or not. + static var notEmpty: Self { .init() } + +} + // MARK: - Helpers private extension NotEmptyValidationRule { @@ -54,10 +65,3 @@ private extension NotEmptyValidationRule { } } - -// MARK: - Constants - -extension InputValidationRule where Self == NotEmptyValidationRule { - /// A validation rule that checks whether an input is empty or not. - static var notEmpty: Self { .init() } -} diff --git a/Sources/DiscogsService/Internal/Validation Rules/NotNilValidationRule.swift b/Sources/DiscogsService/Internal/Validation Rules/NotNilValidationRule.swift index 1b9472e0d..1bac4efe7 100644 --- a/Sources/DiscogsService/Internal/Validation Rules/NotNilValidationRule.swift +++ b/Sources/DiscogsService/Internal/Validation Rules/NotNilValidationRule.swift @@ -29,6 +29,17 @@ struct NotNilValidationRule: InputValidationRule { } +// MARK: - Definitions + +extension InputValidationRule where Self == NotNilValidationRule { + + // MARK: Constants + + /// A validation rule that checks whether an input is nil or not. + static var notNil: Self { .init() } + +} + // MARK: - Helpers private extension NotNilValidationRule { @@ -43,7 +54,7 @@ private extension NotNilValidationRule { /// - Returns: A flag that indicates whether a given input has been validated or not. /// - Throws: An error of type ``InputValidatorError`` in case the validation failed. func validate(input: String?) throws -> Bool { - guard let input else { + guard input != nil else { throw InputValidationError.inputIsNil } @@ -51,10 +62,3 @@ private extension NotNilValidationRule { } } - -// MARK: - Constants - -extension InputValidationRule where Self == NotNilValidationRule { - /// A validation rule that checks whether an input is nil or not. - static var notNil: Self { .init() } -} -- 2.52.0 From c2ab60a5aa0cc06413adcbcaa781cef7a3b164ef Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 19:15:26 +0200 Subject: [PATCH 09/11] Fixed an issue with the way the regex pattern was generated for the SecureValidationRule type in the library target. --- .../SecureValidationRule.swift | 4 +- .../Use Cases/ValidateInputUesCaseTests.swift | 156 ++++++++++++++++-- 2 files changed, 146 insertions(+), 14 deletions(-) diff --git a/Sources/DiscogsService/Internal/Validation Rules/SecureValidationRule.swift b/Sources/DiscogsService/Internal/Validation Rules/SecureValidationRule.swift index e19873465..354b759a3 100644 --- a/Sources/DiscogsService/Internal/Validation Rules/SecureValidationRule.swift +++ b/Sources/DiscogsService/Internal/Validation Rules/SecureValidationRule.swift @@ -81,7 +81,7 @@ private extension SecureValidationRule { /// - Parameter input: An input to validate. /// - Returns: A flag that indicates whether a given input is valid or not. func isValid(_ input: String) -> Bool { - let regexPattern: String = .init(format: .Pattern.securityInput, inputType.rawValue) + let regexPattern = String(format: .Pattern.securityInput, inputType.rawValue) do { if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 6.0, *) { @@ -131,5 +131,5 @@ private extension SecureValidationRule { private extension String.Pattern { /// A regular expression pattern to match the security inputs against. - static let securityInput = "^([a-z]|[A-Z]){%@}$" + static let securityInput = "^([a-z]|[A-Z]){%d}$" } diff --git a/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift b/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift index 631fd12ff..65689b18c 100644 --- a/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift +++ b/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift @@ -23,26 +23,140 @@ struct ValidateInputUseCaseTests { #if swift(>=6.2) @Test(arguments: zip( - Input.inputsToValidate, - Output.inputsToValidate - )) func `validate`( + Input.inputsNotEmpty, + Output.inputsNotEmpty + )) func `validates not empty`( + input: String, + expects error: InputValidationError? + ) async throws { + try assertValidate( + rule: .notEmpty, + input: input, + expects: error + ) + } + + @Test(arguments: zip( + Input.inputsNotNil, + Output.inputsNotNil + )) func `validate not nil`( input: String?, expects error: InputValidationError? ) async throws { try assertValidate( + rule: .notNil, + input: input, + expects: error + ) + } + + @Test(arguments: zip( + Input.inputsSecureConsumerKey, + Output.inputsSecureConsumerKey + )) func `validate secure (consumer key)`( + input: String?, + expects error: InputValidationError? + ) async throws { + try assertValidate( + rule: .secure(.consumerKey), + input: input, + expects: error + ) + } + + @Test(arguments: zip( + Input.inputsSecureConsumerSecret, + Output.inputsSecureConsumerSecret + )) func `validate secure (consumer secret)`( + input: String?, + expects error: InputValidationError? + ) async throws { + try assertValidate( + rule: .secure(.consumerSecret), + input: input, + expects: error + ) + } + + @Test(arguments: zip( + Input.inputsSecureUserToken, + Output.inputsSecureUserToken + )) func `validate secure (user token)`( + input: String?, + expects error: InputValidationError? + ) async throws { + try assertValidate( + rule: .secure(.userToken), input: input, expects: error ) } #else - @Test("validate", arguments: zip( - Input.inputsToValidate, - Output.inputsToValidate - )) func validate( + @Test("validate not empty", arguments: zip( + Input.inputsNotEmpty, + Output.inputsNotEmpty + )) func validateNotEmpty( + input: String, + expects error: InputValidationError? + ) async throws { + try assertValidate( + rule: .notEmpty, + input: input, + expects: error + ) + } + + @Test("validate not nil", arguments: zip( + Input.inputsNotNil, + Output.inputsNotNil + )) func validateNotNil( input: String?, expects error: InputValidationError? ) async throws { try assertValidate( + rule: .notNil, + input: input, + expects: error + ) + } + + @Test("validate secure (consumer key)", arguments: zip( + Input.inputsSecureConsumerKey, + Output.inputsSecureConsumerKey + )) func validateSecureConsumerKey( + input: String?, + expects error: InputValidationError? + ) async throws { + try assertValidate( + rule: .secure(.consumerKey), + input: input, + expects: error + ) + } + + @Test("validate secure (consumer secret)", arguments: zip( + Input.inputsSecureConsumerSecret, + Output.inputsSecureConsumerSecret + )) func validateSecureConsumerSecret( + input: String?, + expects error: InputValidationError? + ) async throws { + try assertValidate( + rule: .secure(.consumerSecret), + input: input, + expects: error + ) + } + + @Test("validate secure (user token)", arguments: zip( + Input.inputsSecureUserToken, + Output.inputsSecureUserToken + )) func validateSecureUserToken( + input: String?, + expects error: InputValidationError? + ) async throws { + try assertValidate( + rule: .secure(.userToken), input: input, expects: error ) @@ -59,15 +173,17 @@ private extension ValidateInputUseCaseTests { /// Asserts an input validation of a ``ValidateInputUseCase`` use case. /// - Parameters: + /// - rule: A validation rule to test. /// - input: An input to validate, if any. /// - error: An expected error, if any. /// - Throws: An error of type ``InputValidationError`` in case of an unexpected test case scenario. func assertValidate( + rule: InputValidationRule, input: String?, expects error: InputValidationError? ) throws { // GIVEN - let validate = ValidateInputUseCase(rules: .notNil, .notEmpty) + let validate = ValidateInputUseCase(rules: rule) // WHEN // THEN @@ -87,11 +203,27 @@ private extension ValidateInputUseCaseTests { // MARK: - Constants private extension Input { - /// A list of inputs to validate against a set of validation rules. - static let inputsToValidate: [String?] = [nil, .empty, "SomeInput"] + /// A list of inputs to validate against the not empty validation rule. + static let inputsNotEmpty: [String] = ["Something", .empty] + /// A list of inputs to validate against the not nil validation rule. + static let inputsNotNil: [String?] = [.empty, nil] + /// A list of inputs to validate against the secure (consumer key) validation rule. + static let inputsSecureConsumerKey: [String] = ["aAbBcCdDeEfFgGhHiIjJ", "aAbBcCdDeEfFgGhH", "aAbBcCdDeEfFgGhHiIjJkK", "a4bBcCdDe3fFg6hH1Ij7"] + /// A list of inputs to validate against the secure (consumer secret) validation rule. + static let inputsSecureConsumerSecret: [String] = ["aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP", "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoO", "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQ", "a4bBcCdDe3fFg6hH1IjJkK1LmMnNo0p9"] + /// A list of inputs to validate against the secure (user token) validation rule. + static let inputsSecureUserToken: [String] = ["aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStT", "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsS", "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuU", "a4bBcCdDe3fFg6hH1IjJkK1LmMnNo0p9qQrRs5t7"] } private extension Output { - /// A list of expected input validation errors to be thrown (if necessary). - static let inputsToValidate: [InputValidationError?] = [.inputIsNil, .inputIsEmpty, nil] + /// A list of expected input validation errors to be thrown after validating inputs against the not empty validation rule. + static let inputsNotEmpty: [InputValidationError?] = [nil, .inputIsEmpty] + /// A list of expected input validation errors to be thrown after validating inputs against the not nil validation rule. + static let inputsNotNil: [InputValidationError?] = [nil, .inputIsNil] + /// A list of expected input validation errors to be thrown after validating inputs against the secure (consumer key) validation rule. + static let inputsSecureConsumerKey: [InputValidationError?] = [nil, .inputNotConsumerKey, .inputNotConsumerKey, .inputNotConsumerKey] + /// A list of expected input validation errors to be thrown after validating inputs against the secure (consumer secret) validation rule. + static let inputsSecureConsumerSecret: [InputValidationError?] = [nil, .inputNotConsumerSecret, .inputNotConsumerSecret, .inputNotConsumerSecret] + /// A list of expected input validation errors to be thrown after validating inputs against the secure (user token) validation rule. + static let inputsSecureUserToken: [InputValidationError?] = [nil, .inputNotUserToken, .inputNotUserToken, .inputNotUserToken] } -- 2.52.0 From 21f290133765f402b21aed008f38875a2aa66247 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 21:26:55 +0200 Subject: [PATCH 10/11] Improved the AuthMiddleware type in the library target by adding input validation and by generating the authentication information once. --- .../Extensions/String+Constants.swift | 9 +- .../Public/Enumerations/AuthTransport.swift | 2 +- .../Public/Middlewares/AuthMiddleware.swift | 127 +++++---- .../Middlewares/AuthMiddlewareTests.swift | 254 +++++++++++++++++- 4 files changed, 323 insertions(+), 69 deletions(-) diff --git a/Sources/DiscogsService/Internal/Extensions/String+Constants.swift b/Sources/DiscogsService/Internal/Extensions/String+Constants.swift index 20e7bde4c..b9682eea5 100644 --- a/Sources/DiscogsService/Internal/Extensions/String+Constants.swift +++ b/Sources/DiscogsService/Internal/Extensions/String+Constants.swift @@ -26,14 +26,7 @@ extension String { 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)=%@" - /// A format for the user agent header. - static let userAgent = "%@/%@ +%@" - } + enum Format {} /// A namespaces assigned for the formats of regular expression patterns. enum Pattern {} diff --git a/Sources/DiscogsService/Public/Enumerations/AuthTransport.swift b/Sources/DiscogsService/Public/Enumerations/AuthTransport.swift index 5e3e67b25..05f4e7328 100644 --- a/Sources/DiscogsService/Public/Enumerations/AuthTransport.swift +++ b/Sources/DiscogsService/Public/Enumerations/AuthTransport.swift @@ -13,7 +13,7 @@ // ===----------------------------------------------------------------------=== /// A representation of the available transport options to send credentials in authenticated requests. -public enum AuthTransport: Sendable { +public enum AuthTransport: CaseIterable, Sendable { /// Authentication credential are sent in a request as an `Authentication` header. /// /// This means that the header will be added to any existing header in a request, like this: diff --git a/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift b/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift index 4b05a7273..bc805abb5 100644 --- a/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift +++ b/Sources/DiscogsService/Public/Middlewares/AuthMiddleware.swift @@ -19,23 +19,24 @@ import protocol OpenAPIRuntime.ClientMiddleware 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 -/// A middleware that attaches any defined authentication credentials into the requests for the service. +/// A middleware that attaches any defined authentication credentials into the requests to 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 + /// A header field that contains the authentication information. + let authField: HTTPField? + /// A list of query items that contains the authentication information. + let authItems: [URLQueryItem]? + // MARK: Initializers /// Initializes this middleware. @@ -45,9 +46,59 @@ public struct AuthMiddleware { public init( method: AuthMethod = .none, transport: AuthTransport - ) { - self.method = method - self.transport = transport + ) throws { + switch method { + case let .consumer(key, secret): + let validateKey = ValidateInputUseCase(rules: .notNil, .notEmpty, .secure(.consumerKey)) + let validateSecret = ValidateInputUseCase(rules: .notNil, .notEmpty, .secure(.consumerSecret)) + + try validateKey(key) + try validateSecret(secret) + + self.authField = switch transport { + case .onQuery: nil + case .onHeader: .init( + name: .authorization, + value: .init(format: .Format.authConsumer, key, secret) + )} + + self.authItems = switch transport { + case .onHeader: nil + case .onQuery: [ + .init(name: .Parameter.key, value: key), + .init(name: .Parameter.secret, value: secret) + ]} + + case let .user(token): + let validateToken = ValidateInputUseCase(rules: .notNil, .notEmpty, .secure(.userToken)) + + try validateToken(token) + + self.authField = switch transport { + case .onQuery: nil + case .onHeader: .init( + name: .authorization, + value: .init(format: .Format.authUser, token) + )} + + self.authItems = switch transport { + case .onHeader: nil + case .onQuery: [ + .init(name: .Parameter.token, value: token) + ] + } + + case .none: + self.authField = nil + self.authItems = nil + } + } + + // MARK: Computed + + /// A flag that indicates whether the middleware should authenticate the intercepted request or not. + var shouldAuthenticate: Bool { + authField != nil || authItems != nil } } @@ -65,29 +116,17 @@ extension AuthMiddleware: ClientMiddleware { operationID: String, next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { - guard method != .none else { + guard shouldAuthenticate 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 + path: authenticatePath(request.path), + headerFields: authenticateHeader(request.headerFields) ), body, baseURL @@ -104,21 +143,14 @@ private extension AuthMiddleware { /// 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. + /// - Returns: An updated set of header fields including the authorization header. 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 + + if let authField { + fields.append(authField) } - - fields.append(.init( - name: .authorization, - value: authorization - )) - + return fields } @@ -127,23 +159,13 @@ private extension AuthMiddleware { /// - Returns: An updated request path including the authentication parameters. func authenticatePath(_ path: String?) -> String? { guard + let authItems, 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) @@ -158,3 +180,12 @@ private extension AuthMiddleware { } } + +// MARK: - Constants + +private extension String.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/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift b/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift index a076128cf..d7abfa385 100644 --- a/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift +++ b/Tests/DiscogsService/Cases/Public/Middlewares/AuthMiddlewareTests.swift @@ -14,6 +14,8 @@ 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 @@ -25,6 +27,92 @@ import Testing @Suite("Auth Middleware", .tags(.middleware)) struct AuthMiddlewareTests { + // MARK: Initializers tests +#if swift(>=6.2) + @Test(arguments: Input.authMethods) + func `initialize`( + _ authMethod: AuthMethod + ) async throws { + try assertInit( + authMethod: authMethod, + authTransport: try randomTransport + ) + } + + @Test(arguments: zip( + Input.authMethodsThrows, + Output.authMethodsThrows + )) + func `initialize throws`( + _ authMethod: AuthMethod, + expects error: InputValidationError? + ) async throws { + try assertInitThrows( + authMethod: authMethod, + authTransport: try randomTransport, + expects: error + ) + } +#else + @Test("initialize", arguments: Input.authMethods) + func initialize( + _ authMethod: AuthMethod + ) throws { + try assertInit( + authMethod: authMethod, + authTransport: try randomTransport + ) + } + + @Test("initialize throws", arguments: zip( + Input.authMethodsThrows, + Output.authMethodsThrows + )) + func initializeThrows( + _ authMethod: AuthMethod, + expects error: InputValidationError? + ) throws { + assertInitThrows( + authMethod: authMethod, + authTransport: try randomTransport, + expects: error + ) + } +#endif + + // MARK: Properties tests +#if swift(>=6.2) + @Test(arguments: zip( + Input.authMethods, + Output.authMethodsShouldAuthenticate + )) + func `should authenticate`( + _ authMethod: AuthMethod, + expects flag: Bool + ) throws { + try assertShouldAuthenticate( + authMethod: authMethod, + authTransport: try randomTransport, + expects: flag + ) + } +#else + @Test("should authenticate", arguments: zip( + Input.authMethods, + Output.authMethodsShouldAuthenticate + )) + func shouldAuthenticate( + _ authMethod: AuthMethod, + expects flag: Bool + ) throws { + try assertShouldAuthenticate( + authMethod: authMethod, + authTransport: try randomTransport, + expects: flag + ) + } +#endif + // MARK: Functions tests #if swift(>=6.2) @@ -134,12 +222,94 @@ private extension AuthMiddlewareTests { // MARK: Functions + /// Asserts the initialization of the middleware, especially the assignment of its properties. + /// - Parameters: + /// - authMethod: A representation of an authentication method. + /// - authTransport: A representation of an authentication transport. + /// - Throws: an error of type ``InputValidationError`` in case of an unexpected error occurs while running test cases. + func assertInit( + authMethod: AuthMethod, + authTransport: AuthTransport, + ) throws { + // GIVEN + // WHEN + let middleware = try AuthMiddleware( + method: authMethod, + transport: authTransport + ) + + // THEN + switch (authMethod, authTransport) { + case let (.consumer(key, secret), .onHeader): + #expect(middleware.authItems == nil) + #expect(middleware.authField == .init( + name: .authorization, + value: "Discogs \(String.Parameter.key)=\(key), \(String.Parameter.secret)=\(secret)" + )) + + case let (.consumer(key, secret), .onQuery): + #expect(middleware.authField == nil) + #expect(middleware.authItems == [ + .init(name: .Parameter.key, value: key), + .init(name: .Parameter.secret, value: secret) + ]) + + case let (.user(token), .onHeader): + #expect(middleware.authItems == nil) + #expect(middleware.authField == .init( + name: .authorization, + value: "Discogs \(String.Parameter.token)=\(token)" + )) + + case let (.user(token), .onQuery): + #expect(middleware.authField == nil) + #expect(middleware.authItems == [ + .init(name: .Parameter.token, value: token) + ]) + + case (.none, _): + #expect(middleware.authField == nil) + #expect(middleware.authItems == nil) + } + } + + /// Asserts the error throwing (if justified) during the initialization of the 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. + func assertInitThrows( + authMethod: AuthMethod, + authTransport: AuthTransport, + expects error: InputValidationError? + ) { + // GIVEN + // WHEN + // THEN + if let error { + #expect(throws: error) { + try AuthMiddleware( + method: authMethod, + transport: authTransport + ) + } + } else { + #expect(throws: Never.self) { + try AuthMiddleware( + method: authMethod, + transport: authTransport + ) + } + } + } + /// 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. + /// - Throws:An error in case of an unexpected issue encountered while running a test case. func assertIntercept( authMethod: AuthMethod, authTransport: AuthTransport, @@ -147,7 +317,7 @@ private extension AuthMiddlewareTests { headerFields: HTTPFields = [:], ) async throws { // GIVEN - let middleware = AuthMiddleware( + let middleware = try AuthMiddleware( method: authMethod, transport: authTransport ) @@ -166,22 +336,24 @@ private extension AuthMiddlewareTests { ) { request, _, _ in // THEN switch (authMethod, authTransport) { - case let (.consumer(key, secret), .onHeader): + case (.consumer, .onHeader): #expect(request.path == path) #expect(request.headerFields != headerFields) - #expect(request.headerFields[.authorization] == "Discogs key=\(key), secret=\(secret)") + #expect(request.headerFields.contains(where: { $0.name == .authorization })) + case (.consumer, .onQuery): - #expect(request.path != path) - try assertAuthInPath(request.path, authMethod) #expect(request.headerFields == headerFields) - case let (.user(token), .onHeader): + try assertAuthInPath(request.path, authMethod) + + case (.user, .onHeader): #expect(request.path == path) #expect(request.headerFields != headerFields) - #expect(request.headerFields[.authorization] == "Discogs token=\(token)") + #expect(request.headerFields.contains(where: { $0.name == .authorization })) + case (.user, .onQuery): - #expect(request.path != path) - try assertAuthInPath(request.path, authMethod) #expect(request.headerFields == headerFields) + try assertAuthInPath(request.path, authMethod) + case (.none, _): #expect(request.path == path) #expect(request.headerFields == headerFields) @@ -194,10 +366,33 @@ private extension AuthMiddlewareTests { } } + /// Asserts the value of `shouldAuthenticate` flag after an initialization of a middleware. + /// - Parameters: + /// - authMethod: A representation of an authentication method. + /// - authTransport: A representation of an authentication transport. + /// - flag: An expected flag that indicates whether the middleware should authenticate its requests or not. + /// - Throws: An error of type ``InputValidationError`` in case of an unexpected issue occurs while running test cases. + func assertShouldAuthenticate( + authMethod: AuthMethod, + authTransport: AuthTransport, + expects flag: Bool + ) throws { + // GIVEN + // WHEN + let middleware = try AuthMiddleware( + method: authMethod, + transport: authTransport + ) + + // THEN + #expect(middleware.shouldAuthenticate == flag) + } + /// Asserts a request path to contain authentication parameters in its query. /// - Parameters: /// - path: A request path /// - authMethod: A representation of an authentication method. + /// - Throws:An error in case of an unexpected issue encountered while unwrapping the optionals. func assertAuthInPath( _ path: String?, _ authMethod: AuthMethod @@ -222,6 +417,19 @@ private extension AuthMiddlewareTests { // MARK: - Helpers +private extension AuthMiddlewareTests { + + // MARK: Properties + + /// Provides a random authentication transport representation. + var randomTransport: AuthTransport { + get throws { + try #require(AuthTransport.allCases.randomElement()) + } + } + +} + private extension HTTPRequest { // MARK: Initializers @@ -250,12 +458,34 @@ private extension HTTPRequest { // MARK: - Constants private extension Input { - /// A list of authentication methods for a request. + /// A list of authentication methods to use in most of the test cases. static let authMethods: [AuthMethod] = [ - .consumer(key: "SomeKey", secret: "SomeSecret"), - .user(token: "SomeToken"), + .consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"), + .user(token: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStT"), .none ] + /// A list of authentication methods to tests for the initialization throw test cases. + static let authMethodsThrows: [AuthMethod] = authMethods + [ + .consumer(key: .empty, secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"), + .consumer(key: "aAbBcCdDeEfFgGhHiI", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"), + .consumer(key: "aAbBcCdDeEfFgGhHiIjJkK", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"), + .consumer(key: "a4bBcCdDe3fFg6hH1Ij7", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"), + .consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: .empty), + .consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoO"), + .consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQ"), + .consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: "a4bBcCdDe3fFg6hH1IjJkK1LmMnNo0p9"), + .user(token: .empty), + .user(token: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsS"), + .user(token: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuU"), + .user(token: "a4bBcCdDe3fFg6hH1IjJkK1LmMnNo0p9qQrRs5t7"), + ] +} + +private extension Output { + /// A list of expected input validation errors (if thrown) coming from the initialization throw test cases. + static let authMethodsThrows: [InputValidationError?] = [nil, nil, nil, .inputIsEmpty, .inputNotConsumerKey, .inputNotConsumerKey, .inputNotConsumerKey, .inputIsEmpty, .inputNotConsumerSecret, .inputNotConsumerSecret, .inputNotConsumerSecret, .inputIsEmpty, .inputNotUserToken, .inputNotUserToken, .inputNotUserToken] + /// A list of expected boolean flags coming from the should authenticate test cases. + static let authMethodsShouldAuthenticate: [Bool] = [true, true, false] } private extension String { -- 2.52.0 From 51189f0127081e3b26556c05ec50420339635e30 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 12 Oct 2025 21:27:30 +0200 Subject: [PATCH 11/11] Added the "useCase" tag for the Tag+Customs in the tests target. --- .../Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift | 2 +- Tests/DiscogsService/Types/Extensions/Tag+Customs.swift | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift b/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift index 65689b18c..da9f63571 100644 --- a/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift +++ b/Tests/DiscogsService/Cases/Internal/Use Cases/ValidateInputUesCaseTests.swift @@ -16,7 +16,7 @@ import Testing @testable import DiscogsService -@Suite("Validate Input Use Cases") +@Suite("Validate Input Use Cases", .tags(.useCase)) struct ValidateInputUseCaseTests { // MARK: Functions diff --git a/Tests/DiscogsService/Types/Extensions/Tag+Customs.swift b/Tests/DiscogsService/Types/Extensions/Tag+Customs.swift index a4c60ab30..85184ee9c 100644 --- a/Tests/DiscogsService/Types/Extensions/Tag+Customs.swift +++ b/Tests/DiscogsService/Types/Extensions/Tag+Customs.swift @@ -21,4 +21,7 @@ extension Tag { /// A tag that indicates tests for a middleware type. @Tag static var middleware: Self + /// A tag that indicates tests for a use case type. + @Tag static var useCase: Self + } -- 2.52.0