mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-04 01:18:20 +02:00
open api implementation - client features controller (#1745)
* open api implementation - client features controller * open api implementation - client features controller * bug fix * test fix * PR comments * OAS for client-api metrics.ts * Refactoring * Refactoring * bug fix * fix PR comments * PR comment * PR comment
This commit is contained in:
parent
b67aca8fbf
commit
e875e67d24
@ -82,10 +82,14 @@ import { emailSchema } from './spec/email-schema';
|
|||||||
import { strategySchema } from './spec/strategy-schema';
|
import { strategySchema } from './spec/strategy-schema';
|
||||||
import { strategiesSchema } from './spec/strategies-schema';
|
import { strategiesSchema } from './spec/strategies-schema';
|
||||||
import { upsertStrategySchema } from './spec/upsert-strategy-schema';
|
import { upsertStrategySchema } from './spec/upsert-strategy-schema';
|
||||||
|
import { clientFeaturesQuerySchema } from './spec/client-features-query-schema';
|
||||||
|
import { clientFeatureSchema } from './spec/client-feature-schema';
|
||||||
|
import { clientFeaturesSchema } from './spec/client-features-schema';
|
||||||
import { eventSchema } from './spec/event-schema';
|
import { eventSchema } from './spec/event-schema';
|
||||||
import { eventsSchema } from './spec/events-schema';
|
import { eventsSchema } from './spec/events-schema';
|
||||||
import { featureEventsSchema } from './spec/feature-events-schema';
|
import { featureEventsSchema } from './spec/feature-events-schema';
|
||||||
import { clientApplicationSchema } from './spec/client-application-schema';
|
import { clientApplicationSchema } from './spec/client-application-schema';
|
||||||
|
import { clientVariantSchema } from './spec/client-variant-schema';
|
||||||
import { IServerOption } from '../types';
|
import { IServerOption } from '../types';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
|
||||||
@ -101,6 +105,10 @@ export const schemas = {
|
|||||||
applicationsSchema,
|
applicationsSchema,
|
||||||
clientApplicationSchema,
|
clientApplicationSchema,
|
||||||
cloneFeatureSchema,
|
cloneFeatureSchema,
|
||||||
|
clientFeatureSchema,
|
||||||
|
clientFeaturesSchema,
|
||||||
|
clientVariantSchema,
|
||||||
|
clientFeaturesQuerySchema,
|
||||||
changePasswordSchema,
|
changePasswordSchema,
|
||||||
constraintSchema,
|
constraintSchema,
|
||||||
contextFieldSchema,
|
contextFieldSchema,
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`clientFeaturesSchema no fields 1`] = `
|
||||||
|
Object {
|
||||||
|
"errors": Array [
|
||||||
|
Object {
|
||||||
|
"instancePath": "",
|
||||||
|
"keyword": "required",
|
||||||
|
"message": "must have required property 'version'",
|
||||||
|
"params": Object {
|
||||||
|
"missingProperty": "version",
|
||||||
|
},
|
||||||
|
"schemaPath": "#/required",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"schema": "#/components/schemas/clientFeaturesSchema",
|
||||||
|
}
|
||||||
|
`;
|
@ -4,13 +4,13 @@ exports[`featureSchema constraints 1`] = `
|
|||||||
Object {
|
Object {
|
||||||
"errors": Array [
|
"errors": Array [
|
||||||
Object {
|
Object {
|
||||||
"instancePath": "/strategies/0",
|
"instancePath": "/strategies/0/constraints/0",
|
||||||
"keyword": "required",
|
"keyword": "required",
|
||||||
"message": "must have required property 'id'",
|
"message": "must have required property 'operator'",
|
||||||
"params": Object {
|
"params": Object {
|
||||||
"missingProperty": "id",
|
"missingProperty": "operator",
|
||||||
},
|
},
|
||||||
"schemaPath": "#/required",
|
"schemaPath": "#/components/schemas/constraintSchema/required",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"schema": "#/components/schemas/featureSchema",
|
"schema": "#/components/schemas/featureSchema",
|
||||||
|
70
src/lib/openapi/spec/client-feature-schema.ts
Normal file
70
src/lib/openapi/spec/client-feature-schema.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { constraintSchema } from './constraint-schema';
|
||||||
|
import { parametersSchema } from './parameters-schema';
|
||||||
|
import { featureStrategySchema } from './feature-strategy-schema';
|
||||||
|
import { clientVariantSchema } from './client-variant-schema';
|
||||||
|
|
||||||
|
export const clientFeatureSchema = {
|
||||||
|
$id: '#/components/schemas/clientFeatureSchema',
|
||||||
|
type: 'object',
|
||||||
|
required: ['name', 'enabled'],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
lastSeenAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
stale: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
impressionData: {
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
strategies: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
$ref: '#/components/schemas/featureStrategySchema',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
$ref: '#/components/schemas/clientVariantSchema',
|
||||||
|
},
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
constraintSchema,
|
||||||
|
parametersSchema,
|
||||||
|
featureStrategySchema,
|
||||||
|
clientVariantSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ClientFeatureSchema = FromSchema<typeof clientFeatureSchema>;
|
24
src/lib/openapi/spec/client-features-query-schema.test.ts
Normal file
24
src/lib/openapi/spec/client-features-query-schema.test.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { validateSchema } from '../validate';
|
||||||
|
import { ClientFeaturesQuerySchema } from './client-features-query-schema';
|
||||||
|
|
||||||
|
test('clientFeatureQuerySchema empty', () => {
|
||||||
|
const data: ClientFeaturesQuerySchema = {};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateSchema('#/components/schemas/clientFeaturesQuerySchema', data),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clientFeatureQuerySchema all fields', () => {
|
||||||
|
const data: ClientFeaturesQuerySchema = {
|
||||||
|
tag: [['some-tag', 'some-other-tag']],
|
||||||
|
project: ['default'],
|
||||||
|
namePrefix: 'some-prefix',
|
||||||
|
environment: 'some-env',
|
||||||
|
inlineSegmentConstraints: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateSchema('#/components/schemas/clientFeaturesQuerySchema', data),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
39
src/lib/openapi/spec/client-features-query-schema.ts
Normal file
39
src/lib/openapi/spec/client-features-query-schema.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const clientFeaturesQuerySchema = {
|
||||||
|
$id: '#/components/schemas/clientFeaturesQuerySchema',
|
||||||
|
type: 'object',
|
||||||
|
required: [],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
tag: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
namePrefix: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
inlineSegmentConstraints: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ClientFeaturesQuerySchema = FromSchema<
|
||||||
|
typeof clientFeaturesQuerySchema
|
||||||
|
>;
|
395
src/lib/openapi/spec/client-features-schema.test.ts
Normal file
395
src/lib/openapi/spec/client-features-schema.test.ts
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
import { validateSchema } from '../validate';
|
||||||
|
import { ClientFeaturesSchema } from './client-features-schema';
|
||||||
|
|
||||||
|
test('clientFeaturesSchema no fields', () => {
|
||||||
|
expect(
|
||||||
|
validateSchema('#/components/schemas/clientFeaturesSchema', {}),
|
||||||
|
).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clientFeaturesSchema required fields', () => {
|
||||||
|
const data: ClientFeaturesSchema = {
|
||||||
|
version: 0,
|
||||||
|
query: {},
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
name: 'some-name',
|
||||||
|
enabled: false,
|
||||||
|
impressionData: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateSchema('#/components/schemas/clientFeaturesSchema', data),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clientFeaturesSchema java-sdk expected response', () => {
|
||||||
|
const json = `{
|
||||||
|
"version": 2,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "some-name",
|
||||||
|
"description": null,
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "some-name",
|
||||||
|
"operator": "IN",
|
||||||
|
"value": "name",
|
||||||
|
"inverted": false,
|
||||||
|
"caseInsensitive": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"name": "Test.old",
|
||||||
|
"description": "No variants here!",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variants": null,
|
||||||
|
"createdAt": "2019-01-24T10:38:10.370Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Test.variants",
|
||||||
|
"description": null,
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"segments": [
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"name": "variant1",
|
||||||
|
"weight": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "variant2",
|
||||||
|
"weight": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createdAt": "2019-01-24T10:41:45.236Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "featureX",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "featureY",
|
||||||
|
"enabled": false,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "baz",
|
||||||
|
"parameters": {
|
||||||
|
"foo": "bar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "featureZ",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hola",
|
||||||
|
"parameters": {
|
||||||
|
"name": "val"
|
||||||
|
},
|
||||||
|
"segments": [1]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateSchema(
|
||||||
|
'#/components/schemas/clientFeaturesSchema',
|
||||||
|
JSON.parse(json),
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clientFeaturesSchema unleash-proxy expected response', () => {
|
||||||
|
const json = `{
|
||||||
|
"version": 2,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "some-name",
|
||||||
|
"description": null,
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "some-name",
|
||||||
|
"operator": "IN",
|
||||||
|
"value": "name",
|
||||||
|
"inverted": false,
|
||||||
|
"caseInsensitive": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"name": "Test.old",
|
||||||
|
"description": "No variants here!",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variants": null,
|
||||||
|
"createdAt": "2019-01-24T10:38:10.370Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Test.variants",
|
||||||
|
"description": null,
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"segments": [
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"name": "variant1",
|
||||||
|
"weight": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "variant2",
|
||||||
|
"weight": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createdAt": "2019-01-24T10:41:45.236Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "featureX",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "featureY",
|
||||||
|
"enabled": false,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "baz",
|
||||||
|
"parameters": {
|
||||||
|
"foo": "bar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "featureZ",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hola",
|
||||||
|
"parameters": {
|
||||||
|
"name": "val"
|
||||||
|
},
|
||||||
|
"segments": [1]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateSchema(
|
||||||
|
'#/components/schemas/clientFeaturesSchema',
|
||||||
|
JSON.parse(json),
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clientFeaturesSchema client specification test 15', () => {
|
||||||
|
const json = `{
|
||||||
|
"version": 2,
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"name": "F9.globalSegmentOn",
|
||||||
|
"description": "With global segment referencing constraint in on state",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"parameters": {},
|
||||||
|
"segments": [1]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "F9.globalSegmentOff",
|
||||||
|
"description": "With global segment referencing constraint in off state",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"parameters": {},
|
||||||
|
"segments": [2]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "F9.globalSegmentAndConstraint",
|
||||||
|
"description": "With global segment and constraint both on",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"parameters": {},
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "version",
|
||||||
|
"operator": "SEMVER_EQ",
|
||||||
|
"value": "1.2.2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"segments": [1]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "F9.withExtraParams",
|
||||||
|
"description": "With global segment that doesn't exist",
|
||||||
|
"enabled": true,
|
||||||
|
"project": "some-project",
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"parameters": {},
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "version",
|
||||||
|
"operator": "SEMVER_EQ",
|
||||||
|
"value": "1.2.2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"segments": [3]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "F9.withSeveralConstraintsAndSegments",
|
||||||
|
"description": "With several segments and constraints",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"parameters": {},
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "customNumber",
|
||||||
|
"operator": "NUM_LT",
|
||||||
|
"value": "10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"contextName": "version",
|
||||||
|
"operator": "SEMVER_LT",
|
||||||
|
"value": "3.2.2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"segments": [1, 4, 5]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "version",
|
||||||
|
"operator": "SEMVER_EQ",
|
||||||
|
"value": "1.2.2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "version",
|
||||||
|
"operator": "SEMVER_EQ",
|
||||||
|
"value": "3.1.4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "version",
|
||||||
|
"operator": "SEMVER_EQ",
|
||||||
|
"value": "3.1.4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "customName",
|
||||||
|
"operator": "STR_CONTAINS",
|
||||||
|
"values": ["Pi"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"contextName": "slicesLeft",
|
||||||
|
"operator": "NUM_LTE",
|
||||||
|
"value": "4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateSchema(
|
||||||
|
'#/components/schemas/clientFeaturesSchema',
|
||||||
|
JSON.parse(json),
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
51
src/lib/openapi/spec/client-features-schema.ts
Normal file
51
src/lib/openapi/spec/client-features-schema.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { clientFeaturesQuerySchema } from './client-features-query-schema';
|
||||||
|
import { segmentSchema } from './segment-schema';
|
||||||
|
import { constraintSchema } from './constraint-schema';
|
||||||
|
import { environmentSchema } from './environment-schema';
|
||||||
|
import { overrideSchema } from './override-schema';
|
||||||
|
import { parametersSchema } from './parameters-schema';
|
||||||
|
import { featureStrategySchema } from './feature-strategy-schema';
|
||||||
|
import { variantSchema } from './variant-schema';
|
||||||
|
import { clientFeatureSchema } from './client-feature-schema';
|
||||||
|
|
||||||
|
export const clientFeaturesSchema = {
|
||||||
|
$id: '#/components/schemas/clientFeaturesSchema',
|
||||||
|
type: 'object',
|
||||||
|
required: ['version', 'features'],
|
||||||
|
properties: {
|
||||||
|
version: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
$ref: '#/components/schemas/clientFeatureSchema',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
$ref: '#/components/schemas/segmentSchema',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
$ref: '#/components/schemas/clientFeaturesQuerySchema',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
constraintSchema,
|
||||||
|
clientFeatureSchema,
|
||||||
|
environmentSchema,
|
||||||
|
segmentSchema,
|
||||||
|
clientFeaturesQuerySchema,
|
||||||
|
overrideSchema,
|
||||||
|
parametersSchema,
|
||||||
|
featureStrategySchema,
|
||||||
|
variantSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ClientFeaturesSchema = FromSchema<typeof clientFeaturesSchema>;
|
31
src/lib/openapi/spec/client-variant-schema.ts
Normal file
31
src/lib/openapi/spec/client-variant-schema.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const clientVariantSchema = {
|
||||||
|
$id: '#/components/schemas/clientVariantSchema',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['name', 'weight'],
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
weight: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['type', 'value'],
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ClientVariantSchema = FromSchema<typeof clientVariantSchema>;
|
@ -6,7 +6,7 @@ export const featureStrategySchema = {
|
|||||||
$id: '#/components/schemas/featureStrategySchema',
|
$id: '#/components/schemas/featureStrategySchema',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['name', 'id'],
|
required: ['name'],
|
||||||
properties: {
|
properties: {
|
||||||
id: {
|
id: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
@ -5,13 +5,17 @@ export const segmentSchema = {
|
|||||||
$id: '#/components/schemas/segmentSchema',
|
$id: '#/components/schemas/segmentSchema',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['name', 'constraints'],
|
required: ['id', 'constraints'],
|
||||||
properties: {
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
},
|
},
|
||||||
constraints: {
|
constraints: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
@ -70,7 +70,10 @@ test('should get empty getFeatures via client', () => {
|
|||||||
test('if caching is enabled should memoize', async () => {
|
test('if caching is enabled should memoize', async () => {
|
||||||
const getClientFeatures = jest.fn().mockReturnValue([]);
|
const getClientFeatures = jest.fn().mockReturnValue([]);
|
||||||
const getActive = jest.fn().mockReturnValue([]);
|
const getActive = jest.fn().mockReturnValue([]);
|
||||||
|
const respondWithValidation = jest.fn().mockReturnValue({});
|
||||||
|
const validPath = jest.fn().mockReturnValue(jest.fn());
|
||||||
const clientSpecService = new ClientSpecService({ getLogger });
|
const clientSpecService = new ClientSpecService({ getLogger });
|
||||||
|
const openApiService = { respondWithValidation, validPath };
|
||||||
const featureToggleServiceV2 = { getClientFeatures };
|
const featureToggleServiceV2 = { getClientFeatures };
|
||||||
const segmentService = { getActive };
|
const segmentService = { getActive };
|
||||||
|
|
||||||
@ -78,6 +81,8 @@ test('if caching is enabled should memoize', async () => {
|
|||||||
{
|
{
|
||||||
clientSpecService,
|
clientSpecService,
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
openApiService,
|
||||||
|
// @ts-expect-error
|
||||||
featureToggleServiceV2,
|
featureToggleServiceV2,
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
segmentService,
|
segmentService,
|
||||||
@ -99,14 +104,19 @@ test('if caching is enabled should memoize', async () => {
|
|||||||
test('if caching is not enabled all calls goes to service', async () => {
|
test('if caching is not enabled all calls goes to service', async () => {
|
||||||
const getClientFeatures = jest.fn().mockReturnValue([]);
|
const getClientFeatures = jest.fn().mockReturnValue([]);
|
||||||
const getActive = jest.fn().mockReturnValue([]);
|
const getActive = jest.fn().mockReturnValue([]);
|
||||||
|
const respondWithValidation = jest.fn().mockReturnValue({});
|
||||||
|
const validPath = jest.fn().mockReturnValue(jest.fn());
|
||||||
const clientSpecService = new ClientSpecService({ getLogger });
|
const clientSpecService = new ClientSpecService({ getLogger });
|
||||||
const featureToggleServiceV2 = { getClientFeatures };
|
const featureToggleServiceV2 = { getClientFeatures };
|
||||||
const segmentService = { getActive };
|
const segmentService = { getActive };
|
||||||
|
const openApiService = { respondWithValidation, validPath };
|
||||||
|
|
||||||
const controller = new FeatureController(
|
const controller = new FeatureController(
|
||||||
{
|
{
|
||||||
clientSpecService,
|
clientSpecService,
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
openApiService,
|
||||||
|
// @ts-expect-error
|
||||||
featureToggleServiceV2,
|
featureToggleServiceV2,
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
segmentService,
|
segmentService,
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import memoizee from 'memoizee';
|
import memoizee from 'memoizee';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import Controller from '../controller';
|
import Controller from '../controller';
|
||||||
import { IUnleashServices } from '../../types/services';
|
import { IUnleashConfig, IUnleashServices } from '../../types';
|
||||||
import { IUnleashConfig } from '../../types/option';
|
|
||||||
import FeatureToggleService from '../../services/feature-toggle-service';
|
import FeatureToggleService from '../../services/feature-toggle-service';
|
||||||
import { Logger } from '../../logger';
|
import { Logger } from '../../logger';
|
||||||
import { querySchema } from '../../schema/feature-schema';
|
import { querySchema } from '../../schema/feature-schema';
|
||||||
@ -14,6 +13,18 @@ import { ALL, isAllProjects } from '../../types/models/api-token';
|
|||||||
import { SegmentService } from '../../services/segment-service';
|
import { SegmentService } from '../../services/segment-service';
|
||||||
import { FeatureConfigurationClient } from '../../types/stores/feature-strategies-store';
|
import { FeatureConfigurationClient } from '../../types/stores/feature-strategies-store';
|
||||||
import { ClientSpecService } from '../../services/client-spec-service';
|
import { ClientSpecService } from '../../services/client-spec-service';
|
||||||
|
import { OpenApiService } from '../../services/openapi-service';
|
||||||
|
import { NONE } from '../../types/permissions';
|
||||||
|
import { createResponseSchema } from '../../openapi';
|
||||||
|
import { ClientFeaturesQuerySchema } from '../../openapi/spec/client-features-query-schema';
|
||||||
|
import {
|
||||||
|
clientFeatureSchema,
|
||||||
|
ClientFeatureSchema,
|
||||||
|
} from '../../openapi/spec/client-feature-schema';
|
||||||
|
import {
|
||||||
|
clientFeaturesSchema,
|
||||||
|
ClientFeaturesSchema,
|
||||||
|
} from '../../openapi/spec/client-features-schema';
|
||||||
|
|
||||||
const version = 2;
|
const version = 2;
|
||||||
|
|
||||||
@ -31,6 +42,8 @@ export default class FeatureController extends Controller {
|
|||||||
|
|
||||||
private clientSpecService: ClientSpecService;
|
private clientSpecService: ClientSpecService;
|
||||||
|
|
||||||
|
private openApiService: OpenApiService;
|
||||||
|
|
||||||
private readonly cache: boolean;
|
private readonly cache: boolean;
|
||||||
|
|
||||||
private cachedFeatures: any;
|
private cachedFeatures: any;
|
||||||
@ -40,9 +53,13 @@ export default class FeatureController extends Controller {
|
|||||||
featureToggleServiceV2,
|
featureToggleServiceV2,
|
||||||
segmentService,
|
segmentService,
|
||||||
clientSpecService,
|
clientSpecService,
|
||||||
|
openApiService,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
IUnleashServices,
|
IUnleashServices,
|
||||||
'featureToggleServiceV2' | 'segmentService' | 'clientSpecService'
|
| 'featureToggleServiceV2'
|
||||||
|
| 'segmentService'
|
||||||
|
| 'clientSpecService'
|
||||||
|
| 'openApiService'
|
||||||
>,
|
>,
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
) {
|
) {
|
||||||
@ -51,10 +68,40 @@ export default class FeatureController extends Controller {
|
|||||||
this.featureToggleServiceV2 = featureToggleServiceV2;
|
this.featureToggleServiceV2 = featureToggleServiceV2;
|
||||||
this.segmentService = segmentService;
|
this.segmentService = segmentService;
|
||||||
this.clientSpecService = clientSpecService;
|
this.clientSpecService = clientSpecService;
|
||||||
|
this.openApiService = openApiService;
|
||||||
this.logger = config.getLogger('client-api/feature.js');
|
this.logger = config.getLogger('client-api/feature.js');
|
||||||
|
|
||||||
this.get('/', this.getAll);
|
this.route({
|
||||||
this.get('/:featureName', this.getFeatureToggle);
|
method: 'get',
|
||||||
|
path: '/:featureName',
|
||||||
|
handler: this.getFeatureToggle,
|
||||||
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
operationId: 'getClientFeature',
|
||||||
|
tags: ['client'],
|
||||||
|
responses: {
|
||||||
|
200: createResponseSchema('clientFeaturesSchema'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'get',
|
||||||
|
path: '',
|
||||||
|
handler: this.getAll,
|
||||||
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
operationId: 'getAllClientFeatures',
|
||||||
|
tags: ['client'],
|
||||||
|
responses: {
|
||||||
|
200: createResponseSchema('clientFeaturesSchema'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
if (clientFeatureCaching?.enabled) {
|
if (clientFeatureCaching?.enabled) {
|
||||||
this.cache = true;
|
this.cache = true;
|
||||||
@ -148,7 +195,10 @@ export default class FeatureController extends Controller {
|
|||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(req: IAuthRequest, res: Response): Promise<void> {
|
async getAll(
|
||||||
|
req: IAuthRequest,
|
||||||
|
res: Response<ClientFeaturesSchema>,
|
||||||
|
): Promise<void> {
|
||||||
const query = await this.resolveQuery(req);
|
const query = await this.resolveQuery(req);
|
||||||
|
|
||||||
const [features, segments] = this.cache
|
const [features, segments] = this.cache
|
||||||
@ -156,13 +206,26 @@ export default class FeatureController extends Controller {
|
|||||||
: await this.resolveFeaturesAndSegments(query);
|
: await this.resolveFeaturesAndSegments(query);
|
||||||
|
|
||||||
if (this.clientSpecService.requestSupportsSpec(req, 'segments')) {
|
if (this.clientSpecService.requestSupportsSpec(req, 'segments')) {
|
||||||
res.json({ version, features, query, segments });
|
this.openApiService.respondWithValidation(
|
||||||
|
200,
|
||||||
|
res,
|
||||||
|
clientFeaturesSchema.$id,
|
||||||
|
{ version, features, query: { ...query }, segments },
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
res.json({ version, features, query });
|
this.openApiService.respondWithValidation(
|
||||||
|
200,
|
||||||
|
res,
|
||||||
|
clientFeaturesSchema.$id,
|
||||||
|
{ version, features, query },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFeatureToggle(req: IAuthRequest, res: Response): Promise<void> {
|
async getFeatureToggle(
|
||||||
|
req: IAuthRequest<{ featureName: string }, ClientFeaturesQuerySchema>,
|
||||||
|
res: Response<ClientFeatureSchema>,
|
||||||
|
): Promise<void> {
|
||||||
const name = req.params.featureName;
|
const name = req.params.featureName;
|
||||||
const featureQuery = await this.resolveQuery(req);
|
const featureQuery = await this.resolveQuery(req);
|
||||||
const q = { ...featureQuery, namePrefix: name };
|
const q = { ...featureQuery, namePrefix: name };
|
||||||
@ -172,6 +235,13 @@ export default class FeatureController extends Controller {
|
|||||||
if (!toggle) {
|
if (!toggle) {
|
||||||
throw new NotFoundError(`Could not find feature toggle ${name}`);
|
throw new NotFoundError(`Could not find feature toggle ${name}`);
|
||||||
}
|
}
|
||||||
res.json(toggle).end();
|
this.openApiService.respondWithValidation(
|
||||||
|
200,
|
||||||
|
res,
|
||||||
|
clientFeatureSchema.$id,
|
||||||
|
{
|
||||||
|
...toggle,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -352,6 +352,151 @@ Object {
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"clientFeatureSchema": Object {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": Object {
|
||||||
|
"createdAt": Object {
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"description": Object {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"enabled": Object {
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
"impressionData": Object {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
"lastSeenAt": Object {
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"name": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"project": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"stale": Object {
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
"strategies": Object {
|
||||||
|
"items": Object {
|
||||||
|
"$ref": "#/components/schemas/featureStrategySchema",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
"type": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"variants": Object {
|
||||||
|
"items": Object {
|
||||||
|
"$ref": "#/components/schemas/clientVariantSchema",
|
||||||
|
},
|
||||||
|
"nullable": true,
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": Array [
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
"clientFeaturesQuerySchema": Object {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": Object {
|
||||||
|
"environment": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"inlineSegmentConstraints": Object {
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
"namePrefix": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"project": Object {
|
||||||
|
"items": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
"tag": Object {
|
||||||
|
"items": Object {
|
||||||
|
"items": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": Array [],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
"clientFeaturesSchema": Object {
|
||||||
|
"properties": Object {
|
||||||
|
"features": Object {
|
||||||
|
"items": Object {
|
||||||
|
"$ref": "#/components/schemas/clientFeatureSchema",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
"query": Object {
|
||||||
|
"$ref": "#/components/schemas/clientFeaturesQuerySchema",
|
||||||
|
},
|
||||||
|
"segments": Object {
|
||||||
|
"items": Object {
|
||||||
|
"$ref": "#/components/schemas/segmentSchema",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
"version": Object {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": Array [
|
||||||
|
"version",
|
||||||
|
"features",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
"clientVariantSchema": Object {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": Object {
|
||||||
|
"name": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"payload": Object {
|
||||||
|
"properties": Object {
|
||||||
|
"type": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"value": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": Array [
|
||||||
|
"type",
|
||||||
|
"value",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
"weight": Object {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": Array [
|
||||||
|
"name",
|
||||||
|
"weight",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
"cloneFeatureSchema": Object {
|
"cloneFeatureSchema": Object {
|
||||||
"properties": Object {
|
"properties": Object {
|
||||||
"name": Object {
|
"name": Object {
|
||||||
@ -952,7 +1097,6 @@ Object {
|
|||||||
},
|
},
|
||||||
"required": Array [
|
"required": Array [
|
||||||
"name",
|
"name",
|
||||||
"id",
|
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
@ -1537,14 +1681,18 @@ Object {
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
},
|
},
|
||||||
"description": Object {
|
"description": Object {
|
||||||
|
"nullable": true,
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
|
"id": Object {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
"name": Object {
|
"name": Object {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": Array [
|
"required": Array [
|
||||||
"name",
|
"id",
|
||||||
"constraints",
|
"constraints",
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -5266,6 +5414,56 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/client/features": Object {
|
||||||
|
"get": Object {
|
||||||
|
"operationId": "getAllClientFeatures",
|
||||||
|
"responses": Object {
|
||||||
|
"200": Object {
|
||||||
|
"content": Object {
|
||||||
|
"application/json": Object {
|
||||||
|
"schema": Object {
|
||||||
|
"$ref": "#/components/schemas/clientFeaturesSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": "clientFeaturesSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tags": Array [
|
||||||
|
"client",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/client/features/{featureName}": Object {
|
||||||
|
"get": Object {
|
||||||
|
"operationId": "getClientFeature",
|
||||||
|
"parameters": Array [
|
||||||
|
Object {
|
||||||
|
"in": "path",
|
||||||
|
"name": "featureName",
|
||||||
|
"required": true,
|
||||||
|
"schema": Object {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"responses": Object {
|
||||||
|
"200": Object {
|
||||||
|
"content": Object {
|
||||||
|
"application/json": Object {
|
||||||
|
"schema": Object {
|
||||||
|
"$ref": "#/components/schemas/clientFeaturesSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": "clientFeaturesSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tags": Array [
|
||||||
|
"client",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/client/register": Object {
|
"/api/client/register": Object {
|
||||||
"post": Object {
|
"post": Object {
|
||||||
"operationId": "registerClientApplication",
|
"operationId": "registerClientApplication",
|
||||||
|
Loading…
Reference in New Issue
Block a user