12 Commits

Author SHA1 Message Date
javier a0672cc4af Implemented the Marketplace endpoints (#15)
This PR contains the work done to implement the *Marketplace* endpoints for the **Discogs API** backend service, which include the following endpoints:
* GET `/users/{username}/inventory` endpoint
* GET `/marketplace/listings/{listing_id}` endpoint
* DELETE `/marketplace/listings/{listing_id}` endpoint
* POST `/marketplace/listings/{listing_id}` endpoint
* POST `/marketplace/listings` endpoint
* GET `/marketplace/orders` endpoint
* GET `/marketplace/orders/{order_id}` endpoint
* GET `/marketplace/orders/{order_id}/messages` endpoint
* POST `/marketplace/orders/{order_id}/messages` endpoint
* GET `/marketplace/fee/{price}` endpoint
* GET `/marketplace/fee/{price}/{currency}` endpoint
* GET `/marketplace/price_suggestions/{release_id}` endpoint
* GET `/marketplace/stats/{release_id}` endpoint

Reviewed-on: #15
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-11-10 00:34:18 +00:00
javier 63118db805 Implemented the User Lists endpoints (#14)
This PR contains the work done to implement the *User Lists* endpoints of the *Discogs API* service in the `Open API` specification document:

* GET `/users/{username}/lists`
* GET `/lists/{list_id}`

Reviewed-on: #14
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-16 17:33:15 +00:00
javier f3d5c0e6ac Implemented the User Wantlist endpoints (#13)
This PR contains the work done to implement the *User Wantlist* endpoints of the *Discogs API* online service in the `Open API` specification document:

* GET `/users/{username}/wants`
* POST `/users/{username}/wants/{release_id}`
* PUT `/users/{username}/wants/{release_id}`
* DELETE `/users/{username}/wants/{release_id}`

Reviewed-on: #13
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-16 16:24:46 +00:00
javier 2a7b9746a7 Implemented the User Collection endpoints (#12)
This PR contains the work done to implement the *User Collection* endpoints of the Discogs API into the `OpenAPI` specification document:

* GET `/users/{username}/collection/folders`
* POST `/users/{username}/collection/folders`
* GET `/users/{username}/collection/folders/{folder_id}`
* POST `/users/{username}/collection/folders/{folder_id}`
* DELETE `/users/{username}/collection/folders/{folder_id}`
* GET `/users/{username}/collection/releases/{release_id}`
* GET `/users/{username}/collection/folders/{folder_id}/releases`
* POST `/users/{username}/collection/folders/{folder_id}/releases/{release_id}`
* POST `/users/{username}/collection/folders/{folder_id}/releases/{release_id}/instances/{instance_id}`
* DELETE `/users/{username}/collection/folders/{folder_id}/releases/{release_id}/instances/{instance_id}`
* GET `/users/{username}/collection/fields`
* POST `/users/{username}/collection/folders/{folder_id}/releases/{release_id}/instances/{instance_id}/fields/{field_id}`
* GET `/users/{username}/collection/value`

Reviewed-on: #12
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-16 14:38:41 +00:00
javier 2c91cad0bf User Identity endpoints (#11)
This PR contains the work done to implement the *User Identity* endpoints of the **Discogs API** online service:
* GET `/users/{username}`
* POST `/users/{username}`
* GET `/users/{username}/contributions`
* GET `/users/{username}/submissions`

Reviewed-on: #11
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-15 21:59:32 +00:00
javier 39e9dc5d53 Fox to Authentication endpoints (#10)
This PR contains the rock done to securize the /GET `/oauth/identity` endpoints on the OpenAPI specification document, plus it also improves the endpoints ordering in the *Database* section.

Reviewed-on: #10
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-13 18:41:24 +00:00
javier 9a30b69561 Implemented the Authentication endpoints (#9)
This PR contains the work done to implement the *Authentication* endpoints of the Discogs API:
* GET `/oauth/request_token`
* POST `/oauth/access_token`
* GET `/oauth/identity`

Reviewed-on: #9
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-13 17:55:48 +00:00
javier de5b4ff5d0 Implemented the missing Database endpoints (#8)
This PR contains the work done to declare the missing, non-GET endpoints of the *Database* section in the [Discogs API documentation](https://www.discogs.com/developers#page:database) into the `OpenAPI` specification document.

Furthermore, documentation extensions have been defined for the `APIProtocol` and `Client` types in the `DocC` documentation catalog to match the categories of these endpoint calls to the categories in the Discogs documentation.

Reviewed-on: #8
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-13 15:20:20 +00:00
javier d01b60e6dd Added the Rate Limiting headers (#7)
This PR contains the work done to define the `RateLimit`, `RateLimitRemaining` and the `RateLimitUsed` heades into the Open API specification document, as well as including these headers as part of the response for every existing endpoint declared in the mentioned document.

In addition, the `openapi-generator-config` files was also changed to have a different naming strategy.

Reviewed-on: #7
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-13 13:09:10 +00:00
javier 791ebf4f78 Implemented the User Agent middleware (#6)
This PR contains the work done to implement the `UserAgentMiddleware` middleware that includes user agent information into a header of the requests sent by the `Client` type, as defined in the [Discogs documentation](https://www.discogs.com/developers/#page:home,header:home-general-information). For this purpose, the `CamelCaseValidationRule`, `SemanticVersionValidationRule` and `URLValidationRule` types were implemented and integrated into the existing `ValidateInputUseCase` type.

Reviewed-on: #6
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-13 00:54:17 +00:00
javier 24d703b967 Added input validation to the Authentication middleware (#5)
This PR contains the work done to improve the existing `AuthMiddleware` type to provide input validations with the `SecureValidationRule` validation rule and also, by generating the authentication information at initialization time.

Reviewed-on: #5
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-12 19:33:45 +00:00
javier a1a649838c Implemented an input validation mechanism (#4)
This PR contains the work done to implement the `ValidateInputUseCase` use case and the `InputValidationRule` protocol, that is essential to define custom validation rules for inputs. In addition, the `NotEmptyValidationRule` and `NotNilValidationRule` rules have also been implemented.

Reviewed-on: #4
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
2025-10-12 13:25:25 +00:00
11035 changed files with 16192 additions and 2291 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ To use this library, then add it as a dependency in the `Package.swift` file of
let package = Package(
// name, platforms, products, etc.
dependencies: [
.package(url: "https://github.com/rock-n-code/discogs-service", from: "0.2.0"),
.package(url: "https://github.com/rock-n-code/discogs-service", from: "0.3.0"),
// other dependencies
],
targets: [
@@ -0,0 +1,133 @@
# ``APIProtocol``
## Topics
### Service endpoints
- ``APIProtocol/getService(_:)``
- ``APIProtocol/getService(headers:)``
### Authentication endpoints
- ``APIProtocol/getRequestToken(_:)``
- ``APIProtocol/getRequestToken(headers:)``
- ``APIProtocol/postAccessToken(_:)``
- ``APIProtocol/postAccessToken(headers:)``
- ``APIProtocol/getUserIdentity(_:)``
- ``APIProtocol/getUserIdentity(headers:)``
### Database endpoints
- ``APIProtocol/searchDatabase(_:)``
- ``APIProtocol/searchDatabase(query:headers:)``
- ``APIProtocol/getArtist(_:)``
- ``APIProtocol/getArtist(path:headers:)``
- ``APIProtocol/getArtistReleases(_:)``
- ``APIProtocol/getArtistReleases(path:query:headers:)``
- ``APIProtocol/getLabel(_:)``
- ``APIProtocol/getLabel(path:headers:)``
- ``APIProtocol/getLabelReleases(_:)``
- ``APIProtocol/getLabelReleases(path:query:headers:)``
- ``APIProtocol/getMaster(_:)``
- ``APIProtocol/getMaster(path:headers:)``
- ``APIProtocol/getMasterVersions(_:)``
- ``APIProtocol/getMasterVersions(path:query:headers:)``
- ``APIProtocol/getRelease(_:)``
- ``APIProtocol/getRelease(path:query:headers:)``
- ``APIProtocol/getReleaseRating(_:)``
- ``APIProtocol/getReleaseRating(path:headers:)``
- ``APIProtocol/getReleaseRatingByUser(_:)``
- ``APIProtocol/getReleaseRatingByUser(path:headers:)``
- ``APIProtocol/putReleaseRatingByUser(_:)``
- ``APIProtocol/putReleaseRatingByUser(path:query:headers:)``
- ``APIProtocol/deleteReleaseRatingByUser(_:)``
- ``APIProtocol/deleteReleaseRatingByUser(path:headers:)``
- ``APIProtocol/getReleaseStats(_:)``
- ``APIProtocol/getReleaseStats(path:headers:)``
### User Identity endpoints
- ``APIProtocol/getUserProfile(_:)``
- ``APIProtocol/getUserProfile(path:headers:)``
- ``APIProtocol/postUserProfile(_:)``
- ``APIProtocol/postUserProfile(path:query:headers:)``
- ``APIProtocol/getUserContributions(_:)``
- ``APIProtocol/getUserContributions(path:query:headers:)``
- ``APIProtocol/getUserSubmissions(_:)``
- ``APIProtocol/getUserSubmissions(path:headers:)``
### User Collection endpoints
- ``APIProtocol/getCollectionFolders(_:)``
- ``APIProtocol/getCollectionFolders(path:headers:)``
- ``APIProtocol/postCollectionFolders(_:)``
- ``APIProtocol/postCollectionFolders(path:query:headers:)``
- ``APIProtocol/getCollectionFolder(_:)``
- ``APIProtocol/getCollectionFolder(path:headers:)``
- ``APIProtocol/postCollectionFolder(_:)``
- ``APIProtocol/postCollectionFolder(path:query:headers:)``
- ``APIProtocol/deleteCollectionFolder(_:)``
- ``APIProtocol/deleteCollectionFolder(path:headers:)``
- ``APIProtocol/getCollectionItemsByRelease(_:)``
- ``APIProtocol/getCollectionItemsByRelease(path:headers:)``
- ``APIProtocol/getCollectionItemsByFolder(_:)``
- ``APIProtocol/getCollectionItemsByFolder(path:query:headers:)``
- ``APIProtocol/postReleaseToCollectionFolder(_:)``
- ``APIProtocol/postReleaseToCollectionFolder(path:headers:)``
- ``APIProtocol/postChangeRatingOfRelease(_:)``
- ``APIProtocol/postChangeRatingOfRelease(path:query:headers:body:)``
- ``APIProtocol/deleteInstanceFromCollectionFolder(_:)``
- ``APIProtocol/deleteInstanceFromCollectionFolder(path:headers:)``
- ``APIProtocol/getCustomFields(_:)``
- ``APIProtocol/getCustomFields(path:headers:)``
- ``APIProtocol/editFieldsInstance(_:)``
- ``APIProtocol/editFieldsInstance(path:query:headers:)``
- ``APIProtocol/getCollectionValue(_:)``
- ``APIProtocol/getCollectionValue(path:headers:)``
### User Wantlist endpoints
- ``APIProtocol/getWantlist(_:)``
- ``APIProtocol/getWantlist(path:query:headers:)``
- ``APIProtocol/addToWantlist(_:)``
- ``APIProtocol/addToWantlist(path:query:headers:)``
- ``APIProtocol/updateInWantlist(_:)``
- ``APIProtocol/updateInWantlist(path:query:headers:)``
- ``APIProtocol/deleteFromWantlist(_:)``
- ``APIProtocol/deleteFromWantlist(path:headers:)``
### User Lists endpoints
- ``APIProtocol/getLists(_:)``
- ``APIProtocol/getLists(path:query:headers:)``
- ``APIProtocol/getList(_:)``
- ``APIProtocol/getList(path:headers:)``
### Marketplace endpoints
- ``APIProtocol/getInventory(_:)``
- ``APIProtocol/getInventory(path:query:headers:)``
- ``APIProtocol/getListing(_:)``
- ``APIProtocol/getListing(path:query:headers:)``
- ``APIProtocol/createListing(_:)``
- ``APIProtocol/createListing(headers:body:)``
- ``APIProtocol/editListing(_:)``
- ``APIProtocol/editListing(path:headers:body:)``
- ``APIProtocol/deleteListing(_:)``
- ``APIProtocol/deleteListing(path:headers:)``
- ``APIProtocol/getOrders(_:)``
- ``APIProtocol/getOrders(query:headers:)``
- ``APIProtocol/getOrder(_:)``
- ``APIProtocol/getOrder(path:headers:)``
- ``APIProtocol/getOrderMessages(_:)``
- ``APIProtocol/getOrderMessages(path:headers:)``
- ``APIProtocol/addOrderMessage(_:)``
- ``APIProtocol/addOrderMessage(path:headers:body:)``
- ``APIProtocol/getFee(_:)``
- ``APIProtocol/getFee(path:headers:)``
- ``APIProtocol/getFeeWithCurrency(_:)``
- ``APIProtocol/getFeeWithCurrency(path:headers:)``
- ``APIProtocol/getPriceSuggestions(_:)``
- ``APIProtocol/getPriceSuggestions(path:headers:)``
- ``APIProtocol/getStatistics(_:)``
- ``APIProtocol/getStatistics(path:query:headers:)``
@@ -0,0 +1,84 @@
# ``Client``
## Topics
### Initializers
- ``Client/init(serverURL:configuration:transport:middlewares:)``
### Service endpoints
- ``Client/getService(_:)``
### Authentication endpoints
- ``Client/getRequestToken(_:)``
- ``Client/postAccessToken(_:)``
- ``Client/getUserIdentity(_:)``
### Database endpoints
- ``Client/searchDatabase(_:)``
- ``Client/getArtist(_:)``
- ``Client/getArtistReleases(_:)``
- ``Client/getLabel(_:)``
- ``Client/getLabelReleases(_:)``
- ``Client/getMaster(_:)``
- ``Client/getMasterVersions(_:)``
- ``Client/getRelease(_:)``
- ``Client/getReleaseRating(_:)``
- ``Client/getReleaseRatingByUser(_:)``
- ``Client/putReleaseRatingByUser(_:)``
- ``Client/deleteReleaseRatingByUser(_:)``
- ``Client/getReleaseStats(_:)``
### User Identity
- ``Client/getUserProfile(_:)``
- ``Client/postUserProfile(_:)``
- ``Client/getUserContributions(_:)``
- ``Client/getUserSubmissions(_:)``
### User Collection
- ``Client/getCollectionFolders(_:)``
- ``Client/postCollectionFolders(_:)``
- ``Client/getCollectionFolder(_:)``
- ``Client/postCollectionFolder(_:)``
- ``Client/deleteCollectionFolder(_:)``
- ``Client/getCollectionItemsByRelease(_:)``
- ``Client/getCollectionItemsByFolder(_:)``
- ``Client/postReleaseToCollectionFolder(_:)``
- ``Client/postChangeRatingOfRelease(_:)``
- ``Client/deleteInstanceFromCollectionFolder(_:)``
- ``Client/getCustomFields(_:)``
- ``Client/editFieldsInstance(_:)``
- ``Client/getCollectionValue(_:)``
### User Wantlist
- ``Client/getWantlist(_:)``
- ``Client/addToWantlist(_:)``
- ``Client/updateInWantlist(_:)``
- ``Client/deleteFromWantlist(_:)``
### User Lists
- ``Client/getLists(_:)``
- ``Client/getList(_:)``
### Marketplace
- ``Client/getInventory(_:)``
- ``Client/getListing(_:)``
- ``Client/createListing(_:)``
- ``Client/editListing(_:)``
- ``Client/deleteListing(_:)``
- ``Client/getOrders(_:)``
- ``Client/getOrder(_:)``
- ``Client/getOrderMessages(_:)``
- ``Client/addOrderMessage(_:)``
- ``Client/getFee(_:)``
- ``Client/getFeeWithCurrency(_:)``
- ``Client/getPriceSuggestions(_:)``
- ``Client/getStatistics(_:)``
@@ -14,7 +14,7 @@
### Servers
- ``Servers/Server1``
- ``LiveService``
### Authentication
@@ -22,12 +22,21 @@
- ``AuthMethod``
- ``AuthTransport``
### User Agent
- ``UserAgentMiddleware``
- ``Product``
### Types
- ``Components``
- ``Operations``
- ``Servers``
### Errors
- ``InputValidationError``
### Protocols
- ``APIProtocol``
@@ -26,11 +26,8 @@ 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)=%@"
}
enum Format {}
/// A namespaces assigned for the formats of regular expression patterns.
enum Pattern {}
}
@@ -0,0 +1,45 @@
// ===----------------------------------------------------------------------===
//
// 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 String {
// MARK: Functions
/// Checks whether a regular expression pattern fully matches a string or not.
/// - Parameter pattern: A regular expression pattern to match a string against.
/// - Returns: A flag that indicates whether a given pattern fully matches a string or not.
func fullyMatch(pattern: String) -> Bool {
do {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 6.0, *) {
let securityInput = try Regex(pattern)
let matches = self.wholeMatch(of: securityInput)
return matches != nil
} else {
let securityInput = try NSRegularExpression(pattern: pattern)
let matches = securityInput.matches(
in: self,
range: .init(location: 0, length: count)
)
return !matches.isEmpty
}
} catch {
return false
}
}
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,77 @@
// ===----------------------------------------------------------------------===
//
// 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 camel-case or not.
struct CamelCaseValidationRule: 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: - Definitions
extension InputValidationRule where Self == CamelCaseValidationRule {
// MARK: Constants
/// A validation rule that checks whether an input is camel-cased or not.
static var camelCase: Self { .init() }
}
// MARK: - Helpers
private extension CamelCaseValidationRule {
// 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
}
guard input.fullyMatch(
pattern: .init(format: .Pattern.camelCase)
) else {
throw InputValidationError.inputNotCamelCase
}
return true
}
}
// MARK: - Constants
private extension String.Pattern {
/// A regular expression pattern that represents camel-cased inputs.
static let camelCase = "([A-Z]([a-z]|[0-9])+)+"
}
@@ -0,0 +1,67 @@
// ===----------------------------------------------------------------------===
//
// 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
#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 == 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 {
// 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
}
guard !input.isEmpty else {
throw InputValidationError.inputIsEmpty
}
return true
}
}
@@ -0,0 +1,64 @@
// ===----------------------------------------------------------------------===
//
// 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: - 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 {
// 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 input != nil else {
throw InputValidationError.inputIsNil
}
return true
}
}
@@ -0,0 +1,112 @@
// ===----------------------------------------------------------------------===
//
// 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
/// 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 input.fullyMatch(
pattern: .init(format: .Pattern.securityInput, inputType.rawValue)
) 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 that represents security inputs.
static let securityInput = "^([a-z]|[A-Z]){%d}$"
}
@@ -0,0 +1,81 @@
// ===----------------------------------------------------------------------===
//
// 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 a semantic version or not.
///
/// This validation rules follows the principles defined in the [Semantic Versioning 2.0.0 documentation](https://semver.org/spec/v2.0.0.html)
struct SemanticVersionValidationRule: 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: - Definitions
extension InputValidationRule where Self == SemanticVersionValidationRule {
// MARK: Constants
/// A validation rule that checks whether an input is semantic version or not.
static var semanticVersion: Self { .init() }
}
// MARK: - Helpers
private extension SemanticVersionValidationRule {
// 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
}
guard input.fullyMatch(
pattern: .init(format: .Pattern.semanticVersioning)
) else {
throw InputValidationError.inputNotSemanticVersion
}
return true
}
}
// MARK: - Constants
private extension String.Pattern {
/// A regular expression pattern that represents semantic version inputs.
///
/// This regular expression is based on the [suggested regular expression](https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string) of the *Semantic Versioning 2.0.0* documentation.
static let semanticVersioning = "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$"
}
@@ -0,0 +1,83 @@
// ===----------------------------------------------------------------------===
//
// 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 a URL or not.
///
/// This validation rule doesn't necessarily follow the [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) standard.
/// Thus it doesn't implement a complex regular expression pattern such as [this one](https://rgxdb.com/r/5JXUI5A2).
/// Instead this validation implements a regular expression sufficient enough to satisfy the requirements for a [user agent definition](https://www.discogs.com/developers/#page:home,header:home-general-information).
struct URLValidationRule: 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: - Definitions
extension InputValidationRule where Self == URLValidationRule {
// MARK: Constants
/// A validation rule that checks whether an input is a URL or not.
static var url: Self { .init() }
}
// MARK: - Helpers
private extension URLValidationRule {
// 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
}
guard input.fullyMatch(
pattern: .init(format: .Pattern.url)
) else {
throw InputValidationError.inputNotURL
}
return true
}
}
// MARK: - Constants
private extension String.Pattern {
/// A regular expression pattern that represents URL inputs.
///
/// This regular expression is based on [this regular expression](https://regex101.com/r/3fYy3x/1) found while researching the topic.
static let url = "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)"
}
@@ -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 reference to a live (or production) service defined in the OpenAPI document.
public typealias LiveService = Servers.Server1
@@ -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:
@@ -0,0 +1,33 @@
// ===----------------------------------------------------------------------===
//
// 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.
public enum InputValidationError: Error {
/// An input is empty.
case inputIsEmpty
/// An input is nil.
case inputIsNil
/// An input is not camel-case.
case inputNotCamelCase
/// 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 is not a semantic version.
case inputNotSemanticVersion
/// An input is not a URL.
case inputNotURL
/// An input does not comply with the user token requirements.
case inputNotUserToken
}
@@ -19,35 +19,87 @@ 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.
/// - 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
) {
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 +117,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 +144,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 +160,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 +181,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)=%@"
}
@@ -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 = "%@/%@ +%@"
}
@@ -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 semantic version of a product.
/// - url: A URI link related to a product.
public init(
name: String,
version: String,
url: String
) {
self.name = name
self.url = url
self.version = version
}
}
@@ -15,5 +15,5 @@
generate:
- types
- client
namingStrategy: defensive
namingStrategy: idiomatic
accessModifier: public
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
// ===----------------------------------------------------------------------===
//
// 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("String Functions", .tags(.extension))
struct StringFunctionsTests {
// MARK: Functions tests
#if swift(>=6.2)
@Test(arguments: zip(
Input.stringsToMatch,
Output.stringsToMatch
))
func `fully match`(
string: String,
expects isMatch: Bool
) {
assertFullyMatch(
string: string,
pattern: .Pattern.sample,
expects: isMatch
)
}
#else
@Test("fully match", arguments: zip(
Input.stringsToMatch,
Output.stringsToMatch
))
func fullyMatch(
string: String,
expects isMatch: Bool
) {
assertFullyMatch(
string: string,
pattern: .Pattern.sample,
expects: isMatch
)
}
#endif
}
// MARK: - Assertions
private extension StringFunctionsTests {
// MARK: Functions
/// Asserts the result of the `fullyMatch` function.
/// - Parameters:
/// - string: A string to match against a pattern.
/// - pattern: A regular expression pattern to match a string against.
/// - isMatch: An expected flag that indicates whether there is a match or not.
func assertFullyMatch(
string: String,
pattern: String,
expects isMatch: Bool
) {
// GIVEN
// WHEN
let result = string.fullyMatch(pattern: pattern)
// THEN
#expect(result == isMatch)
}
}
// MARK: - Constants
private extension Input {
/// A list of strings to match against a regular expression pattern in test cases.
static let stringsToMatch: [String] = [.Pattern.sample, "Some", "Some Other Pattern", "Pattern", .empty]
}
private extension Output {
/// A list of expected results from matching a sample string against a sample regular expression pattern in test cases.
static let stringsToMatch: [Bool] = [true, false, false, false, false]
}
private extension String.Pattern {
/// A sample regular expression pattern to match against.
static let sample = "Some Pattern"
}
@@ -0,0 +1,325 @@
// ===----------------------------------------------------------------------===
//
// 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", .tags(.useCase))
struct ValidateInputUseCaseTests {
// MARK: Functions
#if swift(>=6.2)
@Test(arguments: zip(
Input.inputsAgentName,
Output.inputsAgentName
)) func `validate camel case`(
input: String,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .agentName,
input: input,
expects: error
)
}
@Test(arguments: zip(
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
)
}
@Test(arguments: zip(
Input.inputsSemanticVersion,
Output.inputsSemanticVersion
)) func `validate semantic version`(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .semanticVersion,
input: input,
expects: error
)
}
@Test(arguments: zip(
Input.inputsURL,
Output.inputsURL
)) func `validate url`(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .url,
input: input,
expects: error
)
}
#else
@Test("validate camel case", arguments: zip(
Input.inputsCamelCase,
Output.inputsCamelCase
)) func validateCamelCase(
input: String,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .camelCase,
input: input,
expects: error
)
}
@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
)
}
@Test("validate semantic version", arguments: zip(
Input.inputsSemanticVersion,
Output.inputsSemanticVersion
)) func validateSemanticVersion(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .semanticVersion,
input: input,
expects: error
)
}
@Test("validate url", arguments: zip(
Input.inputsURL,
Output.inputsURL
)) func validateURL(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .url,
input: input,
expects: error
)
}
#endif
}
// MARK: - Assertions
private extension ValidateInputUseCaseTests {
// MARK: Functions
/// 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: rule)
// 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 camel-case validation rule.
static let inputsCamelCase: [String] = ["SampleApp", "Sample4pp", "SampleApp1", "SampleApp🚀", "Sample App", "Sample-App", "Sample_App"]
/// 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"]
/// A list of inputs to validate against the semantic version validation rule.
static let inputsSemanticVersion: [String] = ["0.0.4","1.2.3","10.20.30","1.1.2-prerelease+meta","1.1.2+meta","1.1.2+meta-valid","1.0.0-alpha","1.0.0-beta","1.0.0-alpha.beta","1.0.0-alpha.beta.1","1.0.0-alpha.1","1.0.0-alpha0.valid","1.0.0-alpha.0valid","1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay","1.0.0-rc.1+build.1","2.0.0-rc.1+build.123","1.2.3-beta","10.2.3-DEV-SNAPSHOT","1.2.3-SNAPSHOT-123","1.0.0","2.0.0","1.1.7","2.0.0+build.1848","2.0.1-alpha.1227","1.0.0-alpha+beta","1.2.3----RC-SNAPSHOT.12.9.1--.12+788","1.2.3----R-S.12.9.1--.12+meta","1.2.3----RC-SNAPSHOT.12.9.1--.12","1.0.0+0.build.1-rc.10000aaa-kk-0.1","99999999999999999999999.999999999999999999.99999999999999999","1.0.0-0A.is.legal","1","1.2","1.2.3-0123","1.2.3-0123.0123","1.1.2+.123","+invalid","-invalid","-invalid+invalid","-invalid.01","alpha","alpha.beta","alpha.beta.1","alpha.1","alpha+beta","alpha_beta","alpha.","alpha..","beta","1.0.0-alpha_beta","-alpha.","1.0.0-alpha..","1.0.0-alpha..1","1.0.0-alpha...1","1.0.0-alpha....1","1.0.0-alpha.....1","1.0.0-alpha......1","1.0.0-alpha.......1","01.1.1","1.01.1","1.1.01","1.2","1.2.3.DEV","1.2-SNAPSHOT","1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788","1.2-RC-SNAPSHOT","-1.0.3-gamma+b7718","+justmeta","9.8.7+meta+meta","9.8.7-whatever+meta+meta","99999999999999999999999.999999999999999999.99999999999999999----RC-SNAPSHOT.12.09.1--------------------------------..12"]
/// A list of inputs to validate against the URL validation rule.
static let inputsURL: [String] = ["https://www.google.com", "http://www.google.com", "https://google.com/q=search", "http://google.com/q=search", "3333-768-0948", "1133.168.0248", "7678*999-8978", "httpq://google.com/q=search", "www.google.com", "www.google.com/?search=qppoao", "www . google.com/?search=qppoao", "https : //google.com/q=search", "htt://www.google.com", "://www.google.com", .empty]
}
private extension Output {
/// A list of expected input validation errors to be thrown after validating inputs against the camel-case validation rule.
static let inputsCamelCase: [InputValidationError?] = [nil, nil, nil, .inputNotCamelCase, .inputNotCamelCase, .inputNotCamelCase, .inputNotCamelCase]
/// 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]
/// A list of expected input validation errors to be thrown after validating inputs against the semantic version validation rule.
static let inputsSemanticVersion: [InputValidationError?] = [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion, .inputNotSemanticVersion]
/// A list of expected input validation errors to be thrown after validating inputs against the URL validation rule.
static let inputsURL: [InputValidationError?] = [nil, nil, nil, nil, .inputNotURL, .inputNotURL, .inputNotURL, .inputNotURL, .inputNotURL, .inputNotURL, .inputNotURL, .inputNotURL, .inputNotURL, .inputNotURL, .inputNotURL]
}
@@ -14,6 +14,7 @@
import struct Foundation.URL
import struct Foundation.URLComponents
import struct Foundation.URLQueryItem
import struct HTTPTypes.HTTPFields
import struct HTTPTypes.HTTPRequest
import struct HTTPTypes.HTTPResponse
@@ -25,6 +26,93 @@ 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 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 a 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
)
@@ -161,27 +331,29 @@ 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) {
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,27 +417,15 @@ private extension AuthMiddlewareTests {
// MARK: - Helpers
private extension HTTPRequest {
private extension AuthMiddlewareTests {
// MARK: Initializers
// MARK: Properties
/// 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
)
/// Provides a random authentication transport representation.
var randomTransport: AuthTransport {
get throws {
try #require(AuthTransport.allCases.randomElement())
}
}
}
@@ -250,20 +433,32 @@ 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 use in 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 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")!
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]
}
@@ -0,0 +1,226 @@
// ===----------------------------------------------------------------------===
//
// 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.HTTPField
import struct HTTPTypes.HTTPFields
import struct HTTPTypes.HTTPRequest
import Testing
@testable import DiscogsService
@Suite("User Agent Middleware", .tags(.middleware))
struct UserAgentMiddlewareTests {
// 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]
}
@@ -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
)
}
}
@@ -18,7 +18,13 @@ extension Tag {
// MARK: Constants
/// A tag that indicates tests for a type extension.
@Tag static var `extension`: Self
/// 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
}
@@ -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"
}
}
@@ -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")!
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More