diff --git a/Sources/DocCMiddleware/Internal/Extensions/String+Constants.swift b/Sources/DocCMiddleware/Internal/Extensions/String+Constants.swift index d807c5c..205cfc1 100644 --- a/Sources/DocCMiddleware/Internal/Extensions/String+Constants.swift +++ b/Sources/DocCMiddleware/Internal/Extensions/String+Constants.swift @@ -14,13 +14,13 @@ extension String { /// An empty string. static let empty = "" - /// A namespace that defines logging representations. + /// 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 representations. + /// A namespace that defines relative path values. enum Path { /// A forwarding slash. static let forwardSlash = "/" diff --git a/Sources/DocCMiddleware/Internal/Use Cases/ServeResourceUseCase.swift b/Sources/DocCMiddleware/Internal/Use Cases/ServeResourceUseCase.swift new file mode 100644 index 0000000..3c77779 --- /dev/null +++ b/Sources/DocCMiddleware/Internal/Use Cases/ServeResourceUseCase.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 ServeResourceUseCase { + + // MARK: Properties + + /// A type that conforms to a protocol that defines file system interactions. + let fileProvider: FileSystemProvider + + /// 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: FileSystemProvider, + 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/Tests/DocCMiddleware/Tests/Internal/Extensions/LoggerMetadata+HelpersTests.swift b/Tests/DocCMiddleware/Tests/Internal/Extensions/LoggerMetadata+HelpersTests.swift index dd0c9d0..4d25ed4 100644 --- a/Tests/DocCMiddleware/Tests/Internal/Extensions/LoggerMetadata+HelpersTests.swift +++ b/Tests/DocCMiddleware/Tests/Internal/Extensions/LoggerMetadata+HelpersTests.swift @@ -38,7 +38,7 @@ struct LoggerMetadata_HelpersTests { assertMetadata( method: try randomMethod, statusCode: try randomStatusCode, - redirect: .uriRedirection + redirect: .Sample.uriRedirection ) } #else diff --git a/Tests/DocCMiddleware/Tests/Internal/Use Cases/RedirectURIUseCaseTests.swift b/Tests/DocCMiddleware/Tests/Internal/Use Cases/RedirectURIUseCaseTests.swift index 0f5841c..3057e6f 100644 --- a/Tests/DocCMiddleware/Tests/Internal/Use Cases/RedirectURIUseCaseTests.swift +++ b/Tests/DocCMiddleware/Tests/Internal/Use Cases/RedirectURIUseCaseTests.swift @@ -27,7 +27,7 @@ struct RedirectURIUseCaseTests { func `response when logger expects an event`() async throws { try await assertResponse( logLevel: try randomLogLevelWithEvent, - uriRedirection: .uriRedirection, + uriRedirection: .Sample.uriRedirection, expects: .movedPermanently ) } @@ -36,7 +36,7 @@ struct RedirectURIUseCaseTests { func `response when logger does not expects an event`() async throws { try await assertResponse( logLevel: try randomLogLevelWithNoEvent, - uriRedirection: .uriRedirection, + uriRedirection: .Sample.uriRedirection, expects: .movedPermanently ) } @@ -123,7 +123,7 @@ private extension RedirectURIUseCaseTests { "hb.request.redirect": "\(uriRedirection)" ], message: "The URI path is redirected to this path: \(uriRedirection)", - source: "DocCMiddleware" + source: .Logging.source )) } else { #expect(events.isEmpty) diff --git a/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeResourceUseCaseTests.swift b/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeResourceUseCaseTests.swift new file mode 100644 index 0000000..239db97 --- /dev/null +++ b/Tests/DocCMiddleware/Tests/Internal/Use Cases/ServeResourceUseCaseTests.swift @@ -0,0 +1,268 @@ +// ===----------------------------------------------------------------------=== +// +// 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.ServeResourceUseCase + +@Suite("Serve Resource Use Case", .tags(.useCase)) +struct ServeResourceUseCaseTests { + + // MARK: Use case tests + +#if swift(>=6.2) + @Test + func `response when resource served and logging event triggered`() async throws { + try await assertResponse( + logLevel: .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: .info, + 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: .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: .critical, + uriPath: .Sample.uriResource, + folderPath: .Sample.uriFolder, + expects: .notFound + ) + } + + @Test + func `response throws error when loading resource`() async throws { + try await assertResponse( + logLevel: .warning, + 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: .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: .info, + 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: .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: .critical, + 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: .warning, + uriPath: .Sample.uriResource, + folderPath: .Sample.uriFolder + ) + } +#endif + +} + +// MARK: - Assertions + +private extension ServeResourceUseCaseTests { + + // MARK: Functions + + /// Asserts a response returned by the ``ServeResourceUseCase`` 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 = ServeResourceUseCase( + 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 ServeResourceUseCaseTests { + + // 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. + /// - 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/Types/Extensions/String+Constants.swift b/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift index 72b41b6..bbfb6cf 100644 --- a/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift +++ b/Tests/DocCMiddleware/Types/Extensions/String+Constants.swift @@ -14,7 +14,15 @@ extension String { // MARK: Constants - /// A URI path to use as a redirection sample. - static let uriRedirection = "/some/redirect/path" - + /// A namespace that defines sample values. + enum Sample { + /// 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" + } }