[lcfeaturecontrol-review-fixes] Fix JWT log leak, drop macOS/tvOS, coalesce requests
- FeatureControlManager/NotificationsClient: pass debug: false at every
api.request(...) call site — the API default (debug: true) was printing
Authorization: Bearer <jwt> on every request, release builds included.
- Package.swift: drop macOS/tvOS from platforms — LCEssentials.API (used by
LCFeatureControl) is iOS/watchOS-only and the vendored xcframework has no
macOS/tvOS slice.
- FeatureControlManager: coalesce concurrent evaluate() calls on a cold cache
into a single in-flight request per key instead of firing one per caller.
- FeatureControlManager/NotificationsClient: treat a 200 response carrying
{"error": true} as a failure instead of caching/returning it as success.
- FeatureControlNotificationsClient: thread a cursor param through list() so
the already-decoded nextCursor can actually be used to page.
- Tests: 9 new tests (coalescing, envelope-error-on-200, cursor param, a real
object payload decoded through a full evaluate response, date-decode
failures) and removed the 7 remaining force-unwraps in test scaffolding.
- Documentation/FeatureControl.md: new guide for the LCFeatureControl product,
cross-linked from README.md and Extensions.md.
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
|
||||
Foundation, value-type, string, collection, numeric, date, and crypto helpers, plus
|
||||
the `LCEssentials` namespace itself. UIKit extensions live in
|
||||
[UIKit.md](UIKit.md); SwiftUI helpers in [SwiftUI.md](SwiftUI.md).
|
||||
[UIKit.md](UIKit.md); SwiftUI helpers in [SwiftUI.md](SwiftUI.md); the
|
||||
`LCFeatureControl` sub-product in [FeatureControl.md](FeatureControl.md).
|
||||
|
||||
Every section is a collapsible block — click a heading to expand it.
|
||||
|
||||
|
||||
175
Documentation/FeatureControl.md
Normal file
175
Documentation/FeatureControl.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# LCEssentials — Feature Control
|
||||
|
||||
A typed Swift client for Atomenta's Feature Control API: flag evaluation with a
|
||||
TTL cache and safe-degrade fallback, an in-app notifications inbox, and batched
|
||||
exposure telemetry. Lives in its own SPM product, `LCFeatureControl`, not in
|
||||
`LCEssentials` itself — an optional sub-package that target-depends on
|
||||
`LCEssentials` (same shape as [`LCECryptoKit`](Extensions.md), see its section
|
||||
there for the sub-SPM rationale).
|
||||
|
||||
Every section is a collapsible block — click a heading to expand it.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Evaluating flags](#evaluating-flags)
|
||||
- [Notifications inbox](#notifications-inbox)
|
||||
- [Exposure telemetry](#exposure-telemetry)
|
||||
- [Errors](#errors)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
Add the product explicitly alongside `LCEssentials` to a consumer's `Package.swift`:
|
||||
|
||||
```swift
|
||||
.product(name: "LCEssentials", package: "LCEssentials"),
|
||||
.product(name: "LCFeatureControl", package: "LCEssentials"),
|
||||
```
|
||||
|
||||
```swift
|
||||
import LCFeatureControl
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>FeatureControlConfiguration</b> — wiring for one module deployment</summary>
|
||||
|
||||
```swift
|
||||
let configuration = FeatureControlConfiguration(
|
||||
baseURL: "https://api.example.com",
|
||||
environment: "production",
|
||||
auth: FeatureControlBearerAuth { await session.currentJWT() }
|
||||
)
|
||||
```
|
||||
|
||||
| Property | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `cacheTTL` | `45` seconds | In-memory client-side cache window |
|
||||
| `requestTimeout` | `3` seconds | Per-request timeout budget |
|
||||
| `evaluatePath` / `notificationsPath` / `telemetryPath` | `/api/feature-control/...` | Override only for a non-default BFF mount |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Auth strategies</b> — <code>FeatureControlAuthorizing</code></summary>
|
||||
|
||||
No `Atomenta-Token` (module token) type exists in this package on purpose — that
|
||||
token must never be embedded in a customer-facing app. Pick one:
|
||||
|
||||
```swift
|
||||
// Internal/admin app hitting Atomenta directly with a panel-role JWT.
|
||||
let auth = FeatureControlBearerAuth { await session.currentJWT() }
|
||||
|
||||
// Customer app calling its own BFF, which enforces its own auth —
|
||||
// adds exactly the given headers, never synthesizes Authorization.
|
||||
let auth = FeatureControlHeaderAuth(headers: ["X-BFF-Session": sessionToken])
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Evaluating flags
|
||||
|
||||
<details open>
|
||||
<summary><b>FeatureControlManager</b> — cache, safe-degrade, invalidation</summary>
|
||||
|
||||
```swift
|
||||
let manager = FeatureControlManager(
|
||||
configuration: configuration,
|
||||
defaults: ["fc.checkout_v2": FeatureControlFlag(enabled: false, variant: nil,
|
||||
payload: nil, reason: "default")]
|
||||
)
|
||||
|
||||
let context = FeatureControlContext(subjectType: .customer, subjectId: user.id,
|
||||
storeId: store.id, platform: "ios",
|
||||
appVersion: appVersion)
|
||||
|
||||
let snapshot = await manager.evaluate(keys: ["fc.checkout_v2"], context: context)
|
||||
snapshot.flags["fc.checkout_v2"]?.enabled // Bool
|
||||
```
|
||||
|
||||
`evaluate(keys:context:)` **never throws** — fresh fetch → stale cache → the
|
||||
`defaults` passed at init, in that order. Use it everywhere a flag gates real
|
||||
product behaviour. `evaluateOrThrow(keys:context:)` surfaces the real failure
|
||||
instead, for admin/debug tooling only.
|
||||
|
||||
Concurrent calls for the same key on a cold cache are coalesced onto a single
|
||||
in-flight request — three callers evaluating the same key at once produce one
|
||||
network call, not three.
|
||||
|
||||
```swift
|
||||
await manager.invalidateCache() // call on user/store change
|
||||
```
|
||||
|
||||
The cache key is `environment + subjectId + sortedKeys + platform + appVersion`
|
||||
— it does **not** include `storeId` or `attributes`. If a subject's flags can
|
||||
legitimately differ by store, call `invalidateCache()` on store switch.
|
||||
|
||||
</details>
|
||||
|
||||
## Notifications inbox
|
||||
|
||||
<details>
|
||||
<summary><b>FeatureControlNotificationsClient</b> — JWT-only</summary>
|
||||
|
||||
The OpenAPI fragment declares `security: [bearerAuth]` for every notifications
|
||||
route, no module-token alternative — configure with `FeatureControlBearerAuth`.
|
||||
|
||||
```swift
|
||||
let client = FeatureControlNotificationsClient(configuration: configuration)
|
||||
|
||||
let (items, nextCursor) = try await client.list(status: .unread, limit: 20)
|
||||
let (more, _) = try await client.list(status: .unread, limit: 20, cursor: nextCursor)
|
||||
|
||||
try await client.markRead(id: notification.id)
|
||||
try await client.markAllRead()
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exposure telemetry
|
||||
|
||||
<details>
|
||||
<summary><b>recordExposure(_:)</b> / <b>flushExposures()</b> — best-effort, batched</summary>
|
||||
|
||||
```swift
|
||||
await manager.recordExposure(FeatureControlExposureEvent(
|
||||
featureKey: "fc.checkout_v2", variant: "on",
|
||||
subjectType: .customer, storeId: store.id
|
||||
))
|
||||
// ... later, e.g. on app background or a periodic timer:
|
||||
await manager.flushExposures()
|
||||
```
|
||||
|
||||
Buffers locally — nothing is sent until `flushExposures()` is called. Splits
|
||||
into batches of ≤100 (server `maxItems: 100`). Not a critical-path operation: a
|
||||
failed batch is dropped, never retried indefinitely.
|
||||
|
||||
</details>
|
||||
|
||||
## Errors
|
||||
|
||||
<details>
|
||||
<summary><b>FeatureControlError</b> — from <code>evaluateOrThrow</code> and the notifications client</summary>
|
||||
|
||||
```swift
|
||||
public enum FeatureControlError: Error, Sendable, Equatable {
|
||||
case invalidContext(code: String) // 400
|
||||
case unauthorized(code: String) // 401
|
||||
case forbidden(code: String) // 403
|
||||
case rateLimited(code: String) // 429
|
||||
case server(code: String, status: Int)
|
||||
case transport(message: String) // decode failure / non-HTTP error
|
||||
}
|
||||
```
|
||||
|
||||
The safe `evaluate(...)` path never surfaces this — it degrades to cache/defaults
|
||||
instead. `evaluateOrThrow` also throws `.server(code:status: 200)` if the server
|
||||
responds `200` with an `{"error": true}` envelope (a degraded-but-200 response).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
See [Extensions.md](Extensions.md) for the rest of `LCEssentials`' Foundation
|
||||
helpers, including the sibling `LCECryptoKit` sub-product.
|
||||
Reference in New Issue
Block a user