From 13ef025fab3c330f3e0197e732bb380a838c9125 Mon Sep 17 00:00:00 2001 From: olav Date: Wed, 8 Jun 2022 08:01:14 +0200 Subject: [PATCH] refactor: add soft response schema validation (#1657) * refactor: remove most schema refs * refactor: generalize request/response schemas * refactor: simplify schema date formats * refactor: add soft response schema validation * refactor: fix emptySchema definition * refactor: update json-schema-to-ts and use refs --- package.json | 4 +- src/lib/openapi/index.ts | 139 ++++++++++++----- .../__snapshots__/feature-schema.test.ts.snap | 69 ++++++++ src/lib/openapi/spec/clone-feature-request.ts | 12 -- src/lib/openapi/spec/clone-feature-schema.ts | 11 +- src/lib/openapi/spec/constraint-schema.ts | 11 +- .../openapi/spec/create-feature-request.ts | 12 -- src/lib/openapi/spec/create-feature-schema.ts | 11 +- .../openapi/spec/create-strategy-request.ts | 12 -- .../openapi/spec/create-strategy-schema.ts | 17 +- src/lib/openapi/spec/create-tag-request.ts | 12 -- src/lib/openapi/spec/empty-response-schema.ts | 11 -- src/lib/openapi/spec/empty-response.ts | 12 -- src/lib/openapi/spec/empty-schema.ts | 9 ++ .../spec/feature-environment-response.ts | 12 -- .../spec/feature-environment-schema.ts | 21 +-- src/lib/openapi/spec/feature-response.ts | 12 -- src/lib/openapi/spec/feature-schema.test.ts | 64 ++++++++ src/lib/openapi/spec/feature-schema.ts | 37 ++--- .../openapi/spec/feature-strategy-schema.ts | 19 +-- .../openapi/spec/feature-variants-response.ts | 12 -- .../openapi/spec/feature-variants-schema.ts | 19 ++- src/lib/openapi/spec/features-response.ts | 12 -- src/lib/openapi/spec/features-schema.test.ts | 13 ++ src/lib/openapi/spec/features-schema.ts | 29 ++-- src/lib/openapi/spec/override-schema.ts | 11 +- src/lib/openapi/spec/parameters-schema.ts | 11 +- src/lib/openapi/spec/patch-request.ts | 15 -- ...ch-operation-schema.ts => patch-schema.ts} | 11 +- src/lib/openapi/spec/patches-schema.ts | 17 ++ src/lib/openapi/spec/strategies-response.ts | 15 -- src/lib/openapi/spec/strategy-response.ts | 12 -- src/lib/openapi/spec/strategy-schema.ts | 17 +- src/lib/openapi/spec/tag-response.ts | 12 -- src/lib/openapi/spec/tag-schema.ts | 11 +- src/lib/openapi/spec/tags-response.ts | 12 -- ...tags-response-schema.ts => tags-schema.ts} | 15 +- .../openapi/spec/update-feature-request.ts | 12 -- ...tureSchema.ts => update-feature-schema.ts} | 17 +- .../spec/update-feature-variants-request.ts | 15 -- .../openapi/spec/update-strategy-request.ts | 12 -- .../openapi/spec/update-strategy-schema.ts | 14 +- src/lib/openapi/spec/variant-schema.ts | 15 +- src/lib/openapi/spec/variants-schema.ts | 19 +++ src/lib/openapi/types.ts | 38 ----- src/lib/openapi/validate.ts | 31 ++++ src/lib/routes/admin-api/archive.ts | 39 +++-- src/lib/routes/admin-api/feature.ts | 45 +++--- src/lib/routes/admin-api/project/features.ts | 140 +++++++++++------ src/lib/routes/admin-api/project/variants.ts | 26 ++-- src/lib/services/openapi-service.ts | 46 ++++-- src/lib/types/mutable.ts | 10 +- src/lib/types/serialize-dates.test.ts | 39 +++++ src/lib/types/serialize-dates.ts | 26 ++++ src/lib/util/map-values.test.ts | 18 +++ src/lib/util/map-values.ts | 11 ++ src/lib/util/omit-keys.test.ts | 7 + src/lib/util/omit-keys.ts | 22 +++ src/test/e2e/api/admin/feature.e2e.test.ts | 2 +- .../api/admin/project/variants.e2e.test.ts | 8 +- .../__snapshots__/openapi.e2e.test.ts.snap | 147 ++++++++++-------- yarn.lock | 35 ++++- 62 files changed, 906 insertions(+), 639 deletions(-) create mode 100644 src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap delete mode 100644 src/lib/openapi/spec/clone-feature-request.ts delete mode 100644 src/lib/openapi/spec/create-feature-request.ts delete mode 100644 src/lib/openapi/spec/create-strategy-request.ts delete mode 100644 src/lib/openapi/spec/create-tag-request.ts delete mode 100644 src/lib/openapi/spec/empty-response-schema.ts delete mode 100644 src/lib/openapi/spec/empty-response.ts create mode 100644 src/lib/openapi/spec/empty-schema.ts delete mode 100644 src/lib/openapi/spec/feature-environment-response.ts delete mode 100644 src/lib/openapi/spec/feature-response.ts create mode 100644 src/lib/openapi/spec/feature-schema.test.ts delete mode 100644 src/lib/openapi/spec/feature-variants-response.ts delete mode 100644 src/lib/openapi/spec/features-response.ts create mode 100644 src/lib/openapi/spec/features-schema.test.ts delete mode 100644 src/lib/openapi/spec/patch-request.ts rename src/lib/openapi/spec/{patch-operation-schema.ts => patch-schema.ts} (57%) create mode 100644 src/lib/openapi/spec/patches-schema.ts delete mode 100644 src/lib/openapi/spec/strategies-response.ts delete mode 100644 src/lib/openapi/spec/strategy-response.ts delete mode 100644 src/lib/openapi/spec/tag-response.ts delete mode 100644 src/lib/openapi/spec/tags-response.ts rename src/lib/openapi/spec/{tags-response-schema.ts => tags-schema.ts} (59%) delete mode 100644 src/lib/openapi/spec/update-feature-request.ts rename src/lib/openapi/spec/{updateFeatureSchema.ts => update-feature-schema.ts} (69%) delete mode 100644 src/lib/openapi/spec/update-feature-variants-request.ts delete mode 100644 src/lib/openapi/spec/update-strategy-request.ts create mode 100644 src/lib/openapi/spec/variants-schema.ts delete mode 100644 src/lib/openapi/types.ts create mode 100644 src/lib/openapi/validate.ts create mode 100644 src/lib/types/serialize-dates.test.ts create mode 100644 src/lib/types/serialize-dates.ts create mode 100644 src/lib/util/map-values.test.ts create mode 100644 src/lib/util/map-values.ts create mode 100644 src/lib/util/omit-keys.test.ts create mode 100644 src/lib/util/omit-keys.ts diff --git a/package.json b/package.json index 04b0cf306d..dc06152603 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,8 @@ }, "dependencies": { "@unleash/express-openapi": "^0.2.0", + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", "async": "^3.2.3", "bcryptjs": "^2.4.3", "compression": "^1.7.4", @@ -97,7 +99,7 @@ "helmet": "^5.0.0", "joi": "^17.3.0", "js-yaml": "^4.1.0", - "json-schema-to-ts": "^2.0.0", + "json-schema-to-ts": "^2.5.3", "knex": "^2.0.0", "log4js": "^6.0.0", "make-fetch-happen": "^10.1.2", diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 149dc8dd24..920fb1e616 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -1,22 +1,98 @@ import { OpenAPIV3 } from 'openapi-types'; -import { featuresSchema } from './spec/features-schema'; -import { overrideSchema } from './spec/override-schema'; -import { strategySchema } from './spec/strategy-schema'; -import { variantSchema } from './spec/variant-schema'; -import { createFeatureSchema } from './spec/create-feature-schema'; -import { constraintSchema } from './spec/constraint-schema'; -import { tagSchema } from './spec/tag-schema'; -import { tagsResponseSchema } from './spec/tags-response-schema'; -import { createStrategySchema } from './spec/create-strategy-schema'; -import { featureSchema } from './spec/feature-schema'; -import { parametersSchema } from './spec/parameters-schema'; -import { featureEnvironmentSchema } from './spec/feature-environment-schema'; -import { emptyResponseSchema } from './spec/empty-response-schema'; -import { patchOperationSchema } from './spec/patch-operation-schema'; -import { updateFeatureSchema } from './spec/updateFeatureSchema'; -import { updateStrategySchema } from './spec/update-strategy-schema'; import { cloneFeatureSchema } from './spec/clone-feature-schema'; +import { constraintSchema } from './spec/constraint-schema'; +import { createFeatureSchema } from './spec/create-feature-schema'; +import { createStrategySchema } from './spec/create-strategy-schema'; +import { emptySchema } from './spec/empty-schema'; +import { featureEnvironmentSchema } from './spec/feature-environment-schema'; +import { featureSchema } from './spec/feature-schema'; import { featureStrategySchema } from './spec/feature-strategy-schema'; +import { featureVariantsSchema } from './spec/feature-variants-schema'; +import { featuresSchema } from './spec/features-schema'; +import { mapValues } from '../util/map-values'; +import { omitKeys } from '../util/omit-keys'; +import { overrideSchema } from './spec/override-schema'; +import { parametersSchema } from './spec/parameters-schema'; +import { patchSchema } from './spec/patch-schema'; +import { patchesSchema } from './spec/patches-schema'; +import { strategySchema } from './spec/strategy-schema'; +import { tagSchema } from './spec/tag-schema'; +import { tagsSchema } from './spec/tags-schema'; +import { updateFeatureSchema } from './spec/update-feature-schema'; +import { updateStrategySchema } from './spec/update-strategy-schema'; +import { variantSchema } from './spec/variant-schema'; +import { variantsSchema } from './spec/variants-schema'; + +// Schemas must have $id property on the form "#/components/schemas/mySchema". +export type SchemaId = typeof schemas[keyof typeof schemas]['$id']; + +// Schemas must list all $ref schemas in "components", including nested schemas. +export type SchemaRef = typeof schemas[keyof typeof schemas]['components']; + +export interface AdminApiOperation + extends Omit { + tags: ['admin']; +} + +export interface ClientApiOperation + extends Omit { + tags: ['client']; +} + +export const schemas = { + cloneFeatureSchema, + constraintSchema, + createFeatureSchema, + createStrategySchema, + emptySchema, + featureEnvironmentSchema, + featureSchema, + featureStrategySchema, + featureVariantsSchema, + featuresSchema, + overrideSchema, + parametersSchema, + patchSchema, + patchesSchema, + strategySchema, + tagSchema, + tagsSchema, + updateFeatureSchema, + updateStrategySchema, + variantSchema, + variantsSchema, +}; + +export const createRequestSchema = ( + schemaName: string, +): OpenAPIV3.RequestBodyObject => { + return { + description: schemaName, + required: true, + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/${schemaName}`, + }, + }, + }, + }; +}; + +export const createResponseSchema = ( + schemaName: string, +): OpenAPIV3.ResponseObject => { + return { + description: schemaName, + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/${schemaName}`, + }, + }, + }, + }; +}; export const createOpenApiSchema = ( serverUrl?: string, @@ -26,13 +102,9 @@ export const createOpenApiSchema = ( servers: serverUrl ? [{ url: serverUrl }] : [], info: { title: 'Unleash API', - version: process.env.npm_package_version, + version: process.env.npm_package_version!, }, - security: [ - { - apiKey: [], - }, - ], + security: [{ apiKey: [] }], components: { securitySchemes: { apiKey: { @@ -41,26 +113,9 @@ export const createOpenApiSchema = ( name: 'Authorization', }, }, - schemas: { - constraintSchema, - cloneFeatureSchema, - createFeatureSchema, - createStrategySchema, - featureSchema, - featuresSchema, - featureEnvironmentSchema, - featureStrategySchema, - emptyResponseSchema, - overrideSchema, - parametersSchema, - patchOperationSchema, - strategySchema, - updateStrategySchema, - updateFeatureSchema, - variantSchema, - tagSchema, - tagsResponseSchema, - }, + schemas: mapValues(schemas, (schema) => + omitKeys(schema, '$id', 'components'), + ), }, }; }; diff --git a/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap new file mode 100644 index 0000000000..cb7b10a263 --- /dev/null +++ b/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`featureSchema constraints 1`] = ` +Object { + "data": Object { + "name": "a", + "strategies": Array [ + Object { + "constraints": Array [ + Object { + "contextName": "a", + }, + ], + "name": "a", + }, + ], + }, + "errors": Array [ + Object { + "instancePath": "/strategies/0/constraints/0", + "keyword": "required", + "message": "must have required property 'operator'", + "params": Object { + "missingProperty": "operator", + }, + "schemaPath": "#/components/schemas/constraintSchema/required", + }, + ], + "schema": "#/components/schemas/featureSchema", +} +`; + +exports[`featureSchema overrides 1`] = ` +Object { + "data": Object { + "name": "a", + "variants": Array [ + Object { + "name": "a", + "overrides": Array [ + Object { + "contextName": "a", + "values": "b", + }, + ], + "payload": Object { + "type": "a", + "value": "b", + }, + "stickiness": "a", + "weight": 1, + "weightType": "a", + }, + ], + }, + "errors": Array [ + Object { + "instancePath": "/variants/0/overrides/0/values", + "keyword": "type", + "message": "must be array", + "params": Object { + "type": "array", + }, + "schemaPath": "#/components/schemas/overrideSchema/properties/values/type", + }, + ], + "schema": "#/components/schemas/featureSchema", +} +`; diff --git a/src/lib/openapi/spec/clone-feature-request.ts b/src/lib/openapi/spec/clone-feature-request.ts deleted file mode 100644 index 531677bd97..0000000000 --- a/src/lib/openapi/spec/clone-feature-request.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const cloneFeatureRequest: OpenAPIV3.RequestBodyObject = { - required: true, - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/cloneFeatureSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/clone-feature-schema.ts b/src/lib/openapi/spec/clone-feature-schema.ts index 294e5a7c9b..be85d77cc8 100644 --- a/src/lib/openapi/spec/clone-feature-schema.ts +++ b/src/lib/openapi/spec/clone-feature-schema.ts @@ -1,6 +1,7 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; -const schema = { +export const cloneFeatureSchema = { + $id: '#/components/schemas/cloneFeatureSchema', type: 'object', required: ['name'], properties: { @@ -11,9 +12,7 @@ const schema = { type: 'boolean', }, }, - 'components/schemas': {}, + components: {}, } as const; -export type CloneFeatureSchema = CreateSchemaType; - -export const cloneFeatureSchema = createSchemaObject(schema); +export type CloneFeatureSchema = FromSchema; diff --git a/src/lib/openapi/spec/constraint-schema.ts b/src/lib/openapi/spec/constraint-schema.ts index 3b003b4ad4..eb08a2cdc4 100644 --- a/src/lib/openapi/spec/constraint-schema.ts +++ b/src/lib/openapi/spec/constraint-schema.ts @@ -1,7 +1,8 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; import { ALL_OPERATORS } from '../../util/constants'; -const schema = { +export const constraintSchema = { + $id: '#/components/schemas/constraintSchema', type: 'object', additionalProperties: false, required: ['contextName', 'operator'], @@ -29,9 +30,7 @@ const schema = { type: 'string', }, }, - 'components/schemas': {}, + components: {}, } as const; -export type ConstraintSchema = CreateSchemaType; - -export const constraintSchema = createSchemaObject(schema); +export type ConstraintSchema = FromSchema; diff --git a/src/lib/openapi/spec/create-feature-request.ts b/src/lib/openapi/spec/create-feature-request.ts deleted file mode 100644 index d81a4defce..0000000000 --- a/src/lib/openapi/spec/create-feature-request.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const createFeatureRequest: OpenAPIV3.RequestBodyObject = { - required: true, - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/createFeatureSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/create-feature-schema.ts b/src/lib/openapi/spec/create-feature-schema.ts index 84daa9ebbf..b3a4356da2 100644 --- a/src/lib/openapi/spec/create-feature-schema.ts +++ b/src/lib/openapi/spec/create-feature-schema.ts @@ -1,6 +1,7 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; -const schema = { +export const createFeatureSchema = { + $id: '#/components/schemas/createFeatureSchema', type: 'object', required: ['name'], properties: { @@ -17,9 +18,7 @@ const schema = { type: 'boolean', }, }, - 'components/schemas': {}, + components: {}, } as const; -export type CreateFeatureSchema = CreateSchemaType; - -export const createFeatureSchema = createSchemaObject(schema); +export type CreateFeatureSchema = FromSchema; diff --git a/src/lib/openapi/spec/create-strategy-request.ts b/src/lib/openapi/spec/create-strategy-request.ts deleted file mode 100644 index e350bc4e1e..0000000000 --- a/src/lib/openapi/spec/create-strategy-request.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const createStrategyRequest: OpenAPIV3.RequestBodyObject = { - required: true, - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/createStrategySchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/create-strategy-schema.ts b/src/lib/openapi/spec/create-strategy-schema.ts index 55a3838968..fecae9ae9e 100644 --- a/src/lib/openapi/spec/create-strategy-schema.ts +++ b/src/lib/openapi/spec/create-strategy-schema.ts @@ -1,8 +1,9 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; import { parametersSchema } from './parameters-schema'; import { constraintSchema } from './constraint-schema'; -const schema = { +export const createStrategySchema = { + $id: '#/components/schemas/createStrategySchema', type: 'object', additionalProperties: false, required: ['name'], @@ -23,12 +24,12 @@ const schema = { $ref: '#/components/schemas/parametersSchema', }, }, - 'components/schemas': { - constraintSchema, - parametersSchema, + components: { + schemas: { + constraintSchema, + parametersSchema, + }, }, } as const; -export type CreateStrategySchema = CreateSchemaType; - -export const createStrategySchema = createSchemaObject(schema); +export type CreateStrategySchema = FromSchema; diff --git a/src/lib/openapi/spec/create-tag-request.ts b/src/lib/openapi/spec/create-tag-request.ts deleted file mode 100644 index 37ff3c17f4..0000000000 --- a/src/lib/openapi/spec/create-tag-request.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const createTagRequest: OpenAPIV3.RequestBodyObject = { - required: true, - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/tagSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/empty-response-schema.ts b/src/lib/openapi/spec/empty-response-schema.ts deleted file mode 100644 index 5df5784b02..0000000000 --- a/src/lib/openapi/spec/empty-response-schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; - -const schema = { - type: 'object', - description: 'OK', - 'components/schemas': {}, -} as const; - -export type EmptyResponseSchema = CreateSchemaType; - -export const emptyResponseSchema = createSchemaObject(schema); diff --git a/src/lib/openapi/spec/empty-response.ts b/src/lib/openapi/spec/empty-response.ts deleted file mode 100644 index 7efd6cd6db..0000000000 --- a/src/lib/openapi/spec/empty-response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const emptyResponse: OpenAPIV3.ResponseObject = { - description: 'emptyResponse', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/emptyResponseSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/empty-schema.ts b/src/lib/openapi/spec/empty-schema.ts new file mode 100644 index 0000000000..f3fc5cf171 --- /dev/null +++ b/src/lib/openapi/spec/empty-schema.ts @@ -0,0 +1,9 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const emptySchema = { + $id: '#/components/schemas/emptySchema', + description: 'emptySchema', + components: {}, +} as const; + +export type EmptySchema = FromSchema; diff --git a/src/lib/openapi/spec/feature-environment-response.ts b/src/lib/openapi/spec/feature-environment-response.ts deleted file mode 100644 index 67513fddbb..0000000000 --- a/src/lib/openapi/spec/feature-environment-response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const featureEnvironmentResponse: OpenAPIV3.ResponseObject = { - description: 'featureEnvironmentResponse', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/featureEnvironmentSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/feature-environment-schema.ts b/src/lib/openapi/spec/feature-environment-schema.ts index 72b7876a2e..f0cd821058 100644 --- a/src/lib/openapi/spec/feature-environment-schema.ts +++ b/src/lib/openapi/spec/feature-environment-schema.ts @@ -1,9 +1,10 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; import { featureStrategySchema } from './feature-strategy-schema'; import { constraintSchema } from './constraint-schema'; import { parametersSchema } from './parameters-schema'; -let schema = { +export const featureEnvironmentSchema = { + $id: '#/components/schemas/featureEnvironmentSchema', type: 'object', additionalProperties: false, required: ['name', 'enabled'], @@ -27,13 +28,15 @@ let schema = { }, }, }, - 'components/schemas': { - featureStrategySchema, - constraintSchema, - parametersSchema, + components: { + schemas: { + featureStrategySchema, + constraintSchema, + parametersSchema, + }, }, } as const; -export type FeatureEnvironmentSchema = CreateSchemaType; - -export const featureEnvironmentSchema = createSchemaObject(schema); +export type FeatureEnvironmentSchema = FromSchema< + typeof featureEnvironmentSchema +>; diff --git a/src/lib/openapi/spec/feature-response.ts b/src/lib/openapi/spec/feature-response.ts deleted file mode 100644 index 240865e8de..0000000000 --- a/src/lib/openapi/spec/feature-response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const featureResponse: OpenAPIV3.ResponseObject = { - description: 'featureResponse', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/featureSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/feature-schema.test.ts b/src/lib/openapi/spec/feature-schema.test.ts new file mode 100644 index 0000000000..fbfc3e2feb --- /dev/null +++ b/src/lib/openapi/spec/feature-schema.test.ts @@ -0,0 +1,64 @@ +import { validateSchema } from '../validate'; +import { FeatureSchema } from './feature-schema'; + +test('featureSchema', () => { + const data: FeatureSchema = { + name: 'a', + strategies: [ + { + name: 'a', + constraints: [ + { + contextName: 'a', + operator: 'IN', + }, + ], + }, + ], + variants: [ + { + name: 'a', + weight: 1, + weightType: 'a', + stickiness: 'a', + overrides: [{ contextName: 'a', values: ['a'] }], + payload: { type: 'a', value: 'b' }, + }, + ], + }; + + expect( + validateSchema('#/components/schemas/featureSchema', data), + ).toBeUndefined(); +}); + +test('featureSchema constraints', () => { + const data = { + name: 'a', + strategies: [{ name: 'a', constraints: [{ contextName: 'a' }] }], + }; + + expect( + validateSchema('#/components/schemas/featureSchema', data), + ).toMatchSnapshot(); +}); + +test('featureSchema overrides', () => { + const data = { + name: 'a', + variants: [ + { + name: 'a', + weight: 1, + weightType: 'a', + stickiness: 'a', + overrides: [{ contextName: 'a', values: 'b' }], + payload: { type: 'a', value: 'b' }, + }, + ], + }; + + expect( + validateSchema('#/components/schemas/featureSchema', data), + ).toMatchSnapshot(); +}); diff --git a/src/lib/openapi/spec/feature-schema.ts b/src/lib/openapi/spec/feature-schema.ts index 32923d25ee..17cfdeb017 100644 --- a/src/lib/openapi/spec/feature-schema.ts +++ b/src/lib/openapi/spec/feature-schema.ts @@ -1,13 +1,12 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; -import { strategySchema } from './strategy-schema'; +import { FromSchema } from 'json-schema-to-ts'; import { variantSchema } from './variant-schema'; -import { featureEnvironmentSchema } from './feature-environment-schema'; -import { featureStrategySchema } from './feature-strategy-schema'; +import { strategySchema } from './strategy-schema'; import { constraintSchema } from './constraint-schema'; -import { parametersSchema } from './parameters-schema'; import { overrideSchema } from './override-schema'; +import { parametersSchema } from './parameters-schema'; -const schema = { +export const featureSchema = { + $id: '#/components/schemas/featureSchema', type: 'object', additionalProperties: false, required: ['name'], @@ -38,18 +37,18 @@ const schema = { }, createdAt: { type: 'string', - format: 'date', + format: 'date-time', nullable: true, }, lastSeenAt: { type: 'string', - format: 'date', + format: 'date-time', nullable: true, }, environments: { type: 'array', items: { - $ref: '#/components/schemas/featureEnvironmentSchema', + type: 'object', }, }, strategies: { @@ -65,17 +64,15 @@ const schema = { }, }, }, - 'components/schemas': { - constraintSchema, - featureEnvironmentSchema, - featureStrategySchema, - overrideSchema, - parametersSchema, - strategySchema, - variantSchema, + components: { + schemas: { + constraintSchema, + overrideSchema, + parametersSchema, + strategySchema, + variantSchema, + }, }, } as const; -export type FeatureSchema = CreateSchemaType; - -export const featureSchema = createSchemaObject(schema); +export type FeatureSchema = FromSchema; diff --git a/src/lib/openapi/spec/feature-strategy-schema.ts b/src/lib/openapi/spec/feature-strategy-schema.ts index bd30ab4597..c03cd1cbda 100644 --- a/src/lib/openapi/spec/feature-strategy-schema.ts +++ b/src/lib/openapi/spec/feature-strategy-schema.ts @@ -1,8 +1,9 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; import { constraintSchema } from './constraint-schema'; import { parametersSchema } from './parameters-schema'; -export const schema = { +export const featureStrategySchema = { + $id: '#/components/schemas/featureStrategySchema', type: 'object', additionalProperties: false, required: [ @@ -22,7 +23,7 @@ export const schema = { }, createdAt: { type: 'string', - format: 'date', + format: 'date-time', nullable: true, }, featureName: { @@ -50,12 +51,12 @@ export const schema = { $ref: '#/components/schemas/parametersSchema', }, }, - 'components/schemas': { - constraintSchema, - parametersSchema, + components: { + schemas: { + constraintSchema, + parametersSchema, + }, }, } as const; -export type FeatureStrategySchema = CreateSchemaType; - -export const featureStrategySchema = createSchemaObject(schema); +export type FeatureStrategySchema = FromSchema; diff --git a/src/lib/openapi/spec/feature-variants-response.ts b/src/lib/openapi/spec/feature-variants-response.ts deleted file mode 100644 index 504ddc6882..0000000000 --- a/src/lib/openapi/spec/feature-variants-response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const featureVariantsResponse: OpenAPIV3.ResponseObject = { - description: 'featureVariantResponse', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/featureVariantsSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/feature-variants-schema.ts b/src/lib/openapi/spec/feature-variants-schema.ts index 0031552c38..608f45135c 100644 --- a/src/lib/openapi/spec/feature-variants-schema.ts +++ b/src/lib/openapi/spec/feature-variants-schema.ts @@ -1,7 +1,9 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; import { variantSchema } from './variant-schema'; +import { FromSchema } from 'json-schema-to-ts'; +import { overrideSchema } from './override-schema'; -const schema = { +export const featureVariantsSchema = { + $id: '#/components/schemas/featureVariantsSchema', type: 'object', additionalProperties: false, required: ['version', 'variants'], @@ -16,11 +18,12 @@ const schema = { }, }, }, - 'components/schemas': { - variantSchema, + components: { + schemas: { + variantSchema, + overrideSchema, + }, }, -}; +} as const; -export type FeatureVariantsSchema = CreateSchemaType; - -export const featureVariantsSchema = createSchemaObject(schema); +export type FeatureVariantsSchema = FromSchema; diff --git a/src/lib/openapi/spec/features-response.ts b/src/lib/openapi/spec/features-response.ts deleted file mode 100644 index 7bea40055d..0000000000 --- a/src/lib/openapi/spec/features-response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const featuresResponse: OpenAPIV3.ResponseObject = { - description: 'featuresResponse', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/featuresSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/features-schema.test.ts b/src/lib/openapi/spec/features-schema.test.ts new file mode 100644 index 0000000000..5df85ac5c3 --- /dev/null +++ b/src/lib/openapi/spec/features-schema.test.ts @@ -0,0 +1,13 @@ +import { validateSchema } from '../validate'; +import { FeaturesSchema } from './features-schema'; + +test('featuresSchema', () => { + const data: FeaturesSchema = { + version: 1, + features: [], + }; + + expect( + validateSchema('#/components/schemas/featuresSchema', data), + ).toBeUndefined(); +}); diff --git a/src/lib/openapi/spec/features-schema.ts b/src/lib/openapi/spec/features-schema.ts index 4ed5d52e16..373b5c7873 100644 --- a/src/lib/openapi/spec/features-schema.ts +++ b/src/lib/openapi/spec/features-schema.ts @@ -1,14 +1,13 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; import { featureSchema } from './feature-schema'; import { parametersSchema } from './parameters-schema'; import { variantSchema } from './variant-schema'; import { overrideSchema } from './override-schema'; -import { featureEnvironmentSchema } from './feature-environment-schema'; -import { featureStrategySchema } from './feature-strategy-schema'; import { constraintSchema } from './constraint-schema'; import { strategySchema } from './strategy-schema'; -const schema = { +export const featuresSchema = { + $id: '#/components/schemas/featuresSchema', type: 'object', additionalProperties: false, required: ['version', 'features'], @@ -23,18 +22,16 @@ const schema = { }, }, }, - 'components/schemas': { - featureSchema, - constraintSchema, - featureEnvironmentSchema, - featureStrategySchema, - overrideSchema, - parametersSchema, - strategySchema, - variantSchema, + components: { + schemas: { + constraintSchema, + featureSchema, + overrideSchema, + parametersSchema, + strategySchema, + variantSchema, + }, }, } as const; -export type FeaturesSchema = CreateSchemaType; - -export const featuresSchema = createSchemaObject(schema); +export type FeaturesSchema = FromSchema; diff --git a/src/lib/openapi/spec/override-schema.ts b/src/lib/openapi/spec/override-schema.ts index 6d103e4a06..6341aaa6c8 100644 --- a/src/lib/openapi/spec/override-schema.ts +++ b/src/lib/openapi/spec/override-schema.ts @@ -1,6 +1,7 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; -const schema = { +export const overrideSchema = { + $id: '#/components/schemas/overrideSchema', type: 'object', additionalProperties: false, required: ['contextName', 'values'], @@ -15,9 +16,7 @@ const schema = { }, }, }, - 'components/schemas': {}, + components: {}, } as const; -export type OverrideSchema = CreateSchemaType; - -export const overrideSchema = createSchemaObject(schema); +export type OverrideSchema = FromSchema; diff --git a/src/lib/openapi/spec/parameters-schema.ts b/src/lib/openapi/spec/parameters-schema.ts index f9830ff1ef..7c1fa70a9a 100644 --- a/src/lib/openapi/spec/parameters-schema.ts +++ b/src/lib/openapi/spec/parameters-schema.ts @@ -1,13 +1,12 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; -const schema = { +export const parametersSchema = { + $id: '#/components/schemas/parametersSchema', type: 'object', additionalProperties: { type: 'string', }, - 'components/schemas': {}, + components: {}, } as const; -export type ParametersSchema = CreateSchemaType; - -export const parametersSchema = createSchemaObject(schema); +export type ParametersSchema = FromSchema; diff --git a/src/lib/openapi/spec/patch-request.ts b/src/lib/openapi/spec/patch-request.ts deleted file mode 100644 index 654c9176d7..0000000000 --- a/src/lib/openapi/spec/patch-request.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const patchRequest: OpenAPIV3.RequestBodyObject = { - required: true, - content: { - 'application/json': { - schema: { - type: 'array', - items: { - $ref: '#/components/schemas/patchOperationSchema', - }, - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/patch-operation-schema.ts b/src/lib/openapi/spec/patch-schema.ts similarity index 57% rename from src/lib/openapi/spec/patch-operation-schema.ts rename to src/lib/openapi/spec/patch-schema.ts index 15de7598b4..c8ba1e99e3 100644 --- a/src/lib/openapi/spec/patch-operation-schema.ts +++ b/src/lib/openapi/spec/patch-schema.ts @@ -1,6 +1,7 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; -const schema = { +export const patchSchema = { + $id: '#/components/schemas/patchSchema', type: 'object', required: ['path', 'op'], properties: { @@ -16,9 +17,7 @@ const schema = { }, value: {}, }, - 'components/schemas': {}, + components: {}, } as const; -export type PatchOperationSchema = CreateSchemaType; - -export const patchOperationSchema = createSchemaObject(schema); +export type PatchSchema = FromSchema; diff --git a/src/lib/openapi/spec/patches-schema.ts b/src/lib/openapi/spec/patches-schema.ts new file mode 100644 index 0000000000..b0fa8314ff --- /dev/null +++ b/src/lib/openapi/spec/patches-schema.ts @@ -0,0 +1,17 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { patchSchema } from './patch-schema'; + +export const patchesSchema = { + $id: '#/components/schemas/patchesSchema', + type: 'array', + items: { + $ref: '#/components/schemas/patchSchema', + }, + components: { + schemas: { + patchSchema, + }, + }, +} as const; + +export type PatchesSchema = FromSchema; diff --git a/src/lib/openapi/spec/strategies-response.ts b/src/lib/openapi/spec/strategies-response.ts deleted file mode 100644 index d4b69fc47c..0000000000 --- a/src/lib/openapi/spec/strategies-response.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const strategiesResponse: OpenAPIV3.ResponseObject = { - description: 'strategiesResponse', - content: { - 'application/json': { - schema: { - type: 'array', - items: { - $ref: '#/components/schemas/strategySchema', - }, - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/strategy-response.ts b/src/lib/openapi/spec/strategy-response.ts deleted file mode 100644 index 23a8c49399..0000000000 --- a/src/lib/openapi/spec/strategy-response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const strategyResponse: OpenAPIV3.ResponseObject = { - description: 'strategyResponse', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/featureStrategySchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/strategy-schema.ts b/src/lib/openapi/spec/strategy-schema.ts index e0ba36bc3e..40d37b2e30 100644 --- a/src/lib/openapi/spec/strategy-schema.ts +++ b/src/lib/openapi/spec/strategy-schema.ts @@ -1,8 +1,9 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; import { constraintSchema } from './constraint-schema'; import { parametersSchema } from './parameters-schema'; -export const strategySchemaDefinition = { +export const strategySchema = { + $id: '#/components/schemas/strategySchema', type: 'object', additionalProperties: false, required: ['name'], @@ -26,12 +27,12 @@ export const strategySchemaDefinition = { $ref: '#/components/schemas/parametersSchema', }, }, - 'components/schemas': { - constraintSchema, - parametersSchema, + components: { + schemas: { + constraintSchema, + parametersSchema, + }, }, } as const; -export type StrategySchema = CreateSchemaType; - -export const strategySchema = createSchemaObject(strategySchemaDefinition); +export type StrategySchema = FromSchema; diff --git a/src/lib/openapi/spec/tag-response.ts b/src/lib/openapi/spec/tag-response.ts deleted file mode 100644 index 6c4654d90d..0000000000 --- a/src/lib/openapi/spec/tag-response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const tagResponse: OpenAPIV3.ResponseObject = { - description: 'tagResponse', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/tagSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/tag-schema.ts b/src/lib/openapi/spec/tag-schema.ts index 2f84dc6db8..9e267bc067 100644 --- a/src/lib/openapi/spec/tag-schema.ts +++ b/src/lib/openapi/spec/tag-schema.ts @@ -1,6 +1,7 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; -const schema = { +export const tagSchema = { + $id: '#/components/schemas/tagSchema', type: 'object', additionalProperties: false, required: ['value', 'type'], @@ -12,9 +13,7 @@ const schema = { type: 'string', }, }, - 'components/schemas': {}, + components: {}, } as const; -export type TagSchema = CreateSchemaType; - -export const tagSchema = createSchemaObject(schema); +export type TagSchema = FromSchema; diff --git a/src/lib/openapi/spec/tags-response.ts b/src/lib/openapi/spec/tags-response.ts deleted file mode 100644 index 61191ff76f..0000000000 --- a/src/lib/openapi/spec/tags-response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const tagsResponse: OpenAPIV3.ResponseObject = { - description: 'tagsResponse', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/tagsResponseSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/tags-response-schema.ts b/src/lib/openapi/spec/tags-schema.ts similarity index 59% rename from src/lib/openapi/spec/tags-response-schema.ts rename to src/lib/openapi/spec/tags-schema.ts index 284d969193..2997b5e3bf 100644 --- a/src/lib/openapi/spec/tags-response-schema.ts +++ b/src/lib/openapi/spec/tags-schema.ts @@ -1,7 +1,8 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; import { tagSchema } from './tag-schema'; -const schema = { +export const tagsSchema = { + $id: '#/components/schemas/tagsSchema', type: 'object', additionalProperties: false, required: ['version', 'tags'], @@ -16,11 +17,11 @@ const schema = { }, }, }, - 'components/schemas': { - tagSchema, + components: { + schemas: { + tagSchema, + }, }, } as const; -export type TagsResponseSchema = CreateSchemaType; - -export const tagsResponseSchema = createSchemaObject(schema); +export type TagsSchema = FromSchema; diff --git a/src/lib/openapi/spec/update-feature-request.ts b/src/lib/openapi/spec/update-feature-request.ts deleted file mode 100644 index 2f153e75f7..0000000000 --- a/src/lib/openapi/spec/update-feature-request.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const updateFeatureRequest: OpenAPIV3.RequestBodyObject = { - required: true, - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/updateFeatureSchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/updateFeatureSchema.ts b/src/lib/openapi/spec/update-feature-schema.ts similarity index 69% rename from src/lib/openapi/spec/updateFeatureSchema.ts rename to src/lib/openapi/spec/update-feature-schema.ts index fe38d415c6..26a8aab5a9 100644 --- a/src/lib/openapi/spec/updateFeatureSchema.ts +++ b/src/lib/openapi/spec/update-feature-schema.ts @@ -1,7 +1,8 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; import { constraintSchema } from './constraint-schema'; -const schema = { +export const updateFeatureSchema = { + $id: '#/components/schemas/updateFeatureSchema', type: 'object', required: ['name'], properties: { @@ -22,7 +23,7 @@ const schema = { }, createdAt: { type: 'string', - format: 'date', + format: 'date-time', }, impressionData: { type: 'boolean', @@ -34,11 +35,11 @@ const schema = { }, }, }, - 'components/schemas': { - constraintSchema, + components: { + schemas: { + constraintSchema, + }, }, } as const; -export type UpdateFeatureSchema = CreateSchemaType; - -export const updateFeatureSchema = createSchemaObject(schema); +export type UpdateFeatureSchema = FromSchema; diff --git a/src/lib/openapi/spec/update-feature-variants-request.ts b/src/lib/openapi/spec/update-feature-variants-request.ts deleted file mode 100644 index c416dc6bca..0000000000 --- a/src/lib/openapi/spec/update-feature-variants-request.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const updateFeatureVariantsRequest: OpenAPIV3.RequestBodyObject = { - required: true, - content: { - 'application/json': { - schema: { - type: 'array', - items: { - $ref: '#/components/schemas/variantSchema', - }, - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/update-strategy-request.ts b/src/lib/openapi/spec/update-strategy-request.ts deleted file mode 100644 index 034551dd7b..0000000000 --- a/src/lib/openapi/spec/update-strategy-request.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; - -export const updateStrategyRequest: OpenAPIV3.RequestBodyObject = { - required: true, - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/updateStrategySchema', - }, - }, - }, -}; diff --git a/src/lib/openapi/spec/update-strategy-schema.ts b/src/lib/openapi/spec/update-strategy-schema.ts index 271b9679f3..69ae8f9973 100644 --- a/src/lib/openapi/spec/update-strategy-schema.ts +++ b/src/lib/openapi/spec/update-strategy-schema.ts @@ -1,11 +1,11 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; -import { strategySchemaDefinition } from './strategy-schema'; +import { FromSchema } from 'json-schema-to-ts'; +import { strategySchema } from './strategy-schema'; -const schema = { - ...strategySchemaDefinition, +export const updateStrategySchema = { + ...strategySchema, + $id: '#/components/schemas/updateStrategySchema', required: [], + components: {}, } as const; -export type UpdateStrategySchema = CreateSchemaType; - -export const updateStrategySchema = createSchemaObject(schema); +export type UpdateStrategySchema = FromSchema; diff --git a/src/lib/openapi/spec/variant-schema.ts b/src/lib/openapi/spec/variant-schema.ts index 734c68f83d..65e6880033 100644 --- a/src/lib/openapi/spec/variant-schema.ts +++ b/src/lib/openapi/spec/variant-schema.ts @@ -1,7 +1,8 @@ -import { createSchemaObject, CreateSchemaType } from '../types'; +import { FromSchema } from 'json-schema-to-ts'; import { overrideSchema } from './override-schema'; -const schema = { +export const variantSchema = { + $id: '#/components/schemas/variantSchema', type: 'object', additionalProperties: false, required: ['name', 'weight', 'weightType', 'stickiness'], @@ -37,11 +38,11 @@ const schema = { }, }, }, - 'components/schemas': { - overrideSchema, + components: { + schemas: { + overrideSchema, + }, }, } as const; -export type VariantSchema = CreateSchemaType; - -export const variantSchema = createSchemaObject(schema); +export type VariantSchema = FromSchema; diff --git a/src/lib/openapi/spec/variants-schema.ts b/src/lib/openapi/spec/variants-schema.ts new file mode 100644 index 0000000000..cdc91fbdaa --- /dev/null +++ b/src/lib/openapi/spec/variants-schema.ts @@ -0,0 +1,19 @@ +import { variantSchema } from './variant-schema'; +import { FromSchema } from 'json-schema-to-ts'; +import { overrideSchema } from './override-schema'; + +export const variantsSchema = { + $id: '#/components/schemas/variantsSchema', + type: 'array', + items: { + $ref: '#/components/schemas/variantSchema', + }, + components: { + schemas: { + variantSchema, + overrideSchema, + }, + }, +} as const; + +export type VariantsSchema = FromSchema; diff --git a/src/lib/openapi/types.ts b/src/lib/openapi/types.ts deleted file mode 100644 index d442276d3c..0000000000 --- a/src/lib/openapi/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; -import { FromSchema } from 'json-schema-to-ts'; -import { DeepMutable } from '../types/mutable'; - -// Admin paths must have the "admin" tag. -export interface AdminApiOperation - extends Omit { - tags: ['admin']; -} - -// Client paths must have the "client" tag. -export interface ClientApiOperation - extends Omit { - tags: ['client']; -} - -// Create a type from a const schema object. -export type CreateSchemaType = FromSchema< - T, - { - definitionsPath: 'components/schemas'; - deserialize: [ - { pattern: { type: 'string'; format: 'date' }; output: Date }, - ]; - } ->; - -// Create an OpenAPIV3.SchemaObject from a const schema object. -// Make sure the schema contains an object of refs for type generation. -// Pass an empty 'components/schemas' object if there are no refs in the schema. -export const createSchemaObject = < - T extends { 'components/schemas': { [key: string]: object } }, ->( - schema: T, -): DeepMutable> => { - const { 'components/schemas': schemas, ...rest } = schema; - return rest; -}; diff --git a/src/lib/openapi/validate.ts b/src/lib/openapi/validate.ts new file mode 100644 index 0000000000..fbbb6066be --- /dev/null +++ b/src/lib/openapi/validate.ts @@ -0,0 +1,31 @@ +import Ajv, { ErrorObject } from 'ajv'; +import addFormats from 'ajv-formats'; +import { SchemaId, schemas } from './index'; +import { omitKeys } from '../util/omit-keys'; + +interface ISchemaValidationErrors { + schema: SchemaId; + data: T; + errors: ErrorObject[]; +} + +const ajv = new Ajv({ + schemas: Object.values(schemas).map((schema) => + omitKeys(schema, 'components'), + ), +}); + +addFormats(ajv, ['date-time']); + +export const validateSchema = ( + schema: SchemaId, + data: T, +): ISchemaValidationErrors | undefined => { + if (!ajv.validate(schema, data)) { + return { + schema, + data: data, + errors: ajv.errors ?? [], + }; + } +}; diff --git a/src/lib/routes/admin-api/archive.ts b/src/lib/routes/admin-api/archive.ts index 95d4a91bb7..899f9d1ce9 100644 --- a/src/lib/routes/admin-api/archive.ts +++ b/src/lib/routes/admin-api/archive.ts @@ -2,21 +2,26 @@ import { Request, Response } from 'express'; import { IUnleashConfig } from '../../types/option'; import { IUnleashServices } from '../../types'; import { Logger } from '../../logger'; - import Controller from '../controller'; - import { extractUsername } from '../../util/extract-user'; import { DELETE_FEATURE, NONE, UPDATE_FEATURE } from '../../types/permissions'; import FeatureToggleService from '../../services/feature-toggle-service'; import { IAuthRequest } from '../unleash-types'; -import { featuresResponse } from '../../openapi/spec/features-response'; -import { FeaturesSchema } from '../../openapi/spec/features-schema'; +import { + featuresSchema, + FeaturesSchema, +} from '../../openapi/spec/features-schema'; +import { serializeDates } from '../../types/serialize-dates'; +import { OpenApiService } from '../../services/openapi-service'; +import { createResponseSchema } from '../../openapi'; export default class ArchiveController extends Controller { private readonly logger: Logger; private featureService: FeatureToggleService; + private openApiService: OpenApiService; + constructor( config: IUnleashConfig, { @@ -27,6 +32,7 @@ export default class ArchiveController extends Controller { super(config); this.logger = config.getLogger('/admin-api/archive.js'); this.featureService = featureToggleServiceV2; + this.openApiService = openApiService; this.route({ method: 'get', @@ -36,7 +42,7 @@ export default class ArchiveController extends Controller { middleware: [ openApiService.validPath({ tags: ['admin'], - responses: { 200: featuresResponse }, + responses: { 200: createResponseSchema('featuresSchema') }, deprecated: true, }), ], @@ -50,7 +56,7 @@ export default class ArchiveController extends Controller { middleware: [ openApiService.validPath({ tags: ['admin'], - responses: { 200: featuresResponse }, + responses: { 200: createResponseSchema('featuresSchema') }, deprecated: true, }), ], @@ -71,11 +77,12 @@ export default class ArchiveController extends Controller { const features = await this.featureService.getMetadataForAllFeatures( true, ); - - res.json({ - version: 2, - features: features, - }); + this.openApiService.respondWithValidation( + 200, + res, + featuresSchema.$id, + { version: 2, features: serializeDates(features) }, + ); } async getArchivedFeaturesByProjectId( @@ -88,10 +95,12 @@ export default class ArchiveController extends Controller { true, projectId, ); - res.json({ - version: 2, - features: features, - }); + this.openApiService.respondWithValidation( + 200, + res, + featuresSchema.$id, + { version: 2, features: serializeDates(features) }, + ); } async deleteFeature( diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index 04758c5c00..93524ae78f 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -1,8 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { Request, Response } from 'express'; - import Controller from '../controller'; - import { extractUsername } from '../../util/extract-user'; import { CREATE_FEATURE, @@ -18,20 +16,23 @@ import { IFeatureToggleQuery } from '../../types/model'; import FeatureTagService from '../../services/feature-tag-service'; import { IAuthRequest } from '../unleash-types'; import { DEFAULT_ENV } from '../../util/constants'; -import { featuresResponse } from '../../openapi/spec/features-response'; -import { FeaturesSchema } from '../../openapi/spec/features-schema'; -import { tagsResponse } from '../../openapi/spec/tags-response'; -import { tagResponse } from '../../openapi/spec/tag-response'; -import { createTagRequest } from '../../openapi/spec/create-tag-request'; -import { emptyResponse } from '../../openapi/spec/empty-response'; +import { + featuresSchema, + FeaturesSchema, +} from '../../openapi/spec/features-schema'; import { TagSchema } from '../../openapi/spec/tag-schema'; -import { TagsResponseSchema } from '../../openapi/spec/tags-response-schema'; +import { TagsSchema } from '../../openapi/spec/tags-schema'; +import { serializeDates } from '../../types/serialize-dates'; +import { OpenApiService } from '../../services/openapi-service'; +import { createRequestSchema, createResponseSchema } from '../../openapi'; const version = 1; class FeatureController extends Controller { private tagService: FeatureTagService; + private openApiService: OpenApiService; + private service: FeatureToggleService; constructor( @@ -47,6 +48,7 @@ class FeatureController extends Controller { ) { super(config); this.tagService = featureTagService; + this.openApiService = openApiService; this.service = featureToggleServiceV2; if (!config.disableLegacyFeaturesApi) { @@ -75,7 +77,7 @@ class FeatureController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'getAllToggles', - responses: { 200: featuresResponse }, + responses: { 200: createResponseSchema('featuresSchema') }, deprecated: true, }), ], @@ -90,7 +92,7 @@ class FeatureController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'validateFeature', - responses: { 200: emptyResponse }, + responses: { 200: createResponseSchema('emptySchema') }, }), ], }); @@ -104,7 +106,7 @@ class FeatureController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'listTags', - responses: { 200: tagsResponse }, + responses: { 200: createResponseSchema('tagsSchema') }, }), ], }); @@ -118,8 +120,8 @@ class FeatureController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'addTag', - requestBody: createTagRequest, - responses: { 201: tagResponse }, + requestBody: createRequestSchema('tagSchema'), + responses: { 201: createResponseSchema('tagSchema') }, }), ], }); @@ -134,7 +136,7 @@ class FeatureController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'removeTag', - responses: { 200: emptyResponse }, + responses: { 200: createResponseSchema('emptySchema') }, }), ], }); @@ -175,10 +177,13 @@ class FeatureController extends Controller { ): Promise { const query = await this.prepQuery(req.query); const features = await this.service.getFeatureToggles(query); - res.json({ - version, - features: features, - }); + + this.openApiService.respondWithValidation( + 200, + res, + featuresSchema.$id, + { version, features: serializeDates(features) }, + ); } async getToggle( @@ -192,7 +197,7 @@ class FeatureController extends Controller { async listTags( req: Request<{ featureName: string }, any, any, any>, - res: Response, + res: Response, ): Promise { const tags = await this.tagService.listTags(req.params.featureName); res.json({ version, tags }); diff --git a/src/lib/routes/admin-api/project/features.ts b/src/lib/routes/admin-api/project/features.ts index f873dc0c4a..bb9ccda64c 100644 --- a/src/lib/routes/admin-api/project/features.ts +++ b/src/lib/routes/admin-api/project/features.ts @@ -17,27 +17,24 @@ import { } from '../../../types/permissions'; import { extractUsername } from '../../../util/extract-user'; import { IAuthRequest } from '../../unleash-types'; -import { createFeatureRequest } from '../../../openapi/spec/create-feature-request'; -import { featureResponse } from '../../../openapi/spec/feature-response'; import { CreateFeatureSchema } from '../../../openapi/spec/create-feature-schema'; -import { FeatureSchema } from '../../../openapi/spec/feature-schema'; -import { createStrategyRequest } from '../../../openapi/spec/create-strategy-request'; +import { + featureSchema, + FeatureSchema, +} from '../../../openapi/spec/feature-schema'; import { StrategySchema } from '../../../openapi/spec/strategy-schema'; -import { featuresResponse } from '../../../openapi/spec/features-response'; -import { featureEnvironmentResponse } from '../../../openapi/spec/feature-environment-response'; -import { strategiesResponse } from '../../../openapi/spec/strategies-response'; -import { strategyResponse } from '../../../openapi/spec/strategy-response'; -import { emptyResponse } from '../../../openapi/spec/empty-response'; -import { updateFeatureRequest } from '../../../openapi/spec/update-feature-request'; -import { patchRequest } from '../../../openapi/spec/patch-request'; -import { updateStrategyRequest } from '../../../openapi/spec/update-strategy-request'; -import { cloneFeatureRequest } from '../../../openapi/spec/clone-feature-request'; -import { FeatureEnvironmentSchema } from '../../../openapi/spec/feature-environment-schema'; import { ParametersSchema } from '../../../openapi/spec/parameters-schema'; -import { FeaturesSchema } from '../../../openapi/spec/features-schema'; -import { UpdateFeatureSchema } from '../../../openapi/spec/updateFeatureSchema'; +import { + featuresSchema, + FeaturesSchema, +} from '../../../openapi/spec/features-schema'; +import { UpdateFeatureSchema } from '../../../openapi/spec/update-feature-schema'; import { UpdateStrategySchema } from '../../../openapi/spec/update-strategy-schema'; import { CreateStrategySchema } from '../../../openapi/spec/create-strategy-schema'; +import { serializeDates } from '../../../types/serialize-dates'; +import { OpenApiService } from '../../../services/openapi-service'; +import { createRequestSchema, createResponseSchema } from '../../../openapi'; +import { FeatureEnvironmentSchema } from '../../../openapi/spec/feature-environment-schema'; interface FeatureStrategyParams { projectId: string; @@ -73,6 +70,8 @@ type ProjectFeaturesServices = Pick< export default class ProjectFeaturesController extends Controller { private featureService: FeatureToggleService; + private openApiService: OpenApiService; + private readonly logger: Logger; constructor( @@ -81,6 +80,7 @@ export default class ProjectFeaturesController extends Controller { ) { super(config); this.featureService = featureToggleServiceV2; + this.openApiService = openApiService; this.logger = config.getLogger('/admin-api/project/features.ts'); this.route({ @@ -92,7 +92,9 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'getEnvironment', - responses: { 200: featureEnvironmentResponse }, + responses: { + 200: createResponseSchema('featureEnvironmentSchema'), + }, }), ], }); @@ -106,7 +108,7 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'toggleEnvironmentOff', - responses: { 200: featureResponse }, + responses: { 200: createResponseSchema('featureSchema') }, }), ], }); @@ -120,7 +122,7 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'toggleEnvironmentOn', - responses: { 200: featureResponse }, + responses: { 200: createResponseSchema('featureSchema') }, }), ], }); @@ -134,7 +136,7 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'getStrategies', - responses: { 200: strategiesResponse }, + responses: { 200: createResponseSchema('strategySchema') }, }), ], }); @@ -148,8 +150,10 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'addStrategy', - requestBody: createStrategyRequest, - responses: { 200: strategyResponse }, + requestBody: createRequestSchema('createStrategySchema'), + responses: { + 200: createResponseSchema('featureStrategySchema'), + }, }), ], }); @@ -163,7 +167,9 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'getStrategy', - responses: { 200: strategyResponse }, + responses: { + 200: createResponseSchema('featureStrategySchema'), + }, }), ], }); @@ -177,8 +183,10 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'updateStrategy', - requestBody: updateStrategyRequest, - responses: { 200: strategyResponse }, + requestBody: createRequestSchema('updateStrategySchema'), + responses: { + 200: createResponseSchema('featureStrategySchema'), + }, }), ], }); @@ -191,8 +199,10 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'patchStrategy', - requestBody: patchRequest, - responses: { 200: strategyResponse }, + requestBody: createRequestSchema('patchesSchema'), + responses: { + 200: createResponseSchema('featureStrategySchema'), + }, }), ], }); @@ -206,7 +216,7 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ operationId: 'deleteStrategy', tags: ['admin'], - responses: { 200: emptyResponse }, + responses: { 200: createResponseSchema('emptySchema') }, }), ], }); @@ -220,7 +230,7 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'getFeatures', - responses: { 200: featuresResponse }, + responses: { 200: createResponseSchema('featuresSchema') }, }), ], }); @@ -234,8 +244,8 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'createFeature', - requestBody: createFeatureRequest, - responses: { 200: featureResponse }, + requestBody: createRequestSchema('createFeatureSchema'), + responses: { 200: createResponseSchema('featureSchema') }, }), ], }); @@ -250,8 +260,8 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'cloneFeature', - requestBody: cloneFeatureRequest, - responses: { 200: featureResponse }, + requestBody: createRequestSchema('cloneFeatureSchema'), + responses: { 200: createResponseSchema('featureSchema') }, }), ], }); @@ -265,7 +275,7 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ operationId: 'getFeature', tags: ['admin'], - responses: { 200: featureResponse }, + responses: { 200: createResponseSchema('featureSchema') }, }), ], }); @@ -280,8 +290,8 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'updateFeature', - requestBody: updateFeatureRequest, - responses: { 200: featureResponse }, + requestBody: createRequestSchema('updateFeatureSchema'), + responses: { 200: createResponseSchema('featureSchema') }, }), ], }); @@ -296,8 +306,8 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'patchFeature', - requestBody: patchRequest, - responses: { 200: featureResponse }, + requestBody: createRequestSchema('patchesSchema'), + responses: { 200: createResponseSchema('featureSchema') }, }), ], }); @@ -312,7 +322,7 @@ export default class ProjectFeaturesController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'archiveFeature', - responses: { 200: emptyResponse }, + responses: { 200: createResponseSchema('emptySchema') }, }), ], }); @@ -326,15 +336,19 @@ export default class ProjectFeaturesController extends Controller { const features = await this.featureService.getFeatureOverview( projectId, ); - res.json({ version: 1, features }); + this.openApiService.respondWithValidation( + 200, + res, + featuresSchema.$id, + { version: 2, features: serializeDates(features) }, + ); } async cloneFeature( req: IAuthRequest< FeatureParams, any, - { name: string; replaceGroupId?: boolean }, - any + { name: string; replaceGroupId?: boolean } >, res: Response, ): Promise { @@ -348,7 +362,13 @@ export default class ProjectFeaturesController extends Controller { replaceGroupId, userName, ); - res.status(201).json(created); + + this.openApiService.respondWithValidation( + 201, + res, + featureSchema.$id, + serializeDates(created), + ); } async createFeature( @@ -364,7 +384,12 @@ export default class ProjectFeaturesController extends Controller { userName, ); - res.status(201).json(created); + this.openApiService.respondWithValidation( + 201, + res, + featureSchema.$id, + serializeDates(created), + ); } async getFeature( @@ -380,13 +405,12 @@ export default class ProjectFeaturesController extends Controller { req: IAuthRequest< { projectId: string; featureName: string }, any, - UpdateFeatureSchema, - any + UpdateFeatureSchema >, res: Response, ): Promise { const { projectId, featureName } = req.params; - const data = req.body; + const { createdAt, ...data } = req.body; const userName = extractUsername(req); const created = await this.featureService.updateFeatureToggle( projectId, @@ -394,7 +418,13 @@ export default class ProjectFeaturesController extends Controller { userName, featureName, ); - res.status(200).json(created); + + this.openApiService.respondWithValidation( + 200, + res, + featureSchema.$id, + serializeDates(created), + ); } async patchFeature( @@ -413,7 +443,12 @@ export default class ProjectFeaturesController extends Controller { extractUsername(req), req.body, ); - res.status(200).json(updated); + this.openApiService.respondWithValidation( + 200, + res, + featureSchema.$id, + serializeDates(updated), + ); } // TODO: validate projectId @@ -442,7 +477,12 @@ export default class ProjectFeaturesController extends Controller { environment, featureName, ); - res.status(200).json(environmentInfo); + this.openApiService.respondWithValidation( + 200, + res, + featureSchema.$id, + serializeDates(environmentInfo), + ); } async toggleEnvironmentOn( diff --git a/src/lib/routes/admin-api/project/variants.ts b/src/lib/routes/admin-api/project/variants.ts index b798d353d0..48e9d9cf70 100644 --- a/src/lib/routes/admin-api/project/variants.ts +++ b/src/lib/routes/admin-api/project/variants.ts @@ -9,10 +9,8 @@ import { NONE, UPDATE_FEATURE_VARIANTS } from '../../../types/permissions'; import { IVariant } from '../../../types/model'; import { extractUsername } from '../../../util/extract-user'; import { IAuthRequest } from '../../unleash-types'; -import { featureVariantsResponse } from '../../../openapi/spec/feature-variants-response'; -import { patchRequest } from '../../../openapi/spec/patch-request'; -import { updateFeatureVariantsRequest } from '../../../openapi/spec/update-feature-variants-request'; import { FeatureVariantsSchema } from '../../../openapi/spec/feature-variants-schema'; +import { createRequestSchema, createResponseSchema } from '../../../openapi'; const PREFIX = '/:projectId/features/:featureName/variants'; @@ -47,7 +45,9 @@ export default class VariantsController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'getFeatureVariants', - responses: { 200: featureVariantsResponse }, + responses: { + 200: createResponseSchema('featureVariantsSchema'), + }, }), ], }); @@ -60,8 +60,10 @@ export default class VariantsController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'patchFeatureVariants', - requestBody: patchRequest, - responses: { 200: featureVariantsResponse }, + requestBody: createRequestSchema('patchesSchema'), + responses: { + 200: createResponseSchema('featureVariantsSchema'), + }, }), ], }); @@ -74,8 +76,10 @@ export default class VariantsController extends Controller { openApiService.validPath({ tags: ['admin'], operationId: 'overwriteFeatureVariants', - requestBody: updateFeatureVariantsRequest, - responses: { 200: featureVariantsResponse }, + requestBody: createRequestSchema('variantsSchema'), + responses: { + 200: createResponseSchema('featureVariantsSchema'), + }, }), ], }); @@ -87,7 +91,7 @@ export default class VariantsController extends Controller { ): Promise { const { featureName } = req.params; const variants = await this.featureService.getVariants(featureName); - res.status(200).json({ version: '1', variants: variants || [] }); + res.status(200).json({ version: 1, variants: variants || [] }); } async patchVariants( @@ -104,7 +108,7 @@ export default class VariantsController extends Controller { userName, ); res.status(200).json({ - version: '1', + version: 1, variants: updatedFeature.variants, }); } @@ -122,7 +126,7 @@ export default class VariantsController extends Controller { userName, ); res.status(200).json({ - version: '1', + version: 1, variants: updatedFeature.variants, }); } diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index eeb010df0a..0dba791453 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -1,17 +1,27 @@ import openapi, { IExpressOpenApi } from '@unleash/express-openapi'; -import { Express, RequestHandler } from 'express'; -import { OpenAPIV3 } from 'openapi-types'; +import { Express, RequestHandler, Response } from 'express'; import { IUnleashConfig } from '../types/option'; -import { createOpenApiSchema } from '../openapi'; -import { AdminApiOperation, ClientApiOperation } from '../openapi/types'; +import { + AdminApiOperation, + ClientApiOperation, + createOpenApiSchema, + SchemaId, +} from '../openapi'; +import { Logger } from '../logger'; +import { validateSchema } from '../openapi/validate'; +import { omitKeys } from '../util/omit-keys'; export class OpenApiService { private readonly config: IUnleashConfig; + private readonly logger: Logger; + private readonly api: IExpressOpenApi; constructor(config: IUnleashConfig) { this.config = config; + this.logger = config.getLogger('openapi-service.ts'); + this.api = openapi( this.docsPath(), createOpenApiSchema(config.server?.unleashUrl), @@ -19,35 +29,28 @@ export class OpenApiService { ); } - // Create request validation middleware for an admin or client path. validPath(op: AdminApiOperation | ClientApiOperation): RequestHandler { return this.api.validPath(op); } - // Serve the OpenAPI JSON at `${baseUriPath}/docs/openapi.json`, - // and the OpenAPI SwaggerUI at `${baseUriPath}/docs/openapi`. useDocs(app: Express): void { app.use(this.api); app.use(this.docsPath(), this.api.swaggerui); } - // The OpenAPI docs live at `/docs/openapi{,.json}`. docsPath(): string { const { baseUriPath = '' } = this.config.server ?? {}; return `${baseUriPath}/docs/openapi`; } - // Add custom schemas to the generated OpenAPI spec. - // Used by unleash-enterprise to add its own schemas. - registerCustomSchemas(schemas: { - [name: string]: OpenAPIV3.SchemaObject; + registerCustomSchemas(schemas: { + [name: string]: { $id: string; components: T }; }): void { Object.entries(schemas).forEach(([name, schema]) => { - this.api.schema(name, schema); + this.api.schema(name, omitKeys(schema, '$id', 'components')); }); } - // Catch and format Open API validation errors. useErrorHandler(app: Express): void { app.use((err, req, res, next) => { if (err && err.status && err.validationErrors) { @@ -60,4 +63,19 @@ export class OpenApiService { } }); } + + respondWithValidation( + status: number, + res: Response, + schema: SchemaId, + data: T, + ): void { + const errors = validateSchema(schema, data); + + if (errors) { + this.logger.warn('Invalid response:', errors); + } + + res.status(status).json(data); + } } diff --git a/src/lib/types/mutable.ts b/src/lib/types/mutable.ts index c7ef1cb671..4565f679d7 100644 --- a/src/lib/types/mutable.ts +++ b/src/lib/types/mutable.ts @@ -1,9 +1,9 @@ -// Remove readonly modifiers from properties. -export type Mutable = { - -readonly [P in keyof T]: T[P]; -}; - // Recursively remove readonly modifiers from properties. export type DeepMutable = { -readonly [P in keyof T]: DeepMutable; }; + +// Recursively add readonly modifiers to properties. +export type DeepImmutable = { + readonly [P in keyof T]: DeepImmutable; +}; diff --git a/src/lib/types/serialize-dates.test.ts b/src/lib/types/serialize-dates.test.ts new file mode 100644 index 0000000000..9705c57ffb --- /dev/null +++ b/src/lib/types/serialize-dates.test.ts @@ -0,0 +1,39 @@ +import { serializeDates } from './serialize-dates'; + +test('serializeDates primitives', () => { + expect(serializeDates(undefined)).toEqual(undefined); + expect(serializeDates(null)).toEqual(null); + expect(serializeDates(1)).toEqual(1); + expect(serializeDates('a')).toEqual('a'); +}); + +test('serializeDates arrays', () => { + const now = new Date(); + const iso = now.toISOString(); + + expect(serializeDates([])).toEqual([]); + expect(serializeDates([1])).toEqual([1]); + expect(serializeDates(['2'])).toEqual(['2']); + expect(serializeDates([{ a: 1 }])).toEqual([{ a: 1 }]); + expect(serializeDates([{ a: now }])).toEqual([{ a: iso }]); +}); + +test('serializeDates object', () => { + const now = new Date(); + const iso = now.toISOString(); + + const obj = { + a: 1, + b: '2', + c: now, + d: { e: now }, + f: [{ g: now }], + }; + + expect(serializeDates({})).toEqual({}); + expect(serializeDates(obj).a).toEqual(1); + expect(serializeDates(obj).b).toEqual('2'); + expect(serializeDates(obj).c).toEqual(iso); + expect(serializeDates(obj).d.e).toEqual(iso); + expect(serializeDates(obj).f[0].g).toEqual(iso); +}); diff --git a/src/lib/types/serialize-dates.ts b/src/lib/types/serialize-dates.ts new file mode 100644 index 0000000000..ba36957d9a --- /dev/null +++ b/src/lib/types/serialize-dates.ts @@ -0,0 +1,26 @@ +type SerializedDates = T extends Date + ? string + : T extends object + ? { [P in keyof T]: SerializedDates } + : T; + +// Convert Date objects to strings recursively. +export const serializeDates = (obj: T): SerializedDates => { + if (!obj || typeof obj !== 'object') { + return obj as SerializedDates; + } + + if (Array.isArray(obj)) { + return obj.map(serializeDates) as unknown as SerializedDates; + } + + const entries = Object.entries(obj).map(([k, v]) => { + if (v instanceof Date) { + return [k, v.toJSON()]; + } else { + return [k, serializeDates(v)]; + } + }); + + return Object.fromEntries(entries); +}; diff --git a/src/lib/util/map-values.test.ts b/src/lib/util/map-values.test.ts new file mode 100644 index 0000000000..b9ddb05d8b --- /dev/null +++ b/src/lib/util/map-values.test.ts @@ -0,0 +1,18 @@ +import { mapValues } from './map-values'; + +test('mapValues', () => { + expect( + mapValues( + { + a: 1, + b: 2, + c: 3, + }, + (x) => x + 1, + ), + ).toEqual({ + a: 2, + b: 3, + c: 4, + }); +}); diff --git a/src/lib/util/map-values.ts b/src/lib/util/map-values.ts new file mode 100644 index 0000000000..93fa35847f --- /dev/null +++ b/src/lib/util/map-values.ts @@ -0,0 +1,11 @@ +export const mapValues = ( + object: T, + fn: (value: T[keyof T]) => U, +): Record => { + const entries = Object.entries(object).map(([key, value]) => [ + key, + fn(value), + ]); + + return Object.fromEntries(entries); +}; diff --git a/src/lib/util/omit-keys.test.ts b/src/lib/util/omit-keys.test.ts new file mode 100644 index 0000000000..74fe897c17 --- /dev/null +++ b/src/lib/util/omit-keys.test.ts @@ -0,0 +1,7 @@ +import { omitKeys } from './omit-keys'; + +test('omitKeys', () => { + expect(omitKeys({ a: 1, b: 2, c: 3 }, 'a', 'b')).toEqual({ + c: 3, + }); +}); diff --git a/src/lib/util/omit-keys.ts b/src/lib/util/omit-keys.ts new file mode 100644 index 0000000000..e61b726696 --- /dev/null +++ b/src/lib/util/omit-keys.ts @@ -0,0 +1,22 @@ +interface OmitKeys { + (obj: T, ...keys: K): { + [K2 in Exclude]: T[K2]; + }; +} + +// https://stackoverflow.com/questions/53966509/typescript-type-safe-omit-function +export const omitKeys: OmitKeys = (obj, ...keys) => { + const ret = {} as { + [K in keyof typeof obj]: typeof obj[K]; + }; + + let key: keyof typeof obj; + + for (key in obj) { + if (!keys.includes(key)) { + ret[key] = obj[key]; + } + } + + return ret; +}; diff --git a/src/test/e2e/api/admin/feature.e2e.test.ts b/src/test/e2e/api/admin/feature.e2e.test.ts index 9fd9bd6855..8d27524605 100644 --- a/src/test/e2e/api/admin/feature.e2e.test.ts +++ b/src/test/e2e/api/admin/feature.e2e.test.ts @@ -25,7 +25,7 @@ beforeAll(async () => { app = await setupApp(db.stores); const createToggle = async ( - toggle: FeatureSchema, + toggle: Omit, strategy: Omit = defaultStrategy, projectId: string = 'default', username: string = 'test', diff --git a/src/test/e2e/api/admin/project/variants.e2e.test.ts b/src/test/e2e/api/admin/project/variants.e2e.test.ts index b5370aa601..5044bea52e 100644 --- a/src/test/e2e/api/admin/project/variants.e2e.test.ts +++ b/src/test/e2e/api/admin/project/variants.e2e.test.ts @@ -33,7 +33,7 @@ test('Can get variants for a feature', async () => { .get(`/api/admin/projects/default/features/${featureName}/variants`) .expect(200) .expect((res) => { - expect(res.body.version).toBe('1'); + expect(res.body.version).toBe(1); expect(res.body.variants).toHaveLength(1); expect(res.body.variants[0].name).toBe(variantName); }); @@ -104,7 +104,7 @@ test('Can patch variants for a feature and get a response of new variant', async .send(patch) .expect(200) .expect((res) => { - expect(res.body.version).toBe('1'); + expect(res.body.version).toBe(1); expect(res.body.variants).toHaveLength(1); expect(res.body.variants[0].name).toBe(expectedVariantName); }); @@ -148,7 +148,7 @@ test('Can add variant for a feature', async () => { await app.request .get(`/api/admin/projects/default/features/${featureName}/variants`) .expect((res) => { - expect(res.body.version).toBe('1'); + expect(res.body.version).toBe(1); expect(res.body.variants).toHaveLength(2); expect( res.body.variants.find((x) => x.name === expectedVariantName), @@ -192,7 +192,7 @@ test('Can remove variant for a feature', async () => { await app.request .get(`/api/admin/projects/default/features/${featureName}/variants`) .expect((res) => { - expect(res.body.version).toBe('1'); + expect(res.body.version).toBe(1); expect(res.body.variants).toHaveLength(0); }); }); 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 659598d9c4..d0daa2db66 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 @@ -157,9 +157,8 @@ Object { ], "type": "object", }, - "emptyResponseSchema": Object { - "description": "OK", - "type": "object", + "emptySchema": Object { + "description": "emptySchema", }, "featureEnvironmentSchema": Object { "additionalProperties": false, @@ -196,7 +195,7 @@ Object { "type": "boolean", }, "createdAt": Object { - "format": "date", + "format": "date-time", "nullable": true, "type": "string", }, @@ -208,7 +207,7 @@ Object { }, "environments": Object { "items": Object { - "$ref": "#/components/schemas/featureEnvironmentSchema", + "type": "object", }, "type": "array", }, @@ -216,7 +215,7 @@ Object { "type": "boolean", }, "lastSeenAt": Object { - "format": "date", + "format": "date-time", "nullable": true, "type": "string", }, @@ -260,7 +259,7 @@ Object { "type": "array", }, "createdAt": Object { - "format": "date", + "format": "date-time", "nullable": true, "type": "string", }, @@ -299,6 +298,25 @@ Object { ], "type": "object", }, + "featureVariantsSchema": Object { + "additionalProperties": false, + "properties": Object { + "variants": Object { + "items": Object { + "$ref": "#/components/schemas/variantSchema", + }, + "type": "array", + }, + "version": Object { + "type": "integer", + }, + }, + "required": Array [ + "version", + "variants", + ], + "type": "object", + }, "featuresSchema": Object { "additionalProperties": false, "properties": Object { @@ -343,7 +361,7 @@ Object { }, "type": "object", }, - "patchOperationSchema": Object { + "patchSchema": Object { "properties": Object { "from": Object { "type": "string", @@ -369,6 +387,12 @@ Object { ], "type": "object", }, + "patchesSchema": Object { + "items": Object { + "$ref": "#/components/schemas/patchSchema", + }, + "type": "array", + }, "strategySchema": Object { "additionalProperties": false, "properties": Object { @@ -412,7 +436,7 @@ Object { ], "type": "object", }, - "tagsResponseSchema": Object { + "tagsSchema": Object { "additionalProperties": false, "properties": Object { "tags": Object { @@ -443,7 +467,7 @@ Object { "type": "array", }, "createdAt": Object { - "format": "date", + "format": "date-time", "type": "string", }, "description": Object { @@ -537,6 +561,12 @@ Object { ], "type": "object", }, + "variantsSchema": Object { + "items": Object { + "$ref": "#/components/schemas/variantSchema", + }, + "type": "array", + }, }, "securitySchemes": Object { "apiKey": Object { @@ -563,7 +593,7 @@ Object { }, }, }, - "description": "featuresResponse", + "description": "featuresSchema", }, }, "tags": Array [ @@ -593,7 +623,7 @@ Object { }, }, }, - "description": "featuresResponse", + "description": "featuresSchema", }, }, "tags": Array [ @@ -614,7 +644,7 @@ Object { }, }, }, - "description": "featuresResponse", + "description": "featuresSchema", }, }, "tags": Array [ @@ -630,11 +660,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "$ref": "#/components/schemas/emptyResponseSchema", + "$ref": "#/components/schemas/emptySchema", }, }, }, - "description": "emptyResponse", + "description": "emptySchema", }, }, "tags": Array [ @@ -660,11 +690,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "$ref": "#/components/schemas/tagsResponseSchema", + "$ref": "#/components/schemas/tagsSchema", }, }, }, - "description": "tagsResponse", + "description": "tagsSchema", }, }, "tags": Array [ @@ -691,6 +721,7 @@ Object { }, }, }, + "description": "tagSchema", "required": true, }, "responses": Object { @@ -702,7 +733,7 @@ Object { }, }, }, - "description": "tagResponse", + "description": "tagSchema", }, }, "tags": Array [ @@ -744,11 +775,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "$ref": "#/components/schemas/emptyResponseSchema", + "$ref": "#/components/schemas/emptySchema", }, }, }, - "description": "emptyResponse", + "description": "emptySchema", }, }, "tags": Array [ @@ -778,7 +809,7 @@ Object { }, }, }, - "description": "featuresResponse", + "description": "featuresSchema", }, }, "tags": Array [ @@ -805,6 +836,7 @@ Object { }, }, }, + "description": "createFeatureSchema", "required": true, }, "responses": Object { @@ -816,7 +848,7 @@ Object { }, }, }, - "description": "featureResponse", + "description": "featureSchema", }, }, "tags": Array [ @@ -850,11 +882,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "$ref": "#/components/schemas/emptyResponseSchema", + "$ref": "#/components/schemas/emptySchema", }, }, }, - "description": "emptyResponse", + "description": "emptySchema", }, }, "tags": Array [ @@ -890,7 +922,7 @@ Object { }, }, }, - "description": "featureResponse", + "description": "featureSchema", }, }, "tags": Array [ @@ -921,13 +953,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "items": Object { - "$ref": "#/components/schemas/patchOperationSchema", - }, - "type": "array", + "$ref": "#/components/schemas/patchesSchema", }, }, }, + "description": "patchesSchema", "required": true, }, "responses": Object { @@ -939,7 +969,7 @@ Object { }, }, }, - "description": "featureResponse", + "description": "featureSchema", }, }, "tags": Array [ @@ -974,6 +1004,7 @@ Object { }, }, }, + "description": "updateFeatureSchema", "required": true, }, "responses": Object { @@ -985,7 +1016,7 @@ Object { }, }, }, - "description": "featureResponse", + "description": "featureSchema", }, }, "tags": Array [ @@ -1022,6 +1053,7 @@ Object { }, }, }, + "description": "cloneFeatureSchema", "required": true, }, "responses": Object { @@ -1033,7 +1065,7 @@ Object { }, }, }, - "description": "featureResponse", + "description": "featureSchema", }, }, "tags": Array [ @@ -1079,7 +1111,7 @@ Object { }, }, }, - "description": "featureEnvironmentResponse", + "description": "featureEnvironmentSchema", }, }, "tags": Array [ @@ -1125,7 +1157,7 @@ Object { }, }, }, - "description": "featureResponse", + "description": "featureSchema", }, }, "tags": Array [ @@ -1171,7 +1203,7 @@ Object { }, }, }, - "description": "featureResponse", + "description": "featureSchema", }, }, "tags": Array [ @@ -1213,14 +1245,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "items": Object { - "$ref": "#/components/schemas/strategySchema", - }, - "type": "array", + "$ref": "#/components/schemas/strategySchema", }, }, }, - "description": "strategiesResponse", + "description": "strategySchema", }, }, "tags": Array [ @@ -1263,6 +1292,7 @@ Object { }, }, }, + "description": "createStrategySchema", "required": true, }, "responses": Object { @@ -1274,7 +1304,7 @@ Object { }, }, }, - "description": "strategyResponse", + "description": "featureStrategySchema", }, }, "tags": Array [ @@ -1324,11 +1354,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "$ref": "#/components/schemas/emptyResponseSchema", + "$ref": "#/components/schemas/emptySchema", }, }, }, - "description": "emptyResponse", + "description": "emptySchema", }, }, "tags": Array [ @@ -1380,7 +1410,7 @@ Object { }, }, }, - "description": "strategyResponse", + "description": "featureStrategySchema", }, }, "tags": Array [ @@ -1427,13 +1457,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "items": Object { - "$ref": "#/components/schemas/patchOperationSchema", - }, - "type": "array", + "$ref": "#/components/schemas/patchesSchema", }, }, }, + "description": "patchesSchema", "required": true, }, "responses": Object { @@ -1445,7 +1473,7 @@ Object { }, }, }, - "description": "strategyResponse", + "description": "featureStrategySchema", }, }, "tags": Array [ @@ -1496,6 +1524,7 @@ Object { }, }, }, + "description": "updateStrategySchema", "required": true, }, "responses": Object { @@ -1507,7 +1536,7 @@ Object { }, }, }, - "description": "strategyResponse", + "description": "featureStrategySchema", }, }, "tags": Array [ @@ -1545,7 +1574,7 @@ Object { }, }, }, - "description": "featureVariantResponse", + "description": "featureVariantsSchema", }, }, "tags": Array [ @@ -1576,13 +1605,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "items": Object { - "$ref": "#/components/schemas/patchOperationSchema", - }, - "type": "array", + "$ref": "#/components/schemas/patchesSchema", }, }, }, + "description": "patchesSchema", "required": true, }, "responses": Object { @@ -1594,7 +1621,7 @@ Object { }, }, }, - "description": "featureVariantResponse", + "description": "featureVariantsSchema", }, }, "tags": Array [ @@ -1625,13 +1652,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "items": Object { - "$ref": "#/components/schemas/variantSchema", - }, - "type": "array", + "$ref": "#/components/schemas/variantsSchema", }, }, }, + "description": "variantsSchema", "required": true, }, "responses": Object { @@ -1643,7 +1668,7 @@ Object { }, }, }, - "description": "featureVariantResponse", + "description": "featureVariantsSchema", }, }, "tags": Array [ diff --git a/yarn.lock b/yarn.lock index 25a8c26a1c..99e6238b2b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1356,6 +1356,13 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +ajv-formats@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== + dependencies: + ajv "^8.0.0" + ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4: version "6.12.6" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" @@ -1366,6 +1373,16 @@ ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.0.0, ajv@^8.11.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" + integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: version "4.3.2" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz" @@ -4789,10 +4806,10 @@ json-parse-even-better-errors@^2.3.0: resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== -json-schema-to-ts@^2.0.0: - version "2.3.0" - resolved "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-2.3.0.tgz" - integrity sha512-qBE94lvOfcVmedIgHkKNhDxTG1gPZW8pPIUpRtbPee54jGF2RZnyEOpDdowCU219sXCJ8SDVEMUCG4oMFw7pgA== +json-schema-to-ts@^2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/json-schema-to-ts/-/json-schema-to-ts-2.5.3.tgz#10a1ad27a3cc6117ae9c652cc583a9e0ed10f0c8" + integrity sha512-2vABI+1IZNkChaPfLu7PG192ZY9gvRY00RbuN3VGlNNZkvYRpIECdBZPBVMe41r3wX0sl9emjRyhHT3gTm7HIg== dependencies: "@types/json-schema" "^7.0.9" ts-algebra "^1.1.1" @@ -4803,6 +4820,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3, json-schema@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" @@ -6453,6 +6475,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz"