From f5f14eb97c9afe34577c09d8f64539266abd5158 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 27 Jun 2026 12:16:50 +0000 Subject: [PATCH] File not found middleware for the Website service (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to add a `NotFoundMiddleware` middleware to the Website service so requests matching neither a route nor a static file return a custom 404.html page with a 404 Not Found status. Also enables index.html fallback so the landing page is served at the site root. To provider further details about the work done: * NotFoundMiddleware — intercepts the `.notFound` error from the `FileMiddleware` middleware and serves the preloaded error page with the correct content type; all other errors propagate. Falls back to a minimal body if the file is missing. * Router — wires the `NotFoundMiddleware` middleware ahead of the `FileMiddleware` and enables searchForIndexHtml. * Package — adds the **Hummingbird** product to the Library target. * Tooling — sets a custom working directory in the Xcode scheme; removes the unused `pkg-deps` target from the `Makefile` file. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/4 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../xcshareddata/xcschemes/Website.xcscheme | 3 +- Services/Website/Makefile | 4 - Services/Website/Package.swift | 4 + Services/Website/Sources/App/App+build.swift | 8 +- .../Middlewares/NotFoundMiddleware.swift | 80 +++++++++++++++++++ Services/Website/Tests/App/AppTests.swift | 63 ++++++++++++++- 6 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme index 01a4a46..24b3ac5 100644 --- a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme +++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme @@ -50,7 +50,8 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" - useCustomWorkingDirectory = "NO" + useCustomWorkingDirectory = "YES" + customWorkingDirectory = "/Users/logan/Documents/Development/Platforms/Röck+Cöde/Loud/Services/Website" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" diff --git a/Services/Website/Makefile b/Services/Website/Makefile index 98cb6b7..30aa03f 100644 --- a/Services/Website/Makefile +++ b/Services/Website/Makefile @@ -41,10 +41,6 @@ pkg-clean: ## Remove the Swift build artifacts pkg-reset: ## Resets the complete SPM cache/build folder @swift package reset -.PHONY: pkg-deps -pkg-deps: ## Lists the SPM package dependencies - @swift package show-dependencies - .PHONY: pkg-outdated pkg-outdated: ## Lists the SPM package dependencies that can be updated @swift package update --dry-run diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index e44f6bb..0d09443 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -55,6 +55,10 @@ let package = Package( name: "Configuration", package: "swift-configuration" ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), ], path: "Sources/Library" ), diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index 3561f8b..41d2fee 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -65,8 +65,9 @@ private func logger( /// Builds the application's router. /// -/// Registers the request-logging middleware and the static file middleware that serves -/// the contents of `staticFilesPath`. +/// Registers, in order, the request-logging middleware, the not-found middleware that serves +/// the error page, and the static file middleware that serves the contents of `staticFilesPath` +/// (falling back to `index.html` for directory requests). /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. /// - logLevel: the level the request-logging middleware logs at. @@ -79,9 +80,10 @@ private func router( router.addMiddleware { LogRequestsMiddleware(logLevel) + NotFoundMiddleware(staticFilesPath) FileMiddleware( staticFilesPath, - searchForIndexHtml: false + searchForIndexHtml: true ) } diff --git a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift new file mode 100644 index 0000000..d82fbfe --- /dev/null +++ b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift @@ -0,0 +1,80 @@ +import Foundation +import Hummingbird +import NIOCore + +/// Serves a custom error page for requests that match neither a route nor a static file. +/// +/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error +/// that bubbles up when no file exists for the requested path and responds with the preloaded +/// error page and a `404 Not Found` status. +public struct NotFoundMiddleware { + + // MARK: Properties + + /// The body of the error page served on a not-found response. + private let page: ByteBuffer + + // MARK: Initializers + + /// Creates a middleware that serves the error page (`404.html`) from the static files folder. + /// + /// The page is read once, at construction. A minimal fallback body is used when the file is + /// missing. + /// - Parameter staticFilesPath: the folder, relative to the working directory, the static files are served from. + public init( + _ staticFilesPath: String + ) { + let path = StaticFile.errorHTML.path(relativeTo: staticFilesPath) + + if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { + self.init(page: .init(bytes: data)) + } else { + self.init(page: .init(string: "404 Not Found")) + } + } + + /// Creates a middleware that serves the given error page on a not-found response. + /// - Parameter page: the body of the error page. + init( + page: ByteBuffer + ) { + self.page = page + } + +} + +// MARK: - RouterMiddleware + +extension NotFoundMiddleware: RouterMiddleware { + + // MARK: Functions + + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + do { + return try await next(request, context) + } catch let error { + // Only intercept "not found"; let every other error propagate. + guard + let responseError = error as? any HTTPResponseError, + responseError.status == .notFound + else { + throw error + } + + var headers = HTTPFields() + + headers[.contentType] = StaticFile.errorHTML.contentType + + return Response( + status: .notFound, + headers: headers, + body: .init(byteBuffer: page) + ) + } + } + +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index c784d94..ec7dfb1 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -3,6 +3,7 @@ import Foundation import Hummingbird import HummingbirdTesting import Logging +import NIOCore import Testing @testable import Website @@ -21,9 +22,30 @@ struct AppTests { .deletingLastPathComponent() // package root .appendingPathComponent(.Path.staticResources) .path - + // MARK: Functional tests + @Test + func `landing page to be served at root`() async throws { + let file: StaticFile = .indexHTML + let app = try await application( + reader: reader( + staticFilesPath: staticFilesPath + ) + ) + + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentType] == file.contentType) + #expect(response.body == data(of: file)) + } + } + } + @Test(arguments: StaticFile.allCases) func `static files to be served`( staticFile file: StaticFile @@ -45,14 +67,47 @@ struct AppTests { } } + @Test + func `error page to be served when not found`() async throws { + let file: StaticFile = .errorHTML + let app = try await application( + reader: reader( + staticFilesPath: staticFilesPath + ) + ) + + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + #expect(response.status == .notFound) + #expect(response.headers[.contentType] == file.contentType) + #expect(response.body == data(of: file)) + } + } + } + } // MARK: - Helpers private extension AppTests { - + // MARK: Methods - + + func data( + of file: StaticFile + ) -> ByteBuffer { + let url = URL(fileURLWithPath: file.path(relativeTo: staticFilesPath)) + + guard let data = try? Data(contentsOf: url) else { + return ByteBuffer() + } + + return ByteBuffer(bytes: data) + } + func reader( staticFilesPath: String ) -> ConfigReader { @@ -65,5 +120,5 @@ private extension AppTests { ]) ]) } - + }