From 7542bc595fd79a6a972898cd21be1455cbf76955 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 27 Jun 2026 10:59:32 +0000 Subject: [PATCH] Static file serving support for the Website service (#3) This PR contains the work done to support the static files serving for the **Website** service, and also included the essential boilerplate assets from the **HTML5 boilerplate** project. To provide further details about the work done: * Serving: Added the `FileMiddleware` middlewqare to the router; `path`, `server name`, and `log level` now read from config with defaults. * Library: Added the `StaticFile` enumeration with a `contentType` property, plus typed config-key/value constants and the `Configuration` dependency on WebsiteCore. * Assets: Added the **HTML5 boilerplate** (HTML, CSS, JS, icons, manifest, robots) to the *Resources/Static* folder. * Docker: Stage the Resources directory as read-only. * Tests: Added a test plan, and a shared Xcode scheme. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/3 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/.dockerignore | 1 + .../xcshareddata/xcschemes/Website.xcscheme | 92 +++++++ Services/Website/Dockerfile | 4 +- Services/Website/Package.swift | 10 +- Services/Website/Resources/Static/404.html | 62 +++++ .../Website/Resources/Static/css/style.css | 246 ++++++++++++++++++ Services/Website/Resources/Static/favicon.ico | Bin 0 -> 766 bytes Services/Website/Resources/Static/icon.png | Bin 0 -> 4029 bytes Services/Website/Resources/Static/icon.svg | 1 + Services/Website/Resources/Static/index.html | 33 +++ Services/Website/Resources/Static/js/app.js | 0 Services/Website/Resources/Static/robots.txt | 5 + .../Website/Resources/Static/site.webmanifest | 12 + Services/Website/Sources/App/App+build.swift | 55 +++- Services/Website/Sources/App/App.swift | 2 +- .../Internal/Enumerations/StaticFile.swift | 166 ++++++++++++ .../Website/Sources/Library/Library.swift | 2 - .../AbsoluteConfigKey+Constants.swift | 23 ++ .../Extensions/ConfigKey+Constants.swift | 23 ++ .../Extensions/ConfigValue+Constants.swift | 18 ++ .../Public/Extensions/String+Constants.swift | 12 + Services/Website/Tests/App/AppTests.swift | 63 ++++- .../Enumerations/StaticFileTests.swift | 141 ++++++++++ .../Website/Tests/Library/LibraryTest.swift | 1 - Services/Website/Website.xctestplan | 37 +++ 25 files changed, 973 insertions(+), 36 deletions(-) create mode 100644 Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme create mode 100644 Services/Website/Resources/Static/404.html create mode 100644 Services/Website/Resources/Static/css/style.css create mode 100644 Services/Website/Resources/Static/favicon.ico create mode 100644 Services/Website/Resources/Static/icon.png create mode 100644 Services/Website/Resources/Static/icon.svg create mode 100644 Services/Website/Resources/Static/index.html create mode 100644 Services/Website/Resources/Static/js/app.js create mode 100644 Services/Website/Resources/Static/robots.txt create mode 100644 Services/Website/Resources/Static/site.webmanifest create mode 100644 Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift delete mode 100644 Services/Website/Sources/Library/Library.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/String+Constants.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift delete mode 100644 Services/Website/Tests/Library/LibraryTest.swift create mode 100644 Services/Website/Website.xctestplan diff --git a/Services/Website/.dockerignore b/Services/Website/.dockerignore index 5842fb7..1f3233b 100644 --- a/Services/Website/.dockerignore +++ b/Services/Website/.dockerignore @@ -1,5 +1,6 @@ .build .swiftpm +.DS_Store .env.local docker-compose.* Makefile diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme new file mode 100644 index 0000000..01a4a46 --- /dev/null +++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index a08d2fb..e3f71bf 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -41,9 +41,9 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ # Copy resources bundled by SPM to staging area RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; -# Copy any resouces from the public directory and views directory if the directories exist +# Copy the static files directory (served by FileMiddleware) if it exists # Ensure that by default, neither the directory nor any of its contents are writable. -RUN [ -d /build/public ] && { mv /build/public ./public && chmod -R a-w ./public; } || true +RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true # ================================ # Run image diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index a6d6bf0..e44f6bb 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -36,6 +36,7 @@ let package = Package( .executableTarget( name: "Website", dependencies: [ + .byName(name: "WebsiteCore"), .product( name: "Configuration", package: "swift-configuration" @@ -49,17 +50,22 @@ let package = Package( ), .target( name: "WebsiteCore", - dependencies: [], + dependencies: [ + .product( + name: "Configuration", + package: "swift-configuration" + ), + ], path: "Sources/Library" ), .testTarget( name: "WebsiteTests", dependencies: [ + .byName(name: "Website"), .product( name: "HummingbirdTesting", package: "hummingbird" ), - .byName(name: "Website"), ], path: "Tests/App" ), diff --git a/Services/Website/Resources/Static/404.html b/Services/Website/Resources/Static/404.html new file mode 100644 index 0000000..260cc4c --- /dev/null +++ b/Services/Website/Resources/Static/404.html @@ -0,0 +1,62 @@ + + + + + + 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/css/style.css b/Services/Website/Resources/Static/css/style.css new file mode 100644 index 0000000..ba80dc8 --- /dev/null +++ b/Services/Website/Resources/Static/css/style.css @@ -0,0 +1,246 @@ +/*! HTML5 Boilerplate v9.0.1 | MIT License | https://html5boilerplate.com/ */ + +/* main.css 3.0.0 | MIT License | https://github.com/h5bp/main.css#readme */ +/* + * What follows is the result of much research on cross-browser styling. + * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, + * Kroc Camen, and the H5BP dev community and team. + */ + +/* ========================================================================== + Base styles: opinionated defaults + ========================================================================== */ + +html { + color: #222; + font-size: 1em; + line-height: 1.4; +} + +/* + * Remove text-shadow in selection highlight: + * https://twitter.com/miketaylr/status/12228805301 + * + * Customize the background color to match your design. + */ + +::-moz-selection { + background: #b3d4fc; + text-shadow: none; +} + +::selection { + background: #b3d4fc; + text-shadow: none; +} + +/* + * A better looking default horizontal rule + */ + +hr { + display: block; + height: 1px; + border: 0; + border-top: 1px solid #ccc; + margin: 1em 0; + padding: 0; +} + +/* + * Remove the gap between audio, canvas, iframes, + * images, videos and the bottom of their containers: + * https://github.com/h5bp/html5-boilerplate/issues/440 + */ + +audio, +canvas, +iframe, +img, +svg, +video { + vertical-align: middle; +} + +/* + * Remove default fieldset styles. + */ + +fieldset { + border: 0; + margin: 0; + padding: 0; +} + +/* + * Allow only vertical resizing of textareas. + */ + +textarea { + resize: vertical; +} + +/* ========================================================================== + Author's custom styles + ========================================================================== */ + +/* ========================================================================== + Helper classes + ========================================================================== */ + +/* + * Hide visually and from screen readers + */ + +.hidden, +[hidden] { + display: none !important; +} + +/* + * Hide only visually, but have it available for screen readers: + * https://snook.ca/archives/html_and_css/hiding-content-for-accessibility + * + * 1. For long content, line feeds are not interpreted as spaces and small width + * causes content to wrap 1 word per line: + * https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe + */ + +.visually-hidden { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + /* 1 */ +} + +/* + * Extends the .visually-hidden class to allow the element + * to be focusable when navigated to via the keyboard: + * https://www.drupal.org/node/897638 + */ + +.visually-hidden.focusable:active, +.visually-hidden.focusable:focus { + clip: auto; + height: auto; + margin: 0; + overflow: visible; + position: static; + white-space: inherit; + width: auto; +} + +/* + * Hide visually and from screen readers, but maintain layout + */ + +.invisible { + visibility: hidden; +} + +/* + * Clearfix: contain floats + * + * The use of `table` rather than `block` is only necessary if using + * `::before` to contain the top-margins of child elements. + */ + +.clearfix::before, +.clearfix::after { + content: ""; + display: table; +} + +.clearfix::after { + clear: both; +} + +/* ========================================================================== + EXAMPLE Media Queries for Responsive Design. + These examples override the primary ('mobile first') styles. + Modify as content requires. + ========================================================================== */ + +@media only screen and (min-width: 35em) { + /* Style adjustments for viewports that meet the condition */ +} + +@media print, + (min-resolution: 1.25dppx), + (min-resolution: 120dpi) { + /* Style adjustments for high resolution devices */ +} + +/* ========================================================================== + Print styles. + Inlined to avoid the additional HTTP request: + https://www.phpied.com/delay-loading-your-print-css/ + ========================================================================== */ + +@media print { + *, + *::before, + *::after { + background: #fff !important; + color: #000 !important; + /* Black prints faster */ + box-shadow: none !important; + text-shadow: none !important; + } + + a, + a:visited { + text-decoration: underline; + } + + a[href]::after { + content: " (" attr(href) ")"; + } + + abbr[title]::after { + content: " (" attr(title) ")"; + } + + /* + * Don't show links that are fragment identifiers, + * or use the `javascript:` pseudo protocol + */ + a[href^="#"]::after, + a[href^="javascript:"]::after { + content: ""; + } + + pre { + white-space: pre-wrap !important; + } + + pre, + blockquote { + border: 1px solid #999; + page-break-inside: avoid; + } + + tr, + img { + page-break-inside: avoid; + } + + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + + h2, + h3 { + page-break-after: avoid; + } +} + diff --git a/Services/Website/Resources/Static/favicon.ico b/Services/Website/Resources/Static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..be74abd69ad6a32de7375df13cab9354798e328f GIT binary patch literal 766 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 diff --git a/Services/Website/Resources/Static/index.html b/Services/Website/Resources/Static/index.html new file mode 100644 index 0000000..f3add49 --- /dev/null +++ b/Services/Website/Resources/Static/index.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Hello world! This is HTML5 Boilerplate.

+ + + + + diff --git a/Services/Website/Resources/Static/js/app.js b/Services/Website/Resources/Static/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/Services/Website/Resources/Static/robots.txt b/Services/Website/Resources/Static/robots.txt new file mode 100644 index 0000000..51d2d2e --- /dev/null +++ b/Services/Website/Resources/Static/robots.txt @@ -0,0 +1,5 @@ +# https://www.robotstxt.org/ + +# Allow crawling of all content +User-agent: * +Disallow: diff --git a/Services/Website/Resources/Static/site.webmanifest b/Services/Website/Resources/Static/site.webmanifest new file mode 100644 index 0000000..222ae16 --- /dev/null +++ b/Services/Website/Resources/Static/site.webmanifest @@ -0,0 +1,12 @@ +{ + "short_name": "", + "name": "", + "icons": [{ + "src": "icon.png", + "type": "image/png", + "sizes": "192x192" + }], + "start_url": "/?utm_source=homescreen", + "background_color": "#fafafa", + "theme_color": "#fafafa" +} diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index d5df224..3561f8b 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -1,24 +1,37 @@ import Configuration import Hummingbird import Logging +import WebsiteCore -/// Build application -/// - Parameter reader: configuration reader +/// Builds the website application. +/// +/// Reads the log level, server name, and static files location from the configuration, +/// 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 { let logLevel = reader.string( - forKey: "log.level", + forKey: .Log.level, as: Logger.Level.self, default: .info ) let serverName = reader.string( - forKey: "http.serverName", - default: "LoudWebsite" + forKey: .HTTP.serverName, + default: .Server.name + ) + let staticFilesPath = reader.string( + forKey: .Path.staticFiles, + default: .Path.staticResources ) return Application( - router: try router(), + router: router( + staticFilesPath: staticFilesPath, + logLevel: logLevel + ), configuration: ApplicationConfiguration( reader: reader.scoped(to: "http") ), @@ -34,7 +47,11 @@ func application( // Request context used by application private typealias AppRequestContext = BasicRequestContext -/// Build logger +/// Builds the application's logger. +/// - Parameters: +/// - serverName: the label applied to the logger. +/// - logLevel: the minimum level the logger emits. +/// - Returns: the configured logger. private func logger( serverName: String, logLevel: Logger.Level @@ -46,16 +63,26 @@ private func logger( return logger } -/// Build router -private func router() throws -> Router { +/// Builds the application's router. +/// +/// Registers the request-logging middleware and the static file middleware that serves +/// the contents of `staticFilesPath`. +/// - Parameters: +/// - staticFilesPath: the folder, relative to the working directory, the static files are served from. +/// - logLevel: the level the request-logging middleware logs at. +/// - Returns: the configured router. +private func router( + staticFilesPath: String, + logLevel: Logger.Level +) -> Router { let router = Router(context: AppRequestContext.self) router.addMiddleware { - LogRequestsMiddleware(.info) - } - - router.get("/") { _, _ in - return "Hello!" + LogRequestsMiddleware(logLevel) + FileMiddleware( + staticFilesPath, + searchForIndexHtml: false + ) } return router diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index c46a698..335916c 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -14,7 +14,7 @@ struct App { allowMissing: true ), InMemoryProvider(values: [ - "http.serverName": "LoudWebsite" + .HTTP.serverName: .HTTP.serverName ]), ] ) diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift new file mode 100644 index 0000000..656f369 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -0,0 +1,166 @@ +import Foundation + +/// A static file shipped with the website service. +/// +/// Each case identifies a file stored under the static files root (the `Resources/Static` +/// directory) and served by Hummingbird's `FileMiddleware` middleware. +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. + case siteWebmanifest + /// The `css/style.css` stylesheet. + case styleCSS +} + +// MARK: - Enumerations + +extension StaticFile { + /// A file extension used by a ``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. + case png + /// A Windows icon image. + case ico + /// A Scalable Vector Graphics image. + case svg + /// A plain text file. + case txt + /// A web application manifest file. + case webmanifest + } +} + +// MARK: - Extensions + +extension StaticFile { + + // MARK: Computed + + /// The file's content type. + 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" + case .svg: "image/svg+xml" + case .txt: "text/plain" + case .webmanifest: "application/manifest+json" + } + } + + /// The file's extension. + var fileExtension: Extension { + switch self { + case .styleCSS: .css + case .appJS: .js + case .errorHTML, + .indexHTML: .html + case .faviconICO: .ico + case .iconPNG: .png + case .iconSVG: .svg + case .robotsTXT: .txt + case .siteWebmanifest: .webmanifest + } + } + + /// The file's name, without extension. + 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" + } + } + + /// The path relative to the static files root (e.g. `"css/style.css"`). + /// + /// 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 + ) + + return subdirectory + .map { .init( + format: Constant.Format.path, + $0, + file + )} ?? file + } + + // MARK: Methods + + /// Resolves the file's path against the given base directory. + /// + /// - Parameter basePath: the directory the static files are served from. + /// - Returns: the path to the file, relative to the `basePath` path. + public func path( + relativeTo basePath: String + ) -> String { + guard !basePath.isEmpty else { + return relativePath + } + + return .init( + format: Constant.Format.path, + basePath, + relativePath + ) + } + +} + +// MARK: - Helpers + +private extension StaticFile { + + // MARK: Computed + + /// The sub-directory within the static root that holds the file, if any. + var subdirectory: String? { + switch self { + case .appJS: "js" + case .styleCSS: "css" + default: nil + } + } + +} + +// MARK: - Constants + +private enum Constant { + enum Format { + static let file = "%@.%@" + static let path = "%@/%@" + } +} diff --git a/Services/Website/Sources/Library/Library.swift b/Services/Website/Sources/Library/Library.swift deleted file mode 100644 index 139597f..0000000 --- a/Services/Website/Sources/Library/Library.swift +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift new file mode 100644 index 0000000..94a8cbf --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -0,0 +1,23 @@ +import Configuration + +extension AbsoluteConfigKey { + /// A namespace for the HTTP server configuration keys, as absolute keys. + public enum HTTP { + /// The absolute configuration key for the host the server binds to. + public static let host: AbsoluteConfigKey = .init(.HTTP.host) + /// The absolute configuration key for the port the server listens on. + public static let port: AbsoluteConfigKey = .init(.HTTP.port) + /// The absolute configuration key for the server's name. + public static let serverName: AbsoluteConfigKey = .init(.HTTP.serverName) + } + /// A namespace for the logging configuration keys, as absolute keys. + public enum Log { + /// The absolute configuration key for the minimum log level. + public static let level: AbsoluteConfigKey = .init(.Log.level) + } + /// A namespace for the path configuration keys, as absolute keys. + public enum Path { + /// The absolute configuration key for the directory the static files are served from. + public static let staticFiles: AbsoluteConfigKey = .init(.Path.staticFiles) + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift new file mode 100644 index 0000000..55c6f7d --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -0,0 +1,23 @@ +import Configuration + +extension ConfigKey { + /// A namespace for the HTTP server configuration keys. + public enum HTTP { + /// The configuration key for the host the server binds to. + public static let host: ConfigKey = "http.host" + /// The configuration key for the port the server listens on. + public static let port: ConfigKey = "http.port" + /// The configuration key for the server's name. + public static let serverName: ConfigKey = "http.serverName" + } + /// A namespace for the logging configuration keys. + public enum Log { + /// The configuration key for the minimum log level. + public static let level: ConfigKey = "log.level" + } + /// A namespace for the path configuration keys. + public enum Path { + /// The configuration key for the directory the static files are served from. + public static let staticFiles: ConfigKey = "path.staticFiles" + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift new file mode 100644 index 0000000..58973bf --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift @@ -0,0 +1,18 @@ +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/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift new file mode 100644 index 0000000..22a1e0f --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -0,0 +1,12 @@ +extension String { + /// A namespace for well-known path string constants. + public enum Path { + /// The directory, relative to the working directory, that the website's static files are served from. + public static let staticResources = "Resources/Static" + } + /// A namespace for the server string constants. + public enum Server { + /// The website server's name. + public static let name = "LoudWebsite" + } +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 379de9f..c784d94 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -1,34 +1,69 @@ import Configuration +import Foundation import Hummingbird import HummingbirdTesting import Logging import Testing @testable import Website +@testable import WebsiteCore -private let reader = ConfigReader(providers: [ - InMemoryProvider(values: [ - "http.host": "127.0.0.1", - "http.port": "0", - "log.level": "trace", - ]) -]) - -@Suite +@Suite("App executable") struct AppTests { - @Test - func hello() async throws { + + // MARK: Constants + + // Absolute path to the package's "Resources/Static" folder, derived from this + // file's location so the static files resolve regardless of the working directory. + private let staticFilesPath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/App + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // package root + .appendingPathComponent(.Path.staticResources) + .path + + // MARK: Functional tests + + @Test(arguments: StaticFile.allCases) + func `static files to be served`( + staticFile file: StaticFile + ) async throws { let app = try await application( - reader: reader + reader: reader( + staticFilesPath: staticFilesPath + ) ) try await app.test(.router) { client in try await client.execute( - uri: "/", + uri: "/\(file.relativePath)", method: .get ) { response in - #expect(response.body == ByteBuffer(string: "Hello!")) + #expect(response.status == .ok) + #expect(response.headers[.contentType] == file.contentType) } } } + +} + +// MARK: - Helpers + +private extension AppTests { + + // MARK: Methods + + func reader( + staticFilesPath: String + ) -> ConfigReader { + ConfigReader(providers: [ + InMemoryProvider(values: [ + .HTTP.host: .HTTP.host, + .HTTP.port: .HTTP.port, + .Log.level: .Log.level, + .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 new file mode 100644 index 0000000..1ac3872 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing + +@testable import WebsiteCore + +@Suite("StaticFile enumeration") +struct StaticFileTests { + + // MARK: Type aliases + + typealias File = StaticFile + typealias FileExtension = StaticFile.Extension + + // MARK: Computed tests + + @Test(arguments: zip( + File.allCases, + Self.contentTypes + )) + func `content type`( + for file: File, + expects contentType: String + ) { + #expect(file.contentType == contentType) + } + + @Test(arguments: zip( + File.allCases, + Self.fileExtensions + )) + func `file extension`( + for file: File, + expects `extension`: FileExtension + ) { + #expect(file.fileExtension == `extension`) + } + + @Test(arguments: zip( + File.allCases, + Self.fileNames + )) + func `file name`( + for file: File, + expects fileName: String + ) { + #expect(file.fileName == fileName) + } + + @Test(arguments: zip( + File.allCases, + Self.relativePaths + )) + func `relative path`( + for file: File, + expects relativePath: String + ) { + #expect(file.relativePath == relativePath) + } + + // MARK: Method tests + + @Test(arguments: [ + "", + ".", + "Resources/Static" + ]) + func `path relative to`( + _ basePath: String + ) { + for file in File.allCases { + let pathRelativeToBasePath = file.path(relativeTo: basePath) + + if basePath.isEmpty { + #expect(pathRelativeToBasePath == file.relativePath) + } else { + #expect(pathRelativeToBasePath == "\(basePath)/\(file.relativePath)") + } + } + } + + // MARK: CaseIterable tests + + @Test + func `all cases`() { + #expect(File.allCases.count == 9) + } + +} + +// MARK: - Helpers + +private extension StaticFileTests { + + // MARK: Constants + + 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/LibraryTest.swift b/Services/Website/Tests/Library/LibraryTest.swift deleted file mode 100644 index 8b13789..0000000 --- a/Services/Website/Tests/Library/LibraryTest.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Services/Website/Website.xctestplan b/Services/Website/Website.xctestplan new file mode 100644 index 0000000..5156232 --- /dev/null +++ b/Services/Website/Website.xctestplan @@ -0,0 +1,37 @@ +{ + "configurations" : [ + { + "id" : "F3231E68-357B-4ED7-98E9-F5767AB17024", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "performanceAntipatternCheckerEnabled" : true, + "targetForVariableExpansion" : { + "containerPath" : "container:", + "identifier" : "Website", + "name" : "Website" + }, + "testInteropMode" : "complete" + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "WebsiteTests", + "name" : "WebsiteTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "WebsiteCoreTests", + "name" : "WebsiteCoreTests" + } + } + ], + "version" : 1 +}