1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-04 00:18:40 +01:00

fix: fix broken OpenAPI spec (#1846)

* Wip: fix openapi spec

* Feat: add openapi enforcer for enforcing the generated schema

* Chore: Allow the example keyword in params

* Feat: add validator tests and fix some errors

* Use @apidevtools/swagger-parser for schema validation

* Wip: refactor tests for updated schema name

* Feat: update request params creation method

* Feat: add query params to state

* Refactor: move mapping test into separate function

* Refactor: rename request-parameters -> query-parameters

* Refactor: expose only finished query parameters

* Wip: fixup param types

* Refactor: remove unused types

* Chore: rename and cleanup

* Chore: cleanup

* Fix: Update snapshot

* Fix: use ?? Instead of paramToBool to get defaults

* Wip: generate query param object type from openapi params list

* Wip: use generated types for export query params

* Revert "Fix: use ?? Instead of paramToBool to get defaults"

This reverts commit 842567500b.

Because we accept bools, strings, and numbers, this is the only way to
do it.

* Chore: update and pin json-schema-to-ts

* Fix: use `&` to merge types

* Update snapshot

* Chore: rename export-parameters-schema -> export-query-parameters

When it ends in `schema`, the tests expect it to be included in the
openapi index file.
This commit is contained in:
Thomas Heartman 2022-07-28 09:19:58 +02:00 committed by GitHub
parent f6192b50b0
commit 6afc0a6954
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 407 additions and 139 deletions

View File

@ -100,7 +100,7 @@
"helmet": "^5.0.0",
"joi": "^17.3.0",
"js-yaml": "^4.1.0",
"json-schema-to-ts": "^2.5.3",
"json-schema-to-ts": "2.5.4",
"knex": "^2.0.0",
"log4js": "^6.0.0",
"make-fetch-happen": "^10.1.2",
@ -120,12 +120,14 @@
"semver": "^7.3.5",
"serve-favicon": "^2.5.0",
"stoppable": "^1.1.0",
"ts-toolbelt": "^9.6.0",
"type-is": "^1.6.18",
"unleash-client": "^3.15.0",
"unleash-frontend": "4.14.0-beta.6",
"uuid": "^8.3.2"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@babel/core": "7.18.9",
"@types/bcryptjs": "2.4.2",
"@types/express": "4.17.13",

View File

@ -30,7 +30,6 @@ import { environmentSchema } from './spec/environment-schema';
import { environmentsSchema } from './spec/environments-schema';
import { eventSchema } from './spec/event-schema';
import { eventsSchema } from './spec/events-schema';
import { exportParametersSchema } from './spec/export-parameters-schema';
import { featureEnvironmentMetricsSchema } from './spec/feature-environment-metrics-schema';
import { featureEnvironmentSchema } from './spec/feature-environment-schema';
import { featureEventsSchema } from './spec/feature-events-schema';
@ -139,7 +138,6 @@ export const schemas = {
environmentsSchema,
eventSchema,
eventsSchema,
exportParametersSchema,
featureEnvironmentMetricsSchema,
featureEnvironmentSchema,
featureEventsSchema,

View File

@ -3,7 +3,6 @@ import { FromSchema } from 'json-schema-to-ts';
export const clientFeaturesQuerySchema = {
$id: '#/components/schemas/clientFeaturesQuerySchema',
type: 'object',
required: [],
additionalProperties: false,
properties: {
tag: {

View File

@ -1,32 +0,0 @@
import { FromSchema } from 'json-schema-to-ts';
export const exportParametersSchema = {
$id: '#/components/schemas/exportParametersSchema',
type: 'object',
properties: {
format: {
type: 'string',
},
download: {
type: 'boolean',
},
strategies: {
type: 'boolean',
},
featureToggles: {
type: 'boolean',
},
projects: {
type: 'boolean',
},
tags: {
type: 'boolean',
},
environments: {
type: 'boolean',
},
},
components: {},
} as const;
export type ExportParametersSchema = FromSchema<typeof exportParametersSchema>;

View File

@ -0,0 +1,143 @@
import { FromQueryParams } from '../util/from-query-params';
export const exportQueryParameters = [
{
name: 'format',
schema: {
type: 'string',
enum: ['json', 'yaml'],
default: 'json',
},
description: 'Desired export format. Must be either `json` or `yaml`.',
in: 'query',
},
{
name: 'download',
schema: {
default: false,
anyOf: [
{
type: 'boolean',
},
{
type: 'string',
minLength: 1,
},
{
type: 'number',
},
],
},
description: 'Whether exported data should be downloaded as a file.',
in: 'query',
},
{
name: 'strategies',
schema: {
default: true,
anyOf: [
{
type: 'boolean',
},
{
type: 'string',
minLength: 1,
},
{
type: 'number',
},
],
},
description:
'Whether strategies should be included in the exported data.',
in: 'query',
},
{
name: 'featureToggles',
schema: {
anyOf: [
{
type: 'boolean',
},
{
type: 'string',
minLength: 1,
},
{
type: 'number',
},
],
default: true,
},
description:
'Whether feature toggles should be included in the exported data.',
in: 'query',
},
{
name: 'projects',
schema: {
anyOf: [
{
type: 'boolean',
},
{
type: 'string',
minLength: 1,
},
{
type: 'number',
},
],
default: true,
},
description:
'Whether projects should be included in the exported data.',
in: 'query',
},
{
name: 'tags',
schema: {
anyOf: [
{
type: 'boolean',
},
{
type: 'string',
minLength: 1,
},
{
type: 'number',
},
],
default: true,
},
description:
'Whether tag types, tags, and feature_tags should be included in the exported data.',
in: 'query',
},
{
name: 'environments',
schema: {
anyOf: [
{
type: 'boolean',
},
{
type: 'string',
minLength: 1,
},
{
type: 'number',
},
],
default: true,
},
description:
'Whether environments should be included in the exported data.',
in: 'query',
},
] as const;
export type ExportQueryParameters = FromQueryParams<
typeof exportQueryParameters
>;

View File

@ -4,7 +4,6 @@ export const feedbackSchema = {
$id: '#/components/schemas/feedbackSchema',
type: 'object',
additionalProperties: false,
required: [],
properties: {
userId: {
type: 'number',

View File

@ -10,9 +10,9 @@ export const playgroundFeatureSchema = {
additionalProperties: false,
required: ['name', 'projectId', 'isEnabled', 'variant', 'variants'],
properties: {
name: { type: 'string', examples: ['my-feature'] },
projectId: { type: 'string', examples: ['my-project'] },
isEnabled: { type: 'boolean', examples: [true] },
name: { type: 'string', example: 'my-feature' },
projectId: { type: 'string', example: 'my-project' },
isEnabled: { type: 'boolean', example: true },
variant: {
type: 'object',
additionalProperties: false,
@ -34,7 +34,7 @@ export const playgroundFeatureSchema = {
},
},
nullable: true,
examples: ['green'],
example: { name: 'green', enabled: true },
},
variants: { type: 'array', items: { $ref: variantSchema.$id } },
},

View File

@ -8,13 +8,13 @@ export const playgroundRequestSchema = {
type: 'object',
required: ['environment', 'context'],
properties: {
environment: { type: 'string', examples: ['development'] },
environment: { type: 'string', example: 'development' },
projects: {
oneOf: [
{
type: 'array',
items: { type: 'string' },
examples: ['my-project', 'my-other-project'],
example: ['my-project'],
description: 'A list of projects to check for toggles in.',
},
{

View File

@ -6,40 +6,38 @@ export const sdkContextSchema = {
type: 'object',
additionalProperties: {
type: 'string',
examples: ['top-level custom context value'],
example: 'top-level custom context value',
},
required: ['appName'],
properties: {
appName: {
type: 'string',
minLength: 1,
examples: ['My cool application.'],
example: 'My cool application.',
},
currentTime: {
type: 'string',
format: 'date-time',
examples: ['2022-07-05T12:56:41+02:00'],
example: '2022-07-05T12:56:41+02:00',
},
environment: { type: 'string', deprecated: true },
properties: {
type: 'object',
additionalProperties: { type: 'string' },
examples: [
{
customContextField: 'this is one!',
otherCustomField: 3,
},
],
example: {
customContextField: 'this is one!',
otherCustomField: '3',
},
},
remoteAddress: {
type: 'string',
examples: ['192.168.1.1'],
example: '192.168.1.1',
},
sessionId: {
type: 'string',
examples: ['b65e7b23-fec0-4814-a129-0e9861ef18fc'],
example: 'b65e7b23-fec0-4814-a129-0e9861ef18fc',
},
userId: { type: 'string', examples: ['username@provider.com'] },
userId: { type: 'string', example: 'username@provider.com' },
},
components: {},
} as const;

View File

@ -0,0 +1,32 @@
// module to create typescript types from query param lists. Based on
// input in this GitHub issue:
// https://github.com/ThomasAribart/json-schema-to-ts/issues/82
import { FromSchema, JSONSchema } from 'json-schema-to-ts';
import { O, L, A } from 'ts-toolbelt';
type OpenApiParam = {
readonly name: string;
readonly schema: JSONSchema;
// Parameter types:
// https://swagger.io/docs/specification/describing-parameters/#types
readonly in: 'query' | 'path' | 'header' | 'cookie';
};
type RecurseOnParams<
P extends readonly OpenApiParam[],
R extends O.Object = {},
> = {
continue: RecurseOnParams<
L.Tail<P>,
L.Head<P>['in'] extends 'query'
? R & {
[key in L.Head<P>['name']]: FromSchema<L.Head<P>['schema']>;
}
: R
>;
stop: A.Compute<R>;
}[P extends readonly [OpenApiParam, ...OpenApiParam[]] ? 'continue' : 'stop'];
export type FromQueryParams<P extends readonly OpenApiParam[]> =
RecurseOnParams<P>;

View File

@ -16,6 +16,10 @@ const ajv = new Ajv({
addFormats(ajv, ['date-time']);
// example was superseded by examples in openapi 3.1, but we're still on 3.0, so
// let's add it back in!
ajv.addKeyword('example');
export const validateSchema = (
schema: SchemaId,
data: unknown,

View File

@ -14,8 +14,12 @@ import { IAuthRequest } from '../unleash-types';
import { OpenApiService } from '../../services/openapi-service';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
import { ExportParametersSchema } from '../../openapi/spec/export-parameters-schema';
import {
exportQueryParameters,
ExportQueryParameters,
} from '../../openapi/spec/export-query-parameters';
import { emptyResponse } from '../../openapi/util/standard-responses';
import { OpenAPIV3 } from 'openapi-types';
const upload = multer({ limits: { fileSize: 5242880 } });
const paramToBool = (param, def) => {
@ -75,11 +79,8 @@ class StateController extends Controller {
responses: {
200: createResponseSchema('stateSchema'),
},
parameters: [
{
$ref: '#/components/schema/exportParametersSchema',
},
],
parameters:
exportQueryParameters as unknown as OpenAPIV3.ParameterObject[],
}),
],
});
@ -114,7 +115,7 @@ class StateController extends Controller {
}
async export(
req: Request<unknown, unknown, unknown, ExportParametersSchema>,
req: Request<unknown, unknown, unknown, ExportQueryParameters>,
res: Response,
): Promise<void> {
const { format } = req.query;

View File

@ -553,7 +553,6 @@ Object {
"type": "array",
},
},
"required": Array [],
"type": "object",
},
"clientFeaturesSchema": Object {
@ -1018,32 +1017,6 @@ Object {
],
"type": "object",
},
"exportParametersSchema": Object {
"properties": Object {
"download": Object {
"type": "boolean",
},
"environments": Object {
"type": "boolean",
},
"featureToggles": Object {
"type": "boolean",
},
"format": Object {
"type": "string",
},
"projects": Object {
"type": "boolean",
},
"strategies": Object {
"type": "boolean",
},
"tags": Object {
"type": "boolean",
},
},
"type": "object",
},
"featureEnvironmentMetricsSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -1465,7 +1438,6 @@ Object {
"type": "number",
},
},
"required": Array [],
"type": "object",
},
"groupSchema": Object {
@ -1834,28 +1806,23 @@ Object {
"description": "A simplified feature toggle model intended for the Unleash playground.",
"properties": Object {
"isEnabled": Object {
"examples": Array [
true,
],
"example": true,
"type": "boolean",
},
"name": Object {
"examples": Array [
"my-feature",
],
"example": "my-feature",
"type": "string",
},
"projectId": Object {
"examples": Array [
"my-project",
],
"example": "my-project",
"type": "string",
},
"variant": Object {
"additionalProperties": false,
"examples": Array [
"green",
],
"example": Object {
"enabled": true,
"name": "green",
},
"nullable": true,
"properties": Object {
"enabled": Object {
@ -1915,18 +1882,15 @@ Object {
"$ref": "#/components/schemas/sdkContextSchema",
},
"environment": Object {
"examples": Array [
"development",
],
"example": "development",
"type": "string",
},
"projects": Object {
"oneOf": Array [
Object {
"description": "A list of projects to check for toggles in.",
"examples": Array [
"example": Array [
"my-project",
"my-other-project",
],
"items": Object {
"type": "string",
@ -2074,24 +2038,18 @@ Object {
},
"sdkContextSchema": Object {
"additionalProperties": Object {
"examples": Array [
"top-level custom context value",
],
"example": "top-level custom context value",
"type": "string",
},
"description": "The Unleash context as modeled in client SDKs",
"properties": Object {
"appName": Object {
"examples": Array [
"My cool application.",
],
"example": "My cool application.",
"minLength": 1,
"type": "string",
},
"currentTime": Object {
"examples": Array [
"2022-07-05T12:56:41+02:00",
],
"example": "2022-07-05T12:56:41+02:00",
"format": "date-time",
"type": "string",
},
@ -2103,30 +2061,22 @@ Object {
"additionalProperties": Object {
"type": "string",
},
"examples": Array [
Object {
"customContextField": "this is one!",
"otherCustomField": 3,
},
],
"example": Object {
"customContextField": "this is one!",
"otherCustomField": "3",
},
"type": "object",
},
"remoteAddress": Object {
"examples": Array [
"192.168.1.1",
],
"example": "192.168.1.1",
"type": "string",
},
"sessionId": Object {
"examples": Array [
"b65e7b23-fec0-4814-a129-0e9861ef18fc",
],
"example": "b65e7b23-fec0-4814-a129-0e9861ef18fc",
"type": "string",
},
"userId": Object {
"examples": Array [
"username@provider.com",
],
"example": "username@provider.com",
"type": "string",
},
},
@ -5168,7 +5118,137 @@ If the provided project does not exist, the list of events will be empty.",
"operationId": "export",
"parameters": Array [
Object {
"$ref": "#/components/schema/exportParametersSchema",
"description": "Desired export format. Must be either \`json\` or \`yaml\`.",
"in": "query",
"name": "format",
"schema": Object {
"default": "json",
"enum": Array [
"json",
"yaml",
],
"type": "string",
},
},
Object {
"description": "Whether exported data should be downloaded as a file.",
"in": "query",
"name": "download",
"schema": Object {
"anyOf": Array [
Object {
"type": "boolean",
},
Object {
"minLength": 1,
"type": "string",
},
Object {
"type": "number",
},
],
"default": false,
},
},
Object {
"description": "Whether strategies should be included in the exported data.",
"in": "query",
"name": "strategies",
"schema": Object {
"anyOf": Array [
Object {
"type": "boolean",
},
Object {
"minLength": 1,
"type": "string",
},
Object {
"type": "number",
},
],
"default": true,
},
},
Object {
"description": "Whether feature toggles should be included in the exported data.",
"in": "query",
"name": "featureToggles",
"schema": Object {
"anyOf": Array [
Object {
"type": "boolean",
},
Object {
"minLength": 1,
"type": "string",
},
Object {
"type": "number",
},
],
"default": true,
},
},
Object {
"description": "Whether projects should be included in the exported data.",
"in": "query",
"name": "projects",
"schema": Object {
"anyOf": Array [
Object {
"type": "boolean",
},
Object {
"minLength": 1,
"type": "string",
},
Object {
"type": "number",
},
],
"default": true,
},
},
Object {
"description": "Whether tag types, tags, and feature_tags should be included in the exported data.",
"in": "query",
"name": "tags",
"schema": Object {
"anyOf": Array [
Object {
"type": "boolean",
},
Object {
"minLength": 1,
"type": "string",
},
Object {
"type": "number",
},
],
"default": true,
},
},
Object {
"description": "Whether environments should be included in the exported data.",
"in": "query",
"name": "environments",
"schema": Object {
"anyOf": Array [
Object {
"type": "boolean",
},
Object {
"minLength": 1,
"type": "string",
},
Object {
"type": "number",
},
],
"default": true,
},
},
],
"responses": Object {

View File

@ -1,6 +1,7 @@
import { setupApp } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import SwaggerParser from '@apidevtools/swagger-parser';
let app;
let db;
@ -36,3 +37,19 @@ test('should serve the OpenAPI spec', async () => {
expect(res.body).toMatchSnapshot();
});
});
test('the generated OpenAPI spec is valid', async () => {
const { body } = await app.request
.get('/docs/openapi.json')
.expect('Content-Type', /json/)
.expect(200);
// this throws if the swagger parser can't parse it correctly
// also parses examples, but _does_ do some string coercion in examples
try {
await SwaggerParser.validate(body);
} catch (err) {
console.error(err);
return false;
}
});

View File

@ -9,6 +9,15 @@
dependencies:
"@jridgewell/trace-mapping" "^0.3.0"
"@apidevtools/json-schema-ref-parser@9.0.6":
version "9.0.6"
resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz#5d9000a3ac1fd25404da886da6b266adcd99cf1c"
integrity sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg==
dependencies:
"@jsdevtools/ono" "^7.1.3"
call-me-maybe "^1.0.1"
js-yaml "^3.13.1"
"@apidevtools/json-schema-ref-parser@^9.0.6":
version "9.0.9"
resolved "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz"
@ -19,7 +28,7 @@
call-me-maybe "^1.0.1"
js-yaml "^4.1.0"
"@apidevtools/openapi-schemas@^2.0.4":
"@apidevtools/openapi-schemas@^2.0.4", "@apidevtools/openapi-schemas@^2.1.0":
version "2.1.0"
resolved "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz"
integrity sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==
@ -41,6 +50,19 @@
call-me-maybe "^1.0.1"
z-schema "^5.0.1"
"@apidevtools/swagger-parser@^10.1.0":
version "10.1.0"
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-10.1.0.tgz#a987d71e5be61feb623203be0c96e5985b192ab6"
integrity sha512-9Kt7EuS/7WbMAUv2gSziqjvxwDbFSg3Xeyfuj5laUODX8o/k/CpsAKiQ8W7/R88eXFTMbJYg6+7uAmOWNKmwnw==
dependencies:
"@apidevtools/json-schema-ref-parser" "9.0.6"
"@apidevtools/openapi-schemas" "^2.1.0"
"@apidevtools/swagger-methods" "^3.0.2"
"@jsdevtools/ono" "^7.1.3"
ajv "^8.6.3"
ajv-draft-04 "^1.0.0"
call-me-maybe "^1.0.1"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13":
version "7.12.13"
resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz"
@ -1417,6 +1439,11 @@ aggregate-error@^3.0.0:
clean-stack "^2.0.0"
indent-string "^4.0.0"
ajv-draft-04@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8"
integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==
ajv-formats@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520"
@ -1434,7 +1461,7 @@ 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:
ajv@^8.0.0, ajv@^8.11.0, ajv@^8.6.3:
version "8.11.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f"
integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==
@ -4928,10 +4955,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.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==
json-schema-to-ts@2.5.4:
version "2.5.4"
resolved "https://registry.yarnpkg.com/json-schema-to-ts/-/json-schema-to-ts-2.5.4.tgz#64008cf5e203284289922bd622bff82043a1a4ed"
integrity sha512-wlaYrGg+aYq0aEjSDY3cAFNzJVD2GvdrVIlvMdrbOLwkaMarXBiX+k0qm5Myb2aI3xjvdqsZoGs63JPS/M8+dg==
dependencies:
"@types/json-schema" "^7.0.9"
ts-algebra "^1.1.1"
@ -7658,7 +7685,7 @@ ts-node@10.9.1:
ts-toolbelt@^9.6.0:
version "9.6.0"
resolved "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz"
resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz#50a25426cfed500d4a09bd1b3afb6f28879edfd5"
integrity sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==
tsc-watch@5.0.3: