From 3a9e3d176f14e78d6bf62a23d4af5a88f8c3db13 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 26 Sep 2025 23:54:07 +0000 Subject: [PATCH] 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: https://repo.rock-n-code.com/rock-n-code/hummingbird-docc-middleware/pulls/2 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Internal/Enumerations/AssetFile.swift | 38 ++ .../Internal/Enumerations/AssetFolder.swift | 43 ++ .../Enumerations/DocumentationFolder.swift | 31 + .../Extensions/LoggerMetadata+Helpers.swift | 50 ++ .../Extensions/String+Constants.swift | 30 + .../Internal/Extensions/String+Formats.swift | 38 ++ .../Internal/Protocols/Pathable.swift | 21 + .../Pseudo Types/ContextualInfo.swift | 18 + .../Internal/Use Cases/CheckURIUseCase.swift | 35 ++ .../Use Cases/PrepareURIPathUseCase.swift | 127 ++++ .../Use Cases/RedirectURIUseCase.swift | 63 ++ .../Internal/Use Cases/ServeURIUseCase.swift | 100 +++ .../DoccMiddlewareConfiguration.swift | 52 ++ .../Public/Middlewares/DocCMiddleware.swift | 213 +++++++ .../hummingbird_docc_middleware.swift | 2 - .../Enumerations/AssetFileTests.swift | 77 +++ .../Enumerations/AssetFolderTests.swift | 77 +++ .../DocumentationFolderTests.swift | 77 +++ .../LoggerMetadata+HelpersTests.swift | 128 ++++ .../Use Cases/CheckURIUseCaseTests.swift | 116 ++++ .../PrepareURIPathUseCaseTests.swift | 154 +++++ .../Use Cases/RedirectURIUseCaseTests.swift | 150 +++++ .../Use Cases/ServeURIUseCaseTests.swift | 266 ++++++++ .../Middlewares/DocCMiddlewareTests.swift | 595 ++++++++++++++++++ .../Types/Extensions/Logger+Helpers.swift | 57 ++ .../Extensions/LoggerLevel+Helpers.swift | 61 ++ .../Types/Extensions/Request+Helpers.swift | 41 ++ .../Types/Extensions/String+Constants.swift | 32 + .../Types/Extensions/Tag+Constants.swift | 28 + .../Types/Mocks/FileProviderMock.swift | 102 +++ .../Types/Mocks/LogHandlerMock.swift | 141 +++++ .../Types/Mocks/RequestContextMock.swift | 51 ++ .../Types/Namespaces/Input.swift | 14 + .../Types/Namespaces/Output.swift | 14 + .../Types/Stubs/FileProviderStub.swift | 53 ++ .../hummingbird_docc_middlewareTests.swift | 7 - 36 files changed, 3093 insertions(+), 9 deletions(-) create mode 100644 Sources/DocCMiddleware/Internal/Enumerations/AssetFile.swift create mode 100644 Sources/DocCMiddleware/Internal/Enumerations/AssetFolder.swift create mode 100644 Sources/DocCMiddleware/Internal/Enumerations/DocumentationFolder.swift create mode 100644 Sources/DocCMiddleware/Internal/Extensions/LoggerMetadata+Helpers.swift create mode 100644 Sources/DocCMiddleware/Internal/Extensions/String+Constants.swift create mode 100644 Sources/DocCMiddleware/Internal/Extensions/String+Formats.swift create mode 100644 Sources/DocCMiddleware/Internal/Protocols/Pathable.swift create mode 100644 Sources/DocCMiddleware/Internal/Pseudo Types/ContextualInfo.swift create mode 100644 Sources/DocCMiddleware/Internal/Use Cases/CheckURIUseCase.swift create mode 100644 Sources/DocCMiddleware/Internal/Use Cases/PrepareURIPathUseCase.swift create mode 100644 Sources/DocCMiddleware/Internal/Use Cases/RedirectURIUseCase.swift create mode 100644 Sources/DocCMiddleware/Internal/Use Cases/ServeURIUseCase.swift create mode 100644 Sources/DocCMiddleware/Public/Configurations/DoccMiddlewareConfiguration.swift create mode 100644 Sources/DocCMiddleware/Public/Middlewares/DocCMiddleware.swift delete mode 100644 Sources/DocCMiddleware/hummingbird_docc_middleware.swift create mode 100644 Tests/DocCMiddleware/Tests/Internal/Enumerations/AssetFileTests.swift create mode 100644 Tests/DocCMiddleware/Tests/Internal/Enumerations/AssetFolderTests.swift create mode 100644 Tests/DocCMiddleware/Tests/Internal/Enumerations/DocumentationFolderTests.swift create mode 100644 Tests/DocCMiddleware/Tests/Internal/Extensions/LoggerMetadata+HelpersTests.swift create mode 100644 Tests/DocCMiddleware/Tests/Internal/Use Cases/CheckURIUseCaseTests.swift create mode 100644 Tests/DocCMiddleware/Tests/Internal/Use Cases/PrepareURIPathUseCaseTests.swift create mode 100644 Tests/DocCMiddleware/Tests/Internal/Use Cases/RedirectURIUseCaseTests.swift create mode 100644 Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeURIUseCaseTests.swift create mode 100644 Tests/DocCMiddleware/Tests/Public/Middlewares/DocCMiddlewareTests.swift create mode 100644 Tests/DocCMiddleware/Types/Extensions/Logger+Helpers.swift create mode 100644 Tests/DocCMiddleware/Types/Extensions/LoggerLevel+Helpers.swift create mode 100644 Tests/DocCMiddleware/Types/Extensions/Request+Helpers.swift create mode 100644 Tests/DocCMiddleware/Types/Extensions/String+Constants.swift create mode 100644 Tests/DocCMiddleware/Types/Extensions/Tag+Constants.swift create mode 100644 Tests/DocCMiddleware/Types/Mocks/FileProviderMock.swift create mode 100644 Tests/DocCMiddleware/Types/Mocks/LogHandlerMock.swift create mode 100644 Tests/DocCMiddleware/Types/Mocks/RequestContextMock.swift create mode 100644 Tests/DocCMiddleware/Types/Namespaces/Input.swift create mode 100644 Tests/DocCMiddleware/Types/Namespaces/Output.swift create mode 100644 Tests/DocCMiddleware/Types/Stubs/FileProviderStub.swift delete mode 100644 Tests/DocCMiddleware/hummingbird_docc_middlewareTests.swift diff --git a/Sources/DocCMiddleware/Internal/Enumerations/AssetFile.swift b/Sources/DocCMiddleware/Internal/Enumerations/AssetFile.swift new file mode 100644 index 0000000..89de2c7 --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Enumerations/AssetFile.swift @@ -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) + } + } + +} diff --git a/Sources/DocCMiddleware/Internal/Enumerations/AssetFolder.swift b/Sources/DocCMiddleware/Internal/Enumerations/AssetFolder.swift new file mode 100644 index 0000000..dd7ea2c --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Enumerations/AssetFolder.swift @@ -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) + } + +} diff --git a/Sources/DocCMiddleware/Internal/Enumerations/DocumentationFolder.swift b/Sources/DocCMiddleware/Internal/Enumerations/DocumentationFolder.swift new file mode 100644 index 0000000..403d585 --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Enumerations/DocumentationFolder.swift @@ -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) + } + +} diff --git a/Sources/DocCMiddleware/Internal/Extensions/LoggerMetadata+Helpers.swift b/Sources/DocCMiddleware/Internal/Extensions/LoggerMetadata+Helpers.swift new file mode 100644 index 0000000..bddde68 --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Extensions/LoggerMetadata+Helpers.swift @@ -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 + } + +} diff --git a/Sources/DocCMiddleware/Internal/Extensions/String+Constants.swift b/Sources/DocCMiddleware/Internal/Extensions/String+Constants.swift new file mode 100644 index 0000000..205cfc1 --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Extensions/String+Constants.swift @@ -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 = ".." + } +} diff --git a/Sources/DocCMiddleware/Internal/Extensions/String+Formats.swift b/Sources/DocCMiddleware/Internal/Extensions/String+Formats.swift new file mode 100644 index 0000000..d425b2b --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Extensions/String+Formats.swift @@ -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 = "/%@" + } + } +} diff --git a/Sources/DocCMiddleware/Internal/Protocols/Pathable.swift b/Sources/DocCMiddleware/Internal/Protocols/Pathable.swift new file mode 100644 index 0000000..b3e3425 --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Protocols/Pathable.swift @@ -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 } + +} diff --git a/Sources/DocCMiddleware/Internal/Pseudo Types/ContextualInfo.swift b/Sources/DocCMiddleware/Internal/Pseudo Types/ContextualInfo.swift new file mode 100644 index 0000000..fbfb148 --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Pseudo Types/ContextualInfo.swift @@ -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) diff --git a/Sources/DocCMiddleware/Internal/Use Cases/CheckURIUseCase.swift b/Sources/DocCMiddleware/Internal/Use Cases/CheckURIUseCase.swift new file mode 100644 index 0000000..81ddf5e --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Use Cases/CheckURIUseCase.swift @@ -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 + } + +} diff --git a/Sources/DocCMiddleware/Internal/Use Cases/PrepareURIPathUseCase.swift b/Sources/DocCMiddleware/Internal/Use Cases/PrepareURIPathUseCase.swift new file mode 100644 index 0000000..9611a9b --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Use Cases/PrepareURIPathUseCase.swift @@ -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 + } + +} diff --git a/Sources/DocCMiddleware/Internal/Use Cases/RedirectURIUseCase.swift b/Sources/DocCMiddleware/Internal/Use Cases/RedirectURIUseCase.swift new file mode 100644 index 0000000..8b226a1 --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Use Cases/RedirectURIUseCase.swift @@ -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 + ) + } + +} diff --git a/Sources/DocCMiddleware/Internal/Use Cases/ServeURIUseCase.swift b/Sources/DocCMiddleware/Internal/Use Cases/ServeURIUseCase.swift new file mode 100644 index 0000000..fa54fe1 --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Use Cases/ServeURIUseCase.swift @@ -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 { + + // 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 + ) + } + +} diff --git a/Sources/DocCMiddleware/Public/Configurations/DoccMiddlewareConfiguration.swift b/Sources/DocCMiddleware/Public/Configurations/DoccMiddlewareConfiguration.swift new file mode 100644 index 0000000..0bc36c4 --- /dev/null +++ b/Sources/DocCMiddleware/Public/Configurations/DoccMiddlewareConfiguration.swift @@ -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 + } + + } +} diff --git a/Sources/DocCMiddleware/Public/Middlewares/DocCMiddleware.swift b/Sources/DocCMiddleware/Public/Middlewares/DocCMiddleware.swift new file mode 100644 index 0000000..2f33c20 --- /dev/null +++ b/Sources/DocCMiddleware/Public/Middlewares/DocCMiddleware.swift @@ -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 `/` to the path `//`*; +/// 2. *Redirects the URI path `//` to the path `//documentation`* +/// 3. *Redirects the URI path `//documentation` to the path `//documentation/`* +/// 4. *Redirects the URI path `//tutorials` to the path `//tutorials/`* +/// 5. *Redirects the URI path `//documentation/` to the resource on `/.doccarchive/documentation//index.html`* +/// 6. *Redirects the URI path `//tutorials/` to the resource on `/.doccarchive/tutorials//index.html`* +/// 7. *Redirects the URI path `//data/documentation.json` to the resource on `/.doccarchive/data/documentation/.json`* +/// 8. *Redirects the URI path `//favicon.ico` to the resource on `/.doccarchive/favicon.ico`* +/// 9. *Redirects the URI path `//favicon.svg` to the resource on `/.doccarchive/favicon.svg`* +/// 10. *Redirects the URI path `//theme-settings.json` to the resource on `/.doccarchive/theme-settings.json`* +/// 11. *Redirects the URI path `//css/` to the resource on `/.doccarchive/css/`* +/// 12. *Redirects the URI path `//data/` to the resource on `/.doccarchive/data/`* +/// 13. *Redirects the URI path `//downloads/` to the resource on `/.doccarchive/downloads/`* +/// 14. *Redirects the URI path `//images/` to the resource on `/.doccarchive/images/`* +/// 15. *Redirects the URI path `//img/` to the resource on `/.doccarchive/img/`* +/// 16. *Redirects the URI path `//index/` to the resource on `/.doccarchive/index/`* +/// 17. *Redirects the URI path `//js/` to the resource on `/.doccarchive/js/`* +/// 18. *Redirects the URI path `//videos/` to the resource on `/.doccarchive/videos/`* +/// +public struct DocCMiddleware { + + // 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 + + // 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 // to the path //documentation + ? String(format: .Format.Path.documentation, uriPath) + // Rule #1: Redirects the URI path / to the path // + : 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 //data/documentation.json to the resource on /.doccarchive/data/documentation/.json + ? String(format: .Format.Path.documentationJSON, uriData.archiveReference) + // Rule #8: Redirects the URI path `//favicon.ico` to the resource on `/.doccarchive/favicon.ico` + // Rule #9: Redirects the URI path `//favicon.svg` to the resource on `/.doccarchive/favicon.svg` + // Rule #10: Redirects the URI path `//theme-settings.json` to the resource on `/.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 `//css/` to the resource on `/.doccarchive/css/` + // Rule #12: Redirects the URI path `//data/` to the resource on `/.doccarchive/data/` + // Rule #13: Redirects the URI path `//downloads/` to the resource on `/.doccarchive/downloads/` + // Rule #14: Redirects the URI path `//images/` to the resource on `/.doccarchive/images/` + // Rule #15: Redirects the URI path `//img/` to the resource on `/.doccarchive/img/` + // Rule #16: Redirects the URI path `//index/` to the resource on `/.doccarchive/index/` + // Rule #17: Redirects the URI path `//js/` to the resource on `/.doccarchive/js/` + // Rule #18: Redirects the URI path `//videos/` to the resource on `/.doccarchive/videos/` + 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 //documentation/ to the resource on /.doccarchive/documentation//index.html + // Rule #6: Redirects the URI path //tutorials/ to the resource on /.doccarchive/tutorials//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 //documentation to the path //documentation/ + // Rule #4: Redirects the URI path //tutorials to the path //tutorials/ + return redirectURI( + String(format: .Format.Path.forwardSlash, uriPath), + with: (request, context) + ) + } + } + } + + return try await next(request, context) + } + +} + diff --git a/Sources/DocCMiddleware/hummingbird_docc_middleware.swift b/Sources/DocCMiddleware/hummingbird_docc_middleware.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/DocCMiddleware/hummingbird_docc_middleware.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Tests/DocCMiddleware/Tests/Internal/Enumerations/AssetFileTests.swift b/Tests/DocCMiddleware/Tests/Internal/Enumerations/AssetFileTests.swift new file mode 100644 index 0000000..0ded28e --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Internal/Enumerations/AssetFileTests.swift @@ -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"] +} diff --git a/Tests/DocCMiddleware/Tests/Internal/Enumerations/AssetFolderTests.swift b/Tests/DocCMiddleware/Tests/Internal/Enumerations/AssetFolderTests.swift new file mode 100644 index 0000000..a0b12d1 --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Internal/Enumerations/AssetFolderTests.swift @@ -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/"] +} diff --git a/Tests/DocCMiddleware/Tests/Internal/Enumerations/DocumentationFolderTests.swift b/Tests/DocCMiddleware/Tests/Internal/Enumerations/DocumentationFolderTests.swift new file mode 100644 index 0000000..3004b0c --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Internal/Enumerations/DocumentationFolderTests.swift @@ -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"] +} diff --git a/Tests/DocCMiddleware/Tests/Internal/Extensions/LoggerMetadata+HelpersTests.swift b/Tests/DocCMiddleware/Tests/Internal/Extensions/LoggerMetadata+HelpersTests.swift new file mode 100644 index 0000000..282552e --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Internal/Extensions/LoggerMetadata+HelpersTests.swift @@ -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()) + } + } + +} diff --git a/Tests/DocCMiddleware/Tests/Internal/Use Cases/CheckURIUseCaseTests.swift b/Tests/DocCMiddleware/Tests/Internal/Use Cases/CheckURIUseCaseTests.swift new file mode 100644 index 0000000..922fec2 --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Internal/Use Cases/CheckURIUseCaseTests.swift @@ -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] +} diff --git a/Tests/DocCMiddleware/Tests/Internal/Use Cases/PrepareURIPathUseCaseTests.swift b/Tests/DocCMiddleware/Tests/Internal/Use Cases/PrepareURIPathUseCaseTests.swift new file mode 100644 index 0000000..e73ffa6 --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Internal/Use Cases/PrepareURIPathUseCaseTests.swift @@ -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" +} diff --git a/Tests/DocCMiddleware/Tests/Internal/Use Cases/RedirectURIUseCaseTests.swift b/Tests/DocCMiddleware/Tests/Internal/Use Cases/RedirectURIUseCaseTests.swift new file mode 100644 index 0000000..29953a9 --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Internal/Use Cases/RedirectURIUseCaseTests.swift @@ -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) + } + +} diff --git a/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeURIUseCaseTests.swift b/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeURIUseCaseTests.swift new file mode 100644 index 0000000..bf5d27e --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeURIUseCaseTests.swift @@ -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) + } + +} diff --git a/Tests/DocCMiddleware/Tests/Public/Middlewares/DocCMiddlewareTests.swift b/Tests/DocCMiddleware/Tests/Public/Middlewares/DocCMiddlewareTests.swift new file mode 100644 index 0000000..3eec0dd --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Public/Middlewares/DocCMiddlewareTests.swift @@ -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.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( + configuration: DocCMiddleware.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" + ] +} diff --git a/Tests/DocCMiddleware/Types/Extensions/Logger+Helpers.swift b/Tests/DocCMiddleware/Types/Extensions/Logger+Helpers.swift new file mode 100644 index 0000000..d0cec00 --- /dev/null +++ b/Tests/DocCMiddleware/Types/Extensions/Logger+Helpers.swift @@ -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" +} diff --git a/Tests/DocCMiddleware/Types/Extensions/LoggerLevel+Helpers.swift b/Tests/DocCMiddleware/Types/Extensions/LoggerLevel+Helpers.swift new file mode 100644 index 0000000..680ca8d --- /dev/null +++ b/Tests/DocCMiddleware/Types/Extensions/LoggerLevel+Helpers.swift @@ -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()) + } + +} diff --git a/Tests/DocCMiddleware/Types/Extensions/Request+Helpers.swift b/Tests/DocCMiddleware/Types/Extensions/Request+Helpers.swift new file mode 100644 index 0000000..54ebd8a --- /dev/null +++ b/Tests/DocCMiddleware/Types/Extensions/Request+Helpers.swift @@ -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()) + ) + } + +} diff --git a/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift b/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift new file mode 100644 index 0000000..c41d580 --- /dev/null +++ b/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift @@ -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" + } +} diff --git a/Tests/DocCMiddleware/Types/Extensions/Tag+Constants.swift b/Tests/DocCMiddleware/Types/Extensions/Tag+Constants.swift new file mode 100644 index 0000000..31b3733 --- /dev/null +++ b/Tests/DocCMiddleware/Types/Extensions/Tag+Constants.swift @@ -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 + +} diff --git a/Tests/DocCMiddleware/Types/Mocks/FileProviderMock.swift b/Tests/DocCMiddleware/Types/Mocks/FileProviderMock.swift new file mode 100644 index 0000000..645b34d --- /dev/null +++ b/Tests/DocCMiddleware/Types/Mocks/FileProviderMock.swift @@ -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, + 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 +} diff --git a/Tests/DocCMiddleware/Types/Mocks/LogHandlerMock.swift b/Tests/DocCMiddleware/Types/Mocks/LogHandlerMock.swift new file mode 100644 index 0000000..b5c35ce --- /dev/null +++ b/Tests/DocCMiddleware/Types/Mocks/LogHandlerMock.swift @@ -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 + )} + } + +} diff --git a/Tests/DocCMiddleware/Types/Mocks/RequestContextMock.swift b/Tests/DocCMiddleware/Types/Mocks/RequestContextMock.swift new file mode 100644 index 0000000..fe8b866 --- /dev/null +++ b/Tests/DocCMiddleware/Types/Mocks/RequestContextMock.swift @@ -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) + } + +} diff --git a/Tests/DocCMiddleware/Types/Namespaces/Input.swift b/Tests/DocCMiddleware/Types/Namespaces/Input.swift new file mode 100644 index 0000000..cd82e1a --- /dev/null +++ b/Tests/DocCMiddleware/Types/Namespaces/Input.swift @@ -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 {} diff --git a/Tests/DocCMiddleware/Types/Namespaces/Output.swift b/Tests/DocCMiddleware/Types/Namespaces/Output.swift new file mode 100644 index 0000000..0773041 --- /dev/null +++ b/Tests/DocCMiddleware/Types/Namespaces/Output.swift @@ -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 {} diff --git a/Tests/DocCMiddleware/Types/Stubs/FileProviderStub.swift b/Tests/DocCMiddleware/Types/Stubs/FileProviderStub.swift new file mode 100644 index 0000000..ab4f4b3 --- /dev/null +++ b/Tests/DocCMiddleware/Types/Stubs/FileProviderStub.swift @@ -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, + context: some RequestContext + ) async throws -> ResponseBody { + .init() + } + +} + diff --git a/Tests/DocCMiddleware/hummingbird_docc_middlewareTests.swift b/Tests/DocCMiddleware/hummingbird_docc_middlewareTests.swift deleted file mode 100644 index 730ed9c..0000000 --- a/Tests/DocCMiddleware/hummingbird_docc_middlewareTests.swift +++ /dev/null @@ -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. -}