0
Fork 0
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:
simeng-li 2024-08-19 09:37:23 +08:00 committed by GitHub
parent e9eb212f48
commit cea8aa1120
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 656 additions and 360 deletions

View file

@ -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];

View file

@ -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."
}
]
}

View file

@ -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,

View 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;
};

View file

@ -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_)));

View 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 };
};

View file

@ -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."
}
}
}
}
}
}

View file

@ -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);
}

View 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();
}
);
}

View 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."
}
}
}
}
}
}

View 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();
});
}

View file

@ -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,

View file

@ -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({

View file

@ -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');

View file

@ -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();
});
});