From 43f166e74615a27b4366e4a2c2b56bbafc265adb Mon Sep 17 00:00:00 2001 From: AdonisCodes Date: Mon, 18 May 2026 17:42:15 +0200 Subject: [PATCH 1/2] feat: scaffold features server and client with vapor example --- .gitignore | 2 + Package.resolved | 321 ++++++++++++++++++ Package.swift | 48 +++ README.md | 52 ++- Sources/FeaturesAdmin/FeaturesAdmin.swift | 3 + Sources/FeaturesClient/FeaturesClient.swift | 53 +++ Sources/FeaturesExampleClient/main.swift | 25 ++ .../Controllers/GameController.swift | 31 ++ .../Migrations/CreateUser.swift | 16 + .../FeaturesExampleServer/Models/User.swift | 27 ++ Sources/FeaturesExampleServer/configure.swift | 101 ++++++ Sources/FeaturesExampleServer/main.swift | 9 + .../Config/FeaturesConfiguration.swift | 24 ++ .../Config/FeaturesServer+Application.swift | 47 +++ Sources/FeaturesServer/Core/Bucketing.swift | 14 + .../FeaturesServer/Core/FeatureContext.swift | 54 +++ .../FeaturesServer/Core/FeatureService.swift | 75 ++++ .../Core/SharedDTO+Content.swift | 7 + .../FeatureEvaluationMiddleware.swift | 31 ++ .../Middleware/FeatureRequestStorage.swift | 32 ++ .../Middleware/RequireFeatureMiddleware.swift | 17 + .../Migrations/CreateFeatureOverride.swift | 26 ++ .../Models/FeatureOverride.swift | 41 +++ .../Routes/FeaturesRoutes.swift | 72 ++++ Sources/FeaturesShared/FeatureTypes.swift | 53 +++ 25 files changed, 1169 insertions(+), 12 deletions(-) create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Sources/FeaturesAdmin/FeaturesAdmin.swift create mode 100644 Sources/FeaturesClient/FeaturesClient.swift create mode 100644 Sources/FeaturesExampleClient/main.swift create mode 100644 Sources/FeaturesExampleServer/Controllers/GameController.swift create mode 100644 Sources/FeaturesExampleServer/Migrations/CreateUser.swift create mode 100644 Sources/FeaturesExampleServer/Models/User.swift create mode 100644 Sources/FeaturesExampleServer/configure.swift create mode 100644 Sources/FeaturesExampleServer/main.swift create mode 100644 Sources/FeaturesServer/Config/FeaturesConfiguration.swift create mode 100644 Sources/FeaturesServer/Config/FeaturesServer+Application.swift create mode 100644 Sources/FeaturesServer/Core/Bucketing.swift create mode 100644 Sources/FeaturesServer/Core/FeatureContext.swift create mode 100644 Sources/FeaturesServer/Core/FeatureService.swift create mode 100644 Sources/FeaturesServer/Core/SharedDTO+Content.swift create mode 100644 Sources/FeaturesServer/Middleware/FeatureEvaluationMiddleware.swift create mode 100644 Sources/FeaturesServer/Middleware/FeatureRequestStorage.swift create mode 100644 Sources/FeaturesServer/Middleware/RequireFeatureMiddleware.swift create mode 100644 Sources/FeaturesServer/Migrations/CreateFeatureOverride.swift create mode 100644 Sources/FeaturesServer/Models/FeatureOverride.swift create mode 100644 Sources/FeaturesServer/Routes/FeaturesRoutes.swift create mode 100644 Sources/FeaturesShared/FeatureTypes.swift diff --git a/.gitignore b/.gitignore index 64d0287..dc2a4ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .swiftlint.yml .swiftformat + +.build diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..ddc6b52 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,321 @@ +{ + "originHash" : "3d76d989bc3f9fddf61baee51f06bb74887ebc82d1ecd7abe75ee4e4228b82a8", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9", + "version" : "1.22.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "32ad16dfc7677b927b225595ed18f3debb32f577", + "version" : "4.16.0" + } + }, + { + "identity" : "fluent", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent.git", + "state" : { + "revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", + "version" : "4.13.0" + } + }, + { + "identity" : "fluent-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-kit.git", + "state" : { + "revision" : "ec094096d715593c4944cb9aeb72a633faa82918", + "version" : "1.56.0" + } + }, + { + "identity" : "fluent-sqlite-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-sqlite-driver.git", + "state" : { + "revision" : "e227d94dc3fe7099a096ac2ca28cad1f69bd8d5b", + "version" : "4.9.0" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "3779cedb44b1f374f2cca261c6d28f206024a582", + "version" : "3.36.0" + } + }, + { + "identity" : "sqlite-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sqlite-kit.git", + "state" : { + "revision" : "f35a863ecc2da5d563b836a9a696b148b0f4169f", + "version" : "4.5.2" + } + }, + { + "identity" : "sqlite-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sqlite-nio.git", + "state" : { + "revision" : "95595bbf0e044ee549fbb64aeaeec8d7f7059a16", + "version" : "1.12.8" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "d51c8d13fa366eec807eedb4e37daa60ff5bfdd5", + "version" : "2.10.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", + "version" : "2.99.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", + "version" : "1.34.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "cfd8f434843ac7850e2d97f46c1aa5ddb906cf1c", + "version" : "4.121.4" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", + "version" : "2.16.2" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..3eb9e1e --- /dev/null +++ b/Package.swift @@ -0,0 +1,48 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "Features", + platforms: [ + .macOS(.v14), + .iOS(.v17) + ], + products: [ + .library(name: "FeaturesShared", targets: ["FeaturesShared"]), + .library(name: "FeaturesServer", targets: ["FeaturesServer"]), + .library(name: "FeaturesClient", targets: ["FeaturesClient"]), + .library(name: "FeaturesAdmin", targets: ["FeaturesAdmin"]), + .executable(name: "FeaturesExampleServer", targets: ["FeaturesExampleServer"]), + .executable(name: "FeaturesExampleClient", targets: ["FeaturesExampleClient"]) + ], + dependencies: [ + .package(url: "https://github.com/vapor/vapor.git", from: "4.100.0"), + .package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"), + .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.7.0") + ], + targets: [ + .target(name: "FeaturesShared"), + .target( + name: "FeaturesServer", + dependencies: [ + "FeaturesShared", + .product(name: "Vapor", package: "vapor"), + .product(name: "Fluent", package: "fluent") + ] + ), + .target(name: "FeaturesClient", dependencies: ["FeaturesShared"]), + .target(name: "FeaturesAdmin", dependencies: []), + .executableTarget( + name: "FeaturesExampleServer", + dependencies: [ + "FeaturesServer", + "FeaturesShared", + "FeaturesClient", + .product(name: "Vapor", package: "vapor"), + .product(name: "Fluent", package: "fluent"), + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver") + ] + ), + .executableTarget(name: "FeaturesExampleClient", dependencies: ["FeaturesClient", "FeaturesShared"]) + ] +) diff --git a/README.md b/README.md index 265f910..a8c7351 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,46 @@ -# Base -Automa's Base Repository used for everything! +# Features -# Setup +Feature rollout + experimentation package for Vapor. + +## Targets + +- `FeaturesServer`: evaluation, overrides (with audit history), middleware, and routes. +- `FeaturesClient`: lightweight API client for fetching and toggling features. +- `FeaturesAdmin`: scaffold target (not implemented yet). +- `FeaturesShared`: DTOs and shared feature key/value types. + +## Example App + +`FeaturesExampleServer` uses Vapor + Fluent + SQLite and wires the package in `configure.swift`. + +Run server: -Run the following commands to setup the base of this REPO: ```bash -git clone --recurse-submodules --remote-submodules https://github.com/GetAutomaApp/Base.git -cd Base -npm run install:all +swift run FeaturesExampleServer serve --hostname 127.0.0.1 --port 8080 ``` -> [!NOTE] -> This is a template repo, add any other initialization steps here please! +Seeded users: + +- `11111111-1111-1111-1111-111111111111` (free) +- `22222222-2222-2222-2222-222222222222` (pro) +- `33333333-3333-3333-3333-333333333333` (internal) + +Call routes: + +```bash +curl -s -H 'x-user-id: 11111111-1111-1111-1111-111111111111' http://127.0.0.1:8080/features +curl -s -H 'x-user-id: 11111111-1111-1111-1111-111111111111' -H 'content-type: application/json' -d '{"key":"new_checkout","enabled":true}' http://127.0.0.1:8080/features/toggle +curl -s -H 'x-user-id: 11111111-1111-1111-1111-111111111111' http://127.0.0.1:8080/features/debug +curl -s -H 'x-user-id: 11111111-1111-1111-1111-111111111111' http://127.0.0.1:8080/game +``` + +## Integration Pattern + +Inside your app `configure.swift`: + +1. Register your app DB. +2. Build a `FeatureRegistry` with `active(ctx)` closures. +3. Call `FeaturesServer.configure(on:config:registry:actorResolver:)`. +4. Pass your auth middleware through `FeaturesConfiguration(authMiddleware:)`. -> [!WARNING] -> This REPO uses the GPL-3.0 license, this license only applies if you modify this template and intend to use this as a template repo! -> If this repo is applied as a new project, feel free to close source it! +`FeaturesServer.configure` automatically registers the feature override migration. diff --git a/Sources/FeaturesAdmin/FeaturesAdmin.swift b/Sources/FeaturesAdmin/FeaturesAdmin.swift new file mode 100644 index 0000000..897f0db --- /dev/null +++ b/Sources/FeaturesAdmin/FeaturesAdmin.swift @@ -0,0 +1,3 @@ +public enum FeaturesAdmin { + public static let placeholder = "FeaturesAdmin target scaffolded." +} diff --git a/Sources/FeaturesClient/FeaturesClient.swift b/Sources/FeaturesClient/FeaturesClient.swift new file mode 100644 index 0000000..66359fa --- /dev/null +++ b/Sources/FeaturesClient/FeaturesClient.swift @@ -0,0 +1,53 @@ +import FeaturesShared +import Foundation + +public struct FeaturesClient: Sendable { + private let baseURL: URL + private let session: URLSession + private let authTokenProvider: @Sendable () async throws -> String + + public init( + baseURL: URL, + session: URLSession = .shared, + authTokenProvider: @escaping @Sendable () async throws -> String + ) { + self.baseURL = baseURL + self.session = session + self.authTokenProvider = authTokenProvider + } + + public func getFeatures() async throws -> [String: Bool] { + var request = URLRequest(url: baseURL.appending(path: "features")) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(try await authTokenProvider())", forHTTPHeaderField: "Authorization") + + let (data, response) = try await session.data(for: request) + try ensureSuccess(response: response, data: data) + + let decoded = try JSONDecoder().decode(FeatureMapDTO.self, from: data) + return Dictionary(uniqueKeysWithValues: decoded.features.map { ($0.key, $0.enabled) }) + } + + public func toggleFeature(key: FeatureKey, enabled: Bool) async throws { + var request = URLRequest(url: baseURL.appending(path: "features/toggle")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(try await authTokenProvider())", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONEncoder().encode(ClientFeatureToggleRequestDTO(key: key.rawValue, enabled: enabled)) + + let (data, response) = try await session.data(for: request) + try ensureSuccess(response: response, data: data) + } + + private func ensureSuccess(response: URLResponse, data: Data) throws { + guard let http = response as? HTTPURLResponse, (200 ..< 300).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "unknown error" + throw FeaturesClientError.requestFailed(body) + } + } +} + +public enum FeaturesClientError: Error { + case requestFailed(String) +} diff --git a/Sources/FeaturesExampleClient/main.swift b/Sources/FeaturesExampleClient/main.swift new file mode 100644 index 0000000..09669da --- /dev/null +++ b/Sources/FeaturesExampleClient/main.swift @@ -0,0 +1,25 @@ +import FeaturesClient +import FeaturesShared +import Foundation + +@main +struct FeaturesExampleClient { + static func main() async throws { + let server = URL(string: ProcessInfo.processInfo.environment["FEATURES_SERVER_URL"] ?? "http://127.0.0.1:8080")! + let userID = ProcessInfo.processInfo.environment["FEATURES_USER_ID"] ?? "11111111-1111-1111-1111-111111111111" + + let client = FeaturesClient(baseURL: server) { + "dev-token" + } + + print("Using user: \(userID)") + print("Set header x-user-id manually if testing with curl; this simple CLI only demonstrates SDK calls.") + + let features = try await client.getFeatures() + print("Current features: \(features)") + + try await client.toggleFeature(key: FeatureKey(rawValue: "new_checkout"), enabled: true) + let updated = try await client.getFeatures() + print("After toggle: \(updated)") + } +} diff --git a/Sources/FeaturesExampleServer/Controllers/GameController.swift b/Sources/FeaturesExampleServer/Controllers/GameController.swift new file mode 100644 index 0000000..f446d26 --- /dev/null +++ b/Sources/FeaturesExampleServer/Controllers/GameController.swift @@ -0,0 +1,31 @@ +import FeaturesShared +import FeaturesServer +import Vapor + +struct GameStateDTO: Content { + let message: String + let newCheckoutEnabled: Bool + let proFeatureEnabled: Bool +} + +struct GameController: RouteCollection { + func boot(routes: any RoutesBuilder) throws { + routes.get("game") { req async throws -> GameStateDTO in + let canSeeCastle = req.feature(FeatureKey(rawValue: "new_checkout")) + let hasProSword = req.feature(FeatureKey(rawValue: "pro_feature")) + + let message: String + if canSeeCastle { + message = "You unlocked the castle path." + } else { + message = "You are still in the village tutorial." + } + + return .init( + message: message, + newCheckoutEnabled: canSeeCastle, + proFeatureEnabled: hasProSword + ) + } + } +} diff --git a/Sources/FeaturesExampleServer/Migrations/CreateUser.swift b/Sources/FeaturesExampleServer/Migrations/CreateUser.swift new file mode 100644 index 0000000..421f3d9 --- /dev/null +++ b/Sources/FeaturesExampleServer/Migrations/CreateUser.swift @@ -0,0 +1,16 @@ +import Fluent + +struct CreateUser: AsyncMigration { + func prepare(on database: Database) async throws { + try await database.schema("users") + .id() + .field("name", .string, .required) + .field("plan", .string, .required) + .field("is_internal", .bool, .required) + .create() + } + + func revert(on database: Database) async throws { + try await database.schema("users").delete() + } +} diff --git a/Sources/FeaturesExampleServer/Models/User.swift b/Sources/FeaturesExampleServer/Models/User.swift new file mode 100644 index 0000000..b235176 --- /dev/null +++ b/Sources/FeaturesExampleServer/Models/User.swift @@ -0,0 +1,27 @@ +import Fluent +import Vapor + +final class User: Model, Content, @unchecked Sendable { + static let schema = "users" + + @ID(key: .id) + var id: UUID? + + @Field(key: "name") + var name: String + + @Field(key: "plan") + var plan: String + + @Field(key: "is_internal") + var isInternal: Bool + + init() {} + + init(id: UUID? = nil, name: String, plan: String, isInternal: Bool) { + self.id = id + self.name = name + self.plan = plan + self.isInternal = isInternal + } +} diff --git a/Sources/FeaturesExampleServer/configure.swift b/Sources/FeaturesExampleServer/configure.swift new file mode 100644 index 0000000..36dcecd --- /dev/null +++ b/Sources/FeaturesExampleServer/configure.swift @@ -0,0 +1,101 @@ +import FeaturesServer +import FeaturesShared +import Fluent +import FluentSQLiteDriver +import Vapor + +func configure(_ app: Application) async throws { + app.databases.use(.sqlite(.file("db.sqlite")), as: .sqlite) + + app.migrations.add(CreateUser()) + + let registry = FeatureRegistry(features: [ + .init(key: FeatureKey(rawValue: "new_checkout"), canToggleOnClient: true, active: { ctx in + if ctx.subject.isInternal { + return true + } + return FeatureBucketing.bucket(subjectId: ctx.subject.id, featureKey: FeatureKey(rawValue: "new_checkout")) < 30 + }), + .init(key: FeatureKey(rawValue: "pro_feature"), canToggleOnClient: false, active: { ctx in + ctx.subject.plan == "pro" + }), + ]) + + let auth = MockAuthMiddleware() + + FeaturesServer.configure( + on: app, + config: .init(databaseID: .sqlite, authMiddleware: [auth], routePrefix: "features"), + registry: registry, + actorResolver: { req in + guard let user = req.storage[MockAuthUserKey.self] else { + return nil + } + + return .init( + subject: .init( + id: user.id, + plan: user.plan, + isInternal: user.isInternal, + attributes: ["name": user.name] + ), + changedBy: user.id.uuidString + ) + } + ) + + try app.register(collection: GameController()) + + try await app.autoMigrate() + try await seedIfNeeded(app: app) +} + +struct MockAuthedUser: Sendable { + let id: UUID + let name: String + let plan: String + let isInternal: Bool +} + +struct MockAuthUserKey: StorageKey { + typealias Value = MockAuthedUser +} + +struct MockAuthMiddleware: AsyncMiddleware { + func respond(to req: Request, chainingTo next: AsyncResponder) async throws -> Response { + let rawUserID = req.headers.first(name: "x-user-id") + ?? req.headers.bearerAuthorization?.token + + guard let raw = rawUserID, let id = UUID(uuidString: raw) else { + throw Abort(.unauthorized, reason: "Missing x-user-id header or bearer token containing user UUID") + } + + guard let user = try await User.find(id, on: req.db(.sqlite)) else { + throw Abort(.unauthorized, reason: "User not found") + } + + req.storage[MockAuthUserKey.self] = .init( + id: try user.requireID(), + name: user.name, + plan: user.plan, + isInternal: user.isInternal + ) + + return try await next.respond(to: req) + } +} + +private func seedIfNeeded(app: Application) async throws { + if try await User.query(on: app.db(.sqlite)).count() > 0 { + return + } + + try await User(id: UUID(uuidString: "11111111-1111-1111-1111-111111111111"), name: "Alice", plan: "free", isInternal: false) + .create(on: app.db(.sqlite)) + + try await User(id: UUID(uuidString: "22222222-2222-2222-2222-222222222222"), name: "Bob", plan: "pro", isInternal: false) + .create(on: app.db(.sqlite)) + + try await User(id: UUID(uuidString: "33333333-3333-3333-3333-333333333333"), name: "Carol", plan: "free", isInternal: true) + .create(on: app.db(.sqlite)) +} diff --git a/Sources/FeaturesExampleServer/main.swift b/Sources/FeaturesExampleServer/main.swift new file mode 100644 index 0000000..13dd723 --- /dev/null +++ b/Sources/FeaturesExampleServer/main.swift @@ -0,0 +1,9 @@ +import Vapor + +var env = try Environment.detect() +try LoggingSystem.bootstrap(from: &env) +let app = try await Application.make(env) + +try await configure(app) +try await app.execute() +try await app.asyncShutdown() diff --git a/Sources/FeaturesServer/Config/FeaturesConfiguration.swift b/Sources/FeaturesServer/Config/FeaturesConfiguration.swift new file mode 100644 index 0000000..a1197c9 --- /dev/null +++ b/Sources/FeaturesServer/Config/FeaturesConfiguration.swift @@ -0,0 +1,24 @@ +import Fluent +import Vapor + +public struct FeaturesConfiguration: Sendable { + public let databaseID: DatabaseID? + public let authMiddleware: [Middleware] + public let routePrefix: PathComponent + + public init(databaseID: DatabaseID? = nil, authMiddleware: [Middleware], routePrefix: PathComponent = "features") { + self.databaseID = databaseID + self.authMiddleware = authMiddleware + self.routePrefix = routePrefix + } +} + +public struct FeaturesRouteActor: Sendable { + public let subject: FeatureSubject + public let changedBy: String + + public init(subject: FeatureSubject, changedBy: String) { + self.subject = subject + self.changedBy = changedBy + } +} diff --git a/Sources/FeaturesServer/Config/FeaturesServer+Application.swift b/Sources/FeaturesServer/Config/FeaturesServer+Application.swift new file mode 100644 index 0000000..bf482e5 --- /dev/null +++ b/Sources/FeaturesServer/Config/FeaturesServer+Application.swift @@ -0,0 +1,47 @@ +import Fluent +import Vapor + +public struct FeaturesServerConfigurationStorage: Sendable { + let databaseID: DatabaseID? + let registry: FeatureRegistry +} + +private struct FeaturesServerConfigurationStorageKey: StorageKey { + typealias Value = FeaturesServerConfigurationStorage +} + +public extension Application { + var featuresServer: FeaturesServerConfigurationStorage { + get { + guard let storage = self.storage[FeaturesServerConfigurationStorageKey.self] else { + fatalError("FeaturesServer not configured. Call FeaturesServer.configure first.") + } + return storage + } + set { + self.storage[FeaturesServerConfigurationStorageKey.self] = newValue + } + } +} + +public enum FeaturesServer { + public static func configure( + on app: Application, + config: FeaturesConfiguration, + registry: FeatureRegistry, + actorResolver: @escaping @Sendable (Request) async throws -> FeaturesRouteActor? + ) { + app.featuresServer = .init(databaseID: config.databaseID, registry: registry) + app.migrations.add(CreateFeatureOverride()) + + app.middleware.use( + FeatureEvaluationMiddleware( + registry: registry, + databaseID: config.databaseID, + actorResolver: actorResolver + ) + ) + + FeaturesRoutes.register(app: app, config: config, registry: registry) + } +} diff --git a/Sources/FeaturesServer/Core/Bucketing.swift b/Sources/FeaturesServer/Core/Bucketing.swift new file mode 100644 index 0000000..45b7b09 --- /dev/null +++ b/Sources/FeaturesServer/Core/Bucketing.swift @@ -0,0 +1,14 @@ +import CryptoKit +import FeaturesShared +import Foundation + +public enum FeatureBucketing { + public static func bucket(subjectId: UUID, featureKey: FeatureKey) -> Int { + let input = "\(subjectId.uuidString):\(featureKey.rawValue)" + let hash = SHA256.hash(data: Data(input.utf8)) + let value = hash.prefix(4).reduce(UInt32(0)) { partial, byte in + (partial << 8) | UInt32(byte) + } + return Int(value % 100) + } +} diff --git a/Sources/FeaturesServer/Core/FeatureContext.swift b/Sources/FeaturesServer/Core/FeatureContext.swift new file mode 100644 index 0000000..3842f7e --- /dev/null +++ b/Sources/FeaturesServer/Core/FeatureContext.swift @@ -0,0 +1,54 @@ +import FeaturesShared +import Foundation + +public struct FeatureSubject: Sendable { + public let id: UUID + public let plan: String? + public let isInternal: Bool + public let attributes: [String: String] + + public init(id: UUID, plan: String? = nil, isInternal: Bool = false, attributes: [String: String] = [:]) { + self.id = id + self.plan = plan + self.isInternal = isInternal + self.attributes = attributes + } +} + +public struct FeatureContext: Sendable { + public let subject: FeatureSubject + public let now: Date + + public init(subject: FeatureSubject, now: Date) { + self.subject = subject + self.now = now + } +} + +public struct FeatureDefinition: Sendable { + public let key: FeatureKey + public let canToggleOnClient: Bool + public let active: @Sendable (FeatureContext) -> Bool + + public init( + key: FeatureKey, + canToggleOnClient: Bool = false, + active: @escaping @Sendable (FeatureContext) -> Bool + ) { + self.key = key + self.canToggleOnClient = canToggleOnClient + self.active = active + } +} + +public struct FeatureRegistry: Sendable { + public let features: [FeatureDefinition] + + public init(features: [FeatureDefinition]) { + self.features = features + } + + public func byKey(_ key: String) -> FeatureDefinition? { + features.first { $0.key.rawValue == key } + } +} diff --git a/Sources/FeaturesServer/Core/FeatureService.swift b/Sources/FeaturesServer/Core/FeatureService.swift new file mode 100644 index 0000000..23c6c1a --- /dev/null +++ b/Sources/FeaturesServer/Core/FeatureService.swift @@ -0,0 +1,75 @@ +import FeaturesShared +import Fluent +import Foundation + +public struct FeatureService: Sendable { + public let db: Database + public let registry: FeatureRegistry + + public init(db: Database, registry: FeatureRegistry) { + self.db = db + self.registry = registry + } + + public func resolve(subject: FeatureSubject) async throws -> [String: Bool] { + let overrideRows = try await FeatureOverride.query(on: db) + .filter(\.$subjectId == subject.id) + .filter(\.$latest == true) + .all() + + let overrideMap = Dictionary(uniqueKeysWithValues: overrideRows.map { ($0.featureKey, $0.enabled) }) + let context = FeatureContext(subject: subject, now: Date()) + + var result: [String: Bool] = [:] + for feature in registry.features { + if let overridden = overrideMap[feature.key.rawValue] { + result[feature.key.rawValue] = overridden + } else { + result[feature.key.rawValue] = feature.active(context) + } + } + return result + } + + public func debug(subject: FeatureSubject) async throws -> [FeatureDebugResultDTO] { + let overrideRows = try await FeatureOverride.query(on: db) + .filter(\.$subjectId == subject.id) + .filter(\.$latest == true) + .all() + + let overrideMap = Dictionary(uniqueKeysWithValues: overrideRows.map { ($0.featureKey, $0.enabled) }) + let context = FeatureContext(subject: subject, now: Date()) + + return registry.features.map { feature in + if let overridden = overrideMap[feature.key.rawValue] { + return FeatureDebugResultDTO(key: feature.key.rawValue, enabled: overridden, source: "override") + } + return FeatureDebugResultDTO(key: feature.key.rawValue, enabled: feature.active(context), source: "code") + } + } + + public func applyOverride( + subjectId: UUID, + key: String, + enabled: Bool, + changedBy: String, + channel: String + ) async throws { + try await FeatureOverride.query(on: db) + .filter(\.$subjectId == subjectId) + .filter(\.$featureKey == key) + .filter(\.$latest == true) + .set(\.$latest, to: false) + .update() + + let newRow = FeatureOverride( + subjectId: subjectId, + featureKey: key, + enabled: enabled, + latest: true, + changedBy: changedBy, + channel: channel + ) + try await newRow.create(on: db) + } +} diff --git a/Sources/FeaturesServer/Core/SharedDTO+Content.swift b/Sources/FeaturesServer/Core/SharedDTO+Content.swift new file mode 100644 index 0000000..2f13692 --- /dev/null +++ b/Sources/FeaturesServer/Core/SharedDTO+Content.swift @@ -0,0 +1,7 @@ +import FeaturesShared +import Vapor + +extension FeatureMapDTO: Content {} +extension FeatureValueDTO: Content {} +extension FeatureDebugResultDTO: Content {} +extension ClientFeatureToggleRequestDTO: Content {} diff --git a/Sources/FeaturesServer/Middleware/FeatureEvaluationMiddleware.swift b/Sources/FeaturesServer/Middleware/FeatureEvaluationMiddleware.swift new file mode 100644 index 0000000..7035a83 --- /dev/null +++ b/Sources/FeaturesServer/Middleware/FeatureEvaluationMiddleware.swift @@ -0,0 +1,31 @@ +import Fluent +import Vapor + +public struct FeatureEvaluationMiddleware: AsyncMiddleware { + private let registry: FeatureRegistry + private let databaseID: DatabaseID? + private let actorResolver: @Sendable (Request) async throws -> FeaturesRouteActor? + + public init( + registry: FeatureRegistry, + databaseID: DatabaseID?, + actorResolver: @escaping @Sendable (Request) async throws -> FeaturesRouteActor? + ) { + self.registry = registry + self.databaseID = databaseID + self.actorResolver = actorResolver + } + + public func respond(to req: Request, chainingTo next: AsyncResponder) async throws -> Response { + guard let actor = try await actorResolver(req) else { + return try await next.respond(to: req) + } + + req.setFeatureActor(actor) + let service = FeatureService(db: req.db(databaseID), registry: registry) + let resolved = try await service.resolve(subject: actor.subject) + req.setFeatures(resolved) + + return try await next.respond(to: req) + } +} diff --git a/Sources/FeaturesServer/Middleware/FeatureRequestStorage.swift b/Sources/FeaturesServer/Middleware/FeatureRequestStorage.swift new file mode 100644 index 0000000..1dd51a8 --- /dev/null +++ b/Sources/FeaturesServer/Middleware/FeatureRequestStorage.swift @@ -0,0 +1,32 @@ +import FeaturesShared +import Vapor + +private struct FeatureStorageKey: StorageKey { + typealias Value = [String: Bool] +} + +private struct FeatureActorStorageKey: StorageKey { + typealias Value = FeaturesRouteActor +} + +public extension Request { + var features: [String: Bool] { + storage[FeatureStorageKey.self] ?? [:] + } + + func feature(_ key: FeatureKey) -> Bool { + features[key.rawValue] ?? false + } + + var featureActor: FeaturesRouteActor? { + storage[FeatureActorStorageKey.self] + } + + func setFeatures(_ value: [String: Bool]) { + storage[FeatureStorageKey.self] = value + } + + func setFeatureActor(_ value: FeaturesRouteActor) { + storage[FeatureActorStorageKey.self] = value + } +} diff --git a/Sources/FeaturesServer/Middleware/RequireFeatureMiddleware.swift b/Sources/FeaturesServer/Middleware/RequireFeatureMiddleware.swift new file mode 100644 index 0000000..a63c411 --- /dev/null +++ b/Sources/FeaturesServer/Middleware/RequireFeatureMiddleware.swift @@ -0,0 +1,17 @@ +import FeaturesShared +import Vapor + +public struct RequireFeatureMiddleware: AsyncMiddleware { + public let key: FeatureKey + + public init(key: FeatureKey) { + self.key = key + } + + public func respond(to req: Request, chainingTo next: AsyncResponder) async throws -> Response { + guard req.feature(key) else { + throw Abort(.forbidden, reason: "Feature not enabled: \(key.rawValue)") + } + return try await next.respond(to: req) + } +} diff --git a/Sources/FeaturesServer/Migrations/CreateFeatureOverride.swift b/Sources/FeaturesServer/Migrations/CreateFeatureOverride.swift new file mode 100644 index 0000000..a98d08b --- /dev/null +++ b/Sources/FeaturesServer/Migrations/CreateFeatureOverride.swift @@ -0,0 +1,26 @@ +import Fluent + +public struct CreateFeatureOverride: AsyncMigration { + public init() {} + + public func prepare(on database: Database) async throws { + try await database.schema("feature_overrides") + .id() + .field("subject_id", .uuid, .required) + .field("feature_key", .string, .required) + .field("enabled", .bool, .required) + .field("latest", .bool, .required) + .field("changed_by", .string, .required) + .field("channel", .string, .required) + .field("created_at", .datetime) + .create() + + try await database.schema("feature_overrides") + .unique(on: "subject_id", "feature_key", "latest", name: "uq_feature_override_single_latest") + .update() + } + + public func revert(on database: Database) async throws { + try await database.schema("feature_overrides").delete() + } +} diff --git a/Sources/FeaturesServer/Models/FeatureOverride.swift b/Sources/FeaturesServer/Models/FeatureOverride.swift new file mode 100644 index 0000000..a89a7b8 --- /dev/null +++ b/Sources/FeaturesServer/Models/FeatureOverride.swift @@ -0,0 +1,41 @@ +import Fluent +import Foundation + +public final class FeatureOverride: Model, @unchecked Sendable { + public static let schema = "feature_overrides" + + @ID(key: .id) + public var id: UUID? + + @Field(key: "subject_id") + public var subjectId: UUID + + @Field(key: "feature_key") + public var featureKey: String + + @Field(key: "enabled") + public var enabled: Bool + + @Field(key: "latest") + public var latest: Bool + + @Field(key: "changed_by") + public var changedBy: String + + @Field(key: "channel") + public var channel: String + + @Timestamp(key: "created_at", on: .create) + public var createdAt: Date? + + public init() {} + + public init(subjectId: UUID, featureKey: String, enabled: Bool, latest: Bool, changedBy: String, channel: String) { + self.subjectId = subjectId + self.featureKey = featureKey + self.enabled = enabled + self.latest = latest + self.changedBy = changedBy + self.channel = channel + } +} diff --git a/Sources/FeaturesServer/Routes/FeaturesRoutes.swift b/Sources/FeaturesServer/Routes/FeaturesRoutes.swift new file mode 100644 index 0000000..1561f4f --- /dev/null +++ b/Sources/FeaturesServer/Routes/FeaturesRoutes.swift @@ -0,0 +1,72 @@ +import FeaturesShared +import Vapor + +struct FeatureToggleResponse: Content { + let key: String + let enabled: Bool + let changedBy: String + let source: String +} + +enum FeaturesRoutes { + static func register( + app: Application, + config: FeaturesConfiguration, + registry: FeatureRegistry + ) { + let group = app.grouped(config.authMiddleware).grouped(config.routePrefix) + + group.get(use: getFeatures) + group.get("debug", use: debugFeatures) + group.post("toggle", use: { req in + try await toggleFeature(req: req, config: config, registry: registry) + }) + } + + static func getFeatures(req: Request) async throws -> FeatureMapDTO { + let sortedFeatures = req.features.keys.sorted().map { key in + FeatureValueDTO(key: key, enabled: req.features[key] ?? false) + } + return .init(features: sortedFeatures) + } + + static func debugFeatures(req: Request) async throws -> [FeatureDebugResultDTO] { + guard let actor = req.featureActor else { + throw Abort(.unauthorized) + } + + let featuresServer = req.application.featuresServer + let service = FeatureService(db: req.db(featuresServer.databaseID), registry: featuresServer.registry) + return try await service.debug(subject: actor.subject) + } + + static func toggleFeature(req: Request, config: FeaturesConfiguration, registry: FeatureRegistry) async throws -> FeatureToggleResponse { + guard let actor = req.featureActor else { + throw Abort(.unauthorized) + } + + let input = try req.content.decode(ClientFeatureToggleRequestDTO.self) + + guard let definition = registry.byKey(input.key) else { + throw Abort(.notFound, reason: "Unknown feature key: \(input.key)") + } + + guard definition.canToggleOnClient else { + throw Abort(.forbidden, reason: "Feature cannot be toggled by client: \(input.key)") + } + + let service = FeatureService(db: req.db(config.databaseID), registry: registry) + try await service.applyOverride( + subjectId: actor.subject.id, + key: input.key, + enabled: input.enabled, + changedBy: actor.changedBy, + channel: "client" + ) + + let nextMap = try await service.resolve(subject: actor.subject) + req.setFeatures(nextMap) + + return .init(key: input.key, enabled: input.enabled, changedBy: actor.changedBy, source: "client") + } +} diff --git a/Sources/FeaturesShared/FeatureTypes.swift b/Sources/FeaturesShared/FeatureTypes.swift new file mode 100644 index 0000000..e48c1e4 --- /dev/null +++ b/Sources/FeaturesShared/FeatureTypes.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct FeatureKey: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: StringLiteralType) { + self.init(rawValue: value) + } +} + +public struct FeatureValueDTO: Codable, Sendable { + public let key: String + public let enabled: Bool + + public init(key: String, enabled: Bool) { + self.key = key + self.enabled = enabled + } +} + +public struct FeatureMapDTO: Codable, Sendable { + public let features: [FeatureValueDTO] + + public init(features: [FeatureValueDTO]) { + self.features = features + } +} + +public struct ClientFeatureToggleRequestDTO: Codable, Sendable { + public let key: String + public let enabled: Bool + + public init(key: String, enabled: Bool) { + self.key = key + self.enabled = enabled + } +} + +public struct FeatureDebugResultDTO: Codable, Sendable { + public let key: String + public let enabled: Bool + public let source: String + + public init(key: String, enabled: Bool, source: String) { + self.key = key + self.enabled = enabled + self.source = source + } +} From c7064cecab451ea8e9fa740a17e2ed3d04b4fdca Mon Sep 17 00:00:00 2001 From: AdonisCodes Date: Mon, 18 May 2026 17:52:39 +0200 Subject: [PATCH 2/2] refactor: make feature evaluation context generic --- README.md | 2 +- Sources/FeaturesExampleServer/configure.swift | 39 +++++++++++++------ .../Config/FeaturesConfiguration.swift | 10 +++-- .../Config/FeaturesServer+Application.swift | 30 +++++++------- .../FeaturesServer/Core/FeatureContext.swift | 38 +++++++----------- .../FeaturesServer/Core/FeatureService.swift | 22 +++++------ .../FeatureEvaluationMiddleware.swift | 16 ++++---- .../Middleware/FeatureRequestStorage.swift | 12 +++--- .../Routes/FeaturesRoutes.swift | 35 ++++++++++------- 9 files changed, 108 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index a8c7351..381a8ad 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Inside your app `configure.swift`: 1. Register your app DB. 2. Build a `FeatureRegistry` with `active(ctx)` closures. -3. Call `FeaturesServer.configure(on:config:registry:actorResolver:)`. +3. Call `FeaturesServer.configure(on:config:registry:actorResolver:)` with your app-defined context type. 4. Pass your auth middleware through `FeaturesConfiguration(authMiddleware:)`. `FeaturesServer.configure` automatically registers the feature override migration. diff --git a/Sources/FeaturesExampleServer/configure.swift b/Sources/FeaturesExampleServer/configure.swift index 36dcecd..1f337dc 100644 --- a/Sources/FeaturesExampleServer/configure.swift +++ b/Sources/FeaturesExampleServer/configure.swift @@ -4,20 +4,30 @@ import Fluent import FluentSQLiteDriver import Vapor +struct ExampleFeatureContext: Sendable { + let plan: String + let isInternal: Bool + let countryCode: String + let level: Int +} + func configure(_ app: Application) async throws { app.databases.use(.sqlite(.file("db.sqlite")), as: .sqlite) app.migrations.add(CreateUser()) - let registry = FeatureRegistry(features: [ + let registry = FeatureRegistry(features: [ .init(key: FeatureKey(rawValue: "new_checkout"), canToggleOnClient: true, active: { ctx in - if ctx.subject.isInternal { + if ctx.context.isInternal { return true } - return FeatureBucketing.bucket(subjectId: ctx.subject.id, featureKey: FeatureKey(rawValue: "new_checkout")) < 30 + if ctx.context.countryCode == "ZA" && ctx.context.level >= 5 { + return true + } + return FeatureBucketing.bucket(subjectId: ctx.subjectId, featureKey: FeatureKey(rawValue: "new_checkout")) < 30 }), .init(key: FeatureKey(rawValue: "pro_feature"), canToggleOnClient: false, active: { ctx in - ctx.subject.plan == "pro" + ctx.context.plan == "pro" }), ]) @@ -32,13 +42,16 @@ func configure(_ app: Application) async throws { return nil } + let context = ExampleFeatureContext( + plan: user.plan, + isInternal: user.isInternal, + countryCode: user.countryCode, + level: user.level + ) + return .init( - subject: .init( - id: user.id, - plan: user.plan, - isInternal: user.isInternal, - attributes: ["name": user.name] - ), + subjectId: user.id, + context: context, changedBy: user.id.uuidString ) } @@ -55,6 +68,8 @@ struct MockAuthedUser: Sendable { let name: String let plan: String let isInternal: Bool + let countryCode: String + let level: Int } struct MockAuthUserKey: StorageKey { @@ -78,7 +93,9 @@ struct MockAuthMiddleware: AsyncMiddleware { id: try user.requireID(), name: user.name, plan: user.plan, - isInternal: user.isInternal + isInternal: user.isInternal, + countryCode: "ZA", + level: user.plan == "pro" ? 10 : 3 ) return try await next.respond(to: req) diff --git a/Sources/FeaturesServer/Config/FeaturesConfiguration.swift b/Sources/FeaturesServer/Config/FeaturesConfiguration.swift index a1197c9..b5bbe54 100644 --- a/Sources/FeaturesServer/Config/FeaturesConfiguration.swift +++ b/Sources/FeaturesServer/Config/FeaturesConfiguration.swift @@ -13,12 +13,14 @@ public struct FeaturesConfiguration: Sendable { } } -public struct FeaturesRouteActor: Sendable { - public let subject: FeatureSubject +public struct FeaturesRouteContext: Sendable { + public let subjectId: UUID + public let context: Context public let changedBy: String - public init(subject: FeatureSubject, changedBy: String) { - self.subject = subject + public init(subjectId: UUID, context: Context, changedBy: String) { + self.subjectId = subjectId + self.context = context self.changedBy = changedBy } } diff --git a/Sources/FeaturesServer/Config/FeaturesServer+Application.swift b/Sources/FeaturesServer/Config/FeaturesServer+Application.swift index bf482e5..5ef740e 100644 --- a/Sources/FeaturesServer/Config/FeaturesServer+Application.swift +++ b/Sources/FeaturesServer/Config/FeaturesServer+Application.swift @@ -1,37 +1,33 @@ import Fluent import Vapor -public struct FeaturesServerConfigurationStorage: Sendable { +public struct FeaturesServerConfigurationStorage: Sendable { let databaseID: DatabaseID? - let registry: FeatureRegistry + let registry: FeatureRegistry } private struct FeaturesServerConfigurationStorageKey: StorageKey { - typealias Value = FeaturesServerConfigurationStorage + typealias Value = any Sendable } public extension Application { - var featuresServer: FeaturesServerConfigurationStorage { - get { - guard let storage = self.storage[FeaturesServerConfigurationStorageKey.self] else { - fatalError("FeaturesServer not configured. Call FeaturesServer.configure first.") - } - return storage - } - set { - self.storage[FeaturesServerConfigurationStorageKey.self] = newValue - } + func featuresServerStorage(as _: Context.Type = Context.self) -> FeaturesServerConfigurationStorage? { + storage[FeaturesServerConfigurationStorageKey.self] as? FeaturesServerConfigurationStorage + } + + func setFeaturesServerStorage(_ value: FeaturesServerConfigurationStorage) { + storage[FeaturesServerConfigurationStorageKey.self] = value } } public enum FeaturesServer { - public static func configure( + public static func configure( on app: Application, config: FeaturesConfiguration, - registry: FeatureRegistry, - actorResolver: @escaping @Sendable (Request) async throws -> FeaturesRouteActor? + registry: FeatureRegistry, + actorResolver: @escaping @Sendable (Request) async throws -> FeaturesRouteContext? ) { - app.featuresServer = .init(databaseID: config.databaseID, registry: registry) + app.setFeaturesServerStorage(.init(databaseID: config.databaseID, registry: registry)) app.migrations.add(CreateFeatureOverride()) app.middleware.use( diff --git a/Sources/FeaturesServer/Core/FeatureContext.swift b/Sources/FeaturesServer/Core/FeatureContext.swift index 3842f7e..bd9f4a6 100644 --- a/Sources/FeaturesServer/Core/FeatureContext.swift +++ b/Sources/FeaturesServer/Core/FeatureContext.swift @@ -1,39 +1,27 @@ import FeaturesShared import Foundation -public struct FeatureSubject: Sendable { - public let id: UUID - public let plan: String? - public let isInternal: Bool - public let attributes: [String: String] - - public init(id: UUID, plan: String? = nil, isInternal: Bool = false, attributes: [String: String] = [:]) { - self.id = id - self.plan = plan - self.isInternal = isInternal - self.attributes = attributes - } -} - -public struct FeatureContext: Sendable { - public let subject: FeatureSubject +public struct FeatureContext: Sendable { + public let subjectId: UUID + public let context: Context public let now: Date - public init(subject: FeatureSubject, now: Date) { - self.subject = subject + public init(subjectId: UUID, context: Context, now: Date) { + self.subjectId = subjectId + self.context = context self.now = now } } -public struct FeatureDefinition: Sendable { +public struct FeatureDefinition: Sendable { public let key: FeatureKey public let canToggleOnClient: Bool - public let active: @Sendable (FeatureContext) -> Bool + public let active: @Sendable (FeatureContext) -> Bool public init( key: FeatureKey, canToggleOnClient: Bool = false, - active: @escaping @Sendable (FeatureContext) -> Bool + active: @escaping @Sendable (FeatureContext) -> Bool ) { self.key = key self.canToggleOnClient = canToggleOnClient @@ -41,14 +29,14 @@ public struct FeatureDefinition: Sendable { } } -public struct FeatureRegistry: Sendable { - public let features: [FeatureDefinition] +public struct FeatureRegistry: Sendable { + public let features: [FeatureDefinition] - public init(features: [FeatureDefinition]) { + public init(features: [FeatureDefinition]) { self.features = features } - public func byKey(_ key: String) -> FeatureDefinition? { + public func byKey(_ key: String) -> FeatureDefinition? { features.first { $0.key.rawValue == key } } } diff --git a/Sources/FeaturesServer/Core/FeatureService.swift b/Sources/FeaturesServer/Core/FeatureService.swift index 23c6c1a..8037ce9 100644 --- a/Sources/FeaturesServer/Core/FeatureService.swift +++ b/Sources/FeaturesServer/Core/FeatureService.swift @@ -2,49 +2,49 @@ import FeaturesShared import Fluent import Foundation -public struct FeatureService: Sendable { +public struct FeatureService: Sendable { public let db: Database - public let registry: FeatureRegistry + public let registry: FeatureRegistry - public init(db: Database, registry: FeatureRegistry) { + public init(db: Database, registry: FeatureRegistry) { self.db = db self.registry = registry } - public func resolve(subject: FeatureSubject) async throws -> [String: Bool] { + public func resolve(subjectId: UUID, context: Context) async throws -> [String: Bool] { let overrideRows = try await FeatureOverride.query(on: db) - .filter(\.$subjectId == subject.id) + .filter(\.$subjectId == subjectId) .filter(\.$latest == true) .all() let overrideMap = Dictionary(uniqueKeysWithValues: overrideRows.map { ($0.featureKey, $0.enabled) }) - let context = FeatureContext(subject: subject, now: Date()) + let featureContext = FeatureContext(subjectId: subjectId, context: context, now: Date()) var result: [String: Bool] = [:] for feature in registry.features { if let overridden = overrideMap[feature.key.rawValue] { result[feature.key.rawValue] = overridden } else { - result[feature.key.rawValue] = feature.active(context) + result[feature.key.rawValue] = feature.active(featureContext) } } return result } - public func debug(subject: FeatureSubject) async throws -> [FeatureDebugResultDTO] { + public func debug(subjectId: UUID, context: Context) async throws -> [FeatureDebugResultDTO] { let overrideRows = try await FeatureOverride.query(on: db) - .filter(\.$subjectId == subject.id) + .filter(\.$subjectId == subjectId) .filter(\.$latest == true) .all() let overrideMap = Dictionary(uniqueKeysWithValues: overrideRows.map { ($0.featureKey, $0.enabled) }) - let context = FeatureContext(subject: subject, now: Date()) + let featureContext = FeatureContext(subjectId: subjectId, context: context, now: Date()) return registry.features.map { feature in if let overridden = overrideMap[feature.key.rawValue] { return FeatureDebugResultDTO(key: feature.key.rawValue, enabled: overridden, source: "override") } - return FeatureDebugResultDTO(key: feature.key.rawValue, enabled: feature.active(context), source: "code") + return FeatureDebugResultDTO(key: feature.key.rawValue, enabled: feature.active(featureContext), source: "code") } } diff --git a/Sources/FeaturesServer/Middleware/FeatureEvaluationMiddleware.swift b/Sources/FeaturesServer/Middleware/FeatureEvaluationMiddleware.swift index 7035a83..3898d6a 100644 --- a/Sources/FeaturesServer/Middleware/FeatureEvaluationMiddleware.swift +++ b/Sources/FeaturesServer/Middleware/FeatureEvaluationMiddleware.swift @@ -1,15 +1,15 @@ import Fluent import Vapor -public struct FeatureEvaluationMiddleware: AsyncMiddleware { - private let registry: FeatureRegistry +public struct FeatureEvaluationMiddleware: AsyncMiddleware { + private let registry: FeatureRegistry private let databaseID: DatabaseID? - private let actorResolver: @Sendable (Request) async throws -> FeaturesRouteActor? + private let actorResolver: @Sendable (Request) async throws -> FeaturesRouteContext? public init( - registry: FeatureRegistry, + registry: FeatureRegistry, databaseID: DatabaseID?, - actorResolver: @escaping @Sendable (Request) async throws -> FeaturesRouteActor? + actorResolver: @escaping @Sendable (Request) async throws -> FeaturesRouteContext? ) { self.registry = registry self.databaseID = databaseID @@ -17,13 +17,13 @@ public struct FeatureEvaluationMiddleware: AsyncMiddleware { } public func respond(to req: Request, chainingTo next: AsyncResponder) async throws -> Response { - guard let actor = try await actorResolver(req) else { + guard let routeContext = try await actorResolver(req) else { return try await next.respond(to: req) } - req.setFeatureActor(actor) + req.setFeatureRouteContext(routeContext) let service = FeatureService(db: req.db(databaseID), registry: registry) - let resolved = try await service.resolve(subject: actor.subject) + let resolved = try await service.resolve(subjectId: routeContext.subjectId, context: routeContext.context) req.setFeatures(resolved) return try await next.respond(to: req) diff --git a/Sources/FeaturesServer/Middleware/FeatureRequestStorage.swift b/Sources/FeaturesServer/Middleware/FeatureRequestStorage.swift index 1dd51a8..2818c81 100644 --- a/Sources/FeaturesServer/Middleware/FeatureRequestStorage.swift +++ b/Sources/FeaturesServer/Middleware/FeatureRequestStorage.swift @@ -5,8 +5,8 @@ private struct FeatureStorageKey: StorageKey { typealias Value = [String: Bool] } -private struct FeatureActorStorageKey: StorageKey { - typealias Value = FeaturesRouteActor +private struct FeatureRouteContextStorageKey: StorageKey { + typealias Value = any Sendable } public extension Request { @@ -18,15 +18,15 @@ public extension Request { features[key.rawValue] ?? false } - var featureActor: FeaturesRouteActor? { - storage[FeatureActorStorageKey.self] + func featureRouteContext(as _: Context.Type = Context.self) -> FeaturesRouteContext? { + storage[FeatureRouteContextStorageKey.self] as? FeaturesRouteContext } func setFeatures(_ value: [String: Bool]) { storage[FeatureStorageKey.self] = value } - func setFeatureActor(_ value: FeaturesRouteActor) { - storage[FeatureActorStorageKey.self] = value + func setFeatureRouteContext(_ value: FeaturesRouteContext) { + storage[FeatureRouteContextStorageKey.self] = value } } diff --git a/Sources/FeaturesServer/Routes/FeaturesRoutes.swift b/Sources/FeaturesServer/Routes/FeaturesRoutes.swift index 1561f4f..6f5e6d9 100644 --- a/Sources/FeaturesServer/Routes/FeaturesRoutes.swift +++ b/Sources/FeaturesServer/Routes/FeaturesRoutes.swift @@ -9,15 +9,17 @@ struct FeatureToggleResponse: Content { } enum FeaturesRoutes { - static func register( + static func register( app: Application, config: FeaturesConfiguration, - registry: FeatureRegistry + registry: FeatureRegistry ) { let group = app.grouped(config.authMiddleware).grouped(config.routePrefix) group.get(use: getFeatures) - group.get("debug", use: debugFeatures) + group.get("debug", use: { req in + try await debugFeatures(req: req, contextType: Context.self) + }) group.post("toggle", use: { req in try await toggleFeature(req: req, config: config, registry: registry) }) @@ -30,18 +32,25 @@ enum FeaturesRoutes { return .init(features: sortedFeatures) } - static func debugFeatures(req: Request) async throws -> [FeatureDebugResultDTO] { - guard let actor = req.featureActor else { + static func debugFeatures(req: Request, contextType _: Context.Type) async throws -> [FeatureDebugResultDTO] { + guard let routeContext = req.featureRouteContext(as: Context.self) else { throw Abort(.unauthorized) } - let featuresServer = req.application.featuresServer + guard let featuresServer = req.application.featuresServerStorage(as: Context.self) else { + throw Abort(.internalServerError, reason: "FeaturesServer storage not configured for context type") + } + let service = FeatureService(db: req.db(featuresServer.databaseID), registry: featuresServer.registry) - return try await service.debug(subject: actor.subject) + return try await service.debug(subjectId: routeContext.subjectId, context: routeContext.context) } - static func toggleFeature(req: Request, config: FeaturesConfiguration, registry: FeatureRegistry) async throws -> FeatureToggleResponse { - guard let actor = req.featureActor else { + static func toggleFeature( + req: Request, + config: FeaturesConfiguration, + registry: FeatureRegistry + ) async throws -> FeatureToggleResponse { + guard let routeContext = req.featureRouteContext(as: Context.self) else { throw Abort(.unauthorized) } @@ -57,16 +66,16 @@ enum FeaturesRoutes { let service = FeatureService(db: req.db(config.databaseID), registry: registry) try await service.applyOverride( - subjectId: actor.subject.id, + subjectId: routeContext.subjectId, key: input.key, enabled: input.enabled, - changedBy: actor.changedBy, + changedBy: routeContext.changedBy, channel: "client" ) - let nextMap = try await service.resolve(subject: actor.subject) + let nextMap = try await service.resolve(subjectId: routeContext.subjectId, context: routeContext.context) req.setFeatures(nextMap) - return .init(key: input.key, enabled: input.enabled, changedBy: actor.changedBy, source: "client") + return .init(key: input.key, enabled: input.enabled, changedBy: routeContext.changedBy, source: "client") } }