diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 8b81b82..937f6e0 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -31,6 +31,10 @@ let package = Package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.25.0" ), + .package( + url: "https://github.com/hummingbird-project/hummingbird-compression.git", + from: "2.0.0" + ), .package( url: "https://github.com/apple/swift-configuration.git", from: "1.0.0", @@ -53,6 +57,10 @@ let package = Package( name: "Hummingbird", package: "hummingbird" ), + .product( + name: "HummingbirdCompression", + package: "hummingbird-compression" + ), ], path: "Sources/App" ), diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index d79bf0f..67f9bf4 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -1,12 +1,13 @@ import Configuration import Hummingbird +import HummingbirdCompression import Logging import WebsiteCore /// 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. +/// Reads the log level, server name, static files location, and minimum response size to +/// compress 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. func application( @@ -26,6 +27,10 @@ func application( default: .Cache.maxAgeDefault ) ) + let compressionMinResponseSize = reader.int( + forKey: .Compression.minResponseSize, + default: .Compression.minResponseSize + ) let logLevel = reader.string( forKey: .Log.level, as: Logger.Level.self, @@ -44,6 +49,7 @@ func application( router: router( staticFilesPath: staticFilesPath, cacheControl: cacheControl, + compressionMinResponseSize: compressionMinResponseSize, logLevel: logLevel ), configuration: ApplicationConfiguration( @@ -102,24 +108,30 @@ private func logger( /// Builds the application's router. /// -/// 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` (tagging -/// responses with the given `cacheControl` directives), then adds the `RootController` routes -/// that render the landing page. +/// Registers the request-logging middleware, the response-compression middleware that compresses +/// responses larger than `minimumResponseSizeToCompress` when the client advertises support, the +/// not-found middleware that serves the error page, and the static file middleware that serves the +/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), 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. /// - cacheControl: the cache-control directives applied to the served static files. +/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied. /// - logLevel: the level the request-logging middleware logs at. /// - Returns: the configured router. private func router( staticFilesPath: String, cacheControl: CacheControl, + compressionMinResponseSize: Int, logLevel: Logger.Level ) -> Router { let router = Router(context: AppRequestContext.self) router.addMiddleware { LogRequestsMiddleware(logLevel) + ResponseCompressionMiddleware( + minimumResponseSizeToCompress: compressionMinResponseSize + ) NotFoundMiddleware() FileMiddleware( staticFilesPath, diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index 93dec9c..f1ab24d 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -10,6 +10,11 @@ extension AbsoluteConfigKey { /// The absolute configuration key for the max-age, in seconds, applied to all other static files. public static let maxAgeDefault: AbsoluteConfigKey = .init(.Cache.maxAgeDefault) } + /// A namespace for the response compression configuration keys, as absolute keys. + public enum Compression { + /// The absolute configuration key for the minimum response body size, in bytes, before compression is applied. + public static let minResponseSize: AbsoluteConfigKey = .init(.Compression.minResponseSize) + } /// 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. diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index 225db80..4a34574 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -10,6 +10,11 @@ extension ConfigKey { /// The configuration key for the max-age, in seconds, applied to all other static files (e.g. the web manifest). public static let maxAgeDefault: ConfigKey = "cache.maxAge.default" } + /// A namespace for the response compression configuration keys. + public enum Compression { + /// The configuration key for the minimum response body size, in bytes, before compression is applied. + public static let minResponseSize: ConfigKey = "compression.minimumResponseSize" + } /// A namespace for the HTTP server configuration keys. public enum HTTP { /// The configuration key for the host the server binds to. diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift index 489fa2a..d6976ef 100644 --- a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift @@ -8,4 +8,9 @@ extension Int { /// The default max-age, in seconds, applied to all other static files (1 day). public static let maxAgeDefault = 86_400 } + /// A namespace for the response compression's default configuration values. + public enum Compression { + /// The default minimum response body size, in bytes, before compression is applied (1 KB). + public static let minResponseSize = 1_024 + } } diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 15d0765..13cce8c 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -71,6 +71,33 @@ struct AppTests { } } + @Test + func `response to be compressed when the client supports it`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get, + headers: [.acceptEncoding: "gzip"] + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentEncoding] == "gzip") + } + } + } + + @Test + func `response to not be compressed when the client does not support it`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentEncoding] == nil) + } + } + } + @Test func `error page to be served when not found`() async throws { try await app.test(.router) { client in