Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eaee2b84f0 | |||
| b14a9fa816 | |||
| a0672cc4af | |||
| 63118db805 | |||
| f3d5c0e6ac | |||
| 2a7b9746a7 | |||
| 2c91cad0bf | |||
| 39e9dc5d53 | |||
| 9a30b69561 | |||
| de5b4ff5d0 | |||
| d01b60e6dd | |||
| 791ebf4f78 | |||
| 24d703b967 | |||
| a1a649838c | |||
| bfc9e67d38 |
@@ -2,7 +2,7 @@
|
||||
##
|
||||
## This source file is part of the DiscogsService open source project
|
||||
##
|
||||
## Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
## Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
## Licensed under Apache license v2.0
|
||||
##
|
||||
## See LICENSE for license information
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
##
|
||||
## This source file is part of the DiscogsService open source project
|
||||
##
|
||||
## Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
## Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
## Licensed under Apache license v2.0
|
||||
##
|
||||
## See LICENSE for license information
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
##
|
||||
## This source file is part of the DiscogsService open source project
|
||||
##
|
||||
## Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
## Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
## Licensed under Apache license v2.0
|
||||
##
|
||||
## See LICENSE for license information
|
||||
|
||||
@@ -5,7 +5,7 @@ Please visit the Discogs Service web site for more information:
|
||||
|
||||
* https://github.com/rock-n-code/discogs-service
|
||||
|
||||
Copyright 2025 Röck+Cöde VoF
|
||||
Copyright 2026 Röck+Cöde VoF
|
||||
|
||||
The Discogs Service Project licenses this file to you under the Apache License,
|
||||
version 2.0 (the "License"); you may not use this file except in compliance
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
// Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
// Licensed under Apache license v2.0
|
||||
//
|
||||
// See LICENSE for license information
|
||||
@@ -32,7 +32,7 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-openapi-generator.git", from: "1.3.0"),
|
||||
.package(url: "https://github.com/apple/swift-openapi-generator.git", exact: "1.11.0"),
|
||||
.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.5.0"),
|
||||
.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.2"),
|
||||
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.1.0")
|
||||
|
||||
@@ -1 +1,37 @@
|
||||
[](https://swiftpackageindex.com/rock-n-code/discogs-service)
|
||||
[](https://swiftpackageindex.com/rock-n-code/discogs-service)
|
||||
|
||||
# Discogs Service
|
||||
|
||||
A library written entirely with [Swift](https://www.swift.org) that allow the developer to interact with the [Discogs API](https://www.discogs.com/developers/#) backend service.
|
||||
|
||||
## Installation
|
||||
|
||||
To use this library, then add it as a dependency in the `Package.swift` file of your project:
|
||||
|
||||
```swift
|
||||
let package = Package(
|
||||
// name, platforms, products, etc.
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/rock-n-code/discogs-service", from: "0.6.1"),
|
||||
// other dependencies
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SomeTarget",
|
||||
dependencies: [
|
||||
.product(name: "DiscogsService", package: "discogs-service"),
|
||||
]
|
||||
)
|
||||
// other targets
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
It is also possible to use this library with your app in Xcode, then add it as a dependency in your Xcode project.
|
||||
|
||||
> important: Swift 5.10 or higher is required in order to compile this library.
|
||||
|
||||
## Documentation
|
||||
|
||||
Please refer to the [online documentation](https://rock-n-code.github.io/discogs-service/documentation/discogsservice/) for further informations about this library.
|
||||
|
||||
@@ -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(_:)``
|
||||
@@ -1,13 +1,69 @@
|
||||
# ``DiscogsService``
|
||||
|
||||
<!--@START_MENU_TOKEN@-->Summary<!--@END_MENU_TOKEN@-->
|
||||
A Swift client library for the Discogs API, built on top of Swift OpenAPI.
|
||||
|
||||
## Overview
|
||||
|
||||
<!--@START_MENU_TOKEN@-->Text<!--@END_MENU_TOKEN@-->
|
||||
This library provides a type-safe client for interacting with the [Discogs API](https://www.discogs.com/developers), including endpoints for the database, user identity, user collections, wantlists, lists, and the marketplace. It supports multiple authentication methods and transports credentials via headers or query parameters.
|
||||
|
||||
## Installation
|
||||
|
||||
To use this library, then add it as a dependency in the `Package.swift` file of your project:
|
||||
|
||||
```swift
|
||||
let package = Package(
|
||||
// name, platforms, products, etc.
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/rock-n-code/discogs-service", from: "0.6.1"),
|
||||
// other dependencies
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SomeTarget",
|
||||
dependencies: [
|
||||
.product(name: "DiscogsService", package: "discogs-service"),
|
||||
]
|
||||
)
|
||||
// other targets
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
It is also possible to use this library with your app in Xcode, then add it as a dependency in your Xcode project.
|
||||
|
||||
> important: Swift 5.10 or higher is required in order to compile this library.
|
||||
|
||||
## Topics
|
||||
|
||||
### <!--@START_MENU_TOKEN@-->Group<!--@END_MENU_TOKEN@-->
|
||||
### Clients
|
||||
|
||||
- <!--@START_MENU_TOKEN@-->``Symbol``<!--@END_MENU_TOKEN@-->
|
||||
- ``Client``
|
||||
|
||||
### Servers
|
||||
|
||||
- ``LiveService``
|
||||
|
||||
### Authentication
|
||||
|
||||
- ``AuthMiddleware``
|
||||
- ``AuthMethod``
|
||||
- ``AuthTransport``
|
||||
|
||||
### User Agent
|
||||
|
||||
- ``UserAgentMiddleware``
|
||||
- ``Product``
|
||||
|
||||
### Types
|
||||
|
||||
- ``Components``
|
||||
- ``Operations``
|
||||
- ``Servers``
|
||||
|
||||
### Errors
|
||||
|
||||
- ``InputValidationError``
|
||||
|
||||
### Protocols
|
||||
|
||||
- ``APIProtocol``
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
// Licensed under Apache license v2.0
|
||||
//
|
||||
// See LICENSE for license information
|
||||
// See CONTRIBUTORS for the list of DiscogsService project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// ===----------------------------------------------------------------------===
|
||||
|
||||
extension String {
|
||||
/// An empty string.
|
||||
static let empty = ""
|
||||
|
||||
/// A namespace for the names of parameters.
|
||||
enum Parameter {
|
||||
/// A name for the consumer key.
|
||||
static let key = "key"
|
||||
/// A name for the consumer secret.
|
||||
static let secret = "secret"
|
||||
/// A name for the user token.
|
||||
static let token = "token"
|
||||
}
|
||||
/// A namespace for the formats of string values.
|
||||
enum Format {}
|
||||
|
||||
/// A namespace 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) 2026 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) 2026 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) 2026 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) 2026 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 ``InputValidationError`` 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) 2026 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 ``InputValidationError`` 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) 2026 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 ``InputValidationError`` 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) 2026 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 secret is 32 characters long.
|
||||
case consumerSecret = 32
|
||||
/// A user token 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 ``InputValidationError`` 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) 2026 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 ``InputValidationError`` 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) 2026 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 ``InputValidationError`` 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,18 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 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 type alias for the live (production) Discogs API server defined in the OpenAPI document.
|
||||
///
|
||||
/// Use this as the `serverURL` when initializing a ``Client`` to connect to the production Discogs API.
|
||||
public typealias LiveService = Servers.Server1
|
||||
@@ -0,0 +1,33 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 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 the available authentication methods at the Discogs service.
|
||||
///
|
||||
/// The differences between these authentication methods.
|
||||
///
|
||||
/// Credentials in request | Rate limiting? | Image URLs? |Authenticated as user?
|
||||
/// --- | :---: | :---: | :---:
|
||||
/// None | 🐢 Low tier | ❌ No |❌ No
|
||||
/// Only Consumer key/secret | 🐰 High tier | ✔️ Yes | ❌ No
|
||||
/// Personal access token | 🐰 High tier | ✔️ Yes | ✔️ Yes, for token holder only 👩
|
||||
///
|
||||
/// Please refer to the [Discogs documentation](https://www.discogs.com/developers#page:authentication,header:authentication-discogs-auth-flow) for further details.
|
||||
public enum AuthMethod: Equatable, Sendable {
|
||||
/// A consumer key and secret that allows access to endpoints that requires authentication.
|
||||
case consumer(key: String, secret: String)
|
||||
/// No authentication method defined.
|
||||
case none
|
||||
/// A user token that allows access to its own account information.
|
||||
case user(token: String)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 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 the available transport options to send credentials in authenticated requests.
|
||||
///
|
||||
/// This enumeration is used in conjunction with ``AuthMiddleware`` to determine how authentication credentials
|
||||
/// are attached to outgoing requests.
|
||||
public enum AuthTransport: CaseIterable, Sendable {
|
||||
/// Authentication credentials are sent in a request as an `Authorization` header.
|
||||
///
|
||||
/// This means that the header will be added to any existing header in a request, like this:
|
||||
/// ```bash
|
||||
/// curl "https://api.discogs.com/database/search?q=Slayer" -H "Authorization: Discogs key=foo123, secret=bar456"
|
||||
/// curl "https://api.discogs.com/database/search?q=Slayer" -H "Authorization: Discogs token=abcxyz123456"
|
||||
/// ```
|
||||
case onHeader
|
||||
/// Authentication credentials are sent in a request as parameters in the query string.
|
||||
///
|
||||
/// This means that the parameters will be injected into the query in a request, like this:
|
||||
/// ```bash
|
||||
/// curl "https://api.discogs.com/database/search?q=Slayer&key=foo123&secret=bar456"
|
||||
/// curl "https://api.discogs.com/database/search?q=Slayer&token=abcxyz123456"
|
||||
/// ```
|
||||
case onQuery
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 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 errors 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
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
// Licensed under Apache license v2.0
|
||||
//
|
||||
// See LICENSE for license information
|
||||
// See CONTRIBUTORS for the list of DiscogsService project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// ===----------------------------------------------------------------------===
|
||||
|
||||
import class OpenAPIRuntime.HTTPBody
|
||||
|
||||
import protocol OpenAPIRuntime.ClientMiddleware
|
||||
|
||||
import struct Foundation.URL
|
||||
import struct Foundation.URLComponents
|
||||
import struct Foundation.URLQueryItem
|
||||
import struct HTTPTypes.HTTPField
|
||||
import struct HTTPTypes.HTTPFields
|
||||
import struct HTTPTypes.HTTPRequest
|
||||
import struct HTTPTypes.HTTPResponse
|
||||
|
||||
/// 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 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
|
||||
) 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ClientMiddleware
|
||||
|
||||
extension AuthMiddleware: ClientMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Intercepts an outgoing HTTP request and injects authentication credentials if configured.
|
||||
/// - Parameters:
|
||||
/// - request: The outgoing HTTP request to potentially authenticate.
|
||||
/// - body: The optional body of the HTTP request.
|
||||
/// - baseURL: The base URL of the service.
|
||||
/// - operationID: The identifier of the API operation being called.
|
||||
/// - next: The next middleware or transport to call in the chain.
|
||||
/// - Returns: The HTTP response and optional response body from the service.
|
||||
public func intercept(
|
||||
_ request: HTTPRequest,
|
||||
body: HTTPBody?,
|
||||
baseURL: URL,
|
||||
operationID: String,
|
||||
next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
|
||||
) async throws -> (HTTPResponse, HTTPBody?) {
|
||||
guard shouldAuthenticate else {
|
||||
return try await next(request, body, baseURL)
|
||||
}
|
||||
|
||||
return try await next(
|
||||
.init(
|
||||
method: request.method,
|
||||
scheme: request.scheme,
|
||||
authority: request.authority,
|
||||
path: authenticatePath(request.path),
|
||||
headerFields: authenticateHeader(request.headerFields)
|
||||
),
|
||||
body,
|
||||
baseURL
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension AuthMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Adds an authorization header to the existing header fields.
|
||||
/// - Parameter fields: A set of header fields to update.
|
||||
/// - Returns: An updated set of header fields including the authorization header.
|
||||
func authenticateHeader(_ fields: HTTPFields) -> HTTPFields {
|
||||
var fields = fields
|
||||
|
||||
if let authField {
|
||||
fields.append(authField)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
/// Adds the authentication parameters to the query of a path
|
||||
/// - Parameter path: A request path to authenticate.
|
||||
/// - Returns: An updated request path including the authentication parameters.
|
||||
func authenticatePath(_ path: String?) -> String? {
|
||||
guard
|
||||
let authItems,
|
||||
let path,
|
||||
var urlComponents = URLComponents(string: path)
|
||||
else {
|
||||
return path
|
||||
}
|
||||
|
||||
var queryItems = urlComponents.queryItems ?? []
|
||||
|
||||
queryItems.append(contentsOf: authItems)
|
||||
|
||||
urlComponents.queryItems = queryItems
|
||||
|
||||
return if let urlQuery = urlComponents.query {
|
||||
urlComponents.path + "?" + urlQuery
|
||||
} else {
|
||||
urlComponents.path
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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,117 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 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
|
||||
|
||||
/// Intercepts an outgoing HTTP request and attaches the `User-Agent` header.
|
||||
/// - Parameters:
|
||||
/// - request: The outgoing HTTP request to modify.
|
||||
/// - body: The optional body of the HTTP request.
|
||||
/// - baseURL: The base URL of the service.
|
||||
/// - operationID: The identifier of the API operation being called.
|
||||
/// - next: The next middleware or transport to call in the chain.
|
||||
/// - Returns: The HTTP response and optional response body from the service.
|
||||
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,53 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 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 representation of the product information used to generate the `User-Agent` header for requests to the Discogs API.
|
||||
///
|
||||
/// The Discogs API requires a `User-Agent` header that identifies the application making the request. This model captures
|
||||
/// the product name, version, and URL needed to build that header via the ``UserAgentMiddleware``.
|
||||
///
|
||||
/// The generated `User-Agent` header follows the format: `ProductName/1.0.0 +https://example.com`
|
||||
public struct Product: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The camel-cased name of the product (e.g., `MyDiscogsApp`).
|
||||
let name: String
|
||||
|
||||
/// A URI link related to the product (e.g., `https://example.com`).
|
||||
let url: String
|
||||
|
||||
/// The semantic version of the product (e.g., `1.0.0`).
|
||||
let version: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Initializes this model.
|
||||
/// - Parameters:
|
||||
/// - name: The camel-cased name of the product.
|
||||
/// - version: The semantic version of the product, following [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
|
||||
/// - url: A URI link related to the product.
|
||||
public init(
|
||||
name: String,
|
||||
version: String,
|
||||
url: String
|
||||
) {
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.version = version
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
@@ -2,7 +2,7 @@
|
||||
##
|
||||
## This source file is part of the Discogs Service open source project
|
||||
##
|
||||
## Copyright (c) 2025 Röck+Cöde VoF. and the Discogs Service project authors
|
||||
## Copyright (c) 2026 Röck+Cöde VoF. and the Discogs Service project authors
|
||||
## Licensed under Apache license v2.0
|
||||
##
|
||||
## See LICENSE for license information
|
||||
@@ -15,5 +15,5 @@
|
||||
generate:
|
||||
- types
|
||||
- client
|
||||
namingStrategy: defensive
|
||||
namingStrategy: idiomatic
|
||||
accessModifier: public
|
||||
|
||||
+3434
-270
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) 2026 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) 2026 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]
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
// Licensed under Apache license v2.0
|
||||
//
|
||||
// See LICENSE for license information
|
||||
// See CONTRIBUTORS for the list of DiscogsService project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// ===----------------------------------------------------------------------===
|
||||
|
||||
import struct Foundation.URL
|
||||
import struct Foundation.URLComponents
|
||||
import struct Foundation.URLQueryItem
|
||||
import struct HTTPTypes.HTTPFields
|
||||
import struct HTTPTypes.HTTPRequest
|
||||
import struct HTTPTypes.HTTPResponse
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import DiscogsService
|
||||
|
||||
@Suite("Auth Middleware", .tags(.middleware))
|
||||
struct AuthMiddlewareTests {
|
||||
|
||||
// MARK: 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)
|
||||
@Test(arguments: Input.authMethods)
|
||||
func `intercept with authorization on header`(
|
||||
_ authMethod: AuthMethod
|
||||
) async throws {
|
||||
try await assertIntercept(
|
||||
authMethod: authMethod,
|
||||
authTransport: .onHeader,
|
||||
path: "/some/path/to/resource"
|
||||
)
|
||||
}
|
||||
|
||||
@Test(arguments: Input.authMethods)
|
||||
func `intercept with authorization on query`(
|
||||
_ authMethod: AuthMethod
|
||||
) async throws {
|
||||
try await assertIntercept(
|
||||
authMethod: authMethod,
|
||||
authTransport: .onQuery,
|
||||
path: "/some/path/to/resource"
|
||||
)
|
||||
}
|
||||
|
||||
@Test(arguments: Input.authMethods)
|
||||
func `intercept with authorization on header when headers populated`(
|
||||
_ authMethod: AuthMethod
|
||||
) async throws {
|
||||
try await assertIntercept(
|
||||
authMethod: authMethod,
|
||||
authTransport: .onHeader,
|
||||
path: "/some/path/to/resource",
|
||||
headerFields: [.accept: "*/*"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test(arguments: Input.authMethods)
|
||||
func `intercept with authorization on query when query is populated`(
|
||||
_ authMethod: AuthMethod
|
||||
) async throws {
|
||||
try await assertIntercept(
|
||||
authMethod: authMethod,
|
||||
authTransport: .onQuery,
|
||||
path: "/some/path/to/resource?key=value"
|
||||
)
|
||||
}
|
||||
#else
|
||||
@Test("intercept with authorization on header", arguments: Input.authMethods)
|
||||
func intercept_withAuthOnHeader(
|
||||
_ authMethod: AuthMethod
|
||||
) async throws {
|
||||
try await assertIntercept(
|
||||
authMethod: authMethod,
|
||||
authTransport: .onHeader,
|
||||
path: "/some/path/to/resource"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("intercept with authorization on query", arguments: Input.authMethods)
|
||||
func intercept_withAuthOnQuery(
|
||||
_ authMethod: AuthMethod
|
||||
) async throws {
|
||||
try await assertIntercept(
|
||||
authMethod: authMethod,
|
||||
authTransport: .onQuery,
|
||||
path: "/some/path/to/resource"
|
||||
)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"intercept with authorization on header when headers are populated",
|
||||
arguments: Input.authMethods
|
||||
)
|
||||
func intercept_withAuthOnHeader_whenHeadersPopulated(
|
||||
_ authMethod: AuthMethod
|
||||
) async throws {
|
||||
try await assertIntercept(
|
||||
authMethod: authMethod,
|
||||
authTransport: .onHeader,
|
||||
path: "/some/path/to/resource",
|
||||
headerFields: [.accept: "*/*"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"intercept with authorization on query when query is populated",
|
||||
arguments: Input.authMethods
|
||||
)
|
||||
func intercept_withAuthOnQuery_whenQueryPopulated(
|
||||
_ authMethod: AuthMethod
|
||||
) async throws {
|
||||
try await assertIntercept(
|
||||
authMethod: authMethod,
|
||||
authTransport: .onQuery,
|
||||
path: "/some/path/to/resource?key=value"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Assertions
|
||||
|
||||
private extension AuthMiddlewareTests {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Asserts the 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,
|
||||
path: String,
|
||||
headerFields: HTTPFields = [:],
|
||||
) async throws {
|
||||
// GIVEN
|
||||
let middleware = try AuthMiddleware(
|
||||
method: authMethod,
|
||||
transport: authTransport
|
||||
)
|
||||
let request = HTTPRequest(
|
||||
path: path,
|
||||
headerFields: headerFields
|
||||
)
|
||||
|
||||
// WHEN
|
||||
_ = try await confirmation { confirmation in
|
||||
try await middleware.intercept(
|
||||
request,
|
||||
body: nil,
|
||||
baseURL: .Sample.baseURL,
|
||||
operationID: .Sample.operationId
|
||||
) { request, _, _ in
|
||||
// THEN
|
||||
switch (authMethod, authTransport) {
|
||||
case (.consumer, .onHeader):
|
||||
#expect(request.path == path)
|
||||
#expect(request.headerFields != headerFields)
|
||||
#expect(request.headerFields.contains(where: { $0.name == .authorization }))
|
||||
|
||||
case (.consumer, .onQuery):
|
||||
#expect(request.headerFields == headerFields)
|
||||
try assertAuthInPath(request.path, authMethod)
|
||||
|
||||
case (.user, .onHeader):
|
||||
#expect(request.path == path)
|
||||
#expect(request.headerFields != headerFields)
|
||||
#expect(request.headerFields.contains(where: { $0.name == .authorization }))
|
||||
|
||||
case (.user, .onQuery):
|
||||
#expect(request.headerFields == headerFields)
|
||||
try assertAuthInPath(request.path, authMethod)
|
||||
|
||||
case (.none, _):
|
||||
#expect(request.path == path)
|
||||
#expect(request.headerFields == headerFields)
|
||||
}
|
||||
|
||||
confirmation()
|
||||
|
||||
return (.init(status: .ok) , nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
) throws {
|
||||
let pathRequest = try #require(path)
|
||||
let urlComponents = try #require(URLComponents(string: pathRequest))
|
||||
let queryItems = try #require(urlComponents.queryItems)
|
||||
|
||||
switch authMethod {
|
||||
case .consumer:
|
||||
#expect(queryItems.count >= 2)
|
||||
#expect(queryItems.contains(where: { $0.name == .Parameter.key }))
|
||||
#expect(queryItems.contains(where: { $0.name == .Parameter.secret }))
|
||||
case .user:
|
||||
#expect(queryItems.count >= 1)
|
||||
#expect(queryItems.contains(where: { $0.name == .Parameter.token }))
|
||||
case .none: break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension AuthMiddlewareTests {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Provides a random authentication transport representation.
|
||||
var randomTransport: AuthTransport {
|
||||
get throws {
|
||||
try #require(AuthTransport.allCases.randomElement())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension Input {
|
||||
/// A list of authentication methods to use in most of the test cases.
|
||||
static let authMethods: [AuthMethod] = [
|
||||
.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 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) 2026 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) 2026 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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
// Licensed under Apache license v2.0
|
||||
//
|
||||
// See LICENSE for license information
|
||||
// See CONTRIBUTORS for the list of DiscogsService project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// ===----------------------------------------------------------------------===
|
||||
|
||||
import Testing
|
||||
|
||||
extension Tag {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// A tag that indicates tests for a 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,16 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
// Licensed under Apache license v2.0
|
||||
//
|
||||
// See LICENSE for license information
|
||||
// See CONTRIBUTORS for the list of DiscogsService project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// ===----------------------------------------------------------------------===
|
||||
|
||||
/// A namespace assigned for input arguments on test cases.
|
||||
enum Input {}
|
||||
@@ -0,0 +1,16 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 Röck+Cöde VoF. and the DiscogsService project authors
|
||||
// Licensed under Apache license v2.0
|
||||
//
|
||||
// See LICENSE for license information
|
||||
// See CONTRIBUTORS for the list of DiscogsService project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// ===----------------------------------------------------------------------===
|
||||
|
||||
/// A namespace assigned for output arguments on test cases, that are expected results.
|
||||
enum Output {}
|
||||
@@ -0,0 +1,21 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the DiscogsService open source project
|
||||
//
|
||||
// Copyright (c) 2026 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) 2026 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")!
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import Testing
|
||||
@testable import discogs_service
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
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
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
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
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
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
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
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
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
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
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
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
+1
-1
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
Reference in New Issue
Block a user