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..381a8ad 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:)` with your app-defined context type. +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..1f337dc --- /dev/null +++ b/Sources/FeaturesExampleServer/configure.swift @@ -0,0 +1,118 @@ +import FeaturesServer +import FeaturesShared +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: [ + .init(key: FeatureKey(rawValue: "new_checkout"), canToggleOnClient: true, active: { ctx in + if ctx.context.isInternal { + return true + } + 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.context.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 + } + + let context = ExampleFeatureContext( + plan: user.plan, + isInternal: user.isInternal, + countryCode: user.countryCode, + level: user.level + ) + + return .init( + subjectId: user.id, + context: context, + 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 + let countryCode: String + let level: Int +} + +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, + countryCode: "ZA", + level: user.plan == "pro" ? 10 : 3 + ) + + 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..b5bbe54 --- /dev/null +++ b/Sources/FeaturesServer/Config/FeaturesConfiguration.swift @@ -0,0 +1,26 @@ +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 FeaturesRouteContext: Sendable { + public let subjectId: UUID + public let context: Context + public let changedBy: String + + 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 new file mode 100644 index 0000000..5ef740e --- /dev/null +++ b/Sources/FeaturesServer/Config/FeaturesServer+Application.swift @@ -0,0 +1,43 @@ +import Fluent +import Vapor + +public struct FeaturesServerConfigurationStorage: Sendable { + let databaseID: DatabaseID? + let registry: FeatureRegistry +} + +private struct FeaturesServerConfigurationStorageKey: StorageKey { + typealias Value = any Sendable +} + +public extension Application { + 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( + on app: Application, + config: FeaturesConfiguration, + registry: FeatureRegistry, + actorResolver: @escaping @Sendable (Request) async throws -> FeaturesRouteContext? + ) { + app.setFeaturesServerStorage(.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..bd9f4a6 --- /dev/null +++ b/Sources/FeaturesServer/Core/FeatureContext.swift @@ -0,0 +1,42 @@ +import FeaturesShared +import Foundation + +public struct FeatureContext: Sendable { + public let subjectId: UUID + public let context: Context + public let now: Date + + public init(subjectId: UUID, context: Context, now: Date) { + self.subjectId = subjectId + self.context = context + 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..8037ce9 --- /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(subjectId: UUID, context: Context) async throws -> [String: Bool] { + let overrideRows = try await FeatureOverride.query(on: db) + .filter(\.$subjectId == subjectId) + .filter(\.$latest == true) + .all() + + let overrideMap = Dictionary(uniqueKeysWithValues: overrideRows.map { ($0.featureKey, $0.enabled) }) + 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(featureContext) + } + } + return result + } + + public func debug(subjectId: UUID, context: Context) async throws -> [FeatureDebugResultDTO] { + let overrideRows = try await FeatureOverride.query(on: db) + .filter(\.$subjectId == subjectId) + .filter(\.$latest == true) + .all() + + let overrideMap = Dictionary(uniqueKeysWithValues: overrideRows.map { ($0.featureKey, $0.enabled) }) + 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(featureContext), 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..3898d6a --- /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 -> FeaturesRouteContext? + + public init( + registry: FeatureRegistry, + databaseID: DatabaseID?, + actorResolver: @escaping @Sendable (Request) async throws -> FeaturesRouteContext? + ) { + self.registry = registry + self.databaseID = databaseID + self.actorResolver = actorResolver + } + + public func respond(to req: Request, chainingTo next: AsyncResponder) async throws -> Response { + guard let routeContext = try await actorResolver(req) else { + return try await next.respond(to: req) + } + + req.setFeatureRouteContext(routeContext) + let service = FeatureService(db: req.db(databaseID), registry: registry) + 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 new file mode 100644 index 0000000..2818c81 --- /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 FeatureRouteContextStorageKey: StorageKey { + typealias Value = any Sendable +} + +public extension Request { + var features: [String: Bool] { + storage[FeatureStorageKey.self] ?? [:] + } + + func feature(_ key: FeatureKey) -> Bool { + features[key.rawValue] ?? false + } + + func featureRouteContext(as _: Context.Type = Context.self) -> FeaturesRouteContext? { + storage[FeatureRouteContextStorageKey.self] as? FeaturesRouteContext + } + + func setFeatures(_ value: [String: Bool]) { + storage[FeatureStorageKey.self] = value + } + + func setFeatureRouteContext(_ value: FeaturesRouteContext) { + storage[FeatureRouteContextStorageKey.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..6f5e6d9 --- /dev/null +++ b/Sources/FeaturesServer/Routes/FeaturesRoutes.swift @@ -0,0 +1,81 @@ +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: { 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) + }) + } + + 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, contextType _: Context.Type) async throws -> [FeatureDebugResultDTO] { + guard let routeContext = req.featureRouteContext(as: Context.self) else { + throw Abort(.unauthorized) + } + + 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(subjectId: routeContext.subjectId, context: routeContext.context) + } + + static func toggleFeature( + req: Request, + config: FeaturesConfiguration, + registry: FeatureRegistry + ) async throws -> FeatureToggleResponse { + guard let routeContext = req.featureRouteContext(as: Context.self) 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: routeContext.subjectId, + key: input.key, + enabled: input.enabled, + changedBy: routeContext.changedBy, + channel: "client" + ) + + let nextMap = try await service.resolve(subjectId: routeContext.subjectId, context: routeContext.context) + req.setFeatures(nextMap) + + return .init(key: input.key, enabled: input.enabled, changedBy: routeContext.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 + } +}