mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-18 01:18:23 +02:00
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.
This commit is contained in:
parent
5c23a52119
commit
151db95c2d
@ -2,7 +2,7 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import type { Context } from 'unleash-client';
|
import type { Context } from 'unleash-client';
|
||||||
|
|
||||||
export function createContext(value: any): Context {
|
export function createContext(contextData: any): Context {
|
||||||
const {
|
const {
|
||||||
appName,
|
appName,
|
||||||
environment,
|
environment,
|
||||||
@ -11,7 +11,7 @@ export function createContext(value: any): Context {
|
|||||||
remoteAddress,
|
remoteAddress,
|
||||||
properties,
|
properties,
|
||||||
...rest
|
...rest
|
||||||
} = value;
|
} = contextData;
|
||||||
|
|
||||||
// move non root context fields to properties
|
// move non root context fields to properties
|
||||||
const context: Context = {
|
const context: Context = {
|
||||||
@ -31,8 +31,9 @@ export function createContext(value: any): Context {
|
|||||||
return cleanContext;
|
return cleanContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enrichContextWithIp = (query: any, ip: string): Context => {
|
export const enrichContextWithIp = (contextData: any, ip: string): Context => {
|
||||||
query.remoteAddress = query.remoteAddress || ip;
|
contextData.remoteAddress = contextData.remoteAddress || ip;
|
||||||
query.sessionId = query.sessionId || crypto.randomBytes(18).toString('hex');
|
contextData.sessionId =
|
||||||
return createContext(query);
|
contextData.sessionId || crypto.randomBytes(18).toString('hex');
|
||||||
|
return createContext(contextData);
|
||||||
};
|
};
|
||||||
|
@ -83,8 +83,25 @@ export default class FrontendAPIController extends Controller {
|
|||||||
this.route({
|
this.route({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: '',
|
path: '',
|
||||||
handler: FrontendAPIController.endpointNotImplemented,
|
handler: this.getFrontendApiFeatures,
|
||||||
permission: NONE,
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
this.services.openApiService.validPath({
|
||||||
|
tags: ['Frontend API'],
|
||||||
|
operationId: 'getFrontendApiFeaturesWithPost',
|
||||||
|
requestBody: createRequestSchema(
|
||||||
|
'frontendApiFeaturesPostSchema',
|
||||||
|
),
|
||||||
|
responses: {
|
||||||
|
200: createResponseSchema('frontendApiFeaturesSchema'),
|
||||||
|
...getStandardResponses(401, 404),
|
||||||
|
},
|
||||||
|
summary:
|
||||||
|
'Retrieve enabled feature flags for the provided context, using POST.',
|
||||||
|
description:
|
||||||
|
'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.',
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
@ -233,7 +250,11 @@ export default class FrontendAPIController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static createContext(req: ApiUserRequest): Context {
|
private static createContext(req: ApiUserRequest): Context {
|
||||||
const { query } = req;
|
const { query, body } = req;
|
||||||
return enrichContextWithIp(query, req.ip);
|
|
||||||
|
const bodyContext = body.context ?? {};
|
||||||
|
const contextData = req.method === 'POST' ? bodyContext : query;
|
||||||
|
|
||||||
|
return enrichContextWithIp(contextData, req.ip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -278,12 +278,6 @@ test('should allow requests with a frontend token', async () => {
|
|||||||
|
|
||||||
test('should return 405 from unimplemented endpoints', async () => {
|
test('should return 405 from unimplemented endpoints', async () => {
|
||||||
const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
|
const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
|
||||||
await app.request
|
|
||||||
.post('/api/frontend')
|
|
||||||
.send({})
|
|
||||||
.set('Authorization', frontendToken.secret)
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect(405);
|
|
||||||
await app.request
|
await app.request
|
||||||
.get('/api/frontend/client/features')
|
.get('/api/frontend/client/features')
|
||||||
.set('Authorization', frontendToken.secret)
|
.set('Authorization', frontendToken.secret)
|
||||||
@ -1234,3 +1228,103 @@ test('should resolve variable rollout percentage consistently', async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return enabled feature flags using POST', async () => {
|
||||||
|
const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
|
||||||
|
await createFeatureToggle({
|
||||||
|
name: 'enabledFeature1',
|
||||||
|
enabled: true,
|
||||||
|
strategies: [{ name: 'default', constraints: [], parameters: {} }],
|
||||||
|
});
|
||||||
|
await createFeatureToggle({
|
||||||
|
name: 'enabledFeature2',
|
||||||
|
enabled: true,
|
||||||
|
strategies: [{ name: 'default', constraints: [], parameters: {} }],
|
||||||
|
});
|
||||||
|
await createFeatureToggle({
|
||||||
|
name: 'disabledFeature',
|
||||||
|
enabled: false,
|
||||||
|
strategies: [{ name: 'default', constraints: [], parameters: {} }],
|
||||||
|
});
|
||||||
|
await frontendApiService.refreshData();
|
||||||
|
await app.request
|
||||||
|
.post('/api/frontend')
|
||||||
|
.set('Authorization', frontendToken.secret)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send()
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
toggles: [
|
||||||
|
{
|
||||||
|
name: 'enabledFeature1',
|
||||||
|
enabled: true,
|
||||||
|
impressionData: false,
|
||||||
|
variant: {
|
||||||
|
enabled: false,
|
||||||
|
name: 'disabled',
|
||||||
|
feature_enabled: true,
|
||||||
|
featureEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enabledFeature2',
|
||||||
|
enabled: true,
|
||||||
|
impressionData: false,
|
||||||
|
variant: {
|
||||||
|
enabled: false,
|
||||||
|
name: 'disabled',
|
||||||
|
feature_enabled: true,
|
||||||
|
featureEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return enabled feature flags based on context using POST', async () => {
|
||||||
|
const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
|
||||||
|
const featureName = 'featureWithEnvironmentConstraint';
|
||||||
|
await createFeatureToggle({
|
||||||
|
name: featureName,
|
||||||
|
enabled: true,
|
||||||
|
strategies: [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
constraints: [
|
||||||
|
{
|
||||||
|
contextName: 'userId',
|
||||||
|
operator: 'IN',
|
||||||
|
values: ['1337'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await frontendApiService.refreshData();
|
||||||
|
await app.request
|
||||||
|
.post('/api/frontend')
|
||||||
|
.set('Authorization', frontendToken.secret)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ context: { userId: '1337' } })
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.toggles).toHaveLength(1);
|
||||||
|
expect(res.body.toggles[0].name).toBe(featureName);
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.post('/api/frontend')
|
||||||
|
.set('Authorization', frontendToken.secret)
|
||||||
|
.send({ context: { appName: 'test', userId: '42' } })
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.toggles).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
22
src/lib/openapi/spec/frontend-api-features-post-schema.ts
Normal file
22
src/lib/openapi/spec/frontend-api-features-post-schema.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { sdkContextSchema } from './sdk-context-schema';
|
||||||
|
|
||||||
|
export const frontendApiFeaturesPostSchema = {
|
||||||
|
$id: '#/components/schemas/frontendApiFeaturesPostContextSchema',
|
||||||
|
description: 'The Unleash frontend API POST request body.',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
properties: {
|
||||||
|
context: {
|
||||||
|
description: 'The Unleash context.',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
properties: sdkContextSchema.properties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type FrontendApiFeaturesPostSchema = FromSchema<
|
||||||
|
typeof frontendApiFeaturesPostSchema
|
||||||
|
>;
|
@ -101,6 +101,7 @@ export * from './feedback-response-schema';
|
|||||||
export * from './feedback-update-schema';
|
export * from './feedback-update-schema';
|
||||||
export * from './frontend-api-client-schema';
|
export * from './frontend-api-client-schema';
|
||||||
export * from './frontend-api-feature-schema';
|
export * from './frontend-api-feature-schema';
|
||||||
|
export * from './frontend-api-features-post-schema';
|
||||||
export * from './frontend-api-features-schema';
|
export * from './frontend-api-features-schema';
|
||||||
export * from './group-schema';
|
export * from './group-schema';
|
||||||
export * from './group-user-model-schema';
|
export * from './group-user-model-schema';
|
||||||
|
Loading…
Reference in New Issue
Block a user