From b50b06c257246dacf392c9aeb01d6c8b201e0aad Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Mon, 3 Jul 2023 15:48:09 +0200 Subject: [PATCH] feat: Frontend api openapi spec (#4133) --- src/lib/openapi/spec/proxy-feature-schema.ts | 20 +- src/lib/routes/index.ts | 7 +- src/lib/routes/proxy-api/index.ts | 35 +- .../__snapshots__/openapi.e2e.test.ts.snap | 307 ++++++++++++++++++ 4 files changed, 358 insertions(+), 11 deletions(-) diff --git a/src/lib/openapi/spec/proxy-feature-schema.ts b/src/lib/openapi/spec/proxy-feature-schema.ts index 003437a598..41ac9d57fd 100644 --- a/src/lib/openapi/spec/proxy-feature-schema.ts +++ b/src/lib/openapi/spec/proxy-feature-schema.ts @@ -9,12 +9,19 @@ export const proxyFeatureSchema = { properties: { name: { type: 'string', + example: 'disable-comments', + description: 'Unique feature name.', }, enabled: { type: 'boolean', + example: true, + description: 'Always set to `true`.', }, impressionData: { type: 'boolean', + example: false, + description: + '`true` if the impression data collection is enabled for the feature, otherwise `false`.', }, variant: { type: 'object', @@ -23,20 +30,31 @@ export const proxyFeatureSchema = { properties: { name: { type: 'string', + description: + 'The variants name. Is unique for this feature toggle', + example: 'blue_group', }, enabled: { type: 'boolean', + example: true, + description: 'Whether the variant is enabled or not.', }, payload: { type: 'object', additionalProperties: false, required: ['type', 'value'], + description: 'Extra data configured for this variant', + example: { type: 'json', value: '{color: red}' }, properties: { type: { type: 'string', + description: 'The format of the payload.', enum: Object.values(PayloadType), }, - value: { type: 'string' }, + value: { + type: 'string', + description: 'The payload value stringified.', + }, }, }, }, diff --git a/src/lib/routes/index.ts b/src/lib/routes/index.ts index 94f3a4de5e..1f1c88447e 100644 --- a/src/lib/routes/index.ts +++ b/src/lib/routes/index.ts @@ -10,7 +10,6 @@ const ClientApi = require('./client-api'); const Controller = require('./controller'); import { HealthCheckController } from './health-check'; import ProxyController from './proxy-api'; -import { conditionalMiddleware } from '../middleware'; import EdgeController from './edge-api'; import { PublicInviteController } from './public-invite'; import { Db } from '../db/db'; @@ -47,11 +46,7 @@ class IndexRouter extends Controller { this.use( '/api/frontend', - conditionalMiddleware( - () => config.flagResolver.isEnabled('embedProxy'), - new ProxyController(config, services, config.flagResolver) - .router, - ), + new ProxyController(config, services, config.flagResolver).router, ); this.use('/edge', new EdgeController(config, services).router); diff --git a/src/lib/routes/proxy-api/index.ts b/src/lib/routes/proxy-api/index.ts index 38249a5e2b..2a9f9886b7 100644 --- a/src/lib/routes/proxy-api/index.ts +++ b/src/lib/routes/proxy-api/index.ts @@ -13,6 +13,7 @@ import { createRequestSchema, createResponseSchema, emptyResponse, + getStandardResponses, ProxyClientSchema, proxyFeaturesSchema, ProxyFeaturesSchema, @@ -21,6 +22,7 @@ import { Context } from 'unleash-client'; import { enrichContextWithIp } from '../../proxy'; import { corsOriginMiddleware } from '../../middleware'; import NotImplementedError from '../../error/not-implemented-error'; +import NotFoundError from '../../error/notfound-error'; interface ApiUserRequest< PARAM = any, @@ -68,7 +70,12 @@ export default class ProxyController extends Controller { operationId: 'getFrontendFeatures', responses: { 200: createResponseSchema('proxyFeaturesSchema'), + ...getStandardResponses(401, 404), }, + summary: + 'Retrieve enabled feature toggles for the provided context.', + description: + 'This endpoint returns the list of feature toggles that the proxy evaluates to enabled for the given context. Context values are provided as query parameters. If the Frontend API is disabled 404 is returned.', }), ], }); @@ -95,9 +102,14 @@ export default class ProxyController extends Controller { middleware: [ this.services.openApiService.validPath({ tags: ['Frontend API'], + summary: 'Register client usage metrics', + description: `Registers usage metrics. Stores information about how many times each toggle was evaluated to enabled and disabled within a time frame. If provided, this operation will also store data on how many times each feature toggle's variants were displayed to the end user. If the Frontend API is disabled 404 is returned.`, operationId: 'registerFrontendMetrics', requestBody: createRequestSchema('clientMetricsSchema'), - responses: { 200: emptyResponse }, + responses: { + 200: emptyResponse, + ...getStandardResponses(400, 401, 404), + }, }), ], }); @@ -105,14 +117,20 @@ export default class ProxyController extends Controller { this.route({ method: 'post', path: '/client/register', - handler: ProxyController.registerProxyClient, + handler: this.registerProxyClient, permission: NONE, middleware: [ this.services.openApiService.validPath({ tags: ['Frontend API'], + summary: 'Register a client SDK', + description: + 'This is for future use. Currently Frontend client registration is not supported. Returning 200 for clients that expect this status code. If the Frontend API is disabled 404 is returned.', operationId: 'registerFrontendClient', requestBody: createRequestSchema('proxyClientSchema'), - responses: { 200: emptyResponse }, + responses: { + 200: emptyResponse, + ...getStandardResponses(400, 401, 404), + }, }), ], }); @@ -146,6 +164,9 @@ export default class ProxyController extends Controller { req: ApiUserRequest, res: Response, ) { + if (!this.config.flagResolver.isEnabled('embedProxy')) { + throw new NotFoundError(); + } const toggles = await this.services.proxyService.getProxyFeatures( req.user, ProxyController.createContext(req), @@ -165,6 +186,9 @@ export default class ProxyController extends Controller { req: ApiUserRequest, res: Response, ) { + if (!this.config.flagResolver.isEnabled('embedProxy')) { + throw new NotFoundError(); + } await this.services.proxyService.registerProxyMetrics( req.user, req.body, @@ -173,10 +197,13 @@ export default class ProxyController extends Controller { res.sendStatus(200); } - private static async registerProxyClient( + private async registerProxyClient( req: ApiUserRequest, res: Response, ) { + if (!this.config.flagResolver.isEnabled('embedProxy')) { + throw new NotFoundError(); + } // Client registration is not yet supported by @unleash/proxy, // but proxy clients may still expect a 200 from this endpoint. res.sendStatus(200); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index bfe4933426..b12ec92513 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -4509,27 +4509,43 @@ Stats are divided into current and previous **windows**. "additionalProperties": false, "properties": { "enabled": { + "description": "Always set to \`true\`.", + "example": true, "type": "boolean", }, "impressionData": { + "description": "\`true\` if the impression data collection is enabled for the feature, otherwise \`false\`.", + "example": false, "type": "boolean", }, "name": { + "description": "Unique feature name.", + "example": "disable-comments", "type": "string", }, "variant": { "additionalProperties": false, "properties": { "enabled": { + "description": "Whether the variant is enabled or not.", + "example": true, "type": "boolean", }, "name": { + "description": "The variants name. Is unique for this feature toggle", + "example": "blue_group", "type": "string", }, "payload": { "additionalProperties": false, + "description": "Extra data configured for this variant", + "example": { + "type": "json", + "value": "{color: red}", + }, "properties": { "type": { + "description": "The format of the payload.", "enum": [ "string", "json", @@ -4538,6 +4554,7 @@ Stats are divided into current and previous **windows**. "type": "string", }, "value": { + "description": "The payload value stringified.", "type": "string", }, }, @@ -14484,6 +14501,296 @@ true,false,"[{""range"":""allTime"",""count"":15},{""range"":""30d"",""count"":9 ], }, }, + "/api/frontend": { + "get": { + "description": "This endpoint returns the list of feature toggles that the proxy evaluates to enabled for the given context. Context values are provided as query parameters. If the Frontend API is disabled 404 is returned.", + "operationId": "getFrontendFeatures", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/proxyFeaturesSchema", + }, + }, + }, + "description": "proxyFeaturesSchema", + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "description": "The ID of the error instance", + "example": "9c40958a-daac-400e-98fb-3bb438567008", + "type": "string", + }, + "message": { + "description": "A description of what went wrong.", + "example": "You must log in to use Unleash. Your request had no authorization header, so we could not authorize you. Try logging in at /auth/simple/login.", + "type": "string", + }, + "name": { + "description": "The name of the error kind", + "example": "AuthenticationRequired", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.", + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "description": "The ID of the error instance", + "example": "9c40958a-daac-400e-98fb-3bb438567008", + "type": "string", + }, + "message": { + "description": "A description of what went wrong.", + "example": "Could not find the addon with ID "12345".", + "type": "string", + }, + "name": { + "description": "The name of the error kind", + "example": "NotFoundError", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "The requested resource was not found.", + }, + }, + "summary": "Retrieve enabled feature toggles for the provided context.", + "tags": [ + "Frontend API", + ], + }, + }, + "/api/frontend/client/metrics": { + "post": { + "description": "Registers usage metrics. Stores information about how many times each toggle was evaluated to enabled and disabled within a time frame. If provided, this operation will also store data on how many times each feature toggle's variants were displayed to the end user. If the Frontend API is disabled 404 is returned.", + "operationId": "registerFrontendMetrics", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/clientMetricsSchema", + }, + }, + }, + "description": "clientMetricsSchema", + "required": true, + }, + "responses": { + "200": { + "description": "This response has no body.", + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "description": "The ID of the error instance", + "example": "9c40958a-daac-400e-98fb-3bb438567008", + "type": "string", + }, + "message": { + "description": "A description of what went wrong.", + "example": "The request payload you provided doesn't conform to the schema. The .parameters property should be object. You sent [].", + "type": "string", + }, + "name": { + "description": "The name of the error kind", + "example": "ValidationError", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "The request data does not match what we expect.", + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "description": "The ID of the error instance", + "example": "9c40958a-daac-400e-98fb-3bb438567008", + "type": "string", + }, + "message": { + "description": "A description of what went wrong.", + "example": "You must log in to use Unleash. Your request had no authorization header, so we could not authorize you. Try logging in at /auth/simple/login.", + "type": "string", + }, + "name": { + "description": "The name of the error kind", + "example": "AuthenticationRequired", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.", + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "description": "The ID of the error instance", + "example": "9c40958a-daac-400e-98fb-3bb438567008", + "type": "string", + }, + "message": { + "description": "A description of what went wrong.", + "example": "Could not find the addon with ID "12345".", + "type": "string", + }, + "name": { + "description": "The name of the error kind", + "example": "NotFoundError", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "The requested resource was not found.", + }, + }, + "summary": "Register client usage metrics", + "tags": [ + "Frontend API", + ], + }, + }, + "/api/frontend/client/register": { + "post": { + "description": "This is for future use. Currently Frontend client registration is not supported. Returning 200 for clients that expect this status code. If the Frontend API is disabled 404 is returned.", + "operationId": "registerFrontendClient", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/proxyClientSchema", + }, + }, + }, + "description": "proxyClientSchema", + "required": true, + }, + "responses": { + "200": { + "description": "This response has no body.", + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "description": "The ID of the error instance", + "example": "9c40958a-daac-400e-98fb-3bb438567008", + "type": "string", + }, + "message": { + "description": "A description of what went wrong.", + "example": "The request payload you provided doesn't conform to the schema. The .parameters property should be object. You sent [].", + "type": "string", + }, + "name": { + "description": "The name of the error kind", + "example": "ValidationError", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "The request data does not match what we expect.", + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "description": "The ID of the error instance", + "example": "9c40958a-daac-400e-98fb-3bb438567008", + "type": "string", + }, + "message": { + "description": "A description of what went wrong.", + "example": "You must log in to use Unleash. Your request had no authorization header, so we could not authorize you. Try logging in at /auth/simple/login.", + "type": "string", + }, + "name": { + "description": "The name of the error kind", + "example": "AuthenticationRequired", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.", + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "description": "The ID of the error instance", + "example": "9c40958a-daac-400e-98fb-3bb438567008", + "type": "string", + }, + "message": { + "description": "A description of what went wrong.", + "example": "Could not find the addon with ID "12345".", + "type": "string", + }, + "name": { + "description": "The name of the error kind", + "example": "NotFoundError", + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "description": "The requested resource was not found.", + }, + }, + "summary": "Register a client SDK", + "tags": [ + "Frontend API", + ], + }, + }, "/auth/reset/password": { "post": { "operationId": "changePassword",