Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions packages/core/src/routes/swagger/utils/dev-feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* @file The `x-logto-dev-feature` marker and the pruning of the document parts it marks. Everything
* marked is hidden when dev features are disabled, so in-development API surfaces stay out of the
* public OpenAPI output; the marker itself never reaches a served document.
*/
import { isObject } from '@silverhand/essentials';

import { EnvSet } from '#src/env-set/index.js';

/**
* The OpenAPI schema extension that hides a schema property, query parameter, or response header
* when dev features are disabled.
*/
export const devFeatureSchemaExtension = 'x-logto-dev-feature';

const isRecord = (value: unknown): value is Record<string, unknown> => isObject(value);

/**
* **CAUTION**: This function mutates the input value.
*
* Recursively prune query parameters marked with `x-logto-dev-feature`: when dev features are
* disabled, the parameters are removed; when enabled, only the internal marker is removed.
*
* Run this before {@link removeDevFeatureSchemaProperties}, which strips the marker from every
* record unconditionally.
*/
export const removeDevFeatureParameters = (value: unknown) => {
if (!isRecord(value)) {
return;
}

const { parameters } = value;

if (Array.isArray(parameters)) {
// eslint-disable-next-line @silverhand/fp/no-mutation -- Pruning the supplement document is intentionally in-place.
value.parameters = parameters.filter((parameter) => {
if (!isRecord(parameter) || !isRecord(parameter.schema)) {
return true;
}

const isDevFeature = parameter.schema[devFeatureSchemaExtension] === true;

if (isDevFeature && EnvSet.values.isDevFeaturesEnabled) {
Reflect.deleteProperty(parameter.schema, devFeatureSchemaExtension);
}

return EnvSet.values.isDevFeaturesEnabled || !isDevFeature;
});
}

for (const item of Object.values(value).flat()) {
removeDevFeatureParameters(item);
}
};

const removeRequiredProperty = (
schema: Record<string, unknown>,
required: unknown,
propertyName: string
) => {
if (!Array.isArray(required)) {
return;
}

const nextRequired = required.filter((item) => item !== propertyName);

if (nextRequired.length === 0) {
Reflect.deleteProperty(schema, 'required');

return;
}

// eslint-disable-next-line @silverhand/fp/no-mutation -- Pruning the supplement document is intentionally in-place.
schema.required = nextRequired;
};

const removeDevFeatureSchemaProperty = (
schema: Record<string, unknown>,
properties: Record<string, unknown>,
propertyName: string,
propertySchema: unknown
) => {
const isMarked = isRecord(propertySchema) && propertySchema[devFeatureSchemaExtension] === true;

if (!isMarked) {
return false;
}

if (!EnvSet.values.isDevFeaturesEnabled) {
Reflect.deleteProperty(properties, propertyName);
removeRequiredProperty(schema, schema.required, propertyName);

return true;
}

if (isRecord(propertySchema)) {
Reflect.deleteProperty(propertySchema, devFeatureSchemaExtension);
}

return false;
};

/**
* **CAUTION**: This function mutates the input value.
*
* Recursively prune dev-feature schema properties: when dev features are disabled, properties
* marked with `x-logto-dev-feature` are removed (including their `required` entries); when
* enabled, only the internal marker is removed.
*
* Run this on the assembled document — the base schema properties generated from the env-free
* zod guards carry no markers, so pruning supplements alone cannot remove them.
*/
export const removeDevFeatureSchemaProperties = (value: unknown) => {
if (Array.isArray(value)) {
for (const item of value) {
removeDevFeatureSchemaProperties(item);
}

return;
}

if (!isRecord(value)) {
return;
}

const schema = value;
const { properties } = schema;

if (isRecord(properties)) {
for (const [propertyName, propertySchema] of Object.entries(properties)) {
if (removeDevFeatureSchemaProperty(schema, properties, propertyName, propertySchema)) {
continue;
}
}
}

Reflect.deleteProperty(schema, devFeatureSchemaExtension);

for (const item of Object.values(schema)) {
removeDevFeatureSchemaProperties(item);
}
};

/**
* **CAUTION**: This function mutates the input value.
*
* Recursively prune response headers marked with `x-logto-dev-feature`: when dev features are
* disabled, the headers are removed; when enabled, only the internal marker is removed.
*
* Run this before {@link removeDevFeatureSchemaProperties}, which strips the marker from every
* record unconditionally.
*/
export const removeDevFeatureResponseHeaders = (value: unknown) => {
if (!isRecord(value)) {
return;
}

const { responses } = value;

if (isRecord(responses)) {
for (const response of Object.values(responses)) {
if (!isRecord(response) || !isRecord(response.headers)) {
continue;
}

const { headers } = response;

for (const [name, header] of Object.entries(headers)) {
if (!isRecord(header) || header[devFeatureSchemaExtension] !== true) {
continue;
}

if (EnvSet.values.isDevFeaturesEnabled) {
Reflect.deleteProperty(header, devFeatureSchemaExtension);
} else {
Reflect.deleteProperty(headers, name);
}
}
}
}

for (const item of Object.values(value).flat()) {
removeDevFeatureResponseHeaders(item);
}
};
2 changes: 1 addition & 1 deletion packages/core/src/routes/swagger/utils/documents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { EnvSet } from '#src/env-set/index.js';
import { type DeepPartial } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';

