Skip to content

Commit 875de0b

Browse files
committed
✨ [FFL-2858] add feature-flag catalog browsing to the flags tab
Builds on FFL-2597: fetches the flag catalog via the OAuth-capable FFE UI endpoint (transparently refreshing the token), and renders it with search + type/tag filters and pagination. Read-only; overriding flags comes in FFL-2596.
1 parent 3414896 commit 875de0b

11 files changed

Lines changed: 854 additions & 11 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { fetchFlagCatalogWithToken } from './flagCatalog'
2+
3+
describe('flagCatalog', () => {
4+
describe('fetchFlagCatalogWithToken', () => {
5+
// Returns each provided page in turn, then empty pages (the loop stops on the first empty page).
6+
function mockPages(pages: Array<Array<{ attributes: Record<string, unknown> }>>) {
7+
let call = 0
8+
spyOn(globalThis, 'fetch').and.callFake(() => {
9+
const data = pages[call] ?? []
10+
call += 1
11+
return Promise.resolve(new Response(JSON.stringify({ data })))
12+
})
13+
}
14+
15+
it('paginates until an empty page and maps attributes', async () => {
16+
mockPages([
17+
[
18+
{
19+
attributes: {
20+
key: 'flag-a',
21+
name: 'Flag A',
22+
value_type: 'BOOLEAN',
23+
variants: [{ name: 'on', value: 'true' }],
24+
tags: ['team:x'],
25+
},
26+
},
27+
],
28+
[],
29+
])
30+
31+
expect(await fetchFlagCatalogWithToken('tok', 'datad0g.com')).toEqual([
32+
{ key: 'flag-a', name: 'Flag A', type: 'BOOLEAN', variants: [{ name: 'on', value: true }], tags: ['team:x'] },
33+
])
34+
})
35+
36+
it('dedupes flags repeated across pages, keeping the first occurrence', async () => {
37+
mockPages([
38+
[{ attributes: { key: 'dup', name: 'First', value_type: 'STRING', variants: [], tags: [] } }],
39+
[{ attributes: { key: 'dup', name: 'Second', value_type: 'STRING', variants: [], tags: [] } }],
40+
[],
41+
])
42+
43+
const flags = await fetchFlagCatalogWithToken('tok', 'datad0g.com')
44+
expect(flags.length).toBe(1)
45+
expect(flags[0].name).toBe('First')
46+
})
47+
48+
it('parses variant values by declared type, falling back to the raw string on bad input', async () => {
49+
mockPages([
50+
[
51+
{
52+
attributes: {
53+
key: 'f',
54+
value_type: 'JSON',
55+
variants: [
56+
{ name: 'ok', value: '{"a":1}' },
57+
{ name: 'bad', value: 'not json' },
58+
],
59+
},
60+
},
61+
{
62+
attributes: {
63+
key: 'n',
64+
value_type: 'INTEGER',
65+
variants: [
66+
{ name: 'five', value: '5' },
67+
{ name: 'nan', value: 'abc' },
68+
],
69+
},
70+
},
71+
],
72+
[],
73+
])
74+
75+
const [json, integer] = await fetchFlagCatalogWithToken('tok', 'datad0g.com')
76+
expect(json.variants[0].value).toEqual({ a: 1 })
77+
expect(json.variants[1].value).toBe('not json')
78+
expect(integer.variants[0].value).toBe(5)
79+
expect(integer.variants[1].value).toBe('abc')
80+
})
81+
82+
it('keeps malformed or out-of-range variant values raw instead of coercing them', async () => {
83+
mockPages([
84+
[
85+
{
86+
attributes: {
87+
key: 'b',
88+
value_type: 'BOOLEAN',
89+
variants: [
90+
{ name: 't', value: 'true' },
91+
{ name: 'f', value: 'false' },
92+
{ name: 'weird', value: 'True' },
93+
],
94+
},
95+
},
96+
{
97+
attributes: {
98+
key: 'i',
99+
value_type: 'INTEGER',
100+
variants: [
101+
{ name: 'partial', value: '5abc' },
102+
{ name: 'float', value: '5.5' },
103+
{ name: 'unsafe', value: '9007199254740993' },
104+
],
105+
},
106+
},
107+
{
108+
attributes: {
109+
key: 'd',
110+
value_type: 'NUMERIC',
111+
variants: [
112+
{ name: 'ok', value: '5.5' },
113+
{ name: 'partial', value: '5abc' },
114+
{ name: 'empty', value: '' },
115+
],
116+
},
117+
},
118+
],
119+
[],
120+
])
121+
122+
const [booleanFlag, integer, numeric] = await fetchFlagCatalogWithToken('tok', 'datad0g.com')
123+
expect(booleanFlag.variants.map((variant) => variant.value)).toEqual([true, false, 'True'])
124+
expect(integer.variants.map((variant) => variant.value)).toEqual(['5abc', '5.5', '9007199254740993'])
125+
expect(numeric.variants.map((variant) => variant.value)).toEqual([5.5, '5abc', ''])
126+
})
127+
128+
it('falls back to the key for a missing name and defaults tags/variants', async () => {
129+
mockPages([[{ attributes: { key: 'no-name', value_type: 'STRING' } }], []])
130+
131+
const [flag] = await fetchFlagCatalogWithToken('tok', 'datad0g.com')
132+
expect(flag.name).toBe('no-name')
133+
expect(flag.tags).toEqual([])
134+
expect(flag.variants).toEqual([])
135+
})
136+
137+
it('tolerates a 200 response that omits the data array', async () => {
138+
spyOn(globalThis, 'fetch').and.returnValue(Promise.resolve(new Response(JSON.stringify({ errors: ['x'] }))))
139+
expect(await fetchFlagCatalogWithToken('tok', 'datad0g.com')).toEqual([])
140+
})
141+
142+
it('throws on a non-ok response', async () => {
143+
spyOn(globalThis, 'fetch').and.returnValue(
144+
Promise.resolve(new Response('err', { status: 500, statusText: 'Server Error' }))
145+
)
146+
await expectAsync(fetchFlagCatalogWithToken('tok', 'datad0g.com')).toBeRejectedWithError(
147+
/Failed to fetch flag catalog/
148+
)
149+
})
150+
})
151+
})
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import type { FlagOverrideType } from './flagTypeConstants'
2+
import { getFlagsApiHost } from './oauth'
3+
4+
// A parsed feature-flag value — any JSON value. Objects and arrays are both `object`; `null` and
5+
// primitives are covered explicitly (JSON.parse can return any of them).
6+
export type CatalogVariantValue = boolean | string | number | object | null
7+
8+
export interface CatalogVariant {
9+
name: string
10+
value: CatalogVariantValue
11+
}
12+
13+
export interface CatalogFlag {
14+
key: string
15+
// Human-friendly display name; falls back to the key when the API doesn't provide one.
16+
name: string
17+
type: FlagOverrideType
18+
variants: CatalogVariant[]
19+
tags: string[]
20+
}
21+
22+
interface RawFeatureFlagVariant {
23+
name: string
24+
value: string
25+
}
26+
27+
interface RawFeatureFlagAttributes {
28+
key: string
29+
name?: string
30+
value_type: FlagOverrideType
31+
variants?: RawFeatureFlagVariant[]
32+
tags?: string[]
33+
}
34+
35+
interface RawFeatureFlagResource {
36+
attributes: RawFeatureFlagAttributes
37+
}
38+
39+
interface RawFeatureFlagsResponse {
40+
data: RawFeatureFlagResource[]
41+
}
42+
43+
// Variant values come back from the API as strings regardless of the flag's declared type. Falls
44+
// back to the raw string on unparseable input so one malformed variant can't blow up the mapping
45+
// of the entire catalog.
46+
function parseVariantValue(type: FlagOverrideType, rawValue: string): CatalogVariantValue {
47+
switch (type) {
48+
case 'BOOLEAN':
49+
// Only the exact strings count; anything else (e.g. "True", "falsex", "") is malformed and
50+
// kept raw rather than silently collapsing to false.
51+
if (rawValue === 'true') {
52+
return true
53+
}
54+
if (rawValue === 'false') {
55+
return false
56+
}
57+
return rawValue
58+
case 'INTEGER': {
59+
// parseInt would accept partial/oversized input ("5abc" -> 5, unsafe integers get rounded), so
60+
// require the whole string to be an integer within the safe range; otherwise keep it raw.
61+
const parsed = Number(rawValue)
62+
return /^[+-]?\d+$/.test(rawValue) && Number.isSafeInteger(parsed) ? parsed : rawValue
63+
}
64+
case 'NUMERIC': {
65+
// parseFloat accepts a numeric prefix ("5abc" -> 5) and Number("") is 0, so require a
66+
// non-empty string that parses fully to a finite number; otherwise keep it raw.
67+
const parsed = Number(rawValue)
68+
return rawValue.trim() !== '' && Number.isFinite(parsed) ? parsed : rawValue
69+
}
70+
case 'JSON':
71+
try {
72+
return JSON.parse(rawValue) as CatalogVariantValue
73+
} catch {
74+
return rawValue
75+
}
76+
case 'STRING':
77+
return rawValue
78+
}
79+
}
80+
81+
// The endpoint paginates via limit/offset and enforces its own max page size, so a page can
82+
// come back shorter than PAGE_LIMIT even when more results remain — only an empty page means
83+
// we've reached the end. Advance offset by what actually came back, not by PAGE_LIMIT.
84+
const PAGE_LIMIT = 100
85+
// Safety bound so a backend that ignores `offset` (returns the same page forever) can't spin an
86+
// unbounded loop. 500 pages × 100 = 50k flags, far beyond any real catalog.
87+
const MAX_PAGES = 500
88+
89+
/**
90+
* Fetches the full flag catalog using an OAuth access token, via the FFE UI endpoint
91+
* (GET /api/ui/ffe/feature-flags). OAuth is the only supported auth path (see oauth.ts).
92+
*/
93+
export async function fetchFlagCatalogWithToken(token: string, site: string): Promise<CatalogFlag[]> {
94+
const host = getFlagsApiHost(site)
95+
const resources: RawFeatureFlagResource[] = []
96+
let offset = 0
97+
98+
for (let page = 0; page < MAX_PAGES; page++) {
99+
const url = new URL(`https://${host}/api/ui/ffe/feature-flags`)
100+
url.searchParams.set('limit', String(PAGE_LIMIT))
101+
url.searchParams.set('offset', String(offset))
102+
103+
const response = await fetch(url.toString(), {
104+
headers: {
105+
Authorization: `Bearer ${token}`,
106+
},
107+
})
108+
109+
if (!response.ok) {
110+
throw new Error(`Failed to fetch flag catalog: ${response.status} ${response.statusText}`)
111+
}
112+
113+
const body = (await response.json()) as RawFeatureFlagsResponse
114+
// Tolerate a response that omits/mistypes `data` rather than throwing on `.length`/spread.
115+
const pageResources = Array.isArray(body?.data) ? body.data : []
116+
resources.push(...pageResources)
117+
118+
if (pageResources.length === 0) {
119+
break
120+
}
121+
offset += pageResources.length
122+
}
123+
124+
return mapResources(resources)
125+
}
126+
127+
function mapResources(resources: RawFeatureFlagResource[]): CatalogFlag[] {
128+
// Dedupe by key. The endpoint's offset pagination can return the same flag on more than one
129+
// page, which would otherwise render as duplicate rows AND collide React keys (`key={flag.key}`),
130+
// breaking variant clicks, search, and clear-all. Keep the first occurrence of each key.
131+
const byKey = new Map<string, CatalogFlag>()
132+
for (const { attributes } of resources) {
133+
if (byKey.has(attributes.key)) {
134+
continue
135+
}
136+
byKey.set(attributes.key, {
137+
key: attributes.key,
138+
name: attributes.name || attributes.key,
139+
type: attributes.value_type,
140+
variants: (attributes.variants ?? []).map((variant) => ({
141+
name: variant.name,
142+
value: parseVariantValue(attributes.value_type, variant.value),
143+
})),
144+
tags: attributes.tags ?? [],
145+
})
146+
}
147+
return Array.from(byKey.values())
148+
}

0 commit comments

Comments
 (0)