From a621ada0bf9d3fc1f83b02522a84a78c94d6f363 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 28 Jun 2026 05:07:19 +0000 Subject: [PATCH] HTML rendering support for the Website service (#5) This PR contains the work done to replace the use of static _HTML_ files with type-safe HTML rendered server-side via **Elementary** through **Hummingbird**. To provide further details about the work done: * Added the **Elementary** dependencies. * Added the `IndexPage` and `ErrorPage` pages, ported from the old HTML boilerplate; removed the static files. * Added the `RootController` controller serving GET / using the `IndexPage` page, wired into the router. * Reworked the `NotFoundMiddleware` middleare to render `ErrorPage` page directly; non-notFound errors still propagate. * the `FileMiddleware` middleware no longer searches for any static `index.html` file. * Simplified the `StaticFile` enumeration, dropped unused constants and now-unnecessary throws. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/5 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/Package.swift | 28 +++++++ Services/Website/Resources/Static/404.html | 62 --------------- Services/Website/Resources/Static/index.html | 33 -------- Services/Website/Sources/App/App+build.swift | 18 ++--- Services/Website/Sources/App/App.swift | 2 +- .../Internal/Enumerations/StaticFile.swift | 42 +--------- .../Library/Internal/Pages/ErrorPage.swift | 79 +++++++++++++++++++ .../Library/Internal/Pages/IndexPage.swift | 63 +++++++++++++++ .../Public/Controllers/RootController.swift | 71 +++++++++++++++++ .../Extensions/ConfigValue+Constants.swift | 9 --- .../Middlewares/NotFoundMiddleware.swift | 72 +++++++---------- Services/Website/Tests/App/AppTests.swift | 58 +++++--------- .../Enumerations/StaticFileTests.swift | 10 +-- .../Cases/Internal/Pages/ErrorPageTests.swift | 20 +++++ .../Cases/Internal/Pages/IndexPageTests.swift | 21 +++++ .../Controllers/RootControllerTests.swift | 39 +++++++++ .../Middlewares/NotFoundMiddlewareTests.swift | 77 ++++++++++++++++++ 17 files changed, 460 insertions(+), 244 deletions(-) delete mode 100644 Services/Website/Resources/Static/404.html delete mode 100644 Services/Website/Resources/Static/index.html create mode 100644 Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift create mode 100644 Services/Website/Sources/Library/Internal/Pages/IndexPage.swift create mode 100644 Services/Website/Sources/Library/Public/Controllers/RootController.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 0d09443..8b81b82 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -19,6 +19,14 @@ let package = Package( ) ], dependencies: [ + .package( + url: "https://github.com/elementary-swift/elementary.git", + from: "0.6.0" + ), + .package( + url: "https://github.com/hummingbird-community/hummingbird-elementary.git", + from: "0.3.0" + ), .package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.25.0" @@ -55,10 +63,18 @@ let package = Package( name: "Configuration", package: "swift-configuration" ), + .product( + name: "Elementary", + package: "elementary" + ), .product( name: "Hummingbird", package: "hummingbird" ), + .product( + name: "HummingbirdElementary", + package: "hummingbird-elementary" + ), ], path: "Sources/Library" ), @@ -77,6 +93,18 @@ let package = Package( name: "WebsiteCoreTests", dependencies: [ .byName(name: "WebsiteCore"), + .product( + name: "Elementary", + package: "elementary" + ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), + .product( + name: "HummingbirdTesting", + package: "hummingbird" + ), ], path: "Tests/Library" ), diff --git a/Services/Website/Resources/Static/404.html b/Services/Website/Resources/Static/404.html deleted file mode 100644 index 260cc4c..0000000 --- a/Services/Website/Resources/Static/404.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - Page Not Found - - - - - -

Page Not Found

-

Sorry, but the page you were trying to view does not exist.

- - - - diff --git a/Services/Website/Resources/Static/index.html b/Services/Website/Resources/Static/index.html deleted file mode 100644 index f3add49..0000000 --- a/Services/Website/Resources/Static/index.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Hello world! This is HTML5 Boilerplate.

- - - - - diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index 41d2fee..2358bb5 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -9,10 +9,9 @@ import WebsiteCore /// then assembles the router, server configuration, and logger. /// - Parameter reader: the configuration reader the values are read from. /// - Returns: the configured application, ready to run as a service. -/// - Throws: an error if the router fails to build. func application( reader: ConfigReader -) async throws -> some ApplicationProtocol { +) async -> some ApplicationProtocol { let logLevel = reader.string( forKey: .Log.level, as: Logger.Level.self, @@ -65,9 +64,9 @@ private func logger( /// Builds the application's router. /// -/// 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). +/// Registers the request-logging middleware, the not-found middleware that serves the error +/// page, and the static file middleware that serves the contents of `staticFilesPath`, then +/// adds the `RootController` routes that render the landing page. /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. /// - logLevel: the level the request-logging middleware logs at. @@ -80,12 +79,11 @@ private func router( router.addMiddleware { LogRequestsMiddleware(logLevel) - NotFoundMiddleware(staticFilesPath) - FileMiddleware( - staticFilesPath, - searchForIndexHtml: true - ) + NotFoundMiddleware() + FileMiddleware(staticFilesPath) } + router.addRoutes(RootController().routes) + return router } diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index 335916c..cf3e8bf 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -19,7 +19,7 @@ struct App { ] ) - let app = try await application( + let app = await application( reader: reader ) diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index 656f369..0c72430 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -1,5 +1,3 @@ -import Foundation - /// A static file shipped with the website service. /// /// Each case identifies a file stored under the static files root (the `Resources/Static` @@ -7,16 +5,12 @@ import Foundation enum StaticFile: CaseIterable, Sendable { /// The `js/app.js` script. case appJS - /// The `404.html` error page. - case errorHTML /// The `favicon.ico` icon. case faviconICO /// The `icon.png` icon. case iconPNG /// The `icon.svg` icon. case iconSVG - /// The `index.html` landing page. - case indexHTML /// The `robots.txt` crawler directives. case robotsTXT /// The `site.webmanifest` web application manifest. @@ -32,8 +26,6 @@ extension StaticFile { enum Extension: String, Sendable { /// A Cascading Style Sheets file. case css - /// A HyperText Markup Language file. - case html /// A JavaScript file. case js /// A Portable Network Graphics image. @@ -59,7 +51,6 @@ extension StaticFile { var contentType: String { switch fileExtension { case .css: "text/css" - case .html: "text/html" case .js: "text/javascript" case .png: "image/png" case .ico: "image/vnd.microsoft.icon" @@ -74,8 +65,6 @@ extension StaticFile { switch self { case .styleCSS: .css case .appJS: .js - case .errorHTML, - .indexHTML: .html case .faviconICO: .ico case .iconPNG: .png case .iconSVG: .svg @@ -88,11 +77,9 @@ extension StaticFile { var fileName: String { switch self { case .appJS: "app" - case .errorHTML: "404" case .faviconICO: "favicon" case .iconPNG, .iconSVG: "icon" - case .indexHTML: "index" case .robotsTXT: "robots" case .siteWebmanifest: "site" case .styleCSS: "style" @@ -103,18 +90,10 @@ extension StaticFile { /// /// This also matches the URL path the file is served at by `FileMiddleware`. var relativePath: String { - let file = String( - format: Constant.Format.file, - fileName, - fileExtension.rawValue - ) + let file = "\(fileName).\(fileExtension.rawValue)" return subdirectory - .map { .init( - format: Constant.Format.path, - $0, - file - )} ?? file + .map { "\($0)/\(file)" } ?? file } // MARK: Methods @@ -129,12 +108,8 @@ extension StaticFile { guard !basePath.isEmpty else { return relativePath } - - return .init( - format: Constant.Format.path, - basePath, - relativePath - ) + + return "\(basePath)/\(relativePath)" } } @@ -155,12 +130,3 @@ private extension StaticFile { } } - -// MARK: - Constants - -private enum Constant { - enum Format { - static let file = "%@.%@" - static let path = "%@/%@" - } -} diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift new file mode 100644 index 0000000..b59df57 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -0,0 +1,79 @@ +import Elementary + +/// The HTML page rendered for a not-found response. +struct ErrorPage: HTMLDocument, Sendable { + + // MARK: Document + + /// The page's content. + var body: some HTML { + h1 { "Page Not Found" } + p { "Sorry, but the page you were trying to view does not exist." } + } + + /// The metadata and inline styles placed in the document head. + var head: some HTML { + meta(.charset(.utf8)) + meta( + .name(.viewport), + .content("width=device-width, initial-scale=1") + ) + style { Self.styles } + } + + /// The document language. + var lang: String { "en" } + + /// The document title. + var title: String { "Page Not Found" } + +} + +// MARK: - Constants + +private extension ErrorPage { + /// The inline CSS applied to the page. + static let styles = """ + * { + line-height: 1.2; + margin: 0; + } + + html { + color: #888; + display: table; + font-family: sans-serif; + height: 100%; + text-align: center; + width: 100%; + } + + body { + display: table-cell; + vertical-align: middle; + margin: 2em auto; + } + + h1 { + color: #555; + font-size: 2em; + font-weight: 400; + } + + p { + margin: 0 auto; + width: 280px; + } + + @media only screen and (max-width: 280px) { + body, p { + width: 95%; + } + + h1 { + font-size: 1.5em; + margin: 0 0 0.3em; + } + } + """ +} diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift new file mode 100644 index 0000000..d55429b --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -0,0 +1,63 @@ +import Elementary + +/// The website's landing page. +struct IndexPage: HTMLDocument, Sendable { + + // MARK: Document + + /// The page's content. + var body: some HTML { + p { "Hello world! This is HTML5 Boilerplate." } + script(.src("js/app.js")) {} + } + + /// The metadata, stylesheet, icon, and manifest links placed in the document head. + var head: some HTML { + meta(.charset(.utf8)) + meta( + .name(.viewport), + .content("width=device-width, initial-scale=1") + ) + link( + .rel(.stylesheet), + .href("css/style.css") + ) + link( + .rel(.icon), + .href("/favicon.ico"), + .custom( + name: "sizes", + value: "any" + ) + ) + link( + .rel(.icon), + .href("/icon.svg"), + .custom( + name: "type", + value: "image/svg+xml" + ) + ) + link( + .rel("apple-touch-icon"), + .href("icon.png") + ) + link( + .rel("manifest"), + .href("site.webmanifest") + ) + meta( + .name("theme-color"), + .content("#fafafa") + ) + } + + /// The document language. + var lang: String { + "en" + } + + /// The document title. + var title: String { "" } + +} diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift new file mode 100644 index 0000000..0cd1ab6 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -0,0 +1,71 @@ +import Hummingbird +import HummingbirdElementary + +/// Serves the website's root routes. +/// +/// The controller exposes its routes as a `RouteCollection` so they can be added to a router +/// (or a sub-group) by the application that composes it: +/// +/// ```swift +/// router.addRoutes(RootController().routes) +/// ``` +/// +/// - Note: `Context` is the request context the routes are resolved against, and must match the +/// context of the router the routes are added to. +public struct RootController : Sendable{ + + // MARK: Initializers + + /// Creates a root controller. + public init() {} + + // MARK: Properties + + /// The routes served by the controller. + /// + /// Serves a `GET` request for the root path (`/`) by rendering the ``IndexPage``. + public var routes: RouteCollection { + let routes = RouteCollection(context: Context.self) + + routes.get( + .Root.index, + use: index + ) + + return routes + } + +} + +// MARK: - Helpers + +private extension RootController { + + // MARK: Methods + + /// Handles a request for the landing page. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - Returns: an HTML response that renders the ``IndexPage``. + @Sendable + func index( + request: Request, + context: some RequestContext + ) -> HTMLResponse { + .init { + IndexPage() + } + } + +} + +// MARK: - Constants + +extension RouterPath { + /// A namespace for the ``RootController`` route paths. + enum Root { + /// The path of the landing page. + static let index: RouterPath = "/" + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift index 58973bf..326e429 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift @@ -3,16 +3,7 @@ import Configuration extension ConfigValue { /// A namespace for the HTTP server's default configuration values. public enum HTTP { - /// The default host the server binds to. - public static let host: ConfigValue = .init(stringLiteral: "127.0.0.1") - /// The default port the server listens on. - public static let port: ConfigValue = .init(stringLiteral: "0") /// The default server name. public static let serverName: ConfigValue = .init(stringLiteral: .Server.name) } - /// A namespace for the logging default configuration values. - enum Log { - /// The default minimum log level. - public static let level: ConfigValue = .init(stringLiteral: "trace") - } } diff --git a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift index d82fbfe..6032f9f 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift +++ b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift @@ -1,46 +1,19 @@ -import Foundation +import Elementary import Hummingbird -import NIOCore +import HummingbirdElementary /// 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. +/// that bubbles up when no file exists for the requested path and responds with the rendered +/// ``ErrorPage`` 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 - } - + + /// Creates a not-found middleware. + public init() {} + } // MARK: - RouterMiddleware @@ -49,6 +22,16 @@ extension NotFoundMiddleware: RouterMiddleware { // MARK: Functions + /// Passes the request down the chain, rendering the error page if it results in a not-found + /// response. + /// + /// Any error other than `.notFound` is rethrown unchanged. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - next: the next responder in the middleware chain. + /// - Returns: the downstream response, or the rendered ``ErrorPage`` with a `404 Not Found` status. + /// - Throws: any non-not-found error thrown downstream. public func handle( _ request: Request, context: Context, @@ -57,7 +40,6 @@ extension NotFoundMiddleware: RouterMiddleware { 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 @@ -65,14 +47,14 @@ extension NotFoundMiddleware: RouterMiddleware { throw error } - var headers = HTTPFields() - - headers[.contentType] = StaticFile.errorHTML.contentType - - return Response( - status: .notFound, - headers: headers, - body: .init(byteBuffer: page) + return HTMLResponse( + status: .notFound + ) { + ErrorPage() + } + .response( + from: request, + context: context ) } } diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index ec7dfb1..fd64115 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -27,21 +27,16 @@ struct AppTests { @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 + let body = String(buffer: response.body) + #expect(response.status == .ok) - #expect(response.headers[.contentType] == file.contentType) - #expect(response.body == data(of: file)) + #expect(response.headers[.contentType]?.hasPrefix("text/html") == true) + #expect(body.contains("Hello world!")) } } } @@ -50,12 +45,6 @@ struct AppTests { func `static files to be served`( staticFile file: StaticFile ) async throws { - let app = try await application( - reader: reader( - staticFilesPath: staticFilesPath - ) - ) - try await app.test(.router) { client in try await client.execute( uri: "/\(file.relativePath)", @@ -69,21 +58,16 @@ 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 + let body = String(buffer: response.body) + #expect(response.status == .notFound) - #expect(response.headers[.contentType] == file.contentType) - #expect(response.body == data(of: file)) + #expect(response.headers[.contentType]?.hasPrefix("text/html") == true) + #expect(body.contains("Page Not Found")) } } } @@ -94,28 +78,28 @@ struct AppTests { private extension AppTests { - // MARK: Methods + // MARK: Computed - func data( - of file: StaticFile - ) -> ByteBuffer { - let url = URL(fileURLWithPath: file.path(relativeTo: staticFilesPath)) - - guard let data = try? Data(contentsOf: url) else { - return ByteBuffer() + var app: some ApplicationProtocol { + get async { + await application( + reader: reader( + staticFilesPath: staticFilesPath + ) + ) } - - return ByteBuffer(bytes: data) } + // MARK: Methods + func reader( staticFilesPath: String ) -> ConfigReader { ConfigReader(providers: [ InMemoryProvider(values: [ - .HTTP.host: .HTTP.host, - .HTTP.port: .HTTP.port, - .Log.level: .Log.level, + .HTTP.host: "127.0.0.1", + .HTTP.port: "0", + .Log.level: "trace", .Path.staticFiles: .init(stringLiteral: staticFilesPath), ]) ]) diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 1ac3872..ba7b26d 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -82,7 +82,7 @@ struct StaticFileTests { @Test func `all cases`() { - #expect(File.allCases.count == 9) + #expect(File.allCases.count == 7) } } @@ -95,44 +95,36 @@ private extension StaticFileTests { static let contentTypes: [String] = [ "text/javascript", - "text/html", "image/vnd.microsoft.icon", "image/png", "image/svg+xml", - "text/html", "text/plain", "application/manifest+json", "text/css" ] static let fileExtensions: [FileExtension] = [ .js, - .html, .ico, .png, .svg, - .html, .txt, .webmanifest, .css ] static let fileNames: [String] = [ "app", - "404", "favicon", "icon", "icon", - "index", "robots", "site", "style" ] static let relativePaths: [String] = [ "js/app.js", - "404.html", "favicon.ico", "icon.png", "icon.svg", - "index.html", "robots.txt", "site.webmanifest", "css/style.css" diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift new file mode 100644 index 0000000..45d74cb --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift @@ -0,0 +1,20 @@ +import Elementary +import Testing + +@testable import WebsiteCore + +@Suite("ErrorPage page") +struct ErrorPageTests { + + // MARK: Functional tests + + @Test + func `renders its markup`() { + let html = ErrorPage().render() + + #expect(html.contains("")) + #expect(html.contains("Page Not Found")) + #expect(html.contains("does not exist")) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift new file mode 100644 index 0000000..1e0233c --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -0,0 +1,21 @@ +import Elementary +import Testing + +@testable import WebsiteCore + +@Suite("IndexPage page") +struct IndexPageTests { + + // MARK: Functional tests + + @Test + func `renders its markup`() { + let html = IndexPage().render() + + #expect(html.contains("")) + #expect(html.contains("Hello world!")) + #expect(html.contains("css/style.css")) + #expect(html.contains("js/app.js")) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift new file mode 100644 index 0000000..daa9e79 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -0,0 +1,39 @@ +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import WebsiteCore + +@Suite("RootController controller") +struct RootControllerTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router() + + router.addRoutes(RootController().routes) + + return router + }()) + + // MARK: Functional tests + + @Test + func `serves the landing page at the root path`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .ok) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") + #expect(body.contains("Hello world!")) + } + } + } + +} diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift new file mode 100644 index 0000000..b4646e3 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -0,0 +1,77 @@ +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import WebsiteCore + +@Suite("NotFoundMiddleware middleware") +struct NotFoundMiddlewareTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router() + + router.addMiddleware { + NotFoundMiddleware() + } + + router.get("hello") { _, _ in + "Hello!" + } + + router.get("boom") { _, _ -> String in + throw HTTPError(.badRequest) + } + + return router + }()) + + // MARK: Functional tests + + @Test + func `renders the error page for an unmatched request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .notFound) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") + #expect(body.contains("Page Not Found")) + } + } + } + + @Test + func `passes a matched response through untouched`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.body == ByteBuffer(string: "Hello!")) + } + } + } + + @Test + func `rethrows a non-not-found error unchanged`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/boom", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .badRequest) + #expect(!body.contains("Page Not Found")) + } + } + } + +}