import { devFeatureSchemaExtension } from './dev-feature.js';
import { assembleSwaggerDocument } from './documents.js';
import { devFeatureSchemaExtension } from './general.js';

const originalIsDevFeaturesEnabled = EnvSet.values.isDevFeaturesEnabled;

Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/routes/swagger/utils/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ import { translationSchemas } from '#src/utils/zod.js';

import { managementApiAuthDescription, userApiAuthDescription } from '../consts.js';

import {
removeDevFeatureParameters,
removeDevFeatureResponseHeaders,
removeDevFeatureSchemaProperties,
} from './dev-feature.js';
import {
type FindSupplementFilesOptions,
devFeatureTag,
findSupplementFiles,
pruneSwaggerDocument,
removeDevFeatureParameters,
removeDevFeatureSchemaProperties,
removeUnnecessaryOperations,
shouldThrow,
validateSupplement,
Expand Down Expand Up @@ -287,10 +290,11 @@ export const assembleSwaggerDocument = <ContextT extends IRouterParamContext>(
/**
* Supplements are pruned before merging, but the base document is generated from the env-free
* zod guards in `@logto/schemas` and can still carry dev-feature properties — prune the
* assembled result as well. Parameters must be pruned first, as
* assembled result as well. Parameters and response headers must be pruned first, as
* `removeDevFeatureSchemaProperties` strips the marker from every record unconditionally.
*/
removeDevFeatureParameters(data);
removeDevFeatureResponseHeaders(data);
removeDevFeatureSchemaProperties(data);
pruneSwaggerDocument(data);

Expand Down
88 changes: 86 additions & 2 deletions packages/core/src/routes/swagger/utils/general.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { type DeepPartial } from '#src/test-utils/tenant.js';
import {
devFeatureSchemaExtension,
removeDevFeatureParameters,
removeDevFeatureResponseHeaders,
removeDevFeatureSchemaProperties,
removeUnnecessaryOperations,
} from './general.js';
} from './dev-feature.js';
import { removeUnnecessaryOperations } from './general.js';

const originalIsCloud = EnvSet.values.isCloud;
const originalIsDevFeaturesEnabled = EnvSet.values.isDevFeaturesEnabled;
Expand Down Expand Up @@ -94,6 +95,37 @@ const createDevFeatureOperationDocument = (): DeepPartial<OpenAPIV3.Document> =>
},
});

/**
* `x-logto-dev-feature` is an OpenAPI extension, which `openapi-types` does not model on a
* response header, so the marker needs the same cast the schema variant does.
*/
const createResponseHeaderDocument = (): DeepPartial<OpenAPIV3.Document> => ({
openapi: '3.0.1',
info: { title: 'Test', version: '1.0.0' },
paths: {
'/api/mock': {
patch: {
responses: {
'401': {
description: 'Permission denied.',
headers: {
Stable: {
description: 'Stable header.',
schema: { type: 'string' },
},
Beta: {
description: 'Dev feature header.',
schema: { type: 'string' },
[devFeatureSchemaExtension]: true,
} as unknown as OpenAPIV3.HeaderObject,
},
},
},
},
},
},
});

const loadSearchDocument = async (): Promise<DeepPartial<OpenAPIV3.Document>> =>
JSON.parse(
await fs.readFile(new URL('../../admin-user/search.openapi.json', import.meta.url), 'utf8')
Expand Down Expand Up @@ -220,4 +252,56 @@ describe('swagger general utils', () => {
);
expect(JSON.stringify(document)).not.toContain(devFeatureSchemaExtension);
});

describe('removeDevFeatureResponseHeaders()', () => {
it('should remove dev feature response headers when dev features are disabled', () => {
setDevFeaturesEnabled(false);

const document = createResponseHeaderDocument();
removeDevFeatureResponseHeaders(document);

expect(document).toMatchObject({
paths: {
'/api/mock': {
patch: {
responses: {
'401': {
headers: {
Stable: { description: 'Stable header.' },
},
},
},
},
},
},
});
expect(JSON.stringify(document)).not.toContain('Beta');
expect(JSON.stringify(document)).not.toContain(devFeatureSchemaExtension);
});

it('should keep dev feature response headers without exposing the internal marker when dev features are enabled', () => {
setDevFeaturesEnabled(true);

const document = createResponseHeaderDocument();
removeDevFeatureResponseHeaders(document);

expect(document).toMatchObject({
paths: {
'/api/mock': {
patch: {
responses: {
'401': {
headers: {
Stable: { description: 'Stable header.' },
Beta: { description: 'Dev feature header.' },
},
},
},
},
},
},
});
expect(JSON.stringify(document)).not.toContain(devFeatureSchemaExtension);
});
});
});
Loading
Loading