Skip to content

Commit 151db95

Browse files
authored
feat: frontend API POST endpoint (#9291)
https://linear.app/unleash/issue/2-3260/implement-post-request-support-in-unleashs-frontend-api Implements the POST endpoint in Unleash's frontend API.
1 parent 5c23a52 commit 151db95

File tree

5 files changed

+154
-15
lines changed

5 files changed

+154
-15
lines changed

src/lib/features/frontend-api/create-context.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import crypto from 'crypto';
33
import type { Context } from 'unleash-client';
44

5-
export function createContext(value: any): Context {
5+
export function createContext(contextData: any): Context {
66
const {
77
appName,
88
environment,
@@ -11,7 +11,7 @@ export function createContext(value: any): Context {
1111
remoteAddress,
1212
properties,
1313
...rest
14-
} = value;
14+
} = contextData;
1515

1616
// move non root context fields to properties
1717
const context: Context = {
@@ -31,8 +31,9 @@ export function createContext(value: any): Context {
3131
return cleanContext;
3232
}
3333

34-
export const enrichContextWithIp = (query: any, ip: string): Context => {
35-
query.remoteAddress = query.remoteAddress || ip;
36-
query.sessionId = query.sessionId || crypto.randomBytes(18).toString('hex');
37-
return createContext(query);
34+
export const enrichContextWithIp = (contextData: any, ip: string): Context => {
35+
contextData.remoteAddress = contextData.remoteAddress || ip;
36+
contextData.sessionId =
37+
contextData.sessionId || crypto.randomBytes(18).toString('hex');
38+
return createContext(contextData);
3839
};

src/lib/features/frontend-api/frontend-api-controller.ts

+24-3
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,25 @@ export default class FrontendAPIController extends Controller {
8383
this.route({
8484
method: 'post',
8585
path: '',
86-
handler: FrontendAPIController.endpointNotImplemented,
86+
handler: this.getFrontendApiFeatures,
8787
permission: NONE,
88+
middleware: [
89+
this.services.openApiService.validPath({
90+
tags: ['Frontend API'],
91+
operationId: 'getFrontendApiFeaturesWithPost',
92+
requestBody: createRequestSchema(
93+
'frontendApiFeaturesPostSchema',
94+
),
95+
responses: {
96+
200: createResponseSchema('frontendApiFeaturesSchema'),
97+
...getStandardResponses(401, 404),
98+
},
99+
summary:
100+
'Retrieve enabled feature flags for the provided context, using POST.',
101+
description:
102+
'This endpoint returns the list of feature flags that the frontend API evaluates to enabled for the given context, using POST. Context values are provided as a `context` property in the request body. If the Frontend API is disabled 404 is returned.',
103+
}),
104+
],
88105
});
89106

90107
this.route({
@@ -233,7 +250,11 @@ export default class FrontendAPIController extends Controller {
233250
}
234251

235252
private static createContext(req: ApiUserRequest): Context {
236-
const { query } = req;
237-
return enrichContextWithIp(query, req.ip);
253+
const { query, body } = req;
254+
255+
const bodyContext = body.context ?? {};
256+
const contextData = req.method === 'POST' ? bodyContext : query;
257+
258+
return enrichContextWithIp(contextData, req.ip);
238259
}
239260
}

src/lib/features/frontend-api/frontend-api.e2e.test.ts

+100-6
Original file line numberDiff line numberDiff line change
@@ -278,12 +278,6 @@ test('should allow requests with a frontend token', async () => {
278278

279279
test('should return 405 from unimplemented endpoints', async () => {
280280
const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
281-
await app.request
282-
.post('/api/frontend')
283-
.send({})
284-
.set('Authorization', frontendToken.secret)
285-
.expect('Content-Type', /json/)
286-
.expect(405);
287281
await app.request
288282
.get('/api/frontend/client/features')
289283
.set('Authorization', frontendToken.secret)
@@ -1234,3 +1228,103 @@ test('should resolve variable rollout percentage consistently', async () => {
12341228
}
12351229
}
12361230
});
1231+
1232+
test('should return enabled feature flags using POST', async () => {
1233+
const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
1234+
await createFeatureToggle({
1235+
name: 'enabledFeature1',
1236+
enabled: true,
1237+
strategies: [{ name: 'default', constraints: [], parameters: {} }],
1238+
});
1239+
await createFeatureToggle({
1240+
name: 'enabledFeature2',
1241+
enabled: true,
1242+
strategies: [{ name: 'default', constraints: [], parameters: {} }],
1243+
});
1244+
await createFeatureToggle({
1245+
name: 'disabledFeature',
1246+
enabled: false,
1247+
strategies: [{ name: 'default', constraints: [], parameters: {} }],
1248+
});
1249+
await frontendApiService.refreshData();
1250+
await app.request
1251+
.post('/api/frontend')
1252+
.set('Authorization', frontendToken.secret)
1253+
.set('Content-Type', 'application/json')
1254+
.send()
1255+
.expect('Content-Type', /json/)
1256+
.expect(200)
1257+
.expect((res) => {
1258+
expect(res.body).toEqual({
1259+
toggles: [
1260+
{
1261+
name: 'enabledFeature1',
1262+
enabled: true,
1263+
impressionData: false,
1264+
variant: {
1265+
enabled: false,
1266+
name: 'disabled',
1267+
feature_enabled: true,
1268+
featureEnabled: true,
1269+
},
1270+
},
1271+
{
1272+
name: 'enabledFeature2',
1273+
enabled: true,
1274+
impressionData: false,
1275+
variant: {
1276+
enabled: false,
1277+
name: 'disabled',
1278+
feature_enabled: true,
1279+
featureEnabled: true,
1280+
},
1281+
},
1282+
],
1283+
});
1284+
});
1285+
});
1286+
1287+
test('should return enabled feature flags based on context using POST', async () => {
1288+
const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
1289+
const featureName = 'featureWithEnvironmentConstraint';
1290+
await createFeatureToggle({
1291+
name: featureName,
1292+
enabled: true,
1293+
strategies: [
1294+
{
1295+
name: 'default',
1296+
constraints: [
1297+
{
1298+
contextName: 'userId',
1299+
operator: 'IN',
1300+
values: ['1337'],
1301+
},
1302+
],
1303+
parameters: {},
1304+
},
1305+
],
1306+
});
1307+
1308+
await frontendApiService.refreshData();
1309+
await app.request
1310+
.post('/api/frontend')
1311+
.set('Authorization', frontendToken.secret)
1312+
.set('Content-Type', 'application/json')
1313+
.send({ context: { userId: '1337' } })
1314+
.expect('Content-Type', /json/)
1315+
.expect(200)
1316+
.expect((res) => {
1317+
expect(res.body.toggles).toHaveLength(1);
1318+
expect(res.body.toggles[0].name).toBe(featureName);
1319+
});
1320+
1321+
await app.request
1322+
.post('/api/frontend')
1323+
.set('Authorization', frontendToken.secret)
1324+
.send({ context: { appName: 'test', userId: '42' } })
1325+
.expect('Content-Type', /json/)
1326+
.expect(200)
1327+
.expect((res) => {
1328+
expect(res.body.toggles).toHaveLength(0);
1329+
});
1330+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { FromSchema } from 'json-schema-to-ts';
2+
import { sdkContextSchema } from './sdk-context-schema';
3+
4+
export const frontendApiFeaturesPostSchema = {
5+
$id: '#/components/schemas/frontendApiFeaturesPostContextSchema',
6+
description: 'The Unleash frontend API POST request body.',
7+
type: 'object',
8+
additionalProperties: true,
9+
properties: {
10+
context: {
11+
description: 'The Unleash context.',
12+
type: 'object',
13+
additionalProperties: true,
14+
properties: sdkContextSchema.properties,
15+
},
16+
},
17+
components: {},
18+
} as const;
19+
20+
export type FrontendApiFeaturesPostSchema = FromSchema<
21+
typeof frontendApiFeaturesPostSchema
22+
>;

src/lib/openapi/spec/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export * from './feedback-response-schema';
101101
export * from './feedback-update-schema';
102102
export * from './frontend-api-client-schema';
103103
export * from './frontend-api-feature-schema';
104+
export * from './frontend-api-features-post-schema';
104105
export * from './frontend-api-features-schema';
105106
export * from './group-schema';
106107
export * from './group-user-model-schema';

0 commit comments

Comments
 (0)