diff --git a/src/lib/features/frontend-api/create-context.ts b/src/lib/features/frontend-api/create-context.ts index 921ca3f514..286eddb32f 100644 --- a/src/lib/features/frontend-api/create-context.ts +++ b/src/lib/features/frontend-api/create-context.ts @@ -2,7 +2,7 @@ import crypto from 'crypto'; import type { Context } from 'unleash-client'; -export function createContext(value: any): Context { +export function createContext(contextData: any): Context { const { appName, environment, @@ -11,7 +11,7 @@ export function createContext(value: any): Context { remoteAddress, properties, ...rest - } = value; + } = contextData; // move non root context fields to properties const context: Context = { @@ -31,8 +31,9 @@ export function createContext(value: any): Context { return cleanContext; } -export const enrichContextWithIp = (query: any, ip: string): Context => { - query.remoteAddress = query.remoteAddress || ip; - query.sessionId = query.sessionId || crypto.randomBytes(18).toString('hex'); - return createContext(query); +export const enrichContextWithIp = (contextData: any, ip: string): Context => { + contextData.remoteAddress = contextData.remoteAddress || ip; + contextData.sessionId = + contextData.sessionId || crypto.randomBytes(18).toString('hex'); + return createContext(contextData); }; diff --git a/src/lib/features/frontend-api/frontend-api-controller.ts b/src/lib/features/frontend-api/frontend-api-controller.ts index 62e5947096..beb4b35779 100644 --- a/src/lib/features/frontend-api/frontend-api-controller.ts +++ b/src/lib/features/frontend-api/frontend-api-controller.ts @@ -83,8 +83,25 @@ export default class FrontendAPIController extends Controller { this.route({ method: 'post', path: '', - handler: FrontendAPIController.endpointNotImplemented, + handler: this.getFrontendApiFeatures, 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({ @@ -233,7 +250,11 @@ export default class FrontendAPIController extends Controller { } private static createContext(req: ApiUserRequest): Context { - const { query } = req; - return enrichContextWithIp(query, req.ip); + const { query, body } = req; + + const bodyContext = body.context ?? {}; + const contextData = req.method === 'POST' ? bodyContext : query; + + return enrichContextWithIp(contextData, req.ip); } } diff --git a/src/lib/features/frontend-api/frontend-api.e2e.test.ts b/src/lib/features/frontend-api/frontend-api.e2e.test.ts index f1461cf9f2..14e3e3388b 100644 --- a/src/lib/features/frontend-api/frontend-api.e2e.test.ts +++ b/src/lib/features/frontend-api/frontend-api.e2e.test.ts @@ -278,12 +278,6 @@ test('should allow requests with a frontend token', async () => { test('should return 405 from unimplemented endpoints', async () => { 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 .get('/api/frontend/client/features') .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); + }); +}); diff --git a/src/lib/openapi/spec/frontend-api-features-post-schema.ts b/src/lib/openapi/spec/frontend-api-features-post-schema.ts new file mode 100644 index 0000000000..ee6e0790b3 --- /dev/null +++ b/src/lib/openapi/spec/frontend-api-features-post-schema.ts @@ -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 +>; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index f22c5fa1ac..912979ead8 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -101,6 +101,7 @@ export * from './feedback-response-schema'; export * from './feedback-update-schema'; export * from './frontend-api-client-schema'; export * from './frontend-api-feature-schema'; +export * from './frontend-api-features-post-schema'; export * from './frontend-api-features-schema'; export * from './group-schema'; export * from './group-user-model-schema';