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 0000000..be74abd
Binary files /dev/null and b/Services/Website/Resources/Static/favicon.ico differ
diff --git a/Services/Website/Resources/Static/icon.png b/Services/Website/Resources/Static/icon.png
new file mode 100644
index 0000000..8a42581
Binary files /dev/null and b/Services/Website/Resources/Static/icon.png differ
diff --git a/Services/Website/Resources/Static/icon.svg b/Services/Website/Resources/Static/icon.svg
new file mode 100644
index 0000000..f232922
--- /dev/null
+++ b/Services/Website/Resources/Static/icon.svg
@@ -0,0 +1 @@
+
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
+}