Implemented the "handle(_: context: next: )" function for the DocCMiddleware type in the library target.

This commit is contained in:
2025-09-26 00:27:56 +02:00
parent edeaf219a0
commit 8aa2cf0fb2
4 changed files with 605 additions and 9 deletions
@@ -11,8 +11,12 @@
// ===----------------------------------------------------------------------=== // ===----------------------------------------------------------------------===
import protocol Hummingbird.FileProvider import protocol Hummingbird.FileProvider
import protocol Hummingbird.RequestContext
import protocol Hummingbird.RouterMiddleware
import struct Hummingbird.LocalFileSystem import struct Hummingbird.LocalFileSystem
import struct Hummingbird.Request
import struct Hummingbird.Response
import struct Logging.Logger import struct Logging.Logger
/// A middleware that proxies requests to `DocC` documentation containers within a hosting app. /// A middleware that proxies requests to `DocC` documentation containers within a hosting app.
@@ -32,6 +36,15 @@ public struct DocCMiddleware<FileSystemProvider: FileProvider> {
/// A use case that checks whether a received URI could be processed or not. /// A use case that checks whether a received URI could be processed or not.
private let checkURI: CheckURIUseCase = .init() private let checkURI: CheckURIUseCase = .init()
/// A use case that extracts data from a given URI path, essential for routing the documentation contents.
private let prepareURIPath: PrepareURIPathUseCase
/// A use case that produces a redirect response based on a given URI path.
private let redirectURI: RedirectURIUseCase
/// A use case that serves a resource, defined by its URI path, from a physical location.
private let serveURI: ServeURIUseCase<FileSystemProvider>
// MARK: Initializers // MARK: Initializers
/// Initializes this middleware. /// Initializes this middleware.
@@ -93,11 +106,74 @@ extension DocCMiddleware: RouterMiddleware {
context: any Context, context: any Context,
next: (Input, any Context) async throws -> Output next: (Input, any Context) async throws -> Output
) async throws -> Output { ) async throws -> Output {
guard let uri = checkURI(input.uri) else { guard
let uriPath = checkURI(input.uri),
let uriData = prepareURIPath(uriPath)
else {
return try await next(input, context) return try await next(input, context)
} }
if uriData.resourcePath == .Path.forwardSlash {
// rule #1: Redirects URI root to `/`.
// rule #2: Redirects URI resources with `/` to `/documentation`.
return redirectURI(
uriPath.hasSuffix(.Path.forwardSlash)
? String(format: .Format.Path.documentation, uriPath)
: String(format: .Format.Path.forwardSlash, uriPath),
with: (input, context)
)
}
for assetFile in AssetFile.allCases {
if uriData.resourcePath.contains(assetFile.path) {
return try await serveURI(
assetFile == .documentation
// Rule #6: Redirects URI resources with `/data/documentation.json` to the file in the `data/documentation/`
// folder that has the name of the module and ends with the `.json` extension in the *DocC* archive container.
? String(format: .Format.Path.documentationJSON, uriData.archiveName)
// Rule #7: Redirect URI resources for static files (`favicon.ico`, `favicon.svg`, and `theme-settings.json`)
// to their respective files in the *DocC* archive container.
: uriData.resourcePath,
at: uriData.archivePath,
with: (input, context)
)
}
}
for assetFolder in AssetFolder.allCases {
if uriData.resourcePath.contains(assetFolder.path) {
// Rule #8: Redirect URI resources for asset files (`/css/`, `/data/`, `/downloads/`, `/images/`, `/img/`,
// `/index/`, `/js/`, or `/videos/`) to their respective files in the *DocC* archive container.
return try await serveURI(
uriData.resourcePath,
at: uriData.archivePath,
with: (input, context)
)
}
}
for documentationFolder in DocumentationFolder.allCases {
if uriData.resourcePath.contains(documentationFolder.path) {
if uriData.resourcePath.hasSuffix(.Path.forwardSlash) {
// Rule #5: Redirect URI resources for `/documentation/` and `/tutorials/` folders to their respective `index.html` file.
return try await serveURI(
String(format: .Format.Path.index, documentationFolder.path, uriData.archiveName),
at: uriData.archivePath,
with: (input, context)
)
} else {
// rule #3: Redirects URI resources with `/documentation` to `/documentation/`.
// rule #4: Redirects URI resources with `/tutorials` to `/tutorials/`.
return redirectURI(
String(format: .Format.Path.forwardSlash, uriPath),
with: (input, context)
)
}
}
}
return try await next(input, context) return try await next(input, context)
} }
} }
@@ -245,8 +245,6 @@ private extension ServeURIUseCaseTests {
// MARK: Functions // MARK: Functions
// MARK: Functions
/// Checks whether a logging event should be logged or not, based on a given logging level. /// Checks whether a logging event should be logged or not, based on a given logging level.
/// - Parameters: /// - Parameters:
/// - logLevel: A representation of a logging level defined in in the logger. /// - logLevel: A representation of a logging level defined in in the logger.
@@ -13,8 +13,11 @@
import Testing import Testing
import protocol Hummingbird.FileProvider import protocol Hummingbird.FileProvider
import protocol Hummingbird.RequestContext
import struct Hummingbird.HTTPResponse
import struct Hummingbird.LocalFileSystem import struct Hummingbird.LocalFileSystem
import struct Hummingbird.Request
import struct Logging.Logger import struct Logging.Logger
@testable import struct DocCMiddleware.DocCMiddleware @testable import struct DocCMiddleware.DocCMiddleware
@@ -28,8 +31,8 @@ struct DocCMiddlewareTests {
@Test @Test
func `initialize with URI and folder paths`() { func `initialize with URI and folder paths`() {
assertInit(configuration: .init( assertInit(configuration: .init(
uriRoot: "/path/to/documentation", uriRoot: .Sample.uriResource,
folderRoot: "/location/docc/documentation" folderRoot: .Sample.uriFolder
)) ))
} }
@@ -37,7 +40,7 @@ struct DocCMiddlewareTests {
func `initialize with URI path and type that conforms to the FileProvider protocol`() { func `initialize with URI path and type that conforms to the FileProvider protocol`() {
assertInit( assertInit(
configuration: .init( configuration: .init(
uriRoot: "/path/to/documentation", uriRoot: .Sample.uriResource,
folderRoot: .empty folderRoot: .empty
), ),
fileProvider: FileProviderStub() fileProvider: FileProviderStub()
@@ -47,8 +50,8 @@ struct DocCMiddlewareTests {
@Test("initialize with URI and folder paths") @Test("initialize with URI and folder paths")
func init_withURI_andFolderPaths() { func init_withURI_andFolderPaths() {
assertInit(configuration: .init( assertInit(configuration: .init(
uriRoot: "/path/to/documentation", uriRoot: .Sample.uriResource,
folderRoot: "/location/docc/documentation" folderRoot: .Sample.uriFolder
)) ))
} }
@@ -56,7 +59,7 @@ struct DocCMiddlewareTests {
func init_withURI_path_andFileProviderType() { func init_withURI_path_andFileProviderType() {
assertInit( assertInit(
configuration: .init( configuration: .init(
uriRoot: "/path/to/documentation", uriRoot: .Sample.uriResource,
folderRoot: .empty folderRoot: .empty
), ),
fileProvider: FileProviderStub() fileProvider: FileProviderStub()
@@ -64,6 +67,216 @@ struct DocCMiddlewareTests {
} }
#endif #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 randomLogLevelForRedirectWithEvent,
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 randomLogLevelForRedirectWithNoEvent,
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 randomLogLevel,
uriPath: .Sample.uriDocument + 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 randomLogLevelForServeOKWithEvent,
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 randomLogLevelForServeOKWithNoEvent,
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 randomLogLevelForServeNotFoundWithEvent,
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 randomLogLevelForServeNotFoundWithNoEvent,
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 randomLogLevel,
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 randomLogLevelForRedirectWithEvent,
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 randomLogLevelForRedirectWithNoEvent,
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 randomLogLevel,
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 randomLogLevelForServeOKWithEvent,
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 randomLogLevelForServeOKWithNoEvent,
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 randomLogLevelForServeNotFoundWithEvent,
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 randomLogLevelForServeNotFoundWithNoEvent,
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 randomLogLevel,
uriPath: uriPath
)
}
#endif
} }
// MARK: - Assertions // MARK: - Assertions
@@ -94,6 +307,7 @@ private extension DocCMiddlewareTests {
#expect(middleware.logger.label == logger.label) #expect(middleware.logger.label == logger.label)
#expect(middleware.logger.logLevel == logger.logLevel) #expect(middleware.logger.logLevel == logger.logLevel)
#expect(middleware.logger.metadataProvider == nil)
#expect(type(of:middleware.fileProvider) == LocalFileSystem.self) #expect(type(of:middleware.fileProvider) == LocalFileSystem.self)
} }
@@ -123,8 +337,312 @@ private extension DocCMiddlewareTests {
#expect(middleware.logger.label == logger.label) #expect(middleware.logger.label == logger.label)
#expect(middleware.logger.logLevel == logger.logLevel) #expect(middleware.logger.logLevel == logger.logLevel)
#expect(middleware.logger.metadataProvider == nil)
#expect(type(of:middleware.fileProvider) == FileSystemProvider.self) #expect(type(of:middleware.fileProvider) == FileSystemProvider.self)
} }
/// Asserts an 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)
}
}
/// <#Description#>
/// - Parameters:
/// - logLevel: <#logLevel description#>
/// - uriPath: <#uriPath description#>
/// - uriFile: <#uriFile description#>
/// - folderPath: <#folderPath description#>
/// - statusCode: <#statusCode description#>
/// - 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,
folderPath: String = .Sample.uriFolder,
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: Computed
/// Extracts a random logging level.
var randomLogLevel: Logger.Level {
get throws {
try #require(Logger.Level.allCases.randomElement())
}
}
/// Extracts a random logging level for redirection assertions that support event logging for the use case.
var randomLogLevelForRedirectWithEvent: Logger.Level {
get throws {
try #require([.debug, .trace].randomElement())
}
}
/// Extracts a random logging level for redirection assertions that does not support event logging for the use case.
var randomLogLevelForRedirectWithNoEvent: Logger.Level {
get throws {
try #require([.critical, .error, .info, .notice, .warning].randomElement())
}
}
/// Extracts a random logging level for OK serve assertions that support event logging for the use case.
var randomLogLevelForServeOKWithEvent: Logger.Level {
get throws {
try #require([.debug, .trace].randomElement())
}
}
/// Extracts a random logging level for OK serve assertions that does not support event logging for the use case.
var randomLogLevelForServeOKWithNoEvent: Logger.Level {
get throws {
try #require([.critical, .error, .info, .notice, .warning].randomElement())
}
}
/// Extracts a random logging level for Not Found serve assertions that support event logging for the use case.
var randomLogLevelForServeNotFoundWithEvent: Logger.Level {
get throws {
try #require([.debug, .error, .info, .notice, .trace, .warning].randomElement())
}
}
/// Extracts a random logging level for Not Found serve assertions that does not support event logging for the use case.
var randomLogLevelForServeNotFoundWithNoEvent: Logger.Level {
get throws {
try #require([.critical].randomElement())
}
}
// 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"
]
} }
@@ -16,6 +16,8 @@ extension String {
/// A namespace that defines sample values. /// A namespace that defines sample values.
enum Sample { 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. /// A URI path to use as a file sample.
static let uriFile = uriFolder + uriResource static let uriFile = uriFolder + uriResource
/// A URI path to use as a folder sample. /// A URI path to use as a folder sample.
@@ -24,5 +26,7 @@ extension String {
static let uriRedirection = "/some/redirect/path" static let uriRedirection = "/some/redirect/path"
/// A URI path to use as a resource sample. /// A URI path to use as a resource sample.
static let uriResource = "/some/path/to/resource" static let uriResource = "/some/path/to/resource"
/// A URI path to use as a root sample.
static let uriRoot = "/some/root/path"
} }
} }