Skip to content

Commit f937bcb

Browse files
authored
✨ [FFL-2858] Feature Flags tab — catalog browsing (stacked PR 2 of 3) (#4916)
1 parent 33dfd89 commit f937bcb

11 files changed

Lines changed: 981 additions & 13 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import type { CatalogFlag, FlagCatalogRequest } from './flagCatalog'
2+
import { fetchFlagCatalog } from './flagCatalog'
3+
4+
describe('flagCatalog', () => {
5+
describe('fetchFlagCatalog', () => {
6+
const baseRequest: FlagCatalogRequest = { page: 1, pageSize: 20, search: '', typeFilter: [], tagFilter: [] }
7+
8+
function mockResponse(body: unknown) {
9+
return spyOn(globalThis, 'fetch').and.returnValue(Promise.resolve(new Response(JSON.stringify(body))))
10+
}
11+
12+
it('requests one page server-side (active-only, offset from page) and returns the server total', async () => {
13+
const sampleFlag: CatalogFlag = {
14+
key: 'flag-a',
15+
name: 'Flag A',
16+
type: 'BOOLEAN',
17+
variants: [{ name: 'on', value: true }],
18+
tags: ['x'],
19+
}
20+
const sampleTotal = 42
21+
const spy = mockResponse({
22+
data: [
23+
{
24+
attributes: {
25+
key: sampleFlag.key,
26+
name: sampleFlag.name,
27+
value_type: sampleFlag.type,
28+
// The API returns variant values as strings; parseVariantValue turns them back.
29+
variants: sampleFlag.variants.map(({ name, value }) => ({ name, value: String(value) })),
30+
tags: sampleFlag.tags,
31+
},
32+
},
33+
],
34+
meta: { page: { total: sampleTotal } },
35+
})
36+
37+
const page = await fetchFlagCatalog('tok', 'datad0g.com', { ...baseRequest, page: 3, pageSize: 20 })
38+
39+
const [requestUrl, requestInit] = spy.calls.argsFor(0) as [string, RequestInit]
40+
const url = new URL(requestUrl)
41+
expect(url.pathname).toBe('/api/ui/ffe/feature-flags')
42+
expect(url.searchParams.get('page[limit]')).toBe('20')
43+
expect(url.searchParams.get('page[offset]')).toBe('40') // (3 - 1) * 20
44+
expect(url.searchParams.get('is_archived')).toBe('false')
45+
expect((requestInit.headers as Record<string, string>).Authorization).toBe('Bearer tok')
46+
expect(page.total).toBe(sampleTotal)
47+
expect(page.flags).toEqual([sampleFlag])
48+
})
49+
50+
it('sends search, value_type (repeated), and tags (repeated) as server-side filters', async () => {
51+
const spy = mockResponse({ data: [], meta: { page: { total: 0 } } })
52+
53+
await fetchFlagCatalog('tok', 'datad0g.com', {
54+
page: 1,
55+
pageSize: 20,
56+
search: 'checkout',
57+
typeFilter: ['BOOLEAN', 'STRING'],
58+
tagFilter: ['team:x', 'beta'],
59+
})
60+
61+
const [requestUrl] = spy.calls.argsFor(0) as [string, RequestInit]
62+
const url = new URL(requestUrl)
63+
expect(url.searchParams.get('search')).toBe('checkout')
64+
expect(url.searchParams.getAll('value_type')).toEqual(['BOOLEAN', 'STRING'])
65+
expect(url.searchParams.getAll('tags')).toEqual(['team:x', 'beta'])
66+
})
67+
68+
it('omits the search param when the term is empty', async () => {
69+
const spy = mockResponse({ data: [], meta: { page: { total: 0 } } })
70+
await fetchFlagCatalog('tok', 'datad0g.com', baseRequest)
71+
const [requestUrl] = spy.calls.argsFor(0) as [string, RequestInit]
72+
expect(new URL(requestUrl).searchParams.has('search')).toBe(false)
73+
})
74+
75+
it('parses variant values by declared type, falling back to the raw string on bad input', async () => {
76+
mockResponse({
77+
data: [
78+
{
79+
attributes: {
80+
key: 'f',
81+
value_type: 'JSON',
82+
variants: [
83+
{ name: 'ok', value: '{"a":1}' },
84+
{ name: 'bad', value: 'not json' },
85+
],
86+
},
87+
},
88+
{
89+
attributes: {
90+
key: 'n',
91+
value_type: 'INTEGER',
92+
variants: [
93+
{ name: 'five', value: '5' },
94+
{ name: 'nan', value: 'abc' },
95+
],
96+
},
97+
},
98+
],
99+
meta: { page: { total: 2 } },
100+
})
101+
102+
const { flags } = await fetchFlagCatalog('tok', 'datad0g.com', baseRequest)
103+
expect(flags[0].variants[0].value).toEqual({ a: 1 })
104+
expect(flags[0].variants[1].value).toBe('not json')
105+
expect(flags[1].variants[0].value).toBe(5)
106+
expect(flags[1].variants[1].value).toBe('abc')
107+
})
108+
109+
it('keeps malformed or out-of-range variant values raw instead of coercing them', async () => {
110+
mockResponse({
111+
data: [
112+
{
113+
attributes: {
114+
key: 'b',
115+
value_type: 'BOOLEAN',
116+
variants: [
117+
{ name: 't', value: 'true' },
118+
{ name: 'f', value: 'false' },
119+
{ name: 'weird', value: 'True' },
120+
],
121+
},
122+
},
123+
{
124+
attributes: {
125+
key: 'i',
126+
value_type: 'INTEGER',
127+
variants: [
128+
{ name: 'partial', value: '5abc' },
129+
{ name: 'float', value: '5.5' },
130+
{ name: 'unsafe', value: '9007199254740993' },
131+
],
132+
},
133+
},
134+
{
135+
attributes: {
136+
key: 'd',
137+
value_type: 'NUMERIC',
138+
variants: [
139+
{ name: 'ok', value: '5.5' },
140+
{ name: 'partial', value: '5abc' },
141+
{ name: 'empty', value: '' },
142+
],
143+
},
144+
},
145+
],
146+
meta: { page: { total: 3 } },
147+
})
148+
149+
const { flags } = await fetchFlagCatalog('tok', 'datad0g.com', baseRequest)
150+
const [booleanFlag, integer, numeric] = flags
151+
expect(booleanFlag.variants.map((variant) => variant.value)).toEqual([true, false, 'True'])
152+
expect(integer.variants.map((variant) => variant.value)).toEqual(['5abc', '5.5', '9007199254740993'])
153+
expect(numeric.variants.map((variant) => variant.value)).toEqual([5.5, '5abc', ''])
154+
})
155+
156+
it('dedupes flags sharing a key within a page, keeping the first occurrence', async () => {
157+
mockResponse({
158+
data: [
159+
{ attributes: { key: 'dup', name: 'First', value_type: 'STRING', variants: [], tags: [] } },
160+
{ attributes: { key: 'dup', name: 'Second', value_type: 'STRING', variants: [], tags: [] } },
161+
],
162+
meta: { page: { total: 2 } },
163+
})
164+
165+
const { flags } = await fetchFlagCatalog('tok', 'datad0g.com', baseRequest)
166+
expect(flags.length).toBe(1)
167+
expect(flags[0].name).toBe('First')
168+
})
169+
170+
it('falls back to the key for a missing name and defaults tags/variants', async () => {
171+
mockResponse({ data: [{ attributes: { key: 'no-name', value_type: 'STRING' } }], meta: { page: { total: 1 } } })
172+
173+
const { flags } = await fetchFlagCatalog('tok', 'datad0g.com', baseRequest)
174+
expect(flags[0].name).toBe('no-name')
175+
expect(flags[0].tags).toEqual([])
176+
expect(flags[0].variants).toEqual([])
177+
})
178+
179+
it('tolerates a response that omits data/meta, falling total back to the page length', async () => {
180+
mockResponse({ errors: ['x'] })
181+
const page = await fetchFlagCatalog('tok', 'datad0g.com', baseRequest)
182+
expect(page.flags).toEqual([])
183+
expect(page.total).toBe(0)
184+
})
185+
186+
it('falls back total to the number of returned flags when meta.page.total is missing', async () => {
187+
mockResponse({
188+
data: [{ attributes: { key: 'a', value_type: 'STRING' } }, { attributes: { key: 'b', value_type: 'STRING' } }],
189+
})
190+
expect((await fetchFlagCatalog('tok', 'datad0g.com', baseRequest)).total).toBe(2)
191+
})
192+
193+
it('throws on a non-ok response', async () => {
194+
spyOn(globalThis, 'fetch').and.returnValue(
195+
Promise.resolve(new Response('err', { status: 500, statusText: 'Server Error' }))
196+
)
197+
await expectAsync(fetchFlagCatalog('tok', 'datad0g.com', baseRequest)).toBeRejectedWithError(
198+
/Failed to fetch flag catalog/
199+
)
200+
})
201+
})
202+
})
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { FlagType } from './flagTypeConstants'
2+
import { getFlagsApiHost } from './oauth'
3+
4+
export interface CatalogFlag {
5+
key: string
6+
name: string
7+
type: FlagType
8+
// Parsed value of each variant (any JSON value); see parseVariantValue.
9+
variants: Array<{ name: string; value: unknown }>
10+
tags: string[]
11+
}
12+
13+
// Filters + pagination sent to the server so the FFE endpoint does the work — the extension never
14+
// loads the whole catalog. The endpoint applies all of these itself: `search` matches name/key/tags,
15+
// `tags` are AND-ed, `value_type` is OR-ed (see dd-source ffe-service). `page` is 1-based.
16+
export interface FlagCatalogRequest {
17+
page: number
18+
pageSize: number
19+
search: string
20+
typeFilter: string[]
21+
tagFilter: string[]
22+
}
23+
24+
// One page of results plus the server's total count (for pagination).
25+
export interface FlagCatalogPage {
26+
flags: CatalogFlag[]
27+
total: number
28+
}
29+
30+
interface RawFeatureFlag {
31+
attributes: {
32+
key: string
33+
name?: string
34+
value_type: FlagType
35+
variants?: Array<{ name: string; value: string }>
36+
tags?: string[]
37+
}
38+
}
39+
40+
interface RawFeatureFlagsResponse {
41+
data?: RawFeatureFlag[]
42+
meta?: { page?: { total?: number } }
43+
}
44+
45+
// Variant values come back from the API as strings regardless of the flag's declared type. Falls
46+
// back to the raw string on unparseable input so one malformed variant can't blow up the mapping
47+
// of the entire catalog.
48+
function parseVariantValue(type: FlagType, rawValue: string): unknown {
49+
switch (type) {
50+
case 'BOOLEAN':
51+
// Only the exact strings count; anything else (e.g. "True", "falsex", "") is malformed and
52+
// kept raw rather than silently collapsing to false.
53+
if (rawValue === 'true') {
54+
return true
55+
}
56+
if (rawValue === 'false') {
57+
return false
58+
}
59+
return rawValue
60+
case 'INTEGER': {
61+
// parseInt would accept partial/oversized input ("5abc" -> 5, unsafe integers get rounded), so
62+
// require the whole string to be an integer within the safe range; otherwise keep it raw.
63+
const parsed = Number(rawValue)
64+
return /^[+-]?\d+$/.test(rawValue) && Number.isSafeInteger(parsed) ? parsed : rawValue
65+
}
66+
case 'NUMERIC': {
67+
// parseFloat accepts a numeric prefix ("5abc" -> 5) and Number("") is 0, so require a
68+
// non-empty string that parses fully to a finite number; otherwise keep it raw.
69+
const parsed = Number(rawValue)
70+
return rawValue.trim() !== '' && Number.isFinite(parsed) ? parsed : rawValue
71+
}
72+
case 'JSON':
73+
try {
74+
return JSON.parse(rawValue) as unknown
75+
} catch {
76+
return rawValue
77+
}
78+
case 'STRING':
79+
return rawValue
80+
default:
81+
// The server returned a value_type outside the known union (which is a compile-time
82+
// assumption, not a runtime guarantee) — keep the raw string rather than returning undefined,
83+
// consistent with never letting one odd variant blow up the mapping.
84+
return rawValue
85+
}
86+
}
87+
88+
/**
89+
* Fetches ONE page of the flag catalog via the FFE UI endpoint (GET /api/ui/ffe/feature-flags),
90+
* letting the server apply the filters + pagination. Returns the page's flags plus the server's
91+
* total match count (`meta.page.total`) so the caller can render pagination. OAuth is the only
92+
* supported auth path (see oauth.ts).
93+
*/
94+
export async function fetchFlagCatalog(
95+
token: string,
96+
site: string,
97+
request: FlagCatalogRequest
98+
): Promise<FlagCatalogPage> {
99+
const url = new URL(`https://${getFlagsApiHost(site)}/api/ui/ffe/feature-flags`)
100+
url.searchParams.set('page[limit]', String(request.pageSize))
101+
url.searchParams.set('page[offset]', String((request.page - 1) * request.pageSize))
102+
// Active flags only: with archived included, an archived and an active flag can share a key and
103+
// land on the same page, which would render as duplicate rows and collide React keys.
104+
url.searchParams.set('is_archived', 'false')
105+
if (request.search) {
106+
url.searchParams.set('search', request.search)
107+
}
108+
for (const type of request.typeFilter) {
109+
url.searchParams.append('value_type', type)
110+
}
111+
for (const tag of request.tagFilter) {
112+
url.searchParams.append('tags', tag)
113+
}
114+
115+
const response = await fetch(url.toString(), {
116+
headers: {
117+
Authorization: `Bearer ${token}`,
118+
},
119+
})
120+
if (!response.ok) {
121+
throw new Error(`Failed to fetch flag catalog: ${response.status} ${response.statusText}`)
122+
}
123+
124+
const body = (await response.json()) as RawFeatureFlagsResponse
125+
// Tolerate a response that omits/mistypes `data` rather than throwing on `.map`.
126+
const resources = Array.isArray(body?.data) ? body.data : []
127+
return {
128+
flags: mapResources(resources),
129+
// Fall back to the page length when the server omits the total (e.g. a partial/legacy response).
130+
total: body?.meta?.page?.total ?? resources.length,
131+
}
132+
}
133+
134+
function mapResources(resources: RawFeatureFlag[]): CatalogFlag[] {
135+
// Dedupe by key as a cheap safety net against a flag appearing twice on a page (see is_archived
136+
// note above); collisions would otherwise break React keys (`key={flag.key}`). Keep the first.
137+
const byKey = new Map<string, CatalogFlag>()
138+
for (const { attributes } of resources) {
139+
if (byKey.has(attributes.key)) {
140+
continue
141+
}
142+
byKey.set(attributes.key, {
143+
key: attributes.key,
144+
name: attributes.name || attributes.key,
145+
type: attributes.value_type,
146+
variants: (attributes.variants ?? []).map((variant) => ({
147+
name: variant.name,
148+
value: parseVariantValue(attributes.value_type, variant.value),
149+
})),
150+
tags: attributes.tags ?? [],
151+
})
152+
}
153+
return Array.from(byKey.values())
154+
}

0 commit comments

Comments
 (0)