diff --git a/.gitignore b/.gitignore index 50762abd..69b07027 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .build/ +.index-build/ .vscode/ .index-build/ /Packages diff --git a/.swift-format b/.swift-format new file mode 100644 index 00000000..bb3dcff3 --- /dev/null +++ b/.swift-format @@ -0,0 +1,63 @@ +{ + "version" : 1, + "indentation" : { + "spaces" : 4 + }, + "tabWidth" : 4, + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "spacesAroundRangeFormationOperators" : false, + "indentConditionalCompilationBlocks" : false, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : true, + "lineBreakBeforeEachGenericRequirement" : true, + "lineLength" : 150, + "maximumBlankLines" : 1, + "respectsExistingLineBreaks" : true, + "prioritizeKeepingFunctionOutputTogether" : true, + "multiElementCollectionTrailingCommas" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : false, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : true, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : false, + "UseLetInEveryBoundCaseVariable" : false, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : false, + "UseSynthesizedInitializer" : false, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + } +} diff --git a/README.md b/README.md index 6a085d14..cc97fcf5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Examples converted to Hummingbird 2.0 - [proxy-server](https://github.com/hummingbird-project/hummingbird-examples/tree/main/proxy-server) - Using AsyncHTTPClient to build a proxy server - [response-body-processing](https://github.com/hummingbird-project/hummingbird-examples/tree/main/response-body-processing) - Example showing how to process a response body in middleware. - [s3-file-provider](https://github.com/hummingbird-project/hummingbird-examples/tree/main/s3-file-provider) - Use a custom FileProvider to serve files from S3 with FileMiddleware. +- [server-send-events](https://github.com/hummingbird-project/hummingbird-examples/tree/main/server-send-events) - Server sent events. - [sessions](https://github.com/hummingbird-project/hummingbird-examples/tree/main/sessions) - Username/password and session authentication. - [todos-dynamodb](https://github.com/hummingbird-project/hummingbird-examples/tree/main/todos-dynamodb) - Todos application, based off [TodoBackend](http://todobackend.com) spec, using DynamoDB. - [todos-fluent](https://github.com/hummingbird-project/hummingbird-examples/tree/1.x.x/todos-fluent) - Todos application, based off [TodoBackend](http://todobackend.com) spec, using Fluent diff --git a/auth-cognito/Packages/hummingbird b/auth-cognito/Packages/hummingbird deleted file mode 160000 index 195aeefa..00000000 --- a/auth-cognito/Packages/hummingbird +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 195aeefa7c4b899ab8f338daf18fb42df6b2af01 diff --git a/server-sent-events/.dockerignore b/server-sent-events/.dockerignore new file mode 100644 index 00000000..2fb33437 --- /dev/null +++ b/server-sent-events/.dockerignore @@ -0,0 +1,2 @@ +.build +.git \ No newline at end of file diff --git a/server-sent-events/.gitignore b/server-sent-events/.gitignore new file mode 100644 index 00000000..1cdfa95a --- /dev/null +++ b/server-sent-events/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/.swiftpm +/.devContainer +/Packages +/*.xcodeproj +xcuserdata/ +/.vscode/* +!/.vscode/hummingbird.code-snippets diff --git a/server-sent-events/Dockerfile b/server-sent-events/Dockerfile new file mode 100644 index 00000000..cdc8c427 --- /dev/null +++ b/server-sent-events/Dockerfile @@ -0,0 +1,86 @@ +# ================================ +# Build image +# ================================ +FROM swift:6.0-jammy as build + +# Install OS updates +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get install -y libjemalloc-dev \ + && rm -rf /var/lib/apt/lists/* + +# Set up a build area +WORKDIR /build + +# First just resolve dependencies. +# This creates a cached layer that can be reused +# as long as your Package.swift/Package.resolved +# files do not change. +COPY ./Package.* ./ +RUN swift package resolve + +# Copy entire repo into container +COPY . . + +# Build everything, with optimizations, with static linking, and using jemalloc +RUN swift build -c release \ + --static-swift-stdlib \ + -Xlinker -ljemalloc + +# Switch to the staging area +WORKDIR /staging + +# Copy main executable to staging area +RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./ + +# Copy static swift backtracer binary to staging area +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 +# 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 image +# ================================ +FROM ubuntu:jammy + +# Make sure all system packages are up to date, and install only essential packages. +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get -q install -y \ + libjemalloc2 \ + ca-certificates \ + tzdata \ +# If your app or its dependencies import FoundationNetworking, also install `libcurl4`. + # libcurl4 \ +# If your app or its dependencies import FoundationXML, also install `libxml2`. + # libxml2 \ + && rm -r /var/lib/apt/lists/* + +# Create a hummingbird user and group with /app as its home directory +RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird + +# Switch to the new home directory +WORKDIR /app + +# Copy built executable and any staged resources from builder +COPY --from=build --chown=hummingbird:hummingbird /staging /app + +# Provide configuration needed by the built-in crash reporter and some sensible default behaviors. +ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static + +# Ensure all further commands run as the hummingbird user +USER hummingbird:hummingbird + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Start the Hummingbird service when the image is run, default to listening on 8080 in production environment +ENTRYPOINT ["./App"] +CMD ["--hostname", "0.0.0.0", "--port", "8080"] diff --git a/server-sent-events/Package.swift b/server-sent-events/Package.swift new file mode 100644 index 00000000..18427e91 --- /dev/null +++ b/server-sent-events/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version:6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "server_sent_events", + platforms: [.macOS(.v15), .iOS(.v18), .tvOS(.v18)], + products: [ + .executable(name: "App", targets: ["App"]) + ], + dependencies: [ + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.6.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/orlandos-nl/SSEKit.git", from: "1.1.0"), + ], + targets: [ + .executableTarget( + name: "App", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "SSEKit", package: "SSEKit"), + ], + path: "Sources/App" + ), + .testTarget( + name: "AppTests", + dependencies: [ + .byName(name: "App"), + .product(name: "HummingbirdTesting", package: "hummingbird"), + ], + path: "Tests/AppTests" + ), + ] +) diff --git a/server-sent-events/README.md b/server-sent-events/README.md new file mode 100644 index 00000000..d8d4b980 --- /dev/null +++ b/server-sent-events/README.md @@ -0,0 +1,6 @@ +# Server Send Events + +Example demonstrating how to setup a route returning Server Sent Events. + +The application has one route. `GET /events`. If you call this then every other request sent to the server will be reported back to this route as a server sent event. + diff --git a/server-sent-events/Sources/App/App.swift b/server-sent-events/Sources/App/App.swift new file mode 100644 index 00000000..04f47bb4 --- /dev/null +++ b/server-sent-events/Sources/App/App.swift @@ -0,0 +1,27 @@ +import ArgumentParser +import Hummingbird +import Logging + +@main +struct App: AsyncParsableCommand, AppArguments { + @Option(name: .shortAndLong) + var hostname: String = "127.0.0.1" + + @Option(name: .shortAndLong) + var port: Int = 8080 + + @Option(name: .shortAndLong) + var logLevel: Logger.Level? + + func run() async throws { + let app = try await buildApplication(self) + try await app.runService() + } +} + +/// Extend `Logger.Level` so it can be used as an argument +#if hasFeature(RetroactiveAttribute) + extension Logger.Level: @retroactive ExpressibleByArgument {} +#else + extension Logger.Level: ExpressibleByArgument {} +#endif diff --git a/server-sent-events/Sources/App/Application+build.swift b/server-sent-events/Sources/App/Application+build.swift new file mode 100644 index 00000000..45271f32 --- /dev/null +++ b/server-sent-events/Sources/App/Application+build.swift @@ -0,0 +1,104 @@ +import Hummingbird +import Logging +import NIOCore +import SSEKit +import ServiceLifecycle + +/// Application arguments protocol. We use a protocol so we can call +/// `buildApplication` inside Tests as well as in the App executable. +/// Any variables added here also have to be added to `App` in App.swift and +/// `TestArguments` in AppTest.swift +public protocol AppArguments { + var hostname: String { get } + var port: Int { get } + var logLevel: Logger.Level? { get } +} + +// Request context used by application +struct AppRequestContext: RequestContext { + var coreContext: CoreRequestContextStorage + let channel: Channel + + init(source: Source) { + self.coreContext = .init(source: source) + self.channel = source.channel + } +} + +/// Build application +/// - Parameter arguments: application arguments +public func buildApplication( + _ arguments: some AppArguments +) async throws + -> some ApplicationProtocol +{ + let environment = Environment() + let logger = { + var logger = Logger(label: "server_side_events") + logger.logLevel = + arguments.logLevel ?? environment.get("LOG_LEVEL").map { + Logger.Level(rawValue: $0) ?? .info + } ?? .info + return logger + }() + let requestPublisher = Publisher() + let router = buildRouter(requestPublisher: requestPublisher) + let app = Application( + router: router, + configuration: .init( + address: .hostname(arguments.hostname, port: arguments.port), + serverName: "server_sent_events" + ), + services: [requestPublisher], + logger: logger + ) + return app +} + +/// Build router +func buildRouter(requestPublisher: Publisher) -> Router { + let router = Router(context: AppRequestContext.self) + // Add middleware + router.addMiddleware { + // logging middleware + LogRequestsMiddleware(.info) + PublishRequestsMiddleware(requestPublisher: requestPublisher) + } + router.get("events") { request, context -> Response in + .init( + status: .ok, + headers: [.contentType: "text/event-stream"], + body: .init { writer in + let allocator = ByteBufferAllocator() + let (stream, id) = requestPublisher.subscribe() + try await withGracefulShutdownHandler { + // If connection if closed then this function will call the `onInboundCLosed` closure + try await request.body.consumeWithInboundCloseHandler { requestBody in + for try await value in stream { + try await writer.write( + ServerSentEvent(data: .init(string: value)).makeBuffer( + allocator: allocator + ) + ) + } + } onInboundClosed: { + requestPublisher.unsubscribe(id) + } + } onGracefulShutdown: { + requestPublisher.unsubscribe(id) + } + try await writer.finish(nil) + } + ) + } + return router +} + +/// Middleware to publish requests +struct PublishRequestsMiddleware: RouterMiddleware { + let requestPublisher: Publisher + func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { + await requestPublisher.publish("\(request.method): \(request.uri.path)") + return try await next(request, context) + } +} diff --git a/server-sent-events/Sources/App/Publisher.swift b/server-sent-events/Sources/App/Publisher.swift new file mode 100644 index 00000000..20d4f4ac --- /dev/null +++ b/server-sent-events/Sources/App/Publisher.swift @@ -0,0 +1,66 @@ +import Foundation +import ServiceLifecycle + +/// Basic PUB/SUB service. +actor Publisher: Service { + typealias SubscriptionID = UUID + enum SubscriptionCommand { + case add(SubscriptionID, AsyncStream.Continuation) + case remove(SubscriptionID) + } + nonisolated let (subStream, subSource) = AsyncStream.makeStream() + + init() { + self.subscriptions = [:] + } + + /// Publish to service + /// - Parameter value: Value being published + func publish(_ value: Value) async { + for subscription in self.subscriptions.values { + subscription.yield(value) + } + } + + /// Subscribe to service + /// - Returns: AsyncStream of values, and subscription identifier + nonisolated func subscribe() -> (AsyncStream, SubscriptionID) { + let id = SubscriptionID() + let (stream, source) = AsyncStream.makeStream() + subSource.yield(.add(id, source)) + return (stream, id) + } + + /// Unsubscribe from service + /// - Parameter id: Subscription identifier + nonisolated func unsubscribe(_ id: SubscriptionID) { + subSource.yield(.remove(id)) + } + + /// Service run function + func run() async throws { + try await withGracefulShutdownHandler { + for try await command in self.subStream { + switch command { + case .add(let id, let source): + self._addSubsciber(id, source: source) + case .remove(let id): + self._removeSubsciber(id) + } + } + } onGracefulShutdown: { + self.subSource.finish() + } + } + + private func _addSubsciber(_ id: SubscriptionID, source: AsyncStream.Continuation) { + self.subscriptions[id] = source + } + + private func _removeSubsciber(_ id: SubscriptionID) { + self.subscriptions[id]?.finish() + self.subscriptions[id] = nil + } + + var subscriptions: [UUID: AsyncStream.Continuation] +} diff --git a/server-sent-events/Tests/AppTests/AppTests.swift b/server-sent-events/Tests/AppTests/AppTests.swift new file mode 100644 index 00000000..8489019f --- /dev/null +++ b/server-sent-events/Tests/AppTests/AppTests.swift @@ -0,0 +1,14 @@ +import Hummingbird +import HummingbirdTesting +import Logging +import XCTest + +@testable import App + +final class AppTests: XCTestCase { + struct TestArguments: AppArguments { + let hostname = "127.0.0.1" + let port = 0 + let logLevel: Logger.Level? = .trace + } +} diff --git a/todos-auth-fluent/Packages/hummingbird-auth b/todos-auth-fluent/Packages/hummingbird-auth deleted file mode 120000 index 0d7279b8..00000000 --- a/todos-auth-fluent/Packages/hummingbird-auth +++ /dev/null @@ -1 +0,0 @@ -/Users/adamfowler/Developer/server/hummingbird-project/hummingbird-auth \ No newline at end of file