From 8aa2cf0fb2b764623c622d5f0acfff39a38ca4d7 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 26 Sep 2025 00:27:56 +0200 Subject: [PATCH] Implemented the "handle(_: context: next: )" function for the DocCMiddleware type in the library target. --- .../Public/Middlewares/DocCMiddleware.swift | 78 ++- .../Use Cases/ServeURIUseCaseTests.swift | 2 - .../Middlewares/DocCMiddlewareTests.swift | 530 +++++++++++++++++- .../Types/Extensions/String+Constants.swift | 4 + 4 files changed, 605 insertions(+), 9 deletions(-) diff --git a/Sources/DocCMiddleware/Public/Middlewares/DocCMiddleware.swift b/Sources/DocCMiddleware/Public/Middlewares/DocCMiddleware.swift index f40d1d7..c9f804e 100644 --- a/Sources/DocCMiddleware/Public/Middlewares/DocCMiddleware.swift +++ b/Sources/DocCMiddleware/Public/Middlewares/DocCMiddleware.swift @@ -11,8 +11,12 @@ // ===----------------------------------------------------------------------=== 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. @@ -32,6 +36,15 @@ public struct DocCMiddleware { /// 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. @@ -93,11 +106,74 @@ extension DocCMiddleware: RouterMiddleware { context: any Context, next: (Input, any Context) 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) } + + 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) } } + diff --git a/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeURIUseCaseTests.swift b/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeURIUseCaseTests.swift index fb011a5..6c36e7f 100644 --- a/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeURIUseCaseTests.swift +++ b/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeURIUseCaseTests.swift @@ -245,8 +245,6 @@ private extension ServeURIUseCaseTests { // MARK: Functions - // 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. diff --git a/Tests/DocCMiddleware/Tests/Public/Middlewares/DocCMiddlewareTests.swift b/Tests/DocCMiddleware/Tests/Public/Middlewares/DocCMiddlewareTests.swift index cd5be76..8334415 100644 --- a/Tests/DocCMiddleware/Tests/Public/Middlewares/DocCMiddlewareTests.swift +++ b/Tests/DocCMiddleware/Tests/Public/Middlewares/DocCMiddlewareTests.swift @@ -13,8 +13,11 @@ 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 @@ -28,8 +31,8 @@ struct DocCMiddlewareTests { @Test func `initialize with URI and folder paths`() { assertInit(configuration: .init( - uriRoot: "/path/to/documentation", - folderRoot: "/location/docc/documentation" + uriRoot: .Sample.uriResource, + folderRoot: .Sample.uriFolder )) } @@ -37,7 +40,7 @@ struct DocCMiddlewareTests { func `initialize with URI path and type that conforms to the FileProvider protocol`() { assertInit( configuration: .init( - uriRoot: "/path/to/documentation", + uriRoot: .Sample.uriResource, folderRoot: .empty ), fileProvider: FileProviderStub() @@ -47,8 +50,8 @@ struct DocCMiddlewareTests { @Test("initialize with URI and folder paths") func init_withURI_andFolderPaths() { assertInit(configuration: .init( - uriRoot: "/path/to/documentation", - folderRoot: "/location/docc/documentation" + uriRoot: .Sample.uriResource, + folderRoot: .Sample.uriFolder )) } @@ -56,13 +59,223 @@ struct DocCMiddlewareTests { func init_withURI_path_andFileProviderType() { assertInit( configuration: .init( - uriRoot: "/path/to/documentation", + 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 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 } @@ -94,6 +307,7 @@ private extension DocCMiddlewareTests { #expect(middleware.logger.label == logger.label) #expect(middleware.logger.logLevel == logger.logLevel) + #expect(middleware.logger.metadataProvider == nil) #expect(type(of:middleware.fileProvider) == LocalFileSystem.self) } @@ -123,8 +337,312 @@ private extension DocCMiddlewareTests { #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 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" + ] } diff --git a/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift b/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift index bbfb6cf..c41d580 100644 --- a/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift +++ b/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift @@ -16,6 +16,8 @@ extension String { /// 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. @@ -24,5 +26,7 @@ extension String { 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" } }