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 { ]) ]) } - + }