From 71a26413656f0b65c444298bf974547b250e4c5d Mon Sep 17 00:00:00 2001 From: Moritz Lang <16192401+slashmo@users.noreply.github.com> Date: Sat, 8 Mar 2025 17:04:46 +0100 Subject: [PATCH] Add server example --- .../server/.devcontainer/devcontainer.json | 52 +++++++++++ .../server/.devcontainer/docker-compose.yaml | 6 ++ Examples/server/Package.swift | 50 ++++++++++ Examples/server/README.md | 3 + Examples/server/Sources/API/APIService.swift | 38 ++++++++ .../server/Sources/API/AuthMiddleware.swift | 29 ++++++ .../API/EvaluationContextMiddleware.swift | 29 ++++++ .../server/Sources/API/FeedController.swift | 59 ++++++++++++ .../server/Sources/API/RequestContext.swift | 24 +++++ Examples/server/Sources/API/User.swift | 16 ++++ Examples/server/Sources/CTL/CTL.swift | 91 +++++++++++++++++++ Examples/server/docker-compose.yaml | 48 ++++++++++ Examples/server/flipt-seed.yaml | 23 +++++ Examples/server/otel-collector-config.yaml | 19 ++++ 14 files changed, 487 insertions(+) create mode 100644 Examples/server/.devcontainer/devcontainer.json create mode 100644 Examples/server/.devcontainer/docker-compose.yaml create mode 100644 Examples/server/Package.swift create mode 100644 Examples/server/README.md create mode 100644 Examples/server/Sources/API/APIService.swift create mode 100644 Examples/server/Sources/API/AuthMiddleware.swift create mode 100644 Examples/server/Sources/API/EvaluationContextMiddleware.swift create mode 100644 Examples/server/Sources/API/FeedController.swift create mode 100644 Examples/server/Sources/API/RequestContext.swift create mode 100644 Examples/server/Sources/API/User.swift create mode 100644 Examples/server/Sources/CTL/CTL.swift create mode 100644 Examples/server/docker-compose.yaml create mode 100644 Examples/server/flipt-seed.yaml create mode 100644 Examples/server/otel-collector-config.yaml diff --git a/Examples/server/.devcontainer/devcontainer.json b/Examples/server/.devcontainer/devcontainer.json new file mode 100644 index 0000000..9c34575 --- /dev/null +++ b/Examples/server/.devcontainer/devcontainer.json @@ -0,0 +1,52 @@ +{ + "name": "Server", + "workspaceFolder": "/workspace/server", + "dockerComposeFile": [ + "../docker-compose.yaml", + "docker-compose.yaml" + ], + "service": "server", + "runServices": [ + "flipt", + "jaeger", + "otel-collector", + "server" + ], + "shutdownAction": "stopCompose", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "server", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so", + "swift.autoGenerateLaunchConfigurations": false + }, + "extensions": [ + "swiftlang.swift-vscode" + ] + } + }, + "forwardPorts": [8080, 8081, 16686], + "portsAttributes": { + "8080": { + "label": "API", + "onAutoForward": "notify" + }, + "8081": { + "label": "Flipt" + }, + "16686": { + "label": "Jaeger" + } + }, + "remoteUser": "server" +} diff --git a/Examples/server/.devcontainer/docker-compose.yaml b/Examples/server/.devcontainer/docker-compose.yaml new file mode 100644 index 0000000..3161c7f --- /dev/null +++ b/Examples/server/.devcontainer/docker-compose.yaml @@ -0,0 +1,6 @@ +services: + server: + image: swift:6.1 + command: sleep infinity + volumes: + - ..:/workspace:cached diff --git a/Examples/server/Package.swift b/Examples/server/Package.swift new file mode 100644 index 0000000..544ac0c --- /dev/null +++ b/Examples/server/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "server", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "server", targets: ["CTL"]) + ], + dependencies: [ + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.0.0"), + .package(url: "https://github.com/swift-otel/swift-otel.git", .upToNextMinor(from: "0.11.0")), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird-auth.git", from: "2.0.0"), + .package(url: "https://github.com/swift-open-feature/swift-open-feature.git", branch: "main"), + .package(url: "https://github.com/swift-open-feature/swift-ofrep.git", branch: "main"), + + // override HTTP Client until Tracing PR is merged + .package(url: "https://github.com/slashmo/async-http-client.git", branch: "feature/tracing"), + ], + targets: [ + .executableTarget( + name: "CTL", + dependencies: [ + .target(name: "API"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Tracing", package: "swift-distributed-tracing"), + .product(name: "OTel", package: "swift-otel"), + .product(name: "OTLPGRPC", package: "swift-otel"), + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "OpenFeature", package: "swift-open-feature"), + .product(name: "OpenFeatureTracing", package: "swift-open-feature"), + .product(name: "OFREP", package: "swift-ofrep"), + ] + ), + .target( + name: "API", + dependencies: [ + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "HummingbirdAuth", package: "hummingbird-auth"), + .product(name: "OpenFeature", package: "swift-open-feature"), + ] + ), + ] +) diff --git a/Examples/server/README.md b/Examples/server/README.md new file mode 100644 index 0000000..190ad83 --- /dev/null +++ b/Examples/server/README.md @@ -0,0 +1,3 @@ +# Swift OFREP server example + +An HTTP server that can be altered at runtime using feature flags powered by Swift OFREP. diff --git a/Examples/server/Sources/API/APIService.swift b/Examples/server/Sources/API/APIService.swift new file mode 100644 index 0000000..dde93cd --- /dev/null +++ b/Examples/server/Sources/API/APIService.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Hummingbird +import OpenFeature +import ServiceLifecycle + +public struct APIService: Service { + private let app: Application> + private let client: OpenFeatureClient + + public init(router: Router) { + client = OpenFeatureSystem.client() + + router + .addMiddleware { + AuthMiddleware() + EvaluationContextMiddleware() + } + .addRoutes(FeedController().routes) + + app = Application(router: router) + } + + public func run() async throws { + try await app.run() + } +} diff --git a/Examples/server/Sources/API/AuthMiddleware.swift b/Examples/server/Sources/API/AuthMiddleware.swift new file mode 100644 index 0000000..a4ae967 --- /dev/null +++ b/Examples/server/Sources/API/AuthMiddleware.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes +import Hummingbird +import HummingbirdAuth + +struct AuthMiddleware: AuthenticatorMiddleware { + typealias Context = APIRequestContext + + func authenticate(request: Request, context: APIRequestContext) async throws -> User? { + guard let userID = request.headers[.userID] else { return nil } + return User(id: userID) + } +} + +extension HTTPField.Name { + fileprivate static let userID = Self("X-User-Id")! +} diff --git a/Examples/server/Sources/API/EvaluationContextMiddleware.swift b/Examples/server/Sources/API/EvaluationContextMiddleware.swift new file mode 100644 index 0000000..61a1f4f --- /dev/null +++ b/Examples/server/Sources/API/EvaluationContextMiddleware.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Hummingbird +import OpenFeature + +struct EvaluationContextMiddleware: MiddlewareProtocol { + func handle( + _ request: Request, + context: APIRequestContext, + next: (Request, APIRequestContext) async throws -> Response + ) async throws -> Response { + var evaluationContext = OpenFeatureEvaluationContext.current ?? OpenFeatureEvaluationContext() + evaluationContext.targetingKey = context.identity?.id + return try await OpenFeatureEvaluationContext.$current.withValue(evaluationContext) { + try await next(request, context) + } + } +} diff --git a/Examples/server/Sources/API/FeedController.swift b/Examples/server/Sources/API/FeedController.swift new file mode 100644 index 0000000..c9b0192 --- /dev/null +++ b/Examples/server/Sources/API/FeedController.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Hummingbird +import OpenFeature + +struct FeedController { + private let featureFlags: OpenFeatureClient + + init() { + featureFlags = OpenFeatureSystem.client() + } + + var routes: RouteCollection { + let routes = RouteCollection(context: APIRequestContext.self) + routes.get(use: list) + return routes + } + + private func list(request: Request, context: APIRequestContext) async throws -> Feed { + let useNewFeedAlgorithm = await featureFlags.value(for: "experimental-feed-algorithm", defaultingTo: false) + + if useNewFeedAlgorithm { + // the new algorithm is faster but unfortunately still contains some bugs + if UInt.random(in: 0..<100) == 42 { + throw HTTPError(.internalServerError) + } + try await Task.sleep(for: .seconds(1)) + return Feed.stub + } else { + try await Task.sleep(for: .seconds(2)) + return Feed.stub + } + } +} + +struct Feed: ResponseCodable { + let posts: [Post] + + struct Post: ResponseCodable { + let id: String + } + + static let stub = Feed(posts: [ + Post(id: "1"), + Post(id: "2"), + Post(id: "3"), + ]) +} diff --git a/Examples/server/Sources/API/RequestContext.swift b/Examples/server/Sources/API/RequestContext.swift new file mode 100644 index 0000000..b4a19d6 --- /dev/null +++ b/Examples/server/Sources/API/RequestContext.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Hummingbird +import HummingbirdAuth + +public struct APIRequestContext: AuthRequestContext, Sendable { + public var coreContext: CoreRequestContextStorage + public var identity: User? + + public init(source: ApplicationRequestContextSource) { + coreContext = .init(source: source) + } +} diff --git a/Examples/server/Sources/API/User.swift b/Examples/server/Sources/API/User.swift new file mode 100644 index 0000000..558e41f --- /dev/null +++ b/Examples/server/Sources/API/User.swift @@ -0,0 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public struct User: Identifiable, Sendable { + public let id: String +} diff --git a/Examples/server/Sources/CTL/CTL.swift b/Examples/server/Sources/CTL/CTL.swift new file mode 100644 index 0000000..6c18a3b --- /dev/null +++ b/Examples/server/Sources/CTL/CTL.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import API +import AsyncHTTPClient +import Foundation +import Hummingbird +import Logging +import OFREP +import OTLPGRPC +import OTel +import OpenFeature +import OpenFeatureTracing +import ServiceLifecycle +import Tracing + +@main +enum CTL { + static func main() async throws { + let logger = bootstrappedLogger() + let tracer = try await bootstrappedTracer() + let provider = bootstrappedProvider(logger: logger) + + let router = Router(context: APIRequestContext.self) + router.middlewares.add(TracingMiddleware()) + let api = APIService(router: router) + + let group = ServiceGroup( + services: [tracer, provider, api], + gracefulShutdownSignals: [.sigint, .sigterm], + logger: logger + ) + + try await group.run() + } + + private static func bootstrappedLogger() -> Logger { + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = .trace + return handler + } + return Logger(label: "server") + } + + private static func bootstrappedTracer() async throws -> some Service { + let environment = OTelEnvironment.detected() + let resource = await OTelResourceDetection(detectors: [ + OTelEnvironmentResourceDetector(environment: environment), + OTelProcessResourceDetector(), + .manual(OTelResource(attributes: ["service.name": "server"])), + ]).resource(environment: environment) + let exporter = OTLPGRPCSpanExporter( + configuration: try OTLPGRPCSpanExporterConfiguration(environment: environment) + ) + let processor = OTelBatchSpanProcessor( + exporter: exporter, + configuration: OTelBatchSpanProcessorConfiguration(environment: environment) + ) + let tracer = OTelTracer( + idGenerator: OTelRandomIDGenerator(), + sampler: OTelParentBasedSampler(rootSampler: OTelConstantSampler(isOn: true)), + propagator: OTelW3CPropagator(), + processor: processor, + environment: environment, + resource: resource + ) + InstrumentationSystem.bootstrap(tracer) + return tracer + } + + private static func bootstrappedProvider(logger: Logger) -> some Service { + let ofrepProviderURL = ProcessInfo.processInfo.environment["OFREP_PROVIDER_URL"] ?? "http://localhost:8016" + logger.info("Detected OFREP provider URL.", metadata: ["url": "\(ofrepProviderURL)"]) + + let provider = OFREPProvider(serverURL: URL(string: ofrepProviderURL)!) + OpenFeatureSystem.addHooks([OpenFeatureTracingHook(setSpanStatusOnError: true, recordTargetingKey: true)]) + OpenFeatureSystem.setProvider(provider) + return provider + } +} diff --git a/Examples/server/docker-compose.yaml b/Examples/server/docker-compose.yaml new file mode 100644 index 0000000..a65f536 --- /dev/null +++ b/Examples/server/docker-compose.yaml @@ -0,0 +1,48 @@ +name: swift-ofrep-example-server +services: + # ==/ Feature Flagging Systems \== + + flipt: + image: flipt/flipt:v1.54.2 + command: ["./flipt", "--force-migrate"] + depends_on: + flipt-seed: + condition: service_completed_successfully + volumes: + - flipt_data:/var/opt/flipt + ports: + - 8081:8080 # OFREP & Flipt UI + environment: + FLIPT_LOG_LEVEL: debug + FLIPT_TRACING_ENABLED: true + FLIPT_TRACING_EXPORTER: otlp + FLIPT_TRACING_OTLP_ENDPOINT: otel-collector:4317 + FLIPT_METRICS_ENABLED: false + FLIPT_META_TELEMETRY_ENABLED: false + flipt-seed: + image: flipt/flipt:v1.54.2 + command: ["./flipt", "import", "--skip-existing" , "flipt.yaml"] + volumes: + - ./flipt-seed.yaml:/flipt.yaml + - flipt_data:/var/opt/flipt + environment: + FLIPT_LOG_LEVEL: debug + FLIPT_META_TELEMETRY_ENABLED: false + + # ==/ Observability \== + + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4317:4317" # OTLP/gRPC + + jaeger: + image: jaegertracing/jaeger:2.3.0 + ports: + - "16686:16686" # Jaeger UI + +volumes: + flipt_data: diff --git a/Examples/server/flipt-seed.yaml b/Examples/server/flipt-seed.yaml new file mode 100644 index 0000000..f7c70a1 --- /dev/null +++ b/Examples/server/flipt-seed.yaml @@ -0,0 +1,23 @@ +version: "1.4" +namespace: + key: default + name: Default + description: Default namespace +flags: +- key: experimental-feed-algorithm + name: experimental-feed-algorithm + type: BOOLEAN_FLAG_TYPE + enabled: false + rollouts: + - segment: + key: internal-testers + value: true +segments: +- key: internal-testers + name: internal-testers + constraints: + - type: STRING_COMPARISON_TYPE + property: targetingKey + operator: isoneof + value: '["42"]' + match_type: ALL_MATCH_TYPE diff --git a/Examples/server/otel-collector-config.yaml b/Examples/server/otel-collector-config.yaml new file mode 100644 index 0000000..642ffee --- /dev/null +++ b/Examples/server/otel-collector-config.yaml @@ -0,0 +1,19 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: "otel-collector:4317" + +exporters: + otlp/jaeger: + endpoint: "jaeger:4317" + tls: + insecure: true + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [otlp/jaeger] + +# yaml-language-server: $schema=https://raw.githubusercontent.com/srikanthccv/otelcol-jsonschema/main/schema.json