This commit is contained in:
Daniel Arantes Loverde
2026-04-17 14:57:53 -03:00
parent 5f7e8b29d5
commit 66de1e3031
3 changed files with 31 additions and 18 deletions

View File

@@ -8,6 +8,11 @@ BFF mínimo para expor flags ao app sem expor `Atomenta-Token` no cliente.
- `POST /feature-control/telemetry/exposure` - `POST /feature-control/telemetry/exposure`
- `GET /health` - `GET /health`
## Notificações
No momento este BFF cobre apenas Feature Control (`bootstrap` + `exposure`).
Para notificações segmentadas por audiência (`todos`, `feature:fc.ios`), precisamos adicionar o contrato de endpoint de notificações do Atomenta para integrar no app.
## Variáveis de ambiente ## Variáveis de ambiente
- `PORT` (default: `8787`) - `PORT` (default: `8787`)
@@ -22,8 +27,11 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
```json ```json
{ {
"fc.checkout_v2": { "enabled": false, "variant": "off" }, "fc.promo": { "enabled": true, "variant": "on" },
"fc.search_ranking_v3": { "enabled": true, "variant": "on", "payload": { "model": "v3" } } "fc.promo-codes": { "enabled": true, "variant": "on" },
"fc.android": { "enabled": true, "variant": "on" },
"fc.city-aguai": { "enabled": true, "variant": "on" },
"fc.ios": { "enabled": true, "variant": "on" }
} }
``` ```
@@ -32,7 +40,7 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
```json ```json
{ {
"environment": "production", "environment": "production",
"keys": ["fc.checkout_v2", "fc.search_ranking_v3"], "keys": ["fc.promo", "fc.promo-codes", "fc.android", "fc.city-aguai", "fc.ios"],
"context": { "context": {
"subjectType": "customer", "subjectType": "customer",
"subjectId": "cust_123", "subjectId": "cust_123",
@@ -58,11 +66,14 @@ Se o app enviar `Authorization: Bearer <JWT>`, o BFF tenta extrair `subjectId` d
"configVersion": 7, "configVersion": 7,
"evaluatedAt": "2026-04-16T12:00:00.000Z", "evaluatedAt": "2026-04-16T12:00:00.000Z",
"flags": { "flags": {
"checkoutV2": true, "promo": true,
"searchRankingV3": "off" "promoCodes": true,
"android": true,
"cityAguai": true,
"ios": true
}, },
"raw": { "raw": {
"fc.checkout_v2": { "fc.promo": {
"enabled": true, "enabled": true,
"variant": "on", "variant": "on",
"payload": null, "payload": null,

View File

@@ -91,8 +91,9 @@ function createUpstreamStub(options = {}) {
evaluatedAt: "2026-04-16T12:00:00.000Z", evaluatedAt: "2026-04-16T12:00:00.000Z",
configVersion: 7, configVersion: 7,
flags: { flags: {
"fc.checkout_v2": { enabled: true, variant: "on", payload: null, reason: "rollout" }, "fc.promo": { enabled: true, variant: "on", payload: null, reason: "rollout" },
"fc.search_ranking_v3": { enabled: false, variant: "off", payload: null, reason: "default" } "fc.promo-codes": { enabled: true, variant: "on", payload: null, reason: "rollout" },
"fc.ios": { enabled: true, variant: "on", payload: null, reason: "audience" }
} }
} }
}) })
@@ -118,7 +119,7 @@ function createUpstreamStub(options = {}) {
function bootstrapBody() { function bootstrapBody() {
return { return {
environment: "production", environment: "production",
keys: ["fc.checkout_v2", "fc.search_ranking_v3"], keys: ["fc.promo", "fc.promo-codes", "fc.ios"],
context: { context: {
subjectType: "customer", subjectType: "customer",
subjectId: "cust_123", subjectId: "cust_123",
@@ -151,9 +152,10 @@ test("bootstrap sucesso retorna flags simplificadas e raw", async () => {
const json = await response.json(); const json = await response.json();
assert.equal(json.ok, true); assert.equal(json.ok, true);
assert.equal(json.configVersion, 7); assert.equal(json.configVersion, 7);
assert.equal(json.flags.checkoutV2, true); assert.equal(json.flags.promo, true);
assert.equal(json.flags.searchRankingV3, "off"); assert.equal(json.flags.promoCodes, true);
assert.ok(json.raw["fc.checkout_v2"]); assert.equal(json.flags.ios, true);
assert.ok(json.raw["fc.promo"]);
assert.equal(upstream.state.evaluatePayload.context.subjectId, "cust_123"); assert.equal(upstream.state.evaluatePayload.context.subjectId, "cust_123");
} finally { } finally {
await bff.stop(); await bff.stop();
@@ -170,7 +172,7 @@ test("bootstrap com 429 devolve fallback sem quebrar cliente", async () => {
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
FEATURE_CONTROL_MODULE_TOKEN: "token", FEATURE_CONTROL_MODULE_TOKEN: "token",
FEATURE_CONTROL_DEFAULTS_JSON: JSON.stringify({ FEATURE_CONTROL_DEFAULTS_JSON: JSON.stringify({
"fc.checkout_v2": { enabled: false, variant: "off" } "fc.promo": { enabled: false, variant: "off" }
}) })
}); });
@@ -184,7 +186,7 @@ test("bootstrap com 429 devolve fallback sem quebrar cliente", async () => {
const json = await response.json(); const json = await response.json();
assert.equal(json.source, "fallback"); assert.equal(json.source, "fallback");
assert.equal(json.upstreamError.status, 429); assert.equal(json.upstreamError.status, 429);
assert.equal(json.flags.checkoutV2, "off"); assert.equal(json.flags.promo, "off");
} finally { } finally {
await bff.stop(); await bff.stop();
await closeServer(upstream.server); await closeServer(upstream.server);
@@ -234,14 +236,14 @@ test("telemetria de exposure encaminha lote para upstream", async () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
events: [ events: [
{ featureKey: "fc.checkout_v2", variant: "on", subjectType: "customer", storeId: "store_1" } { featureKey: "fc.promo", variant: "on", subjectType: "customer", storeId: "store_1" }
] ]
}) })
}); });
assert.equal(response.status, 200); assert.equal(response.status, 200);
const json = await response.json(); const json = await response.json();
assert.equal(json.ok, true); assert.equal(json.ok, true);
assert.equal(upstream.state.exposurePayload.events[0].featureKey, "fc.checkout_v2"); assert.equal(upstream.state.exposurePayload.events[0].featureKey, "fc.promo");
} finally { } finally {
await bff.stop(); await bff.stop();
await closeServer(upstream.server); await closeServer(upstream.server);

View File

@@ -258,12 +258,12 @@ final class FeatureControlService {
} }
private func featureKeys() -> [String] { private func featureKeys() -> [String] {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "fc.checkout_v2,fc.search_ranking_v3" let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "fc.promo,fc.promo-codes,fc.android,fc.city-aguai,fc.ios"
let items = raw let items = raw
.split(separator: ",") .split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false } .filter { $0.isEmpty == false }
return items.isEmpty ? ["fc.checkout_v2"] : items return items.isEmpty ? ["fc.ios"] : items
} }
private func platformName() -> String { private func platformName() -> String {