Implemented the DocC archives support for the middleware (#2)

This PR contains the work done to implement the support for `DocC` archives (or `.doccarchive` containers) into the middleware.

Reviewed-on: rock-n-code/hummingbird-docc-middleware#2
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #2.
This commit is contained in:
2025-09-26 23:54:07 +00:00
committed by Javier Cicchelli
parent 854fd8e048
commit 3a9e3d176f
36 changed files with 3093 additions and 9 deletions
@@ -0,0 +1,38 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
/// An enumeration that represents the essential static files that could be generated by the `DocC` building process.
enum AssetFile: String, CaseIterable {
/// A file defining all the documentation available, which will be used to redirect to the root of the documentation's root article.
case documentation = "documentation.json"
/// A file containing the icon in `.ico` format within the documentation generated by the `DocC` building process.
case faviconICO = "favicon.ico"
/// A file containing the icon in `.svg` format within the documentation generated by the `DocC` building process.
case faviconSVG = "favicon.svg"
/// A file containing the theme settings within the documentation generated by the `DocC` building process.
case themeSettings = "theme-settings.json"
}
// MARK: - Pathable
extension AssetFile: Pathable {
// MARK: Computed
var path: String {
switch self {
case .documentation: .init(format: .Format.Path.data, rawValue)
default: .init(format: .Format.Path.root, rawValue)
}
}
}
@@ -0,0 +1,43 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
/// An enumeration that represents all possible asset folders that could be generated by the `DocC` building process.
enum AssetFolder: String, CaseIterable {
/// A folder that contains all CSS style sheets.
case css
/// A folder that contains all documentation data.
case data
/// A folder that contains all other resources.
case downloads
/// A folder that contains all image resources.
case images
/// A folder that contains all image resources.
case img
/// A folder that contains all generated `HTML` code.
case index
/// A folder that contains all generated `Javascript` code.
case js
/// A folder that contains all video resources.
case videos
}
// MARK: - Pathable
extension AssetFolder: Pathable {
// MARK: Computed
var path: String {
.init(format: .Format.Path.folder, rawValue)
}
}
@@ -0,0 +1,31 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
/// An enumeration that represents the documentation folders that could be generated by the `DocC` building process.
enum DocumentationFolder: String, CaseIterable {
/// An article document, which can also be used for (source code generated) technical documentation as well.
case article = "documentation"
/// A tutorial document.
case tutorial = "tutorials"
}
// MARK: - Pathable
extension DocumentationFolder: Pathable {
// MARK: Computed
var path: String {
.init(format: .Format.Path.root, rawValue)
}
}
@@ -0,0 +1,50 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import protocol Hummingbird.RequestContext
import struct Hummingbird.HTTPResponse
import struct Hummingbird.Request
import struct Logging.Logger
extension Logger.Metadata {
// MARK: Functions
/// Generates a dictionary to use as metadata for events to log into the logging system.
/// - Parameters:
/// - context: A type that contains all the parameters associated with a given request, and that conforms to the `RequestContext` protocol.
/// - request: A type that contains all the parameters to process as a request.
/// - statusCode: A representation of a response status to provide as a response.
/// - redirect: A URI path to use in a redirection event, if any.
/// - Returns: A generated metadata dictionary for an event to log into the logging system.
static func metadata(
context: any RequestContext,
request: Request,
statusCode: HTTPResponse.Status,
redirect: String? = nil
) -> Self {
var metadata: Logger.Metadata = [
"hb.request.id": "\(context.id)",
"hb.request.method": "\(request.method.rawValue)",
"hb.request.path": "\(request.uri.path)",
"hb.request.status": "\(statusCode.code)"
]
if let redirect {
metadata["hb.request.redirect"] = "\(redirect)"
}
return metadata
}
}
@@ -0,0 +1,30 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
extension String {
/// An empty string.
static let empty = ""
/// A namespace that defines logging values.
enum Logging {
/// A name of the middleware that triggered a logging event.
static let source = "DocCMiddleware"
}
/// A namespace that defines relative path values.
enum Path {
/// A forwarding slash.
static let forwardSlash = "/"
/// An indication of a previous folder in a path component.
static let previousFolder = ".."
}
}
@@ -0,0 +1,38 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
extension String {
/// A namespace that defines the format patterns used to generate strings.
enum Format {
/// A namespace that defines the format patterns used to generate relative path representations.
enum Path {
/// A format pattern used to generate relative paths that starts with the `/` string and finishes with the `.doccarchive` string.
static let archive = "/%@.doccarchive"
/// A format pattern used to generate relative paths that starts with the `/data` string.
static let data = "/data/%@"
/// A format pattern used to generate relative paths that starts with the `/docs` string.
static let docs = "/docs/%@"
/// A format pattern used to generate relative paths that finishes with the `/documentation` string.
static let documentation = "%@documentation"
/// A format pattern used to generate relative paths for JSON documentation files.
static let documentationJSON = "/data/documentation/%@.json"
/// A format pattern used to generate relative paths that starts and finishes with the `/` string.
static let folder = "/%@/"
///A format pattern used to generate relative paths that finishes with the `/` string.
static let forwardSlash = "%@/"
/// A format pattern used to generate relative paths for index files.
static let index = "%@/%@/index.html"
/// A format pattern used to generate relative paths that starts with the `/` string.
static let root = "/%@"
}
}
}
@@ -0,0 +1,21 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
/// A protocol that provides a relative path representation.
protocol Pathable {
// MARK: Properties
/// A (relative) path to a resource.
var path: String { get }
}
@@ -0,0 +1,18 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import protocol Hummingbird.RequestContext
import struct Hummingbird.Request
/// A pseudo-type that contains data about a request and its related context.
typealias ContextualInfo = (request: Request, context: any RequestContext)
@@ -0,0 +1,35 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import struct HummingbirdCore.URI
/// A use case that checks whether a given URI against a set of conditions, to determine whether the URI could be used by the middleware or not.
struct CheckURIUseCase {
// MARK: Functions
/// Checks whether a provided URI against a set of conditions, so it could be used by the middleware.
/// - Parameter uri: A URI to check.
/// - Returns: A non-encoded URI, which is ready to be used by the middleware.
func callAsFunction(_ uri: URI) -> String? {
guard
let uriPath = uri.path.removingPercentEncoding,
!uriPath.contains(.Path.previousFolder),
uriPath.hasPrefix(.Path.forwardSlash)
else {
return nil
}
return uriPath
}
}
@@ -0,0 +1,127 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Foundation
import RegexBuilder
/// A use case that extracts data from a given URI path, essential for routing the documentation contents.
struct PrepareURIPathUseCase {
// MARK: Type aliases
/// A pseudo-type that contains the archive name, reference and URI path, plus the resource URI and relative paths used for routing the documentation contents.
typealias PreparedURIPaths = (archiveName: String, archiveReference: String, archivePath: String, resourcePath: String)
// MARK: Properties
/// A root path that suffixes the documentation resource.
private let uriRoot: String
// MARK: Initializers
/// Initializes this use case.
///
/// > important: It is assumed that the `uriRoot` parameter is not empty and that it is prefixed by the `/` character.
///
/// - Parameter uriRoot: A root path that prefixes the documentation resource.
init(uriRoot: String) {
self.uriRoot = uriRoot
}
// MARK: Functions
/// Extracts some necessary data essential for documentation contents routing from a given URI path.
///
/// The necessary data to extract from a given URI path is:
/// 1. the `DocC` documentation archive name;
/// 2. the `DocC` documentation archive reference;
/// 3. the `DocC` documentation archive URI path;
/// 4. the `DocC` documentation resource URI path.
///
/// > important: It is assumed that the `uriPath` parameter is a URI path that does not contain any percent encoded strings.
///
/// - Parameter uriPath: A URI path to extract the data from.
/// - Returns: A pseudo-type that contains the archive' name, reference and URI path, plus the resource URI paths.
func callAsFunction(_ uriPath: String) -> PreparedURIPaths? {
guard let uriRest = restOfURIPath(from: uriPath) else {
return nil
}
let archiveName = uriRest
.split(separator: .Path.forwardSlash)
.map(String.init)
.first
let archiveReference: String = if let archiveName {
archiveName.lowercased()
} else {
.empty
}
let archivePath: String = if let archiveName {
.init(format: .Format.Path.archive, archiveName)
} else {
.empty
}
return (
archiveName ?? .empty,
archiveReference,
archivePath,
uriRest
)
}
}
// MARK: - Helpers
private extension PrepareURIPathUseCase {
// MARK: Functions
/// Extracts the rest of the URI path from a given URI path against a defined URI root path.
///
/// A given URI path is matched against a regular expression, which is generated from a provided URI root path.
/// So this function would return either a string that represents a partial URI path, or a `nil` instance depending the result of the match between
/// the URI path and the regular expression:
/// * A `nil` instance in case there is no match;
/// * A `/` string in case there is a perfect match;
/// * A partial URI path prefixed with the `/` character in case there is an offset in the match.
///
/// - Parameter uriPath: A URI path to get the rest of the URI path from.
/// - Returns: A rest of the URI path prefixed by the `/`character in case where there is any offset path after extracting the root path from the given URI path or not. Otherwise, a `nil` value is returned.
func restOfURIPath(from uriPath: String) -> String? {
let restReference = Reference(String.self)
let uriPattern = Regex {
uriRoot
Optionally {
Capture(as: restReference) {
OneOrMore(.anyNonNewline)
} transform: { output in
String(output)
}
}
}
guard let matches = uriPath.prefixMatch(of: uriPattern) else {
return nil
}
guard let uriRest = matches.output.1 else {
return .Path.forwardSlash
}
guard uriRest.hasPrefix(.Path.forwardSlash) else {
return .init(format: .Format.Path.root, uriRest)
}
return uriRest
}
}
@@ -0,0 +1,63 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import struct Hummingbird.Response
import struct Logging.Logger
/// A use case that produces a redirect response based on a given URI path.
struct RedirectURIUseCase {
// MARK: Properties
/// A type that interacts with the logging system.
private let logger: Logger
// MARK: Initializers
/// Initializes this use case.
/// - Parameter logger: A type that interacts with the logging system.
init(logger: Logger) {
self.logger = logger
}
// MARK: Functions
/// Produces a redirect response based on a given URI path
/// - Parameters:
/// - uriPath: A URI path to use in the redirection.
/// - contextualInfo: A pseudo-type that contains data about a request and its related context.
/// - Returns: A redirection response created out of a given URI path plus contextual information.
func callAsFunction(
_ uriPath: String,
with contextualInfo: ContextualInfo
) -> Response {
defer {
logger.log(
level: .debug,
"The URI path is redirected to this path: \(uriPath)",
metadata: .metadata(
context: contextualInfo.context,
request: contextualInfo.request,
statusCode: .movedPermanently,
redirect: uriPath
),
source: .Logging.source
)
}
return .redirect(
to: uriPath,
type: .permanent
)
}
}
@@ -0,0 +1,100 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import protocol Hummingbird.FileProvider
import struct Hummingbird.Response
import struct Logging.Logger
/// A use case that serves a resource, defined by its URI path, from a physical location.
struct ServeURIUseCase<Provider: FileProvider> {
// MARK: Properties
/// A type that conforms to a protocol that defines file system interactions.
private let fileProvider: Provider
/// A type that interacts with the logging system.
private let logger: Logger
// MARK: Initializers
/// Initializes this use case.
/// - Parameters:
/// - fileProvider: A type that conforms to a protocol that defines file system interactions.
/// - logger: A type that interacts with the logging system.
init(
fileProvider: Provider,
logger: Logger
) {
self.fileProvider = fileProvider
self.logger = logger
}
// MARK: Functions
/// Serves a certain resource based on a given URI path from a physical location.
/// - Parameters:
/// - uriPath: A URI path that represents a resource to be served.
/// - folderPath: A URI path to a physical folder that contains the resource.
/// - contextualInfo: A pseudo-type that contains data about a request and its related context.
/// - Returns: A response that either contains the data of the resource in its body in case the resource is found, or a not found otherwise.
/// - Throws: An error in case an issue is encountered while serving the resource.
func callAsFunction(
_ uriPath: String,
at folderPath: String,
with contextualInfo: ContextualInfo
) async throws -> Response {
let filePath = folderPath + uriPath
guard let fileIdentifier = fileProvider.getFileIdentifier(filePath) else {
defer {
logger.log(
level: .error,
"The resource \(filePath) has not been found.",
metadata: .metadata(
context: contextualInfo.context,
request: contextualInfo.request,
statusCode: .notFound
),
source: .Logging.source
)
}
return .init(status: .notFound)
}
let body = try await fileProvider.loadFile(
id: fileIdentifier,
context: contextualInfo.context
)
defer {
logger.log(
level: .debug,
"The body of the resource \(filePath) has \(body.contentLength ?? 0) bytes.",
metadata: .metadata(
context: contextualInfo.context,
request: contextualInfo.request,
statusCode: .ok
),
source: .Logging.source
)
}
return .init(
status: .ok,
body: body
)
}
}
@@ -0,0 +1,52 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import class NIOPosix.NIOThreadPool
extension DocCMiddleware {
/// A type that contains all the parameters to configure the ``DocCMiddleware`` middleware.
public struct Configuration: Sendable {
// MARK: Properties
/// A path to the physical location where the `DocC` documentation containers are stored.
let folderRoot: String
/// A URI path that prefixes the `DocC` documentation resources.
let uriRoot: String
/// A type that define a mechanism to use in case some blocking work needs to be performed for which no non-blocking API exists.
let threadPool: NIOThreadPool
// MARK: Initializers
/// Initializes this configuration type.
///
/// > important: It is assumed that both the `uriRoot` and the `folderRoot` parameters should not be empty, and that they should be prefixed
/// with the `/` forward slash character.
///
/// - Parameters:
/// - uriRoot: A URI path that prefixes the `DocC` documentation resources.
/// - folderRoot: A path to the physical location where the `DocC` documentation containers are stored.
/// - threadPool: A type that define a mechanism to use in case some blocking work needs to be performed for which no non-blocking API exists.
public init(
uriRoot: String,
folderRoot: String,
threadPool: NIOThreadPool = .singleton
) {
self.folderRoot = folderRoot
self.uriRoot = uriRoot
self.threadPool = threadPool
}
}
}
@@ -0,0 +1,213 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import protocol Hummingbird.FileProvider
import protocol Hummingbird.RequestContext
import protocol Hummingbird.RouterMiddleware
import struct Hummingbird.LocalFileSystem
import struct Hummingbird.Request
import struct Hummingbird.Response
import struct Logging.Logger
/// A middleware that proxies requests to `DocC` documentation containers within a hosting app.
///
/// This middleware routes the contents of a `DocC` documentation container, defined by its resource URI paths, following these rules:
///
/// 1. *Redirects the URI path `/<ArchiveName>` to the path `/<ArchiveName>/`*;
/// 2. *Redirects the URI path `/<ArchiveName>/` to the path `/<ArchiveName>/documentation`*
/// 3. *Redirects the URI path `/<ArchiveName>/documentation` to the path `/<ArchiveName>/documentation/`*
/// 4. *Redirects the URI path `/<ArchiveName>/tutorials` to the path `/<ArchiveName>/tutorials/`*
/// 5. *Redirects the URI path `/<ArchiveName>/documentation/` to the resource on `/<ArchiveName>.doccarchive/documentation/<ArchiveReference>/index.html`*
/// 6. *Redirects the URI path `/<ArchiveName>/tutorials/` to the resource on `/<ArchiveName>.doccarchive/tutorials/<ArchiveReference>/index.html`*
/// 7. *Redirects the URI path `/<ArchiveName>/data/documentation.json` to the resource on `/<ArchiveName>.doccarchive/data/documentation/<ArchiveReference>.json`*
/// 8. *Redirects the URI path `/<ArchiveName>/favicon.ico` to the resource on `/<ArchiveName>.doccarchive/favicon.ico`*
/// 9. *Redirects the URI path `/<ArchiveName>/favicon.svg` to the resource on `/<ArchiveName>.doccarchive/favicon.svg`*
/// 10. *Redirects the URI path `/<ArchiveName>/theme-settings.json` to the resource on `/<ArchiveName>.doccarchive/theme-settings.json`*
/// 11. *Redirects the URI path `/<ArchiveName>/css/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/css/<path/to/file>`*
/// 12. *Redirects the URI path `/<ArchiveName>/data/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/data/<path/to/file>`*
/// 13. *Redirects the URI path `/<ArchiveName>/downloads/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/downloads/<path/to/file>`*
/// 14. *Redirects the URI path `/<ArchiveName>/images/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/images/<path/to/file>`*
/// 15. *Redirects the URI path `/<ArchiveName>/img/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/img/<path/to/file>`*
/// 16. *Redirects the URI path `/<ArchiveName>/index/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/index/<path/to/file>`*
/// 17. *Redirects the URI path `/<ArchiveName>/js/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/js/<path/to/file>`*
/// 18. *Redirects the URI path `/<ArchiveName>/videos/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/videos/<path/to/file>`*
///
public struct DocCMiddleware<FileSystemProvider: FileProvider> {
// MARK: Properties
/// A type that contains the parameters to configure the middleware.
let configuration: Configuration
/// A type that conforms to a protocol that defines file system interactions.
let fileProvider: FileSystemProvider
/// A type that interacts with the logging system.
let logger: Logger
/// A use case that checks whether a received URI could be processed or not.
private let checkURI: CheckURIUseCase = .init()
/// A use case that extracts data from a given URI path, essential for routing the documentation contents.
private let prepareURIPath: PrepareURIPathUseCase
/// A use case that produces a redirect response based on a given URI path.
private let redirectURI: RedirectURIUseCase
/// A use case that serves a resource, defined by its URI path, from a physical location.
private let serveURI: ServeURIUseCase<FileSystemProvider>
// MARK: Initializers
/// Initializes this middleware.
/// - Parameters:
/// - configuration: A type that contains the parameters to configure the middleware.
/// - logger: A type that interacts with the logging system.
public init(
configuration: Configuration,
logger: Logger
) where FileSystemProvider == LocalFileSystem {
self.init(
configuration: configuration,
fileProvider: LocalFileSystem(
rootFolder: configuration.folderRoot,
threadPool: configuration.threadPool,
logger: logger
),
logger: logger,
)
}
/// Initializes this middleware with a concrete file provider type.
/// - Parameters:
/// - configuration: A type that contains the parameters to configure the middleware.
/// - fileProvider: A type that conforms to the protocol that defines file system interactions.
/// - logger: A type that interacts with the logging system.
init(
configuration: Configuration,
fileProvider: FileSystemProvider,
logger: Logger,
) {
self.logger = logger
self.configuration = configuration
self.fileProvider = fileProvider
self.prepareURIPath = .init(uriRoot: configuration.uriRoot)
self.redirectURI = .init(logger: logger)
self.serveURI = .init(
fileProvider: fileProvider,
logger: logger
)
}
}
// MARK: - RouterMiddleware
extension DocCMiddleware: RouterMiddleware {
// MARK: Type aliases
public typealias Context = RequestContext
public typealias Input = Request
public typealias Output = Response
// MARK: Functions
public func handle(
_ request: Input,
context: any Context,
next: (Input, any Context) async throws -> Output
) async throws -> Output {
guard
let uriPath = checkURI(request.uri),
let uriData = prepareURIPath(uriPath)
else {
return try await next(request, context)
}
let rootPaths: [String] = [
String(format: .Format.Path.root, uriData.archiveName),
String(format: .Format.Path.folder, uriData.archiveName)
]
if rootPaths.contains(uriData.resourcePath) {
return redirectURI(
uriPath.hasSuffix(.Path.forwardSlash)
// Rule #2: Redirects the URI path /<ArchiveName>/ to the path /<ArchiveName>/documentation
? String(format: .Format.Path.documentation, uriPath)
// Rule #1: Redirects the URI path /<ArchiveName> to the path /<ArchiveName>/
: String(format: .Format.Path.forwardSlash, uriPath),
with: (request, context)
)
}
for assetFile in AssetFile.allCases {
if uriData.resourcePath.contains(assetFile.path) {
return try await serveURI(
assetFile == .documentation
// Rule #7: Redirects the URI path /<ArchiveName>/data/documentation.json to the resource on /<ArchiveName>.doccarchive/data/documentation/<ArchiveReference>.json
? String(format: .Format.Path.documentationJSON, uriData.archiveReference)
// Rule #8: Redirects the URI path `/<ArchiveName>/favicon.ico` to the resource on `/<ArchiveName>.doccarchive/favicon.ico`
// Rule #9: Redirects the URI path `/<ArchiveName>/favicon.svg` to the resource on `/<ArchiveName>.doccarchive/favicon.svg`
// Rule #10: Redirects the URI path `/<ArchiveName>/theme-settings.json` to the resource on `/<ArchiveName>.doccarchive/theme-settings.json`
: uriData.resourcePath,
at: uriData.archivePath,
with: (request, context)
)
}
}
for assetFolder in AssetFolder.allCases {
if uriData.resourcePath.contains(assetFolder.path) {
// Rule #11: Redirects the URI path `/<ArchiveName>/css/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/css/<path/to/file>`
// Rule #12: Redirects the URI path `/<ArchiveName>/data/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/data/<path/to/file>`
// Rule #13: Redirects the URI path `/<ArchiveName>/downloads/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/downloads/<path/to/file>`
// Rule #14: Redirects the URI path `/<ArchiveName>/images/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/images/<path/to/file>`
// Rule #15: Redirects the URI path `/<ArchiveName>/img/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/img/<path/to/file>`
// Rule #16: Redirects the URI path `/<ArchiveName>/index/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/index/<path/to/file>`
// Rule #17: Redirects the URI path `/<ArchiveName>/js/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/js/<path/to/file>`
// Rule #18: Redirects the URI path `/<ArchiveName>/videos/<path/to/file>` to the resource on `/<ArchiveName>.doccarchive/videos/<path/to/file>`
return try await serveURI(
uriData.resourcePath,
at: uriData.archivePath,
with: (request, context)
)
}
}
for documentationFolder in DocumentationFolder.allCases {
if uriData.resourcePath.contains(documentationFolder.path) {
if uriData.resourcePath.hasSuffix(.Path.forwardSlash) {
// Rule #5: Redirects the URI path /<ArchiveName>/documentation/ to the resource on /<ArchiveName>.doccarchive/documentation/<ArchiveReference>/index.html
// Rule #6: Redirects the URI path /<ArchiveName>/tutorials/ to the resource on /<ArchiveName>.doccarchive/tutorials/<ArchiveReference>/index.html
return try await serveURI(
String(format: .Format.Path.index, documentationFolder.path, uriData.archiveReference),
at: uriData.archivePath,
with: (request, context)
)
} else {
// Rule #3: Redirects the URI path /<ArchiveName>/documentation to the path /<ArchiveName>/documentation/
// Rule #4: Redirects the URI path /<ArchiveName>/tutorials to the path /<ArchiveName>/tutorials/
return redirectURI(
String(format: .Format.Path.forwardSlash, uriPath),
with: (request, context)
)
}
}
}
return try await next(request, context)
}
}
@@ -1,2 +0,0 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
@@ -0,0 +1,77 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
@testable import enum DocCMiddleware.AssetFile
@Suite("Asset File", .tags(.enumeration))
struct AssetFileTests {
// MARK: Properties tests
#if swift(>=6.2)
@Test(arguments: zip(
AssetFile.allCases,
Output.assetFilePaths
))
func `path`(
`case`: AssetFile,
expects result: String
) {
assertPath(`case`, expects: result)
}
#else
@Test("path", arguments: zip(
AssetFile.allCases,
Output.assetFilePaths
))
func path(
`case`: AssetFile,
expects result: String
) {
assertPath(`case`, expects: result)
}
#endif
}
// MARK: - Assertions
private extension AssetFileTests {
// MARK: Functions
/// Asserts the path property based on a given ``AssetFile`` enumeration case and an expected result.
/// - Parameters:
/// - case: A representation of the ``AssetFile`` enumeration
/// - result: An expected result coming out of the property.
func assertPath(
_ case: AssetFile,
expects result: String
) {
// GIVEN
// WHEN
let output = `case`.path
// THEN
#expect(output == result)
}
}
// MARK: - Constants
private extension Output {
/// A list of expected outputs for the paths of the ``AssetFile`` enumeration cases.
static let assetFilePaths: [String] = ["/data/documentation.json", "/favicon.ico", "/favicon.svg", "/theme-settings.json"]
}
@@ -0,0 +1,77 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
@testable import enum DocCMiddleware.AssetFolder
@Suite("Asset Folder", .tags(.enumeration))
struct AssetFolderTests {
// MARK: Properties tests
#if swift(>=6.2)
@Test(arguments: zip(
AssetFolder.allCases,
Output.assetFolderPaths
))
func `path`(
`case`: AssetFolder,
expects result: String
) {
assertPath(`case`, expects: result)
}
#else
@Test("path", arguments: zip(
AssetFolder.allCases,
Output.assetFolderPaths
))
func path(
`case`: AssetFolder,
expects result: String
) {
assertPath(`case`, expects: result)
}
#endif
}
// MARK: - Assertions
private extension AssetFolderTests {
// MARK: Functions
/// Asserts the path property based on a given ``AssetFolder`` enumeration case and an expected result.
/// - Parameters:
/// - case: A representation of the ``AssetFolder`` enumeration
/// - result: An expected result coming out of the property.
func assertPath(
_ case: AssetFolder,
expects result: String
) {
// GIVEN
// WHEN
let output = `case`.path
// THEN
#expect(output == result)
}
}
// MARK: - Constants
private extension Output {
/// A list of expected outputs for the paths of the ``AssetFolder`` enumeration cases.
static let assetFolderPaths: [String] = ["/css/", "/data/", "/downloads/", "/images/", "/img/", "/index/", "/js/", "/videos/"]
}
@@ -0,0 +1,77 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
@testable import enum DocCMiddleware.DocumentationFolder
@Suite("Documentation Type", .tags(.enumeration))
struct DocumentationTypeTests {
// MARK: Properties tests
#if swift(>=6.2)
@Test(arguments: zip(
DocumentationFolder.allCases,
Output.documentationFolderPaths
))
func `path`(
`case`: DocumentationFolder,
expects result: String
) {
assertPath(`case`, expects: result)
}
#else
@Test("path", arguments: zip(
DocumentationType.allCases,
Output.documentationTypePaths
))
func path(
`case`: DocumentationType,
expects result: String
) {
assertPath(`case`, expects: result)
}
#endif
}
// MARK: - Assertions
private extension DocumentationTypeTests {
// MARK: Functions
/// Asserts the path property based on a given ``DocumentationFolder`` enumeration case and an expected result.
/// - Parameters:
/// - case: A representation of the ``DocumentationFolder`` enumeration
/// - result: An expected result coming out of the property.
func assertPath(
_ case: DocumentationFolder,
expects result: String
) {
// GIVEN
// WHEN
let output = `case`.path
// THEN
#expect(output == result)
}
}
// MARK: - Constants
private extension Output {
/// A list of expected outputs for the paths of the ``DocumentationFolder`` enumeration cases.
static let documentationFolderPaths: [String] = ["/documentation", "/tutorials"]
}
@@ -0,0 +1,128 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
import struct Hummingbird.HTTPRequest
import struct Hummingbird.HTTPResponse
import struct Hummingbird.Request
import struct Logging.Logger
@testable import DocCMiddleware
@Suite("Logger Metadata Helpers", .tags(.extension))
struct LoggerMetadata_HelpersTests {
// MARK: Functions tests
#if swift(>=6.2)
@Test
func `metadata with HTTP method and status code`() throws {
assertMetadata(
method: try randomMethod,
statusCode: try randomStatusCode
)
}
@Test
func `metadata with HTTP method, status code and redirection URI path`() throws {
assertMetadata(
method: try randomMethod,
statusCode: try randomStatusCode,
redirect: .Sample.uriRedirection
)
}
#else
@Test("metadata with HTTP method and status code")
func metadata_withMethod_andStatusCode() throws {
assertMetadata(
method: try randomMethod,
statusCode: try randomStatusCode
)
}
@Test("metadata with HTTP method, status code and redirection URI path")
func metadata_withMethod_statusCode_andRedirection() throws {
assertMetadata(
method: try randomMethod,
statusCode: try randomStatusCode,
redirect: .uriRedirection
)
}
#endif
}
// MARK: - Assertions
private extension LoggerMetadata_HelpersTests {
// MARK: Functions
/// Asserts the generated metadata dictionary based on provided parameters.
/// - Parameters:
/// - method: A HTTP method of the request.
/// - statusCode: A status code of the response.
/// - redirect: A redirection URI path, if any.
func assertMetadata(
method: HTTPRequest.Method,
statusCode: HTTPResponse.Status,
redirect: String? = nil
) {
// GIVEN
let logger: Logger = .test()
let context: RequestContextMock = .init(logger: logger)
let request: Request = .test(method: method)
// WHEN
let metadata: Logger.Metadata = .metadata(
context: context,
request: request,
statusCode: statusCode,
redirect: redirect
)
// THEN
#expect(metadata.keys.count == (redirect == nil ? 4 : 5))
#expect(metadata["hb.request.id"] == logger[metadataKey: "hb.request.id"])
#expect(metadata["hb.request.method"] == "\(method.rawValue)")
#expect(metadata["hb.request.path"] == "/")
#expect(metadata["hb.request.status"] == "\(statusCode.code)")
if let redirect {
#expect(metadata["hb.request.redirect"] == "\(redirect)")
}
}
}
// MARK: - Helpers
private extension LoggerMetadata_HelpersTests {
// MARK: Computed
/// Extracts a random HTTP method of the request from a list of pre-defined values.
var randomMethod: HTTPRequest.Method {
get throws {
try #require([.connect, .delete, .get, .head, .options, .patch, .post, .put, .trace].randomElement())
}
}
/// Extracts a random status code of the response from a list of pre-defined values.
var randomStatusCode: HTTPResponse.Status {
get throws {
try #require([.`continue`, .earlyHints, .ok, .accepted, .multipleChoices, .seeOther, .badRequest, .notFound, .internalServerError, .serviceUnavailable].randomElement())
}
}
}
@@ -0,0 +1,116 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
import struct HummingbirdCore.URI
@testable import struct DocCMiddleware.CheckURIUseCase
@Suite("Check URI use case", .tags(.useCase))
struct CheckURIUseCaseTests {
// MARK: Properties
private let useCase: CheckURIUseCase = .init()
// MARK: Use case tests
#if swift(>=6.2)
@Test(arguments: zip(
Input.nonEncodedURIs,
Output.nonEncodedURIs
))
func `check non encoded URIs`(
uri uriPath: String,
expects result: String?
) {
assertURI(uriPath, expects: result)
}
@Test(arguments: zip(
Input.percentEncodedURIs,
Output.percentEncodedURIs
))
func `check percent-encoded URIs`(
uri uriPath: String,
expects result: String?
) {
assertURI(uriPath, expects: result)
}
#else
@Test("check non-encoded URIs", arguments: zip(
Input.nonEncodedURIs,
Output.nonEncodedURIs
))
func check_nonEncodedURIs(
uri uriPath: String,
expects result: String?
) {
assertURI(uriPath, expects: result)
}
@Test("check percent-encoded URIs", arguments: zip(
Input.percentEncodedURIs,
Output.percentEncodedURIs
))
func check_percentEncodedURIs(
uri uriPath: String,
expects result: String?
) {
assertURI(uriPath, expects: result)
}
#endif
}
// MARK: - Assertions
private extension CheckURIUseCaseTests {
// MARK: Functions
/// Asserts a URI path provided by the ``CheckURIPathUseCase`` use case based on a given path and an expected result.
/// - Parameters:
/// - uriPath: A URI path to use with a URI type.
/// - result: An expected result coming out of the use case.
func assertURI(
_ uriPath: String,
expects result: String?
) {
// GIVEN
let uri = URI(uriPath)
// WHEN
let output = useCase(uri)
// THEN
#expect(output == result)
}
}
// MARK: - Constants
private extension Input {
/// A list of non-encoded URI samples.
static let nonEncodedURIs: [String] = ["/", "/some/known/path", "", "/some/../path", "some/other/path"]
/// A list of percent-encoded URI samples.
static let percentEncodedURIs: [String] = ["%2F", "/some%2Fknown%3Fpath", "%20", "/some/%2E%2E/path", "some%2Fother%3Fpath"]
}
private extension Output {
/// A list of expected outputs for the non-encoded URI samples.
static let nonEncodedURIs: [String?] = ["/", "/some/known/path", "/", nil, nil]
/// A list of expected outputs for the percent-encoded URI samples.
static let percentEncodedURIs: [String?] = ["/", "/some/known?path", nil, nil, nil]
}
@@ -0,0 +1,154 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
@testable import struct DocCMiddleware.PrepareURIPathUseCase
@Suite("Prepare URI Path Use Case", .tags(.useCase))
struct PrepareURIPathUseCaseTests {
// MARK: Use case tests
#if swift(>=6.2)
@Test(arguments: zip(
Input.prepareURIPaths,
Output.prepareURIPaths
))
func `extract data with URI root not suffixed with forward slash`(
uri uriPath: String,
expects result: PrepareURIPathUseCase.PreparedURIPaths?
) throws {
try assertData(
uriRoot: .uriRoot,
uriPath: uriPath,
expects: result
)
}
@Test(arguments: zip(
Input.prepareURIPathsSlashed,
Output.prepareURIPaths
))
func `extract data with URI root suffixed with forward slash`(
uri uriPath: String,
expects result: PrepareURIPathUseCase.PreparedURIPaths?
) throws {
try assertData(
uriRoot: .uriRootSlashed,
uriPath: uriPath,
expects: result
)
}
#else
@Test("extract data with URI root not suffixed with forward slash", arguments: zip(
Input.prepareURIPaths,
Output.prepareURIPaths
))
func data_withURIRoot_notSuffixed_withForwardSlash(
uri uriPath: String,
expects result: PrepareURIPathUseCase.PreparedURIPaths?
) throws {
try assertData(
uriRoot: .uriRoot,
uriPath: uriPath,
expects: result
)
}
@Test("extract data with URI root suffixed with forward slash", arguments: zip(
Input.prepareURIPathsSlashed,
Output.prepareURIPaths
))
func data_withURIRoot_suffixed_withForwardSlash(
uri uriPath: String,
expects result: PrepareURIPathUseCase.PreparedURIPaths?
) throws {
try assertData(
uriRoot: .uriRootSlashed,
uriPath: uriPath,
expects: result
)
}
#endif
}
// MARK: - Assertions
private extension PrepareURIPathUseCaseTests {
// MARK: Functions
/// Asserts the data returned by the ``PrepareURIPathUseCase`` use case based on the given `uriRoot` and `uriPath` URI paths plus
/// an expected result.
/// - Parameters:
/// - uriRoot: A URI path to initialize the use case with.
/// - uriPath: A URI path to use with the use case.
/// - result: An expected result coming out of the use case.
func assertData(
uriRoot: String,
uriPath: String,
expects result: PrepareURIPathUseCase.PreparedURIPaths?
) throws {
// GIVEN
let useCase = PrepareURIPathUseCase(uriRoot: uriRoot)
// WHEN
let output = useCase(uriPath)
// THEN
if !uriPath.contains(uriRoot) {
#expect(output == nil)
} else {
#expect(output != nil)
let data = try #require(output)
#expect(data.archiveName == result?.archiveName)
#expect(data.archivePath == result?.archivePath)
#expect(data.resourcePath == result?.resourcePath)
}
}
}
// MARK: - Constants
private extension Input {
/// A list of URI paths to match against the root URI path not suffixed with a forward slash.
static let prepareURIPaths: [String] = [.uriOffset, .uriRoot, .uriOther]
/// A list of URI paths to match against the root URI path suffixed with a forward slash.
static let prepareURIPathsSlashed: [String] = [.uriOffsetSlashed, .uriRootSlashed, .uriOther]
}
private extension Output {
/// A list of expected outputs for the URI path samples, regardless their match against suffixed or not suffixed root URI paths.
static let prepareURIPaths: [PrepareURIPathUseCase.PreparedURIPaths?] = [
("SomeArchive", "somearchive", "/SomeArchive.doccarchive", "/SomeArchive/some/content/path"),
(.empty, .empty, .empty, .Path.forwardSlash),
nil
]
}
private extension String {
/// A root URI path to initialize the use case with.
static let uriRoot: Self = "/some/path"
/// A root URI path suffixed with a forward slash to initialize the use case with.
static let uriRootSlashed: Self = "/some/path/"
/// A URI path prefixed with a root URI path not suffixed with a forward slash.
static let uriOffset: Self = .uriRoot + "/SomeArchive/some/content/path"
/// A URI path prefixed with a root URI path suffixed with a forward slash.
static let uriOffsetSlashed: Self = .uriRootSlashed + "SomeArchive/some/content/path"
/// A URI path not related to any root URI path.
static let uriOther: Self = "/some/other/path"
}
@@ -0,0 +1,150 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
import protocol Hummingbird.RequestContext
import struct Hummingbird.HTTPResponse
import struct Hummingbird.Request
import struct Logging.Logger
@testable import struct DocCMiddleware.RedirectURIUseCase
@Suite("Redirect URI Use Case", .tags(.useCase))
struct RedirectURIUseCaseTests {
// MARK: Use case tests
#if swift(>=6.2)
@Test
func `response when logging event triggered`() async throws {
try await assertResponse(
logLevel: try .random(upTo: .debug),
uriRedirection: .Sample.uriRedirection,
expects: .movedPermanently
)
}
@Test
func `response when logging event not triggered`() async throws {
try await assertResponse(
logLevel: try .random(fromExclusive: .debug),
uriRedirection: .Sample.uriRedirection,
expects: .movedPermanently
)
}
#else
@Test("response when logging event triggered")
func response_whenEventTriggered() async throws {
try await assertResponse(
logLevel: try .random(upTo: .debug),
uriRedirection: .uriRedirection,
expects: .movedPermanently
)
}
@Test("response when logging event not triggered")
func response_whenEventNotTriggered() async throws {
try await assertResponse(
logLevel: try randomLogLevelWithNoEvent,
uriRedirection: .uriRedirection,
expects: .movedPermanently
)
}
#endif
}
// MARK: - Assertions
private extension RedirectURIUseCaseTests {
// MARK: Functions
/// Asserts a response returned by the ``RedirectURIUseCase`` use case.
/// - Parameters:
/// - logLevel: A representation of the logging level to set in the `Logger` instance.
/// - uriRedirection: A URI path to use in the redirection.
/// - statusCode: An expected status code from the response coming out of the use case.
/// - Throws: An error in case an issue is encountered while asserting the use case.
func assertResponse(
logLevel: Logger.Level,
uriRedirection: String,
expects statusCode: HTTPResponse.Status
) async throws {
let logHandler = LogHandlerMock()
let logger = Logger.test(
level: logLevel,
handler: logHandler
)
let context: any RequestContext = RequestContextMock(logger: logger)
let request: Request = .test(method: .get)
let useCase = RedirectURIUseCase(logger: logger)
// WHEN
let result = useCase(
uriRedirection,
with: (request, context)
)
// THEN
#expect(result.status == .movedPermanently)
#expect(result.body.contentLength == 0)
#expect(result.headers == [
.location: uriRedirection,
.contentLength: "0"
])
let events = await logHandler.entries
if shouldEventBeLogged(logLevel) {
#expect(!events.isEmpty)
#expect(events.count == 1)
let loggedEvent = try #require(events.first)
#expect(loggedEvent == .init(
level: .debug,
metadata: [
"hb.request.id": "\(context.id)",
"hb.request.method": "\(request.method.rawValue)",
"hb.request.path": "\(request.uri.path)",
"hb.request.status": "\(statusCode.code)",
"hb.request.redirect": "\(uriRedirection)"
],
message: "The URI path is redirected to this path: \(uriRedirection)",
source: .Logging.source
))
} else {
#expect(events.isEmpty)
}
}
}
// MARK: - Helpers
private extension RedirectURIUseCaseTests {
// MARK: Functions
/// Checks whether a logging event should be logged or not.
/// - Parameter logLevel: A representation of a logging level defined in the logger.
/// - Returns: A boolean value that indicates whether a logging event should have been logged or not.
func shouldEventBeLogged(_ logLevel: Logger.Level) -> Bool {
[Logger.Level.debug, .trace].contains(logLevel)
}
}
@@ -0,0 +1,266 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
import protocol Hummingbird.RequestContext
import struct Hummingbird.HTTPResponse
import struct Hummingbird.Request
import struct Logging.Logger
@testable import struct DocCMiddleware.ServeURIUseCase
@Suite("Serve URI Use Case", .tags(.useCase))
struct ServeURIUseCaseTests {
// MARK: Use case tests
#if swift(>=6.2)
@Test
func `response when resource served and logging event triggered`() async throws {
try await assertResponse(
logLevel: try .random(upTo: .debug),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder,
expects: .ok
)
}
@Test
func `response when resource served and logging event not triggered`() async throws {
try await assertResponse(
logLevel: try .random(fromExclusive: .debug),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder,
expects: .ok
)
}
@Test
func `response when resource not found and logging event triggered`() async throws {
try await assertResponse(
logLevel: try .random(upTo: .error),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder,
expects: .notFound
)
}
@Test
func `response when resource not found and logging event not triggered`() async throws {
try await assertResponse(
logLevel: try .random(fromExclusive: .error),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder,
expects: .notFound
)
}
@Test
func `response throws error when loading resource`() async throws {
try await assertResponse(
logLevel: try .random(),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder
)
}
#else
@Test("response when resource served and logging event triggered")
func response_whenResourceServed_andEventTriggered() async throws {
try await assertResponse(
logLevel: try .random(upTo: .debug),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder,
expects: .ok
)
}
@Test("response when resource served and logging event not triggered")
func response_whenResourceServed_andEventNotTriggered() async throws {
try await assertResponse(
logLevel: try .random(fromExclusive: .debug),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder,
expects: .ok
)
}
@Test("response when resource not found and logging event triggered")
func resource_whenResourceNotFound_andEventTriggered() async throws {
try await assertResponse(
logLevel: try .random(upTo: .error),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder,
expects: .notFound
)
}
@Test("response when resource not found and logging event not triggered")
func resource_whenResourceNotFound_andEventNotTriggered() async throws {
try await assertResponse(
logLevel: try .random(fromExclusive: .error),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder,
expects: .notFound
)
}
@Test("response throws error when loading resource")
func resource_throwsError_whenLoadingResource() async throws {
try await assertResponse(
logLevel: try .random(),
uriPath: .Sample.uriResource,
folderPath: .Sample.uriFolder
)
}
#endif
}
// MARK: - Assertions
private extension ServeURIUseCaseTests {
// MARK: Functions
/// Asserts a response returned by the ``ServeURIUseCase`` use case.
///
/// > important: In case no `statusCode` value is given, the function then assumes that the loading of a file will throw an error.
///
/// - Parameters:
/// - logLevel: A representation of the logging level to set in the `Logger` instance.
/// - uriPath: A URI path to a resource.
/// - folderPath: A URI path to a folder that contains the resource.
/// - statusCode: An expected status code from the response coming out of the use case, if any.
/// - Throws: An error in case an issue is encountered while asserting the use case.
func assertResponse(
logLevel: Logger.Level,
uriPath: String,
folderPath: String,
expects statusCode: HTTPResponse.Status? = nil
) async throws {
// GIVEN
let logHandler = LogHandlerMock()
let logger = Logger.test(
level: logLevel,
handler: logHandler
)
let fileProvider: FileProviderMock = switch statusCode {
case .ok: .init(fileIdentifier: .init())
case .notFound: .init()
default: .init(fileIdentifier: .init(), shouldLoadFile: false)
}
let context: any RequestContext = RequestContextMock(logger: logger)
let request: Request = .test(method: .get)
let useCase = ServeURIUseCase(
fileProvider: fileProvider,
logger: logger
)
// WHEN
// THEN
if let statusCode {
let result = try await useCase(
uriPath,
at: folderPath,
with: (request, context)
)
#expect(result.headers[.contentLength] == (statusCode == .ok ? "36" : "0"))
#expect(result.status == statusCode)
let contentLength = try #require(result.body.contentLength)
if statusCode == .ok {
#expect(contentLength > 0)
} else {
#expect(contentLength == 0)
}
let events = await logHandler.entries
if shouldEventBeLogged(
logLevel: logLevel,
statusCode: statusCode
) {
#expect(!events.isEmpty)
#expect(events.count == 1)
let loggedEvent = try #require(events.first)
let filePath: String = .Sample.uriFile
#expect(loggedEvent == .init(
level: statusCode == .ok ? .debug : .error,
metadata: [
"hb.request.id": "\(context.id)",
"hb.request.method": "\(request.method.rawValue)",
"hb.request.path": "\(request.uri.path)",
"hb.request.status": "\(statusCode.code)"
],
message: {
if statusCode == .ok {
"The body of the resource \(filePath) has \(contentLength) bytes."
} else {
"The resource \(filePath) has not been found."
}
}(),
source: .Logging.source
))
} else {
#expect(events.isEmpty)
}
} else {
do {
_ = try await useCase(
uriPath,
at: folderPath,
with: (request, context)
)
} catch is FileProviderMockError {
#expect(true)
} catch {
#expect(true == false)
}
}
}
}
// MARK: - Helpers
private extension ServeURIUseCaseTests {
// MARK: Functions
/// Checks whether a logging event should be logged or not, based on a given logging level.
/// - Parameters:
/// - logLevel: A representation of a logging level defined in in the logger.
/// - statusCode: A representation of a status code from the response.
/// - Returns: A boolean value that indicates whether a logging event should have been logged or not.
func shouldEventBeLogged(
logLevel: Logger.Level,
statusCode: HTTPResponse.Status
) -> Bool {
let levels: [Logger.Level] = switch statusCode {
case .ok: [.debug, .trace]
case .notFound: [.debug, .error, .info, .notice, .trace, .warning]
default: []
}
return levels.contains(logLevel)
}
}
@@ -0,0 +1,595 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
import protocol Hummingbird.FileProvider
import protocol Hummingbird.RequestContext
import struct Hummingbird.HTTPResponse
import struct Hummingbird.LocalFileSystem
import struct Hummingbird.Request
import struct Logging.Logger
@testable import struct DocCMiddleware.DocCMiddleware
@Suite("DocC Middleware", .tags(.middleware))
struct DocCMiddlewareTests {
// MARK: Initializers tests
#if swift(>=6.2)
@Test
func `initialize with URI and folder paths`() {
assertInit(configuration: .init(
uriRoot: .Sample.uriResource,
folderRoot: .Sample.uriFolder
))
}
@Test
func `initialize with URI path and type that conforms to the FileProvider protocol`() {
assertInit(
configuration: .init(
uriRoot: .Sample.uriResource,
folderRoot: .empty
),
fileProvider: FileProviderStub()
)
}
#else
@Test("initialize with URI and folder paths")
func init_withURI_andFolderPaths() {
assertInit(configuration: .init(
uriRoot: .Sample.uriResource,
folderRoot: .Sample.uriFolder
))
}
@Test("initialize with type that conforms to the FileProvider protocol")
func init_withURI_path_andFileProviderType() {
assertInit(
configuration: .init(
uriRoot: .Sample.uriResource,
folderRoot: .empty
),
fileProvider: FileProviderStub()
)
}
#endif
// MARK: RouterMiddleware tests
#if swift(>=6.2)
@Test(arguments: zip(
Input.redirectURIPaths,
Output.redirectURIPaths
))
func `redirect a URI path while triggering logging event`(
uriPath: String,
expects uriRedirect: String
) async throws {
try await assertRedirect(
logLevel: try .random(upTo: .debug),
uriPath: .Sample.uriDocument + uriPath,
to: .Sample.uriDocument + uriRedirect
)
}
@Test(arguments: zip(
Input.redirectURIPaths,
Output.redirectURIPaths
))
func `redirect a URI path without triggering logging event`(
uriPath: String,
expects uriRedirect: String
) async throws {
try await assertRedirect(
logLevel: try .random(fromExclusive: .debug),
uriPath: .Sample.uriDocument + uriPath,
to: .Sample.uriDocument + uriRedirect
)
}
@Test(arguments: Input.redirectURIPaths)
func `redirect a URI path not prefixed with root URI path`(uriPath: String) async throws {
try await assertRedirect(
logLevel: try .random(),
uriPath: uriPath,
expects: .ok
)
}
@Test(arguments: zip(
Input.serveURIPaths,
Output.serveURIFilePaths
))
func `serve an existing URI resource while triggering logging event`(
uriPath: String,
uriFile: String
) async throws {
try await assertServe(
logLevel: try .random(upTo: .debug),
uriPath: .Sample.uriDocument + uriPath,
uriFile: uriFile,
statusCode: .ok
)
}
@Test(arguments: Input.serveURIPaths)
func `serve an existing URI resource without triggering logging event`(
uriPath: String
) async throws {
try await assertServe(
logLevel: try .random(fromExclusive: .debug),
uriPath: .Sample.uriDocument + uriPath,
statusCode: .ok
)
}
@Test(arguments: zip(
Input.serveURIPaths,
Output.serveURIFilePaths
))
func `serve a non existing URI resource while triggering logging event`(
uriPath: String,
uriFile: String
) async throws {
try await assertServe(
logLevel: try .random(upTo: .error),
uriPath: .Sample.uriDocument + uriPath,
uriFile: uriFile,
statusCode: .notFound
)
}
@Test(arguments: Input.serveURIPaths)
func `serve a non existing URI resource without triggering logging event`(
uriPath: String
) async throws {
try await assertServe(
logLevel: try .random(fromExclusive: .error),
uriPath: .Sample.uriDocument + uriPath,
statusCode: .notFound
)
}
@Test(arguments: Input.serveURIPaths)
func `serve a URI resource not prefixed with root URI path`(
uriPath: String
) async throws {
try await assertServe(
logLevel: try .random(),
uriPath: uriPath
)
}
#else
@Test("redirect a URI path while triggering logging event", arguments: zip(
Input.redirectURIPaths,
Output.redirectURIPaths
))
func redirect_aURIPath_triggeringLoggingEvent(
uriPath: String,
expects uriRedirect: String
) async throws {
try await assertRedirect(
logLevel: try .random(upTo: .debug),
uriPath: .Sample.uriRoot + uriPath,
to: .Sample.uriRoot + uriRedirect
)
}
@Test("redirect a URI path without triggering logging event", arguments: zip(
Input.redirectURIPaths,
Output.redirectURIPaths
))
func redirect_aURIPath_notTriggeringLoggingEvent(
uriPath: String,
expects uriRedirect: String
) async throws {
try await assertRedirect(
logLevel: try .random(fromExclusive: .debug),
uriPath: .Sample.uriRoot + uriPath,
to: .Sample.uriRoot + uriRedirect
)
}
@Test("redirect a URI path not prefixed with root URI path", arguments: Input.redirectURIPaths)
func redirect_aURIPath_notPrefixedURIRoot(uriPath: String) async throws {
try await assertRedirect(
logLevel: try .random(),
uriPath: .Sample.uriResource + uriPath,
expects: .ok
)
}
@Test("serve an existing URI resource while triggering logging event", arguments: zip(
Input.serveURIPaths,
Output.serveURIFilePaths
))
func serve_exitingURIResource_triggeringLoggingEvent(
uriPath: String,
uriFile: String
) async throws {
try await assertServe(
logLevel: try .random(upTo: .debug),
uriPath: .Sample.uriDocument + uriPath,
uriFile: uriFile,
statusCode: .ok
)
}
@Test("serve an existing URI resource without triggering logging event", arguments: Input.serveURIPaths)
func server_existingURIResource_notTriggeringLoggingEvent(
uriPath: String
) async throws {
try await assertServe(
logLevel: try .random(fromExclusive: .debug),
uriPath: .Sample.uriDocument + uriPath,
statusCode: .ok
)
}
@Test("serve a non existing URI resource while triggering logging event", arguments: zip(
Input.serveURIPaths,
Output.serveURIFilePaths
))
func serve_notExistingURIResource_triggeringLoggingEvent(
uriPath: String,
uriFile: String
) async throws {
try await assertServe(
logLevel: try .random(upTo: .error),
uriPath: .Sample.uriDocument + uriPath,
uriFile: uriFile,
statusCode: .notFound
)
}
@Test("serve a non existing URI resource without triggering logging event", arguments: Input.serveURIPaths)
func serve_notExistingURIResource_triggeringLoggingEvent(
uriPath: String
) async throws {
try await assertServe(
logLevel: try .random(fromExclusive: .error),
uriPath: .Sample.uriDocument + uriPath,
statusCode: .notFound
)
}
@Test("serve a URI resource not prefixed with root URI path", arguments: Input.serveURIPaths)
func server_aURIResource_notPrefixed_withURIRoot(
uriPath: String
) async throws {
try await assertServe(
logLevel: try .random(),
uriPath: uriPath
)
}
#endif
}
// MARK: - Assertions
private extension DocCMiddlewareTests {
// MARK: Functions
/// Asserts the public initializer.
/// - Parameters:
/// - configuration: A type that contains the parameters to configure the middleware.
/// - logger: A type that interacts with the logging system.
func assertInit(
configuration: DocCMiddleware<LocalFileSystem>.Configuration,
logger: Logger = .test()
) {
// GIVEN
// WHEN
let middleware = DocCMiddleware(
configuration: configuration,
logger: logger
)
// THEN
#expect(middleware.configuration.folderRoot == configuration.folderRoot)
#expect(middleware.configuration.uriRoot == configuration.uriRoot)
#expect(middleware.configuration.threadPool === configuration.threadPool)
#expect(middleware.logger.label == logger.label)
#expect(middleware.logger.logLevel == logger.logLevel)
#expect(middleware.logger.metadataProvider == nil)
#expect(type(of:middleware.fileProvider) == LocalFileSystem.self)
}
/// Asserts the internal initializer with a concrete file provider type.
/// - Parameters:
/// - configuration: A type that contains the parameters to configure the middleware.
/// - logger: A type that interacts with the logging system.
/// - fileProvider: A type that conforms to the protocol that defines file system interactions, if any.
func assertInit<FileSystemProvider: FileProvider>(
configuration: DocCMiddleware<FileSystemProvider>.Configuration,
logger: Logger = .test(),
fileProvider: FileSystemProvider
) {
// GIVEN
// WHEN
let middleware = DocCMiddleware(
configuration: configuration,
fileProvider: fileProvider,
logger: logger
)
// THEN
#expect(middleware.configuration.folderRoot == configuration.folderRoot)
#expect(middleware.configuration.uriRoot == configuration.uriRoot)
#expect(middleware.configuration.threadPool === configuration.threadPool)
#expect(middleware.logger.label == logger.label)
#expect(middleware.logger.logLevel == logger.logLevel)
#expect(middleware.logger.metadataProvider == nil)
#expect(type(of:middleware.fileProvider) == FileSystemProvider.self)
}
/// Asserts a URI path redirection done by the middleware.
/// - Parameters:
/// - logLevel: A representation of the logging level to set in the `Logger` instance.
/// - uriPath: A URI path to a resource.
/// - uriRedirect: A redirected URI path, if any.
/// - statusCode: An expected status code from the response coming out of the use case.
/// - Throws: An error in case an issue is encountered while asserting URI path redirections by the middleware.
func assertRedirect(
logLevel: Logger.Level,
uriPath: String,
to uriRedirect: String? = nil,
expects statusCode: HTTPResponse.Status = .movedPermanently
) async throws {
// GIVEN
let logHandler: LogHandlerMock = .init()
let logger: Logger = .test(
level: logLevel,
handler: logHandler
)
let context: any RequestContext = RequestContextMock(logger: logger)
let request: Request = .test(
method: .get,
path: uriPath
)
let middleware = DocCMiddleware(
configuration: .init(
uriRoot: .Sample.uriRoot,
folderRoot: .Sample.uriFolder
),
fileProvider: FileProviderMock(),
logger: logger
)
// WHEN
let result = try await middleware.handle(request, context: context) { _, _ in
.init(status: .ok)
}
// THEN
#expect(result.status == statusCode)
let events = await logHandler.entries
if statusCode == .movedPermanently, let uriRedirect {
#expect(result.body.contentLength == 0)
#expect(result.headers == [
.location: uriRedirect,
.contentLength: "0"
])
if shouldEventBeLogged(
logLevel: logLevel,
statusCode: statusCode
) {
#expect(!events.isEmpty)
#expect(events.count == 1)
let loggedEvent = try #require(events.first)
#expect(loggedEvent == .init(
level: .debug,
metadata: [
"hb.request.id": "\(context.id)",
"hb.request.method": "\(request.method.rawValue)",
"hb.request.path": "\(request.uri.path)",
"hb.request.status": "\(statusCode.code)",
"hb.request.redirect": "\(uriRedirect)"
],
message: "The URI path is redirected to this path: \(uriRedirect)",
source: .Logging.source
))
} else {
#expect(events.isEmpty)
}
} else {
#expect(events.isEmpty)
}
}
/// Asserts a URI resource serving done by the middleware.
/// - Parameters:
/// - logLevel: A representation of the logging level to set in the `Logger` instance.
/// - uriPath: A URI path for a resource.
/// - uriFile: A URI path for a file in the local file system.
/// - statusCode: An expected status code from the response coming out of the use case, if any.
/// - Throws: An error in case an issue is encountered while asserting URI path servings by the middleware.
func assertServe(
logLevel: Logger.Level,
uriPath: String,
uriFile: String? = nil,
statusCode: HTTPResponse.Status? = nil
) async throws {
// GIVEN
let logHandler: LogHandlerMock = .init()
let logger: Logger = .test(
level: logLevel,
handler: logHandler
)
let fileProvider: FileProviderMock = switch statusCode {
case .ok: .init(fileIdentifier: .init())
case .notFound: .init()
default: .init(fileIdentifier: .init(), shouldLoadFile: false)
}
let context: any RequestContext = RequestContextMock(logger: logger)
let request: Request = .test(
method: .get,
path: uriPath
)
let middleware = DocCMiddleware(
configuration: .init(
uriRoot: .Sample.uriRoot,
folderRoot: .Sample.uriFolder
),
fileProvider: fileProvider,
logger: logger
)
// WHEN
let result = try await middleware.handle(request, context: context) { _, _ in
.init(status: .ok)
}
// THEN
if let statusCode {
#expect(result.status == statusCode)
#expect(result.headers == [
.contentLength: (statusCode == .ok ? "36" : "0")
])
let contentLength = try #require(result.body.contentLength)
if statusCode == .ok {
#expect(contentLength > 0)
} else {
#expect(contentLength == 0)
}
let events = await logHandler.entries
if shouldEventBeLogged(
logLevel: logLevel,
statusCode: statusCode
) {
#expect(!events.isEmpty)
#expect(events.count == 1)
let loggedEvent = try #require(events.first)
let uriFile = try #require(uriFile)
#expect(loggedEvent == .init(
level: statusCode == .ok ? .debug : .error,
metadata: [
"hb.request.id": "\(context.id)",
"hb.request.method": "\(request.method.rawValue)",
"hb.request.path": "\(request.uri.path)",
"hb.request.status": "\(statusCode.code)"
],
message: {
if statusCode == .ok {
"The body of the resource \(uriFile) has \(contentLength) bytes."
} else {
"The resource \(uriFile) has not been found."
}
}(),
source: .Logging.source
))
} else {
#expect(events.isEmpty)
}
} else {
#expect(result.status == .ok)
}
}
}
// MARK: - Helpers
private extension DocCMiddlewareTests {
// MARK: Functions
/// Checks whether a logging event should be logged or not, based on a given logging level.
/// - Parameters:
/// - logLevel: A representation of a logging level defined in in the logger.
/// - statusCode: A representation of a status code from the response.
/// - Returns: A boolean value that indicates whether a logging event should have been logged or not.
func shouldEventBeLogged(
logLevel: Logger.Level,
statusCode: HTTPResponse.Status
) -> Bool {
let levels: [Logger.Level] = switch statusCode {
case .movedPermanently, .ok: [.debug, .trace]
case .notFound: [.debug, .error, .info, .notice, .trace, .warning]
default: []
}
return levels.contains(logLevel)
}
}
// MARK: - Constants
private extension Input {
/// A list of relative URI paths to match against the URI path redirections done by the middleware.
static let redirectURIPaths: [String] = [.empty, .Path.forwardSlash, "/documentation", "/tutorials"]
/// A list of relative URI paths to match against the URI path servings done by the middleware.
static let serveURIPaths: [String] = [
"/documentation/",
"/tutorials/",
"/data/documentation.json",
"/favicon.ico",
"/favicon.svg",
"/theme-settings.json",
"/css/file.css",
"/data/data.bin",
"/downloads/file.txt",
"/images/image.png",
"/img/image.jpg",
"/index/file",
"/js/file.js",
"/videos/video.mp4"
]
}
private extension Output {
/// A list of expected relative URI path redirections outputs coming out of the URI path redirections done by the middleware.
static let redirectURIPaths: [String] = [.Path.forwardSlash, "/documentation", "/documentation/", "/tutorials/"]
/// A list of expected relative file URI paths of the logged messages coming out of the URI path servings done by the middleware.
static let serveURIFilePaths: [String] = [
"/SomeDocument.doccarchive/documentation/somedocument/index.html",
"/SomeDocument.doccarchive/tutorials/somedocument/index.html",
"/SomeDocument.doccarchive/data/documentation/somedocument.json",
"/SomeDocument.doccarchive/SomeDocument/favicon.ico",
"/SomeDocument.doccarchive/SomeDocument/favicon.svg",
"/SomeDocument.doccarchive/SomeDocument/theme-settings.json",
"/SomeDocument.doccarchive/SomeDocument/css/file.css",
"/SomeDocument.doccarchive/SomeDocument/data/data.bin",
"/SomeDocument.doccarchive/SomeDocument/downloads/file.txt",
"/SomeDocument.doccarchive/SomeDocument/images/image.png",
"/SomeDocument.doccarchive/SomeDocument/img/image.jpg",
"/SomeDocument.doccarchive/SomeDocument/index/file",
"/SomeDocument.doccarchive/SomeDocument/js/file.js",
"/SomeDocument.doccarchive/SomeDocument/videos/video.mp4"
]
}
@@ -0,0 +1,57 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Foundation
import Testing
import protocol Logging.LogHandler
import struct Logging.Logger
extension Logger {
// MARK: Functions
/// Generates a logger instance that is ready to use in test cases.
/// - Parameters:
/// - level: A logger level, if any.
/// - handler: A custom log handler, if any.
/// - Returns: A generated logger instance ready to use in test cases.
static func test(
level: Logger.Level? = nil,
handler: (any LogHandler)? = nil
) -> Self {
var logger: Logger = if let handler {
.init(label: .loggerLabel) { _ in handler }
} else {
.init(label: .loggerLabel)
}
logger.logLevel = if let level {
level
} else {
try! #require(Logger.Level.allCases.randomElement())
}
logger[metadataKey: "hb.request.id"] = "\(UUID().uuidString)"
return logger
}
}
// MARK: - Constants
private extension String {
/// A label to assign to a test logger instance.
static let loggerLabel = "test.hummingbird-docc-middleware.logger"
}
@@ -0,0 +1,61 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
import struct Logging.Logger
extension Logger.Level {
// MARK: Functions
/// Extracts a random logging level value out of an inclusive subset of logging levels, arranged by severity.
/// - Parameter level: A representation of a logging level that defines a subset of values to choose from, if any.
/// - Returns: A randomized logging value.
/// - Throws: An error thrown in case an issue is encountered when deciding for a random value.
static func random(upTo level: Self? = nil) throws -> Self {
guard let level else {
return try #require(Self.allCases.randomElement())
}
let levels: [Self] = switch level {
case .trace: [.trace]
case .debug: [.debug, .trace]
case .info: [.debug, .info, .trace]
case .notice: [.debug, .info, .notice, .trace]
case .warning: [.debug, .info, .notice, .trace, .warning]
case .error: [.debug, .error, .info, .notice, .trace, .warning]
case .critical: Self.allCases
}
return try #require(levels.randomElement())
}
/// /// Extracts a random logging level value out of an exclusive subset of logging levels, arranged by severity.
/// - Parameter level: A representation of a logging level that defines a subset of values to choose from.
/// - Returns: A randomized logging value.
/// - Throws: An error thrown in case an issue is encountered when deciding for a random value.
static func random(fromExclusive level: Self) throws -> Self {
let levels: [Self] = switch level {
case .trace: [.critical, .debug, .error, .info, .notice, .warning]
case .debug: [.critical, .error, .info, .notice, .warning]
case .info: [.critical, .error, .notice, .warning]
case .notice: [.critical, .error, .warning]
case .warning: [.critical, .error]
case .error: [.critical]
case .critical: []
}
return try #require(levels.randomElement())
}
}
@@ -0,0 +1,41 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import struct Hummingbird.HTTPRequest
import struct Hummingbird.Request
import struct Hummingbird.RequestBody
extension Request {
// MARK: Functions
/// Generates a request that is ready to use in test case.
/// - Parameters:
/// - method: A HTTP method.
/// - path: A URI path, if any.
/// - Returns: A generated request instance to use in test cases.
static func test(
method: HTTPRequest.Method,
path: String? = nil
) -> Self {
.init(
head: .init(
method: method,
scheme: nil,
authority: nil,
path: path
),
body: .init(buffer: .init())
)
}
}
@@ -0,0 +1,32 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
extension String {
// MARK: Constants
/// A namespace that defines sample values.
enum Sample {
/// A URI path to use as a documentation root sample.
static let uriDocument = uriRoot + "/SomeDocument"
/// A URI path to use as a file sample.
static let uriFile = uriFolder + uriResource
/// A URI path to use as a folder sample.
static let uriFolder = "/some/folder/path"
/// A URI path to use as a redirection sample.
static let uriRedirection = "/some/redirect/path"
/// A URI path to use as a resource sample.
static let uriResource = "/some/path/to/resource"
/// A URI path to use as a root sample.
static let uriRoot = "/some/root/path"
}
}
@@ -0,0 +1,28 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Testing
extension Tag {
// MARK: Constants
/// Tag that indicate a test case for an enumeration type.
@Tag static var enumeration: Self
/// Tag that indicate a test case for an extended type.
@Tag static var `extension`: Self
/// Tag that indicate a test case for a middleware type.
@Tag static var middleware: Self
/// Tag that indicate a test case for a use case type.
@Tag static var useCase: Self
}
@@ -0,0 +1,102 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import protocol Hummingbird.FileProvider
import protocol Hummingbird.RequestContext
import struct Foundation.Data
import struct Foundation.UUID
import struct Hummingbird.ResponseBody
/// A mock that conforms to the `FileProvider` protocol.
struct FileProviderMock {
// MARK: Properties
/// A type that identifies a sample file.
private let fileIdentifier: UUID?
/// A flag that indicates whether a file should be loaded or not.
private let shouldLoadFile: Bool
// MARK: Initializers
/// Initializes this mock.
/// - Parameters:
/// - fileIdentifier: A type that identifies a sample file, if any.
/// - shouldLoadFile: A flag that indicates whether a file should be loaded or not.
init(
fileIdentifier: UUID? = nil,
shouldLoadFile: Bool = true
) {
self.fileIdentifier = fileIdentifier
self.shouldLoadFile = shouldLoadFile
}
}
// MARK: - FileProvider
extension FileProviderMock: FileProvider {
// MARK: Type aliases
typealias FileAttributes = String
typealias FileIdentifier = String
// MARK: Functions
func getFileIdentifier(_ path: String) -> String? {
fileIdentifier?.uuidString
}
func getAttributes(id: String) async throws -> String? {
nil
}
func loadFile(
id: String,
context: some RequestContext
) async throws -> ResponseBody {
guard shouldLoadFile else {
throw FileProviderMockError.fileNotLoaded
}
guard let content = fileIdentifier?.uuidString else {
return .init()
}
return .init(byteBuffer: .init(
data: .init(content.utf8)
))
}
func loadFile(
id: String,
range: ClosedRange<Int>,
context: some RequestContext
) async throws -> ResponseBody {
try await loadFile(
id: id,
context: context
)
}
}
// MARK: - FileProviderMockError
/// An error type that can only be thrown by the ``FileProviderMock`` mock.
enum FileProviderMockError: Error {
/// An error encountered while mocking the loading of a file.
case fileNotLoaded
}
@@ -0,0 +1,141 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Foundation
import protocol Logging.LogHandler
import struct Logging.Logger
/// A mock that conforms to the `LogHandler` protocol.
struct LogHandlerMock {
// MARK: Properties
/// A representation of the logging level assigned to this mock.
private var _logLevel: Logger.Level = .debug
/// A dictionary that contains all the metadata assigned to this mock.
private var _metadata: Logger.Metadata = [:]
/// A logging event recorder attached to this mock.
private let recorder: LogRecorder = .init()
// MARK: Computed
/// A list of all the logged events that are being persisted in the recorder.
var entries: [LogEntry] {
get async { await recorder.entries }
}
}
// MARK: - LogEntry
/// A type that contains the information logged in a logging event.
struct LogEntry: Equatable {
// MARK: Properties
/// A representation of the level attached to a logged event.
let level: Logger.Level
/// A metadata dictionary that contains additional information attached to a logged event.
let metadata: Logger.Metadata?
/// A message attached to a logged event.
let message: Logger.Message
/// A source from where a logged event was triggered.
let source: String
}
// MARK: - LogRecorder
extension LogHandlerMock {
/// An actor that persists all the events logged by the ``LogHandlerMock`` mock handler.
actor LogRecorder {
// MARK: Properties
/// A list of all the logged events.
private(set) var entries: [LogEntry] = []
// MARK: Functions
/// Records data related to a logged event.
/// - Parameters:
/// - level: A representation of the level attached to a logged event.
/// - metadata: A metadata dictionary that contains additional information attached to a logged event.
/// - message: A message attached to a logged event.
/// - source: A source from where a logged event was triggered.
func record(
level: Logger.Level,
metadata: Logger.Metadata?,
message: Logger.Message,
source: String
) async {
entries += [.init(
level: level,
metadata: metadata,
message: message,
source: source
)]
}
}
}
// MARK: - LogHandler
extension LogHandlerMock: LogHandler {
// MARK: Properties
var metadata: Logger.Metadata {
get { _metadata }
set(newValue) { _metadata = newValue }
}
var logLevel: Logger.Level {
get { _logLevel }
set(newValue) { _logLevel = newValue }
}
// MARK: Subscripts
subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? {
get { _metadata[metadataKey] }
set(newValue) { _metadata[metadataKey] = newValue }
}
// MARK: Functions
func log(
level: Logger.Level,
message: Logger.Message,
metadata: Logger.Metadata?,
source: String,
file: String,
function: String,
line: UInt
) {
Task { await recorder.record(
level: level,
metadata: metadata,
message: message,
source: source
)}
}
}
@@ -0,0 +1,51 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import class NIOEmbedded.NIOAsyncTestingChannel
import protocol Hummingbird.RequestContext
import struct Hummingbird.ApplicationRequestContextSource
import struct Hummingbird.CoreRequestContextStorage
import struct Logging.Logger
/// A mock that conforms to the `RequestContext` protocol.
struct RequestContextMock {
// MARK: Properties
var coreContext: CoreRequestContextStorage
// MARK: Initializers
/// Initializes this mock.
/// - Parameter logger: A type that interacts with the logging system.
init(logger: Logger) {
self.coreContext = .init(source: ApplicationRequestContextSource(
channel: NIOAsyncTestingChannel(),
logger: logger
))
}
}
// MARK: - RequestContext
extension RequestContextMock: RequestContext {
// MARK: Initializers
init(source: ApplicationRequestContextSource) {
self.coreContext = .init(source: source)
}
}
@@ -0,0 +1,14 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
/// A namespace assigned for test arguments
enum Input {}
@@ -0,0 +1,14 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
/// A namespace assigned for test arguments that would be expected outputs coming from results of test cases.
enum Output {}
@@ -0,0 +1,53 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the Hummingbird DocC Middleware open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the Hummingbird DocC Middleware project authors
// Licensed under the EUPL 1.2 or later.
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of Hummingbird DocC Middleware project authors
//
// ===----------------------------------------------------------------------===
import Hummingbird
/// A stub that conforms to the `FileProvider` protocol.
struct FileProviderStub {}
// MARK: - FileProvider
extension FileProviderStub: FileProvider {
// MARK: Type aliases
typealias FileAttributes = String
typealias FileIdentifier = String
// MARK: Functions
func getFileIdentifier(_ path: String) -> String? {
nil
}
func getAttributes(id: String) async throws -> String? {
nil
}
func loadFile(
id: String,
context: some RequestContext
) async throws -> ResponseBody {
.init()
}
func loadFile(
id: String,
range: ClosedRange<Int>,
context: some RequestContext
) async throws -> ResponseBody {
.init()
}
}
@@ -1,7 +0,0 @@
import Testing
@testable import DocCMiddleware
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}