mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): implement wellknown swagger endpoints (#6445)
* feat(core): implement wellknown swagger endpoints implement wellknown swagger endpoints * chore(core): rename rename * refactor(core): extract common util methods extract common util methods * fix(core): fix lint error fix lint error * refactor(core): shared code optimization shared code optimization * chore(core): remove type assertion remove type assertion
This commit is contained in:
parent
e9eb212f48
commit
cea8aa1120
15 changed files with 656 additions and 360 deletions
|
@ -45,7 +45,8 @@ import systemRoutes from './system.js';
|
|||
import type { AnonymousRouter, ManagementApiRouter } from './types.js';
|
||||
import userAssetsRoutes from './user-assets.js';
|
||||
import verificationCodeRoutes from './verification-code.js';
|
||||
import wellKnownRoutes from './well-known.js';
|
||||
import wellKnownRoutes from './well-known/index.js';
|
||||
import wellKnownOpenApiRoutes from './well-known/well-known.openapi.js';
|
||||
|
||||
const createRouters = (tenant: TenantContext) => {
|
||||
const interactionRouter: AnonymousRouter = new Router();
|
||||
|
@ -96,15 +97,23 @@ const createRouters = (tenant: TenantContext) => {
|
|||
subjectTokenRoutes(managementRouter, tenant);
|
||||
|
||||
const anonymousRouter: AnonymousRouter = new Router();
|
||||
|
||||
wellKnownRoutes(anonymousRouter, tenant);
|
||||
wellKnownOpenApiRoutes(anonymousRouter, {
|
||||
experienceRouters: [experienceRouter, interactionRouter],
|
||||
managementRouters: [managementRouter, anonymousRouter],
|
||||
});
|
||||
|
||||
statusRoutes(anonymousRouter, tenant);
|
||||
authnRoutes(anonymousRouter, tenant);
|
||||
|
||||
// The swagger.json should contain all API routers.
|
||||
swaggerRoutes(anonymousRouter, [
|
||||
interactionRouter,
|
||||
managementRouter,
|
||||
anonymousRouter,
|
||||
experienceRouter,
|
||||
// TODO: interactionRouter should be removed from swagger.json
|
||||
interactionRouter,
|
||||
]);
|
||||
|
||||
return [experienceRouter, interactionRouter, managementRouter, anonymousRouter];
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"tags": [
|
||||
{
|
||||
"name": "Interaction",
|
||||
"description": "Interaction endpoints are used to manage and process interactions for end-users, such as sign-in experience. Currently, all interaction endpoints are used internally as part of the authentication flow, and they are not useful to developers directly."
|
||||
"description": "Interaction endpoints are used to manage and process interactions for end-users, such as sign-in experience. Interaction endpoints are legacy endpoints that are used internally, will be replaced with Experience endpoints instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,181 +1,14 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { httpCodeToMessage } from '@logto/core-kit';
|
||||
import { cond, condArray, condString, conditionalArray, deduplicate } from '@silverhand/essentials';
|
||||
import deepmerge from 'deepmerge';
|
||||
import { findUp } from 'find-up';
|
||||
import type { IMiddleware } from 'koa-router';
|
||||
import type Router from 'koa-router';
|
||||
import { type OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { isKoaAuthMiddleware } from '#src/middleware/koa-auth/index.js';
|
||||
import type { WithGuardConfig } from '#src/middleware/koa-guard.js';
|
||||
import { isGuardMiddleware } from '#src/middleware/koa-guard.js';
|
||||
import { isPaginationMiddleware } from '#src/middleware/koa-pagination.js';
|
||||
import { type DeepPartial } from '#src/test-utils/tenant.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||
import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js';
|
||||
|
||||
import type { AnonymousRouter } from '../types.js';
|
||||
|
||||
import { managementApiAuthDescription } from './consts.js';
|
||||
import {
|
||||
buildTag,
|
||||
devFeatureTag,
|
||||
findSupplementFiles,
|
||||
normalizePath,
|
||||
pruneSwaggerDocument,
|
||||
removeUnnecessaryOperations,
|
||||
shouldThrow,
|
||||
validateSupplement,
|
||||
validateSwaggerDocument,
|
||||
} from './utils/general.js';
|
||||
import { buildOperationId, customRoutes, throwByDifference } from './utils/operation-id.js';
|
||||
import {
|
||||
buildParameters,
|
||||
paginationParameters,
|
||||
searchParameters,
|
||||
buildPathIdParameters,
|
||||
mergeParameters,
|
||||
customParameters,
|
||||
} from './utils/parameters.js';
|
||||
|
||||
const anonymousPaths = new Set<string>([
|
||||
'interaction',
|
||||
'.well-known',
|
||||
'authn',
|
||||
'swagger.json',
|
||||
'status',
|
||||
'experience',
|
||||
]);
|
||||
|
||||
const advancedSearchPaths = new Set<string>([
|
||||
'/applications',
|
||||
'/applications/:applicationId/roles',
|
||||
'/resources/:resourceId/scopes',
|
||||
'/roles/:id/applications',
|
||||
'/roles/:id/scopes',
|
||||
'/roles',
|
||||
'/roles/:id/users',
|
||||
'/users',
|
||||
'/users/:userId/roles',
|
||||
]);
|
||||
|
||||
type RouteObject = {
|
||||
path: string;
|
||||
method: OpenAPIV3.HttpMethods;
|
||||
operation: OpenAPIV3.OperationObject;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
const buildOperation = (
|
||||
method: OpenAPIV3.HttpMethods,
|
||||
stack: IMiddleware[],
|
||||
path: string,
|
||||
isAuthGuarded: boolean
|
||||
): OpenAPIV3.OperationObject => {
|
||||
const guard = stack.find((function_): function_ is WithGuardConfig<IMiddleware> =>
|
||||
isGuardMiddleware(function_)
|
||||
);
|
||||
const { params, query, body, response, status } = guard?.config ?? {};
|
||||
const pathParameters = buildParameters(params, 'path', path);
|
||||
|
||||
const hasPagination = stack.some((function_) => isPaginationMiddleware(function_));
|
||||
const queryParameters = [
|
||||
...buildParameters(query, 'query'),
|
||||
...(hasPagination ? paginationParameters : []),
|
||||
...(advancedSearchPaths.has(path) && method === 'get' ? [searchParameters] : []),
|
||||
];
|
||||
|
||||
const requestBody = body && {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: zodTypeToSwagger(body),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const hasInputGuard = Boolean(params ?? query ?? body);
|
||||
const responses: OpenAPIV3.ResponsesObject = Object.fromEntries(
|
||||
deduplicate(
|
||||
conditionalArray(status ?? 200, hasInputGuard && 400, isAuthGuarded && [401, 403])
|
||||
).map<[number, OpenAPIV3.ResponseObject]>((status) => {
|
||||
const description = httpCodeToMessage[status];
|
||||
|
||||
if (!description) {
|
||||
throw new Error(`Invalid status code ${status}.`);
|
||||
}
|
||||
|
||||
if (status === 200 || status === 201) {
|
||||
return [
|
||||
status,
|
||||
{
|
||||
description,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: response && zodTypeToSwagger(response),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [status, { description }];
|
||||
})
|
||||
);
|
||||
|
||||
const [firstSegment] = path.split('/').slice(1);
|
||||
|
||||
return {
|
||||
operationId: buildOperationId(method, path),
|
||||
tags: [buildTag(path)],
|
||||
parameters: [...pathParameters, ...queryParameters],
|
||||
requestBody,
|
||||
responses,
|
||||
security: cond(firstSegment && anonymousPaths.has(firstSegment) && []),
|
||||
};
|
||||
};
|
||||
|
||||
const isManagementApiRouter = ({ stack }: Router) =>
|
||||
stack
|
||||
.filter(({ path }) => !path.includes('.*'))
|
||||
.some(({ stack }) => stack.some((function_) => isKoaAuthMiddleware(function_)));
|
||||
|
||||
// Add more components here to cover more ID parameters in paths. For example, if there is a
|
||||
// path `/foo/:barBazId`, then add `bar-baz` to the array.
|
||||
const identifiableEntityNames = Object.freeze([
|
||||
'key',
|
||||
'connector-factory',
|
||||
'factory',
|
||||
'application',
|
||||
'connector',
|
||||
'sso-connector',
|
||||
'resource',
|
||||
'user',
|
||||
'log',
|
||||
'role',
|
||||
'scope',
|
||||
'hook',
|
||||
'domain',
|
||||
'verification',
|
||||
'organization',
|
||||
'organization-role',
|
||||
'organization-scope',
|
||||
'organization-invitation',
|
||||
]);
|
||||
|
||||
/** Additional tags that cannot be inferred from the path. */
|
||||
const additionalTags = Object.freeze(
|
||||
condArray<string>(
|
||||
'Organization applications',
|
||||
EnvSet.values.isDevFeaturesEnabled && 'Custom UI assets',
|
||||
'Organization users'
|
||||
)
|
||||
);
|
||||
assembleSwaggerDocument,
|
||||
buildManagementApiBaseDocument,
|
||||
getSupplementDocuments,
|
||||
} from './utils/documents.js';
|
||||
import { buildRouterObjects, groupRoutesByPath } from './utils/operation.js';
|
||||
|
||||
/**
|
||||
* Attach the `/swagger.json` route which returns the generated OpenAPI document for the
|
||||
|
@ -191,149 +24,18 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
|||
allRouters: R[]
|
||||
) {
|
||||
router.get('/swagger.json', async (ctx, next) => {
|
||||
/**
|
||||
* A set to store all custom routes that have been built.
|
||||
* @see {@link customRoutes}
|
||||
*/
|
||||
const builtCustomRoutes = new Set<string>();
|
||||
const routes = buildRouterObjects(allRouters, { guardCustomRoutes: true });
|
||||
const { pathMap, tags } = groupRoutesByPath(routes);
|
||||
|
||||
const routes = allRouters.flatMap<RouteObject>((router) => {
|
||||
const isAuthGuarded = isManagementApiRouter(router);
|
||||
const supplementDocuments = await getSupplementDocuments();
|
||||
|
||||
return (
|
||||
router.stack
|
||||
// Filter out universal routes (mostly like a proxy route to withtyped)
|
||||
.filter(({ path }) => !path.includes('.*'))
|
||||
.flatMap<RouteObject>(({ path: routerPath, stack, methods }) =>
|
||||
methods
|
||||
.map((method) => method.toLowerCase())
|
||||
// There is no need to show the HEAD method.
|
||||
.filter((method): method is OpenAPIV3.HttpMethods => method !== 'head')
|
||||
.map((httpMethod) => {
|
||||
const path = normalizePath(routerPath);
|
||||
const operation = buildOperation(httpMethod, stack, routerPath, isAuthGuarded);
|
||||
|
||||
if (customRoutes[`${httpMethod} ${routerPath}`]) {
|
||||
builtCustomRoutes.add(`${httpMethod} ${routerPath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
method: httpMethod,
|
||||
operation,
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// Ensure all custom routes are built.
|
||||
throwByDifference(builtCustomRoutes);
|
||||
|
||||
const pathMap = new Map<string, OpenAPIV3.PathItemObject>();
|
||||
const tags = new Set<string>();
|
||||
|
||||
// Group routes by path
|
||||
for (const { path, method, operation } of routes) {
|
||||
if (operation.tags) {
|
||||
// Collect all tags for sorting
|
||||
for (const tag of operation.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
pathMap.set(path, { ...pathMap.get(path), [method]: operation });
|
||||
}
|
||||
|
||||
const routesDirectory = await findUp('routes', {
|
||||
type: 'directory',
|
||||
cwd: fileURLToPath(import.meta.url),
|
||||
});
|
||||
assertThat(routesDirectory, new Error('Cannot find routes directory.'));
|
||||
|
||||
const supplementPaths = await findSupplementFiles(routesDirectory);
|
||||
const allSupplementDocuments = await Promise.all(
|
||||
supplementPaths.map(async (path) =>
|
||||
removeUnnecessaryOperations(
|
||||
// eslint-disable-next-line no-restricted-syntax -- trust the type here as we'll validate it later
|
||||
JSON.parse(await fs.readFile(path, 'utf8')) as DeepPartial<OpenAPIV3.Document>
|
||||
)
|
||||
)
|
||||
const baseDocument: OpenAPIV3.Document = buildManagementApiBaseDocument(
|
||||
pathMap,
|
||||
tags,
|
||||
ctx.request.origin
|
||||
);
|
||||
|
||||
// Filter out supplement documents that are for dev features when dev features are disabled.
|
||||
const supplementDocuments = allSupplementDocuments.filter(
|
||||
(supplement) =>
|
||||
EnvSet.values.isDevFeaturesEnabled ||
|
||||
!supplement.tags?.find((tag) => tag?.name === devFeatureTag)
|
||||
);
|
||||
|
||||
const baseDocument: OpenAPIV3.Document = {
|
||||
openapi: '3.0.1',
|
||||
servers: [
|
||||
{
|
||||
url: EnvSet.values.isCloud ? 'https://[tenant_id].logto.app/' : ctx.request.origin,
|
||||
description: 'Logto endpoint address.',
|
||||
},
|
||||
],
|
||||
info: {
|
||||
title: 'Logto API references',
|
||||
description:
|
||||
'API references for Logto services.' +
|
||||
condString(
|
||||
EnvSet.values.isCloud &&
|
||||
'\n\nNote: The documentation is for Logto Cloud. If you are using Logto OSS, please refer to the response of `/api/swagger.json` endpoint on your Logto instance.'
|
||||
),
|
||||
version: 'Cloud',
|
||||
},
|
||||
paths: Object.fromEntries(pathMap),
|
||||
security: [{ OAuth2: ['all'] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
OAuth2: {
|
||||
type: 'oauth2',
|
||||
description: managementApiAuthDescription,
|
||||
flows: {
|
||||
clientCredentials: {
|
||||
tokenUrl: '/oidc/token',
|
||||
scopes: {
|
||||
all: 'All scopes',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
schemas: translationSchemas,
|
||||
parameters: identifiableEntityNames.reduce(
|
||||
(previous, entityName) => ({
|
||||
...previous,
|
||||
...buildPathIdParameters(entityName),
|
||||
}),
|
||||
customParameters()
|
||||
),
|
||||
},
|
||||
tags: [...tags, ...additionalTags].map((tag) => ({ name: tag })),
|
||||
};
|
||||
|
||||
const data = supplementDocuments.reduce<OpenAPIV3.Document>(
|
||||
(document, supplement) =>
|
||||
deepmerge<OpenAPIV3.Document, DeepPartial<OpenAPIV3.Document>>(document, supplement, {
|
||||
arrayMerge: mergeParameters,
|
||||
}),
|
||||
baseDocument
|
||||
);
|
||||
|
||||
pruneSwaggerDocument(data);
|
||||
|
||||
if (EnvSet.values.isUnitTest) {
|
||||
getConsoleLogFromContext(ctx).warn('Skip validating swagger document in unit test.');
|
||||
}
|
||||
// Don't throw for integrity check in production as it has no benefit.
|
||||
else if (shouldThrow()) {
|
||||
for (const document of supplementDocuments) {
|
||||
validateSupplement(baseDocument, document);
|
||||
}
|
||||
validateSwaggerDocument(data);
|
||||
}
|
||||
const data = assembleSwaggerDocument(supplementDocuments, baseDocument, ctx);
|
||||
|
||||
ctx.body = {
|
||||
...data,
|
||||
|
|
218
packages/core/src/routes/swagger/utils/documents.ts
Normal file
218
packages/core/src/routes/swagger/utils/documents.ts
Normal file
|
@ -0,0 +1,218 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { condString, condArray } from '@silverhand/essentials';
|
||||
import deepmerge from 'deepmerge';
|
||||
import { findUp } from 'find-up';
|
||||
import { type IRouterParamContext } from 'koa-router';
|
||||
import { type OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { type DeepPartial } from '#src/test-utils/tenant.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||
import { translationSchemas } from '#src/utils/zod.js';
|
||||
|
||||
import { managementApiAuthDescription } from '../consts.js';
|
||||
|
||||
import {
|
||||
type FindSupplementFilesOptions,
|
||||
devFeatureTag,
|
||||
findSupplementFiles,
|
||||
pruneSwaggerDocument,
|
||||
removeUnnecessaryOperations,
|
||||
shouldThrow,
|
||||
validateSupplement,
|
||||
validateSwaggerDocument,
|
||||
} from './general.js';
|
||||
import { buildPathIdParameters, customParameters, mergeParameters } from './parameters.js';
|
||||
|
||||
// Add more components here to cover more ID parameters in paths. For example, if there is a
|
||||
const managementApiIdentifiableEntityNames = Object.freeze([
|
||||
'key',
|
||||
'connector-factory',
|
||||
'factory',
|
||||
'application',
|
||||
'connector',
|
||||
'sso-connector',
|
||||
'resource',
|
||||
'user',
|
||||
'log',
|
||||
'role',
|
||||
'scope',
|
||||
'hook',
|
||||
'domain',
|
||||
'verification',
|
||||
'organization',
|
||||
'organization-role',
|
||||
'organization-scope',
|
||||
'organization-invitation',
|
||||
]);
|
||||
|
||||
/** Additional tags that cannot be inferred from the path. */
|
||||
const additionalTags = Object.freeze(
|
||||
condArray<string>(
|
||||
'Organization applications',
|
||||
EnvSet.values.isDevFeaturesEnabled && 'Custom UI assets',
|
||||
'Organization users'
|
||||
)
|
||||
);
|
||||
|
||||
export const buildManagementApiBaseDocument = (
|
||||
pathMap: Map<string, OpenAPIV3.PathItemObject>,
|
||||
tags: Set<string>,
|
||||
origin: string
|
||||
): OpenAPIV3.Document => ({
|
||||
openapi: '3.0.1',
|
||||
servers: [
|
||||
{
|
||||
url: EnvSet.values.isCloud ? 'https://[tenant_id].logto.app/' : origin,
|
||||
description: 'Logto endpoint address.',
|
||||
},
|
||||
],
|
||||
info: {
|
||||
title: 'Logto API references',
|
||||
description:
|
||||
'API references for Logto services.' +
|
||||
condString(
|
||||
EnvSet.values.isCloud &&
|
||||
'\n\nNote: The documentation is for Logto Cloud. If you are using Logto OSS, please refer to the response of `/api/swagger.json` endpoint on your Logto instance.'
|
||||
),
|
||||
version: 'Cloud',
|
||||
},
|
||||
paths: Object.fromEntries(pathMap),
|
||||
security: [{ OAuth2: ['all'] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
OAuth2: {
|
||||
type: 'oauth2',
|
||||
description: managementApiAuthDescription,
|
||||
flows: {
|
||||
clientCredentials: {
|
||||
tokenUrl: '/oidc/token',
|
||||
scopes: {
|
||||
all: 'All scopes',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
schemas: translationSchemas,
|
||||
parameters: managementApiIdentifiableEntityNames.reduce(
|
||||
(previous, entityName) => ({
|
||||
...previous,
|
||||
...buildPathIdParameters(entityName),
|
||||
}),
|
||||
customParameters()
|
||||
),
|
||||
},
|
||||
tags: [...tags, ...additionalTags].map((tag) => ({ name: tag })),
|
||||
});
|
||||
|
||||
// ID parameters for experience API entities.
|
||||
const experienceIdentifiableEntityNames = Object.freeze(['connector', 'verification']);
|
||||
|
||||
export const buildExperienceApiBaseDocument = (
|
||||
pathMap: Map<string, OpenAPIV3.PathItemObject>,
|
||||
tags: Set<string>,
|
||||
origin: string
|
||||
): OpenAPIV3.Document => ({
|
||||
openapi: '3.0.1',
|
||||
servers: [
|
||||
{
|
||||
url: EnvSet.values.isCloud ? 'https://[tenant_id].logto.app/' : origin,
|
||||
description: 'Logto endpoint address.',
|
||||
},
|
||||
],
|
||||
info: {
|
||||
title: 'Logto experience API references',
|
||||
description:
|
||||
'API references for Logto experience interaction.' +
|
||||
condString(
|
||||
EnvSet.values.isCloud &&
|
||||
'\n\nNote: The documentation is for Logto Cloud. If you are using Logto OSS, please refer to the response of `/api/swagger.json` endpoint on your Logto instance.'
|
||||
),
|
||||
version: 'Cloud',
|
||||
},
|
||||
paths: Object.fromEntries(pathMap),
|
||||
security: [{ cookieAuth: ['all'] }],
|
||||
components: {
|
||||
schemas: translationSchemas,
|
||||
securitySchemes: {
|
||||
cookieAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: '_interaction',
|
||||
},
|
||||
},
|
||||
parameters: experienceIdentifiableEntityNames.reduce(
|
||||
(previous, entityName) => ({
|
||||
...previous,
|
||||
...buildPathIdParameters(entityName),
|
||||
}),
|
||||
customParameters()
|
||||
),
|
||||
},
|
||||
tags: [...tags].map((tag) => ({ name: tag })),
|
||||
});
|
||||
|
||||
export const getSupplementDocuments = async (
|
||||
directory = 'routes',
|
||||
option?: FindSupplementFilesOptions
|
||||
) => {
|
||||
// Find supplemental documents
|
||||
const routesDirectory = await findUp(directory, {
|
||||
type: 'directory',
|
||||
cwd: fileURLToPath(import.meta.url),
|
||||
});
|
||||
assertThat(routesDirectory, new Error('Cannot find routes directory.'));
|
||||
|
||||
const supplementPaths = await findSupplementFiles(routesDirectory, option);
|
||||
|
||||
const allSupplementDocuments = await Promise.all(
|
||||
supplementPaths.map(async (path) =>
|
||||
removeUnnecessaryOperations(
|
||||
// eslint-disable-next-line no-restricted-syntax -- trust the type here as we'll validate it later
|
||||
JSON.parse(await fs.readFile(path, 'utf8')) as DeepPartial<OpenAPIV3.Document>
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Filter out supplement documents that are for dev features when dev features are disabled.
|
||||
const supplementDocuments = allSupplementDocuments.filter(
|
||||
(supplement) =>
|
||||
EnvSet.values.isDevFeaturesEnabled ||
|
||||
!supplement.tags?.find((tag) => tag?.name === devFeatureTag)
|
||||
);
|
||||
|
||||
return supplementDocuments;
|
||||
};
|
||||
|
||||
export const assembleSwaggerDocument = <ContextT extends IRouterParamContext>(
|
||||
supplementDocuments: Array<DeepPartial<OpenAPIV3.Document>>,
|
||||
baseDocument: OpenAPIV3.Document,
|
||||
ctx: ContextT
|
||||
) => {
|
||||
const data = supplementDocuments.reduce<OpenAPIV3.Document>(
|
||||
(document, supplement) =>
|
||||
deepmerge<OpenAPIV3.Document, DeepPartial<OpenAPIV3.Document>>(document, supplement, {
|
||||
arrayMerge: mergeParameters,
|
||||
}),
|
||||
baseDocument
|
||||
);
|
||||
|
||||
pruneSwaggerDocument(data);
|
||||
|
||||
if (EnvSet.values.isUnitTest) {
|
||||
getConsoleLogFromContext(ctx).warn('Skip validating swagger document in unit test.');
|
||||
}
|
||||
// Don't throw for integrity check in production as it has no benefit.
|
||||
else if (shouldThrow()) {
|
||||
for (const document of supplementDocuments) {
|
||||
validateSupplement(baseDocument, document);
|
||||
}
|
||||
validateSwaggerDocument(data);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
|
@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
|
||||
import { isKeyInObject, isObject, type Optional } from '@silverhand/essentials';
|
||||
import type Router from 'koa-router';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -10,6 +11,8 @@ import { EnvSet } from '#src/env-set/index.js';
|
|||
import { type DeepPartial } from '#src/test-utils/tenant.js';
|
||||
import { devConsole } from '#src/utils/console.js';
|
||||
|
||||
import { isKoaAuthMiddleware } from '../../../middleware/koa-auth/index.js';
|
||||
|
||||
const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
|
||||
|
||||
/** The tag name used in the supplement document to indicate that the operation is cloud only. */
|
||||
|
@ -54,11 +57,28 @@ export const buildTag = (path: string) => {
|
|||
* directory.
|
||||
*/
|
||||
/* eslint-disable @silverhand/fp/no-mutating-methods, no-await-in-loop */
|
||||
export const findSupplementFiles = async (directory: string) => {
|
||||
export type FindSupplementFilesOptions = {
|
||||
excludeDirectories?: string[];
|
||||
includeDirectories?: string[];
|
||||
};
|
||||
|
||||
export const findSupplementFiles = async (
|
||||
directory: string,
|
||||
option?: FindSupplementFilesOptions
|
||||
) => {
|
||||
const result: string[] = [];
|
||||
|
||||
for (const file of await fs.readdir(directory)) {
|
||||
const stats = await fs.stat(path.join(directory, file));
|
||||
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
option?.excludeDirectories?.includes(file) ||
|
||||
(option?.includeDirectories && !option.includeDirectories.includes(file))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
result.push(...(await findSupplementFiles(path.join(directory, file))));
|
||||
} else if (file.endsWith('.openapi.json')) {
|
||||
|
@ -293,3 +313,12 @@ export const pruneSwaggerDocument = (document: OpenAPIV3.Document) => {
|
|||
|
||||
prune(document);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given router is a Management API router. The function will check if the router
|
||||
* contains the `koaAuth` middleware.
|
||||
*/
|
||||
export const isManagementApiRouter = ({ stack }: Router) =>
|
||||
stack
|
||||
.filter(({ path }) => !path.includes('.*'))
|
||||
.some(({ stack }) => stack.some((function_) => isKoaAuthMiddleware(function_)));
|
||||
|
|
183
packages/core/src/routes/swagger/utils/operation.ts
Normal file
183
packages/core/src/routes/swagger/utils/operation.ts
Normal file
|
@ -0,0 +1,183 @@
|
|||
import { httpCodeToMessage } from '@logto/core-kit';
|
||||
import { deduplicate, conditionalArray, cond } from '@silverhand/essentials';
|
||||
import type Router from 'koa-router';
|
||||
import { type IMiddleware } from 'koa-router';
|
||||
import { type OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
import { type WithGuardConfig, isGuardMiddleware } from '#src/middleware/koa-guard.js';
|
||||
import { isPaginationMiddleware } from '#src/middleware/koa-pagination.js';
|
||||
import { zodTypeToSwagger } from '#src/utils/zod.js';
|
||||
|
||||
import { buildTag, isManagementApiRouter, normalizePath } from './general.js';
|
||||
import { buildOperationId, customRoutes, throwByDifference } from './operation-id.js';
|
||||
import { buildParameters, paginationParameters, searchParameters } from './parameters.js';
|
||||
|
||||
const anonymousPaths = new Set<string>([
|
||||
'interaction',
|
||||
'.well-known',
|
||||
'authn',
|
||||
'swagger.json',
|
||||
'status',
|
||||
'experience',
|
||||
]);
|
||||
|
||||
const advancedSearchPaths = new Set<string>([
|
||||
'/applications',
|
||||
'/applications/:applicationId/roles',
|
||||
'/resources/:resourceId/scopes',
|
||||
'/roles/:id/applications',
|
||||
'/roles/:id/scopes',
|
||||
'/roles',
|
||||
'/roles/:id/users',
|
||||
'/users',
|
||||
'/users/:userId/roles',
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
const buildOperation = (
|
||||
method: OpenAPIV3.HttpMethods,
|
||||
stack: IMiddleware[],
|
||||
path: string,
|
||||
isAuthGuarded: boolean
|
||||
): OpenAPIV3.OperationObject => {
|
||||
const guard = stack.find((function_): function_ is WithGuardConfig<IMiddleware> =>
|
||||
isGuardMiddleware(function_)
|
||||
);
|
||||
const { params, query, body, response, status } = guard?.config ?? {};
|
||||
|
||||
const pathParameters = buildParameters(params, 'path', path);
|
||||
|
||||
const hasPagination = stack.some((function_) => isPaginationMiddleware(function_));
|
||||
|
||||
const queryParameters = [
|
||||
...buildParameters(query, 'query'),
|
||||
...(hasPagination ? paginationParameters : []),
|
||||
...(advancedSearchPaths.has(path) && method === 'get' ? [searchParameters] : []),
|
||||
];
|
||||
|
||||
const requestBody = body && {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: zodTypeToSwagger(body),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const hasInputGuard = Boolean(params ?? query ?? body);
|
||||
|
||||
const responses: OpenAPIV3.ResponsesObject = Object.fromEntries(
|
||||
deduplicate(
|
||||
conditionalArray(status ?? 200, hasInputGuard && 400, isAuthGuarded && [401, 403])
|
||||
).map<[number, OpenAPIV3.ResponseObject]>((status) => {
|
||||
const description = httpCodeToMessage[status];
|
||||
|
||||
if (!description) {
|
||||
throw new Error(`Invalid status code ${status}.`);
|
||||
}
|
||||
|
||||
if (status === 200 || status === 201) {
|
||||
return [
|
||||
status,
|
||||
{
|
||||
description,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: response && zodTypeToSwagger(response),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [status, { description }];
|
||||
})
|
||||
);
|
||||
|
||||
const [firstSegment] = path.split('/').slice(1);
|
||||
|
||||
return {
|
||||
operationId: buildOperationId(method, path),
|
||||
tags: [buildTag(path)],
|
||||
parameters: [...pathParameters, ...queryParameters],
|
||||
requestBody,
|
||||
responses,
|
||||
security: cond(firstSegment && anonymousPaths.has(firstSegment) && []),
|
||||
};
|
||||
};
|
||||
|
||||
type RouteObject = {
|
||||
path: string;
|
||||
method: OpenAPIV3.HttpMethods;
|
||||
operation: OpenAPIV3.OperationObject;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type UnknownRouter = Router<unknown, any>;
|
||||
|
||||
type Options = {
|
||||
guardCustomRoutes?: boolean;
|
||||
};
|
||||
|
||||
export const buildRouterObjects = <T extends UnknownRouter>(routers: T[], options?: Options) => {
|
||||
/**
|
||||
* A set to store all custom routes that have been built.
|
||||
* @see {@link customRoutes}
|
||||
*/
|
||||
const builtCustomRoutes = new Set<string>();
|
||||
|
||||
const routes = routers.flatMap<RouteObject>((router) => {
|
||||
const isAuthGuarded = isManagementApiRouter(router);
|
||||
|
||||
return (
|
||||
router.stack
|
||||
// Filter out universal routes (mostly like a proxy route to withtyped)
|
||||
.filter(({ path }) => !path.includes('.*'))
|
||||
.flatMap<RouteObject>(({ path: routerPath, stack, methods }) =>
|
||||
methods
|
||||
.map((method) => method.toLowerCase())
|
||||
// There is no need to show the HEAD method.
|
||||
.filter((method): method is OpenAPIV3.HttpMethods => method !== 'head')
|
||||
.map((httpMethod) => {
|
||||
const path = normalizePath(routerPath);
|
||||
const operation = buildOperation(httpMethod, stack, routerPath, isAuthGuarded);
|
||||
|
||||
if (options?.guardCustomRoutes && customRoutes[`${httpMethod} ${routerPath}`]) {
|
||||
builtCustomRoutes.add(`${httpMethod} ${routerPath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
method: httpMethod,
|
||||
operation,
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// Ensure all custom routes are built.
|
||||
if (options?.guardCustomRoutes) {
|
||||
throwByDifference(builtCustomRoutes);
|
||||
}
|
||||
|
||||
return routes;
|
||||
};
|
||||
|
||||
export const groupRoutesByPath = (routes: RouteObject[]) => {
|
||||
const pathMap = new Map<string, OpenAPIV3.PathItemObject>();
|
||||
const tags = new Set<string>();
|
||||
|
||||
// Group routes by path
|
||||
for (const { path, method, operation } of routes) {
|
||||
if (operation.tags) {
|
||||
// Collect all tags for sorting
|
||||
for (const tag of operation.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
pathMap.set(path, { ...pathMap.get(path), [method]: operation });
|
||||
}
|
||||
|
||||
return { pathMap, tags };
|
||||
};
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"tags": [
|
||||
{
|
||||
"name": "Well-known",
|
||||
"description": "Well-Known routes provide information and resources that can be discovered by clients without the need for authentication."
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/.well-known/sign-in-exp": {
|
||||
"get": {
|
||||
"summary": "Get full sign-in experience",
|
||||
"description": "Get the full sign-in experience configuration.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The full sign-in experience configuration."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/.well-known/phrases": {
|
||||
"get": {
|
||||
"summary": "Get localized phrases",
|
||||
"description": "Get localized phrases based on the specified language.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "lng",
|
||||
"in": "query",
|
||||
"description": "The language tag for localization."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Localized phrases for the specified language."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,12 +3,16 @@ import { z } from 'zod';
|
|||
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import { getExperienceLanguage } from '#src/utils/i18n.js';
|
||||
|
||||
import type { AnonymousRouter, RouterInitArgs } from './types.js';
|
||||
import type { AnonymousRouter } from '../types.js';
|
||||
|
||||
import experienceRoutes from './well-known.experience.js';
|
||||
|
||||
export default function wellKnownRoutes<T extends AnonymousRouter>(
|
||||
...[router, { libraries, queries, id: tenantId }]: RouterInitArgs<T>
|
||||
router: T,
|
||||
{ libraries, queries, id: tenantId }: TenantContext
|
||||
) {
|
||||
const {
|
||||
signInExperiences: { getFullSignInExperience },
|
||||
|
@ -76,4 +80,6 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
experienceRoutes(router, libraries);
|
||||
}
|
27
packages/core/src/routes/well-known/well-known.experience.ts
Normal file
27
packages/core/src/routes/well-known/well-known.experience.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { fullSignInExperienceGuard } from '@logto/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
|
||||
import { type AnonymousRouter } from '../types.js';
|
||||
|
||||
export default function experienceRoutes<T extends AnonymousRouter>(
|
||||
router: T,
|
||||
{ signInExperiences: { getFullSignInExperience } }: Libraries
|
||||
) {
|
||||
router.get(
|
||||
'/.well-known/experience',
|
||||
koaGuard({
|
||||
query: z.object({ organizationId: z.string(), appId: z.string() }).partial(),
|
||||
response: fullSignInExperienceGuard,
|
||||
status: 200,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { organizationId, appId } = ctx.guard.query;
|
||||
ctx.body = await getFullSignInExperience({ locale: ctx.locale, organizationId, appId });
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
73
packages/core/src/routes/well-known/well-known.openapi.json
Normal file
73
packages/core/src/routes/well-known/well-known.openapi.json
Normal file
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"tags": [
|
||||
{
|
||||
"name": "Well-known",
|
||||
"description": "Well-Known routes provide information and resources that can be discovered by clients without the need for authentication."
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/.well-known/sign-in-exp": {
|
||||
"get": {
|
||||
"deprecated": true,
|
||||
"summary": "Get full sign-in experience",
|
||||
"description": "Get the full sign-in experience configuration.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The full sign-in experience configuration."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/.well-known/phrases": {
|
||||
"get": {
|
||||
"summary": "Get localized phrases",
|
||||
"description": "Get localized phrases based on the specified language.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "lng",
|
||||
"in": "query",
|
||||
"description": "The language tag for localization."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Localized phrases for the specified language."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/.well-known/experience": {
|
||||
"get": {
|
||||
"summary": "Get full sign-in experience",
|
||||
"description": "Get the full sign-in experience configuration.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The full sign-in experience configuration."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/.well-known/management.openapi.json": {
|
||||
"get": {
|
||||
"summary": "Get Management API swagger JSON",
|
||||
"description": "The endpoint for the Management API JSON document. The JSON conforms to the [OpenAPI v3.0.1](https://spec.openapis.org/oas/v3.0.1) (a.k.a. Swagger) specification.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The JSON document."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/.well-known/experience.openapi.json": {
|
||||
"get": {
|
||||
"summary": "Get Experience API swagger JSON",
|
||||
"description": "The endpoint for the Experience API JSON document. The JSON conforms to the [OpenAPI v3.0.1](https://spec.openapis.org/oas/v3.0.1) (a.k.a. Swagger) specification.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The JSON document."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
65
packages/core/src/routes/well-known/well-known.openapi.ts
Normal file
65
packages/core/src/routes/well-known/well-known.openapi.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
buildExperienceApiBaseDocument,
|
||||
buildManagementApiBaseDocument,
|
||||
getSupplementDocuments,
|
||||
assembleSwaggerDocument,
|
||||
} from '#src/routes/swagger/utils/documents.js';
|
||||
import {
|
||||
buildRouterObjects,
|
||||
groupRoutesByPath,
|
||||
type UnknownRouter,
|
||||
} from '#src/routes/swagger/utils/operation.js';
|
||||
import { type AnonymousRouter } from '#src/routes/types.js';
|
||||
|
||||
type OpenApiRouters<R> = {
|
||||
managementRouters: R[];
|
||||
experienceRouters: R[];
|
||||
};
|
||||
|
||||
export default function openapiRoutes<T extends AnonymousRouter, R extends UnknownRouter>(
|
||||
router: T,
|
||||
{ managementRouters, experienceRouters }: OpenApiRouters<R>
|
||||
) {
|
||||
router.get('/.well-known/management.openapi.json', async (ctx, next) => {
|
||||
const managementApiRoutes = buildRouterObjects(managementRouters, {
|
||||
guardCustomRoutes: true,
|
||||
});
|
||||
|
||||
const { pathMap, tags } = groupRoutesByPath(managementApiRoutes);
|
||||
|
||||
// Find supplemental documents
|
||||
const supplementDocuments = await getSupplementDocuments('routes', {
|
||||
excludeDirectories: ['experience', 'interaction'],
|
||||
});
|
||||
const baseDocument = buildManagementApiBaseDocument(pathMap, tags, ctx.request.origin);
|
||||
|
||||
const data = assembleSwaggerDocument(supplementDocuments, baseDocument, ctx);
|
||||
|
||||
ctx.body = {
|
||||
...data,
|
||||
tags: data.tags?.slice().sort((tagA, tagB) => tagA.name.localeCompare(tagB.name)),
|
||||
};
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.get('/.well-known/experience.openapi.json', async (ctx, next) => {
|
||||
const experienceApiRoutes = buildRouterObjects(experienceRouters);
|
||||
const { pathMap, tags } = groupRoutesByPath(experienceApiRoutes);
|
||||
|
||||
// Find supplemental documents
|
||||
const supplementDocuments = await getSupplementDocuments('routes', {
|
||||
includeDirectories: ['experience', 'interaction'],
|
||||
});
|
||||
const baseDocument = buildExperienceApiBaseDocument(pathMap, tags, ctx.request.origin);
|
||||
|
||||
const data = assembleSwaggerDocument(supplementDocuments, baseDocument, ctx);
|
||||
|
||||
ctx.body = {
|
||||
...data,
|
||||
tags: data.tags?.slice().sort((tagA, tagB) => tagA.name.localeCompare(tagB.name)),
|
||||
};
|
||||
|
||||
return next();
|
||||
});
|
||||
}
|
|
@ -33,7 +33,7 @@ const tenantContext = new MockTenant(
|
|||
{ phrases: { getPhrases: jest.fn().mockResolvedValue(en) } }
|
||||
);
|
||||
|
||||
const phraseRoutes = await pickDefault(import('./well-known.js'));
|
||||
const phraseRoutes = await pickDefault(import('./index.js'));
|
||||
|
||||
const phraseRequest = createRequester({
|
||||
anonymousRoutes: phraseRoutes,
|
|
@ -1,5 +1,5 @@
|
|||
import zhCN from '@logto/phrases-experience/lib/locales/zh-cn/index.js';
|
||||
import type { CustomPhrase, SignInExperience } from '@logto/schemas';
|
||||
import { type CustomPhrase, type SignInExperience } from '@logto/schemas';
|
||||
import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
||||
|
||||
import { zhCnTag } from '#src/__mocks__/custom-phrase.js';
|
||||
|
@ -58,7 +58,7 @@ const tenantContext = new MockTenant(
|
|||
{ phrases: { getPhrases } }
|
||||
);
|
||||
|
||||
const phraseRoutes = await pickDefault(import('./well-known.js'));
|
||||
const phraseRoutes = await pickDefault(import('./index.js'));
|
||||
|
||||
const { createRequester } = await import('#src/utils/test-utils.js');
|
||||
const phraseRequest = createRequester({
|
|
@ -27,7 +27,7 @@ const sieQueries = {
|
|||
};
|
||||
const { findDefaultSignInExperience } = sieQueries;
|
||||
|
||||
const wellKnownRoutes = await pickDefault(import('#src/routes/well-known.js'));
|
||||
const wellKnownRoutes = await pickDefault(import('#src/routes/well-known/index.js'));
|
||||
const { createMockProvider } = await import('#src/test-utils/oidc-provider.js');
|
||||
const { MockTenant } = await import('#src/test-utils/tenant.js');
|
||||
const { createRequester } = await import('#src/utils/test-utils.js');
|
|
@ -0,0 +1,23 @@
|
|||
import * as SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import Validator from 'openapi-schema-validator';
|
||||
import { type OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
import { adminTenantApi } from '#src/api/api.js';
|
||||
|
||||
const { default: OpenApiSchemaValidator } = Validator;
|
||||
|
||||
describe('.well-known openapi.json endpoints', () => {
|
||||
it.each(['management', 'experience'])('should return %s.openapi.json', async (type) => {
|
||||
const response = await adminTenantApi.get(`.well-known/${type}.openapi.json`);
|
||||
|
||||
expect(response).toHaveProperty('status', 200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const json = await response.json<OpenAPIV3.Document>();
|
||||
|
||||
const validator = new OpenApiSchemaValidator({ version: 3 });
|
||||
const result = validator.validate(json);
|
||||
expect(result.errors).toEqual([]);
|
||||
await expect(SwaggerParser.default.validate(json)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue