feat(feature-control): add standalone BFF with bootstrap, exposure telemetry and tests
This commit is contained in:
89
feature-control-bff/README.md
Normal file
89
feature-control-bff/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Feature Control BFF
|
||||||
|
|
||||||
|
BFF mínimo para expor flags ao app sem expor `Atomenta-Token` no cliente.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
- `POST /feature-control/bootstrap`
|
||||||
|
- `POST /feature-control/telemetry/exposure`
|
||||||
|
- `GET /health`
|
||||||
|
|
||||||
|
## Variáveis de ambiente
|
||||||
|
|
||||||
|
- `PORT` (default: `8787`)
|
||||||
|
- `ATOMENTA_ORIGIN` (default: `https://atomenta.com.br`)
|
||||||
|
- `FEATURE_CONTROL_MODULE_TOKEN` (ou `ATOMENTA_FEATURE_CONTROL_MODULE_ID`) **obrigatório**
|
||||||
|
- `FEATURE_CONTROL_ENVIRONMENT` (default: `production`)
|
||||||
|
- `FEATURE_CONTROL_CACHE_TTL_MS` (default: `60000`)
|
||||||
|
- `FEATURE_CONTROL_TIMEOUT_MS` (default: `2000`)
|
||||||
|
- `FEATURE_CONTROL_DEFAULTS_JSON` (opcional, JSON com defaults de fallback)
|
||||||
|
|
||||||
|
Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fc.checkout_v2": { "enabled": false, "variant": "off" },
|
||||||
|
"fc.search_ranking_v3": { "enabled": true, "variant": "on", "payload": { "model": "v3" } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request (App -> BFF)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"environment": "production",
|
||||||
|
"keys": ["fc.checkout_v2", "fc.search_ranking_v3"],
|
||||||
|
"context": {
|
||||||
|
"subjectType": "customer",
|
||||||
|
"subjectId": "cust_123",
|
||||||
|
"storeId": "store_001",
|
||||||
|
"platform": "ios",
|
||||||
|
"appVersion": "2.3.1",
|
||||||
|
"attributes": {
|
||||||
|
"city": "Belo Horizonte",
|
||||||
|
"tier": "gold"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Se o app enviar `Authorization: Bearer <JWT>`, o BFF tenta extrair `subjectId` do JWT (`sub`/`customerId`/`id`) quando `context.subjectId` não vier.
|
||||||
|
|
||||||
|
## Response (BFF -> App)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"source": "live",
|
||||||
|
"configVersion": 7,
|
||||||
|
"evaluatedAt": "2026-04-16T12:00:00.000Z",
|
||||||
|
"flags": {
|
||||||
|
"checkoutV2": true,
|
||||||
|
"searchRankingV3": "off"
|
||||||
|
},
|
||||||
|
"raw": {
|
||||||
|
"fc.checkout_v2": {
|
||||||
|
"enabled": true,
|
||||||
|
"variant": "on",
|
||||||
|
"payload": null,
|
||||||
|
"reason": "rollout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Quando o upstream falha (ex.: `429`, `500`, timeout), o BFF devolve `fallback` com defaults estáticos.
|
||||||
|
|
||||||
|
## Execução local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd feature-control-bff
|
||||||
|
FEATURE_CONTROL_MODULE_TOKEN="550e8400-e29b-41d4-a716-44665544000b" npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd feature-control-bff
|
||||||
|
npm test
|
||||||
|
```
|
||||||
333
feature-control-bff/core.mjs
Normal file
333
feature-control-bff/core.mjs
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export function loadConfig(env = process.env) {
|
||||||
|
return {
|
||||||
|
atomentaOrigin: (env.ATOMENTA_ORIGIN || "https://atomenta.com.br").replace(/\/+$/, ""),
|
||||||
|
moduleToken: env.FEATURE_CONTROL_MODULE_TOKEN || env.ATOMENTA_FEATURE_CONTROL_MODULE_ID || "",
|
||||||
|
defaultEnvironment: env.FEATURE_CONTROL_ENVIRONMENT || "production",
|
||||||
|
cacheTtlMs: Number(env.FEATURE_CONTROL_CACHE_TTL_MS || 60_000),
|
||||||
|
requestTimeoutMs: Number(env.FEATURE_CONTROL_TIMEOUT_MS || 2_000),
|
||||||
|
staticDefaults: parseDefaults(env.FEATURE_CONTROL_DEFAULTS_JSON)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuntime(config) {
|
||||||
|
return { cache: new Map(), inFlight: new Map(), config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeJson(res, status, payload) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
res.writeHead(status, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Length": Buffer.byteLength(body),
|
||||||
|
"Cache-Control": "no-store"
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 128 * 1024;
|
||||||
|
const MAX_KEYS = 100;
|
||||||
|
const MAX_ATTRIBUTES_BYTES = 16 * 1024;
|
||||||
|
const MAX_EXPOSURE_EVENTS = 100;
|
||||||
|
|
||||||
|
function parseDefaults(raw) {
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = safeParseJson(raw);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJson(input) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(input);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeString(value) {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubjectFromAuthHeader(authHeader) {
|
||||||
|
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) return null;
|
||||||
|
const token = authHeader.slice(7).trim();
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payloadBase64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = payloadBase64 + "=".repeat((4 - (payloadBase64.length % 4)) % 4);
|
||||||
|
const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf-8"));
|
||||||
|
const subjectId = normalizeString(payload.sub) || normalizeString(payload.customerId) || normalizeString(payload.id);
|
||||||
|
if (!subjectId) return null;
|
||||||
|
return { subjectType: "customer", subjectId };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCacheKey(input) {
|
||||||
|
const stable = JSON.stringify({
|
||||||
|
env: input.environment,
|
||||||
|
keys: input.keys,
|
||||||
|
context: {
|
||||||
|
subjectType: input.context.subjectType,
|
||||||
|
subjectId: input.context.subjectId,
|
||||||
|
storeId: input.context.storeId || "",
|
||||||
|
platform: input.context.platform || "",
|
||||||
|
appVersion: input.context.appVersion || "",
|
||||||
|
attributes: input.context.attributes || {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return crypto.createHash("sha256").update(stable).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(req) {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of req) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BODY_BYTES) {
|
||||||
|
throw Object.assign(new Error("Payload muito grande"), { status: 413, code: "PAYLOAD_TOO_LARGE" });
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBootstrapRequest(body, authHeader, config) {
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||||
|
throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Array.isArray(body.keys) ? body.keys.map(normalizeString).filter(Boolean) : [];
|
||||||
|
if (!keys.length) throw Object.assign(new Error("`keys` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_KEYS_REQUIRED" });
|
||||||
|
if (keys.length > MAX_KEYS) {
|
||||||
|
throw Object.assign(new Error(`Máximo de ${MAX_KEYS} chaves por request`), { status: 400, code: "FEATURE_CONTROL_TOO_MANY_KEYS" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = normalizeString(body.environment) || config.defaultEnvironment;
|
||||||
|
const contextInput = body.context && typeof body.context === "object" ? body.context : {};
|
||||||
|
const fromJwt = getSubjectFromAuthHeader(authHeader);
|
||||||
|
const subjectType = normalizeString(contextInput.subjectType) || fromJwt?.subjectType || "customer";
|
||||||
|
const subjectId = normalizeString(contextInput.subjectId) || fromJwt?.subjectId || "";
|
||||||
|
if (!subjectId) {
|
||||||
|
throw Object.assign(new Error("`context.subjectId` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_ID_REQUIRED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set(["user", "customer", "store", "anonymous"]);
|
||||||
|
if (!allowed.has(subjectType)) {
|
||||||
|
throw Object.assign(new Error("`context.subjectType` inválido"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_TYPE_INVALID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributes = contextInput.attributes && typeof contextInput.attributes === "object" && !Array.isArray(contextInput.attributes)
|
||||||
|
? contextInput.attributes
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (Buffer.byteLength(JSON.stringify(attributes), "utf-8") > MAX_ATTRIBUTES_BYTES) {
|
||||||
|
throw Object.assign(new Error("`context.attributes` excede limite"), { status: 400, code: "FEATURE_CONTROL_ATTRIBUTES_TOO_LARGE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
environment,
|
||||||
|
keys: [...new Set(keys)].sort(),
|
||||||
|
context: {
|
||||||
|
subjectType,
|
||||||
|
subjectId,
|
||||||
|
storeId: normalizeString(contextInput.storeId) || undefined,
|
||||||
|
platform: normalizeString(contextInput.platform) || undefined,
|
||||||
|
appVersion: normalizeString(contextInput.appVersion) || undefined,
|
||||||
|
attributes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExposureRequest(body) {
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||||
|
throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(body.events) || body.events.length === 0) {
|
||||||
|
throw Object.assign(new Error("`events` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EVENTS_REQUIRED" });
|
||||||
|
}
|
||||||
|
if (body.events.length > MAX_EXPOSURE_EVENTS) {
|
||||||
|
throw Object.assign(new Error(`Máximo de ${MAX_EXPOSURE_EVENTS} eventos por request`), {
|
||||||
|
status: 400,
|
||||||
|
code: "FEATURE_CONTROL_EXPOSURE_TOO_MANY_EVENTS"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = body.events.map((event) => ({
|
||||||
|
featureKey: normalizeString(event?.featureKey),
|
||||||
|
variant: normalizeString(event?.variant) || "off",
|
||||||
|
subjectType: normalizeString(event?.subjectType) || "customer",
|
||||||
|
storeId: normalizeString(event?.storeId) || undefined
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.featureKey) {
|
||||||
|
throw Object.assign(new Error("`featureKey` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EXPOSURE_FEATURE_KEY_REQUIRED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { events };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackFlags(keys, config) {
|
||||||
|
const raw = {};
|
||||||
|
const simplified = {};
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const fromEnv = config.staticDefaults[key];
|
||||||
|
const enabled = Boolean(fromEnv?.enabled);
|
||||||
|
const variant = typeof fromEnv?.variant === "string" ? fromEnv.variant : enabled ? "on" : "off";
|
||||||
|
raw[key] = { enabled, variant, payload: fromEnv?.payload ?? null, reason: "fallback_default" };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, "");
|
||||||
|
if (!simplified[simplifiedKey]) {
|
||||||
|
simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { raw, simplified };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUpstreamError(error) {
|
||||||
|
return {
|
||||||
|
status: Number(error?.status || 500),
|
||||||
|
code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR",
|
||||||
|
message: normalizeString(error?.message) || "Erro ao avaliar flags"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callAtomenta(path, requestPayload, config) {
|
||||||
|
if (!config.moduleToken) {
|
||||||
|
throw Object.assign(new Error("Token de módulo não configurado"), { status: 500, code: "FEATURE_CONTROL_MODULE_TOKEN_MISSING" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.atomentaOrigin}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Atomenta-Token": config.moduleToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const parsed = safeParseJson(text) || {};
|
||||||
|
if (!response.ok) {
|
||||||
|
const upstreamCode = parsed.code || "FEATURE_CONTROL_UPSTREAM_ERROR";
|
||||||
|
const upstreamMessage = parsed.message || `Erro upstream (${response.status})`;
|
||||||
|
throw Object.assign(new Error(upstreamMessage), { status: response.status, code: upstreamCode });
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
throw Object.assign(new Error("Timeout no upstream"), { status: 504, code: "FEATURE_CONTROL_UPSTREAM_TIMEOUT" });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBffResponse(evaluated, requestPayload, source = "live") {
|
||||||
|
const result = evaluated?.result || {};
|
||||||
|
const flags = result.flags && typeof result.flags === "object" ? result.flags : {};
|
||||||
|
const simplified = {};
|
||||||
|
for (const key of requestPayload.keys) {
|
||||||
|
const value = flags[key];
|
||||||
|
if (!value || typeof value !== "object") continue;
|
||||||
|
const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, "");
|
||||||
|
if (!simplified[simplifiedKey]) simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant || "off";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source,
|
||||||
|
configVersion: Number(result.configVersion || 0),
|
||||||
|
evaluatedAt: result.evaluatedAt || nowIso(),
|
||||||
|
flags: simplified,
|
||||||
|
raw: flags
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateWithCache(requestPayload, runtime) {
|
||||||
|
const cacheKey = buildCacheKey(requestPayload);
|
||||||
|
const current = runtime.cache.get(cacheKey);
|
||||||
|
if (current && current.expiresAt > Date.now()) return current.payload;
|
||||||
|
|
||||||
|
const inflight = runtime.inFlight.get(cacheKey);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
|
||||||
|
const work = (async () => {
|
||||||
|
const evaluated = await callAtomenta("/api/feature-control/evaluate", requestPayload, runtime.config);
|
||||||
|
const payload = formatBffResponse(evaluated, requestPayload, "live");
|
||||||
|
runtime.cache.set(cacheKey, { payload, expiresAt: Date.now() + runtime.config.cacheTtlMs });
|
||||||
|
return payload;
|
||||||
|
})();
|
||||||
|
runtime.inFlight.set(cacheKey, work);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await work;
|
||||||
|
} finally {
|
||||||
|
runtime.inFlight.delete(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleBootstrap(req, res, runtime) {
|
||||||
|
try {
|
||||||
|
const body = safeParseJson(await readBody(req));
|
||||||
|
if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" });
|
||||||
|
const requestPayload = validateBootstrapRequest(body, req.headers.authorization, runtime.config);
|
||||||
|
try {
|
||||||
|
const payload = await evaluateWithCache(requestPayload, runtime);
|
||||||
|
return writeJson(res, 200, payload);
|
||||||
|
} catch (error) {
|
||||||
|
const mapped = mapUpstreamError(error);
|
||||||
|
const fallback = buildFallbackFlags(requestPayload.keys, runtime.config);
|
||||||
|
return writeJson(res, mapped.status === 400 ? 400 : 200, {
|
||||||
|
ok: mapped.status === 400 ? false : true,
|
||||||
|
source: "fallback",
|
||||||
|
configVersion: 0,
|
||||||
|
evaluatedAt: nowIso(),
|
||||||
|
flags: fallback.simplified,
|
||||||
|
raw: fallback.raw,
|
||||||
|
upstreamError: mapped
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return writeJson(res, Number(error?.status || 500), {
|
||||||
|
ok: false,
|
||||||
|
code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR",
|
||||||
|
message: normalizeString(error?.message) || "Erro interno"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleExposure(req, res, runtime) {
|
||||||
|
try {
|
||||||
|
const body = safeParseJson(await readBody(req));
|
||||||
|
if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" });
|
||||||
|
const requestPayload = validateExposureRequest(body);
|
||||||
|
const response = await callAtomenta("/api/feature-control/telemetry/exposure", requestPayload, runtime.config);
|
||||||
|
return writeJson(res, 200, { ok: true, code: "FEATURE_CONTROL_EXPOSURE_ACCEPTED", result: response?.result ?? null });
|
||||||
|
} catch (error) {
|
||||||
|
return writeJson(res, Number(error?.status || 500), {
|
||||||
|
ok: false,
|
||||||
|
code: normalizeString(error?.code) || "FEATURE_CONTROL_EXPOSURE_ERROR",
|
||||||
|
message: normalizeString(error?.message) || "Falha ao enviar exposição"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
feature-control-bff/package.json
Normal file
10
feature-control-bff/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "feature-control-bff",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs",
|
||||||
|
"test": "node --test tests/*.test.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
feature-control-bff/server.mjs
Normal file
34
feature-control-bff/server.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import { createRuntime, handleBootstrap, handleExposure, loadConfig, nowIso, writeJson } from "./core.mjs";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 8787);
|
||||||
|
const runtime = createRuntime(loadConfig(process.env));
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const method = req.method || "GET";
|
||||||
|
const url = req.url || "/";
|
||||||
|
|
||||||
|
if (method === "GET" && url === "/health") {
|
||||||
|
return writeJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
service: "feature-control-bff",
|
||||||
|
time: nowIso(),
|
||||||
|
cacheEntries: runtime.cache.size,
|
||||||
|
inFlight: runtime.inFlight.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && url === "/feature-control/bootstrap") {
|
||||||
|
return handleBootstrap(req, res, runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && url === "/feature-control/telemetry/exposure") {
|
||||||
|
return handleExposure(req, res, runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeJson(res, 404, { ok: false, code: "NOT_FOUND", message: "Rota não encontrada" });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[feature-control-bff] listening on :${PORT}`);
|
||||||
|
});
|
||||||
275
feature-control-bff/tests/bff.test.mjs
Normal file
275
feature-control-bff/tests/bff.test.mjs
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import http from "node:http";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
function listen(server) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(url, timeoutMs = 5000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${url}/health`);
|
||||||
|
if (response.ok) return;
|
||||||
|
} catch {}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error("BFF health timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBff(env) {
|
||||||
|
const child = spawn("node", ["server.mjs"], {
|
||||||
|
cwd: new URL("..", import.meta.url),
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
child.stdout.on("data", (chunk) => logs.push(chunk.toString("utf-8")));
|
||||||
|
child.stderr.on("data", (chunk) => logs.push(chunk.toString("utf-8")));
|
||||||
|
|
||||||
|
const port = Number(env.PORT);
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
await waitForHealth(baseUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
logs,
|
||||||
|
stop: async () => {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
await new Promise((resolve) => child.on("exit", () => resolve()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUpstreamStub(options = {}) {
|
||||||
|
const state = {
|
||||||
|
evaluateMode: options.evaluateMode || "success",
|
||||||
|
evaluatePayload: null,
|
||||||
|
exposurePayload: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
if (req.method === "POST" && req.url === "/api/feature-control/evaluate") {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
state.evaluatePayload = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
||||||
|
|
||||||
|
if (state.evaluateMode === "429") {
|
||||||
|
res.writeHead(429, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ code: "FEATURE_CONTROL_RATE_LIMIT", message: "rate" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.evaluateMode === "500") {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ code: "FEATURE_CONTROL_ERROR", message: "boom" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.evaluateMode === "timeout") {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: false,
|
||||||
|
code: "FEATURE_CONTROL_EVALUATED",
|
||||||
|
result: {
|
||||||
|
evaluatedAt: "2026-04-16T12:00:00.000Z",
|
||||||
|
configVersion: 7,
|
||||||
|
flags: {
|
||||||
|
"fc.checkout_v2": { enabled: true, variant: "on", payload: null, reason: "rollout" },
|
||||||
|
"fc.search_ranking_v3": { enabled: false, variant: "off", payload: null, reason: "default" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && req.url === "/api/feature-control/telemetry/exposure") {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
state.exposurePayload = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: false, code: "FEATURE_CONTROL_EXPOSURE_TRACKED", result: { accepted: true } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { server, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootstrapBody() {
|
||||||
|
return {
|
||||||
|
environment: "production",
|
||||||
|
keys: ["fc.checkout_v2", "fc.search_ranking_v3"],
|
||||||
|
context: {
|
||||||
|
subjectType: "customer",
|
||||||
|
subjectId: "cust_123",
|
||||||
|
storeId: "store_001",
|
||||||
|
platform: "ios",
|
||||||
|
appVersion: "2.3.1",
|
||||||
|
attributes: { city: "Campinas" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("bootstrap sucesso retorna flags simplificadas e raw", async () => {
|
||||||
|
const upstream = createUpstreamStub({ evaluateMode: "success" });
|
||||||
|
const upstreamPort = await listen(upstream.server);
|
||||||
|
const bffPort = upstreamPort + 1000;
|
||||||
|
const bff = await startBff({
|
||||||
|
PORT: String(bffPort),
|
||||||
|
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
FEATURE_CONTROL_MODULE_TOKEN: "token",
|
||||||
|
FEATURE_CONTROL_TIMEOUT_MS: "300"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(bootstrapBody())
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const json = await response.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(json.configVersion, 7);
|
||||||
|
assert.equal(json.flags.checkoutV2, true);
|
||||||
|
assert.equal(json.flags.searchRankingV3, "off");
|
||||||
|
assert.ok(json.raw["fc.checkout_v2"]);
|
||||||
|
assert.equal(upstream.state.evaluatePayload.context.subjectId, "cust_123");
|
||||||
|
} finally {
|
||||||
|
await bff.stop();
|
||||||
|
await closeServer(upstream.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bootstrap com 429 devolve fallback sem quebrar cliente", async () => {
|
||||||
|
const upstream = createUpstreamStub({ evaluateMode: "429" });
|
||||||
|
const upstreamPort = await listen(upstream.server);
|
||||||
|
const bffPort = upstreamPort + 1000;
|
||||||
|
const bff = await startBff({
|
||||||
|
PORT: String(bffPort),
|
||||||
|
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
FEATURE_CONTROL_MODULE_TOKEN: "token",
|
||||||
|
FEATURE_CONTROL_DEFAULTS_JSON: JSON.stringify({
|
||||||
|
"fc.checkout_v2": { enabled: false, variant: "off" }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(bootstrapBody())
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const json = await response.json();
|
||||||
|
assert.equal(json.source, "fallback");
|
||||||
|
assert.equal(json.upstreamError.status, 429);
|
||||||
|
assert.equal(json.flags.checkoutV2, "off");
|
||||||
|
} finally {
|
||||||
|
await bff.stop();
|
||||||
|
await closeServer(upstream.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bootstrap com timeout devolve fallback", async () => {
|
||||||
|
const upstream = createUpstreamStub({ evaluateMode: "timeout" });
|
||||||
|
const upstreamPort = await listen(upstream.server);
|
||||||
|
const bffPort = upstreamPort + 1000;
|
||||||
|
const bff = await startBff({
|
||||||
|
PORT: String(bffPort),
|
||||||
|
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
FEATURE_CONTROL_MODULE_TOKEN: "token",
|
||||||
|
FEATURE_CONTROL_TIMEOUT_MS: "100"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(bootstrapBody())
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const json = await response.json();
|
||||||
|
assert.equal(json.source, "fallback");
|
||||||
|
assert.equal(json.upstreamError.code, "FEATURE_CONTROL_UPSTREAM_TIMEOUT");
|
||||||
|
} finally {
|
||||||
|
await bff.stop();
|
||||||
|
await closeServer(upstream.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("telemetria de exposure encaminha lote para upstream", async () => {
|
||||||
|
const upstream = createUpstreamStub({ evaluateMode: "success" });
|
||||||
|
const upstreamPort = await listen(upstream.server);
|
||||||
|
const bffPort = upstreamPort + 1000;
|
||||||
|
const bff = await startBff({
|
||||||
|
PORT: String(bffPort),
|
||||||
|
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
FEATURE_CONTROL_MODULE_TOKEN: "token"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${bff.baseUrl}/feature-control/telemetry/exposure`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
events: [
|
||||||
|
{ featureKey: "fc.checkout_v2", variant: "on", subjectType: "customer", storeId: "store_1" }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const json = await response.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(upstream.state.exposurePayload.events[0].featureKey, "fc.checkout_v2");
|
||||||
|
} finally {
|
||||||
|
await bff.stop();
|
||||||
|
await closeServer(upstream.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("telemetria rejeita payload sem eventos", async () => {
|
||||||
|
const upstream = createUpstreamStub({ evaluateMode: "success" });
|
||||||
|
const upstreamPort = await listen(upstream.server);
|
||||||
|
const bffPort = upstreamPort + 1000;
|
||||||
|
const bff = await startBff({
|
||||||
|
PORT: String(bffPort),
|
||||||
|
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
FEATURE_CONTROL_MODULE_TOKEN: "token"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${bff.baseUrl}/feature-control/telemetry/exposure`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ events: [] })
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
const json = await response.json();
|
||||||
|
assert.equal(json.ok, false);
|
||||||
|
assert.equal(json.code, "FEATURE_CONTROL_EVENTS_REQUIRED");
|
||||||
|
} finally {
|
||||||
|
await bff.stop();
|
||||||
|
await closeServer(upstream.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user