diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index e447ae63f..9d21f4274 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -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]; diff --git a/packages/core/src/routes/interaction/index.openapi.json b/packages/core/src/routes/interaction/index.openapi.json index 93e1d5ed4..9e121858e 100644 --- a/packages/core/src/routes/interaction/index.openapi.json +++ b/packages/core/src/routes/interaction/index.openapi.json @@ -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." } ] } diff --git a/packages/core/src/routes/swagger/index.ts b/packages/core/src/routes/swagger/index.ts index 315c5f927..3e7f57bb9 100644 --- a/packages/core/src/routes/swagger/index.ts +++ b/packages/core/src/routes/swagger/index.ts @@ -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([ - 'interaction', - '.well-known', - 'authn', - 'swagger.json', - 'status', - 'experience', -]); - -const advancedSearchPaths = new Set([ - '/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 => - 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( - '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 { - /** - * A set to store all custom routes that have been built. - * @see {@link customRoutes} - */ - const builtCustomRoutes = new Set(); + const routes = buildRouterObjects(allRouters, { guardCustomRoutes: true }); + const { pathMap, tags } = groupRoutesByPath(routes); - const routes = allRouters.flatMap((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(({ 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(); - const tags = new Set(); - - // 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 - ) - ) + 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( - (document, supplement) => - deepmerge>(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, diff --git a/packages/core/src/routes/swagger/utils/documents.ts b/packages/core/src/routes/swagger/utils/documents.ts new file mode 100644 index 000000000..651383ddd --- /dev/null +++ b/packages/core/src/routes/swagger/utils/documents.ts @@ -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( + 'Organization applications', + EnvSet.values.isDevFeaturesEnabled && 'Custom UI assets', + 'Organization users' + ) +); + +export const buildManagementApiBaseDocument = ( + pathMap: Map, + tags: Set, + 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, + tags: Set, + 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 + ) + ) + ); + + // 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 = ( + supplementDocuments: Array>, + baseDocument: OpenAPIV3.Document, + ctx: ContextT +) => { + const data = supplementDocuments.reduce( + (document, supplement) => + deepmerge>(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; +}; diff --git a/packages/core/src/routes/swagger/utils/general.ts b/packages/core/src/routes/swagger/utils/general.ts index a8d7522c3..798b43217 100644 --- a/packages/core/src/routes/swagger/utils/general.ts +++ b/packages/core/src/routes/swagger/utils/general.ts @@ -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_))); diff --git a/packages/core/src/routes/swagger/utils/operation.ts b/packages/core/src/routes/swagger/utils/operation.ts new file mode 100644 index 000000000..4a3b726b1 --- /dev/null +++ b/packages/core/src/routes/swagger/utils/operation.ts @@ -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([ + 'interaction', + '.well-known', + 'authn', + 'swagger.json', + 'status', + 'experience', +]); + +const advancedSearchPaths = new Set([ + '/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 => + 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; + +type Options = { + guardCustomRoutes?: boolean; +}; + +export const buildRouterObjects = (routers: T[], options?: Options) => { + /** + * A set to store all custom routes that have been built. + * @see {@link customRoutes} + */ + const builtCustomRoutes = new Set(); + + const routes = routers.flatMap((router) => { + const isAuthGuarded = isManagementApiRouter(router); + + return ( + router.stack + // Filter out universal routes (mostly like a proxy route to withtyped) + .filter(({ path }) => !path.includes('.*')) + .flatMap(({ 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(); + const tags = new Set(); + + // 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 }; +}; diff --git a/packages/core/src/routes/well-known.openapi.json b/packages/core/src/routes/well-known.openapi.json deleted file mode 100644 index e9e7f9d8a..000000000 --- a/packages/core/src/routes/well-known.openapi.json +++ /dev/null @@ -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." - } - } - } - } - } -} diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known/index.ts similarity index 88% rename from packages/core/src/routes/well-known.ts rename to packages/core/src/routes/well-known/index.ts index 416d4b451..a54888bbe 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known/index.ts @@ -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( - ...[router, { libraries, queries, id: tenantId }]: RouterInitArgs + router: T, + { libraries, queries, id: tenantId }: TenantContext ) { const { signInExperiences: { getFullSignInExperience }, @@ -76,4 +80,6 @@ export default function wellKnownRoutes( return next(); } ); + + experienceRoutes(router, libraries); } diff --git a/packages/core/src/routes/well-known/well-known.experience.ts b/packages/core/src/routes/well-known/well-known.experience.ts new file mode 100644 index 000000000..5e8331720 --- /dev/null +++ b/packages/core/src/routes/well-known/well-known.experience.ts @@ -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( + 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(); + } + ); +} diff --git a/packages/core/src/routes/well-known/well-known.openapi.json b/packages/core/src/routes/well-known/well-known.openapi.json new file mode 100644 index 000000000..f475da57b --- /dev/null +++ b/packages/core/src/routes/well-known/well-known.openapi.json @@ -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." + } + } + } + } + } +} diff --git a/packages/core/src/routes/well-known/well-known.openapi.ts b/packages/core/src/routes/well-known/well-known.openapi.ts new file mode 100644 index 000000000..c4cad16fc --- /dev/null +++ b/packages/core/src/routes/well-known/well-known.openapi.ts @@ -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 = { + managementRouters: R[]; + experienceRouters: R[]; +}; + +export default function openapiRoutes( + router: T, + { managementRouters, experienceRouters }: OpenApiRouters +) { + 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(); + }); +} diff --git a/packages/core/src/routes/well-known.phrases.content-language.test.ts b/packages/core/src/routes/well-known/well-known.phrases.content-language.test.ts similarity index 98% rename from packages/core/src/routes/well-known.phrases.content-language.test.ts rename to packages/core/src/routes/well-known/well-known.phrases.content-language.test.ts index ccb017e7b..1dfc4a761 100644 --- a/packages/core/src/routes/well-known.phrases.content-language.test.ts +++ b/packages/core/src/routes/well-known/well-known.phrases.content-language.test.ts @@ -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, diff --git a/packages/core/src/routes/well-known.phrases.test.ts b/packages/core/src/routes/well-known/well-known.phrases.test.ts similarity index 97% rename from packages/core/src/routes/well-known.phrases.test.ts rename to packages/core/src/routes/well-known/well-known.phrases.test.ts index bd2811d21..b4c5cf175 100644 --- a/packages/core/src/routes/well-known.phrases.test.ts +++ b/packages/core/src/routes/well-known/well-known.phrases.test.ts @@ -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({ diff --git a/packages/core/src/routes/well-known.test.ts b/packages/core/src/routes/well-known/well-known.test.ts similarity index 99% rename from packages/core/src/routes/well-known.test.ts rename to packages/core/src/routes/well-known/well-known.test.ts index f1f6432eb..247c6cf0c 100644 --- a/packages/core/src/routes/well-known.test.ts +++ b/packages/core/src/routes/well-known/well-known.test.ts @@ -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'); diff --git a/packages/integration-tests/src/tests/api/well-known.openapi.test.ts b/packages/integration-tests/src/tests/api/well-known.openapi.test.ts new file mode 100644 index 000000000..1dde36fc1 --- /dev/null +++ b/packages/integration-tests/src/tests/api/well-known.openapi.test.ts @@ -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(); + + const validator = new OpenApiSchemaValidator({ version: 3 }); + const result = validator.validate(json); + expect(result.errors).toEqual([]); + await expect(SwaggerParser.default.validate(json)).resolves.not.toThrow(); + }); +});