From f1150eca30b215881aeb05f1056dd5c34606a1ab Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 3 Dec 2023 14:30:33 +0800 Subject: [PATCH] refactor(core): validate supplement openapi document --- .../connector/authorization-uri.openapi.json | 2 +- .../connector/config-testing.openapi.json | 2 +- packages/core/src/routes/log.openapi.json | 4 +- .../sign-in-experience/index.openapi.json | 6 +- .../routes/sso-connector/index.openapi.json | 9 +- packages/core/src/routes/swagger/index.ts | 11 +- .../core/src/routes/swagger/utils/general.ts | 102 ++++++++++++++++-- .../core/src/routes/well-known.openapi.json | 13 +-- 8 files changed, 119 insertions(+), 30 deletions(-) diff --git a/packages/core/src/routes/connector/authorization-uri.openapi.json b/packages/core/src/routes/connector/authorization-uri.openapi.json index 7dd88870d..3381f429e 100644 --- a/packages/core/src/routes/connector/authorization-uri.openapi.json +++ b/packages/core/src/routes/connector/authorization-uri.openapi.json @@ -1,6 +1,6 @@ { "paths": { - "/api/connectors/{id}/authorization-uri": { + "/api/connectors/{connectorId}/authorization-uri": { "post": { "summary": "Get connector's authorization URI", "description": "Get authorization URI for specified connector by providing redirect URI and randomly generated state.", diff --git a/packages/core/src/routes/connector/config-testing.openapi.json b/packages/core/src/routes/connector/config-testing.openapi.json index 144c14e0c..d090cedf3 100644 --- a/packages/core/src/routes/connector/config-testing.openapi.json +++ b/packages/core/src/routes/connector/config-testing.openapi.json @@ -1,6 +1,6 @@ { "paths": { - "/api/connectors/:factoryId/test": { + "/api/connectors/{factoryId}/test": { "post": { "summary": "Test passwordless connector", "description": "Test a passwordless (email or SMS) connector by sending a test message to the given phone number or email address.", diff --git a/packages/core/src/routes/log.openapi.json b/packages/core/src/routes/log.openapi.json index 5c5fb9a00..19468ddd5 100644 --- a/packages/core/src/routes/log.openapi.json +++ b/packages/core/src/routes/log.openapi.json @@ -1,8 +1,8 @@ { "tags": [ { - "name": "Logs", - "description": "Logs (audit logs) are used to track end-user activities in Logto sign-in experience and other flows. It does not include activities in Logto Console." + "name": "Audit logs", + "description": "Audit logs are used to track end-user activities in Logto sign-in experience and other flows. It does not include activities in Logto Console." } ], "paths": { diff --git a/packages/core/src/routes/sign-in-experience/index.openapi.json b/packages/core/src/routes/sign-in-experience/index.openapi.json index f29aeaedb..7a5b60d68 100644 --- a/packages/core/src/routes/sign-in-experience/index.openapi.json +++ b/packages/core/src/routes/sign-in-experience/index.openapi.json @@ -1,7 +1,7 @@ { "tags": [ { - "name": "Sign in exp", + "name": "Sign-in experience", "description": "Set the Sign-in experience configuration to customize your sign-in experience." } ], @@ -24,10 +24,10 @@ "description": "The language detection policy for the sign-in page." }, "signIn": { - "description": "Sign-in method settings" + "description": "Sign-in method settings." }, "signUp": { - "description": "Sign-up method settings", + "description": "Sign-up method settings.", "properties": { "identifiers": { "description": "Allowed identifiers when signing-up." diff --git a/packages/core/src/routes/sso-connector/index.openapi.json b/packages/core/src/routes/sso-connector/index.openapi.json index 5666e4b11..9b7cd35b5 100644 --- a/packages/core/src/routes/sso-connector/index.openapi.json +++ b/packages/core/src/routes/sso-connector/index.openapi.json @@ -1,13 +1,16 @@ { "tags": [ { - "name": "Sso connectors", - "description": "APIs for managing single sign-on (SSO) connectors. Your sign-in experience can use these well-configured SSO connectors to authenticate users and sync user attributes from external identity providers (IdPs).\n\nSSO connectors are created by SSO connector factories." + "name": "SSO connectors", + "description": "Endpoints for managing single sign-on (SSO) connectors. Your sign-in experience can use these well-configured SSO connectors to authenticate users and sync user attributes from external identity providers (IdPs).\n\nSSO connectors are created by SSO connector factories." + }, + { + "name": "SSO connector factories", + "description": "Endpoints for SSO (single sign-on) connector factories.\n\nSSO connector factories provide the metadata and configuration templates for creating SSO connectors." } ], "paths": { "/api/sso-connector-factories": { - "summary": "APIs for SSO (single sign-on) connector factories", "description": "SSO connector factories are used to create Enterprise SSO connectors. The created connectors are used to connect to external SSO providers.", "get": { "summary": "Get SSO connector factories", diff --git a/packages/core/src/routes/swagger/index.ts b/packages/core/src/routes/swagger/index.ts index 5deee4c7e..3eb8bfb7d 100644 --- a/packages/core/src/routes/swagger/index.ts +++ b/packages/core/src/routes/swagger/index.ts @@ -19,7 +19,12 @@ import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js'; import type { AnonymousRouter } from '../types.js'; -import { buildTag, findSupplementFiles, normalizePath } from './utils/general.js'; +import { + buildTag, + findSupplementFiles, + normalizePath, + validateSupplement, +} from './utils/general.js'; import { buildParameters, paginationParameters, @@ -214,6 +219,10 @@ export default function swaggerRoutes ({ name: tag })), }; + for (const document of supplementDocuments) { + validateSupplement(baseDocument, document); + } + const data = supplementDocuments.reduce( (document, supplement) => deepmerge(document, supplement, { arrayMerge: mergeParameters }), baseDocument diff --git a/packages/core/src/routes/swagger/utils/general.ts b/packages/core/src/routes/swagger/utils/general.ts index a43fe2dc2..3d2cecae8 100644 --- a/packages/core/src/routes/swagger/utils/general.ts +++ b/packages/core/src/routes/swagger/utils/general.ts @@ -1,24 +1,41 @@ +import assert from 'node:assert'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { isKeyInObject, type Optional } from '@silverhand/essentials'; +import { OpenAPIV3 } from 'openapi-types'; +import { z } from 'zod'; + const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); /** * Get the root component name from the given absolute path. - * @example '/organization/:id' -> 'organization' + * @example '/organizations/:id' -> 'organizations' */ export const getRootComponent = (path?: string) => path?.split('/')[1]; +/** Map from root component name to tag name. */ +const tagMap = new Map([ + ['logs', 'Audit logs'], + ['sign-in-exp', 'Sign-in experience'], + ['sso-connectors', 'SSO connectors'], + ['sso-connector-factories', 'SSO connector factories'], + ['.well-known', 'Well-known'], +]); + /** - * Build a tag name from the given absolute path. The tag name is the sentence case of the root - * component name. + * Build a tag name from the given absolute path. The function will get the root component name + * from the path and try to find the mapping in the {@link tagMap}. If the mapping is not found, + * the function will convert the name to sentence case. + * * @example '/organization-roles' -> 'Organization roles' + * @example '/logs/:id' -> 'Audit logs' + * @see {@link tagMap} for the full list of mappings. */ export const buildTag = (path: string) => { - const rootComponent = (getRootComponent(path) ?? 'General').replaceAll('-', ' '); - return rootComponent.startsWith('.') - ? capitalize(rootComponent.slice(1)) - : capitalize(rootComponent); + const rootComponent = getRootComponent(path); + assert(rootComponent, `Cannot find root component for path ${path}.`); + return tagMap.get(rootComponent) ?? capitalize(rootComponent).replaceAll('-', ' '); }; /** @@ -54,3 +71,74 @@ export const normalizePath = (path: string) => .split('/') .map((part) => (part.startsWith(':') ? `{${part.slice(1)}}` : part)) .join('/'); + +/** + * Check if the supplement paths only contains operations (path + method) that are in the original + * paths. The function will also check if the supplement operations contain `tags` property, which + * is not allowed in our case. + */ +const validateSupplementPaths = ( + originalPaths: Map>, + supplementPaths: Map +) => { + for (const [path, operations] of supplementPaths) { + if (!operations) { + continue; + } + + const originalOperations = originalPaths.get(path); + assert(originalOperations, `Supplement document contains extra path: \`${path}\`.`); + + assert( + typeof operations === 'object' && !Array.isArray(operations), + `Supplement document contains invalid operations on path \`${path}\`.` + ); + + const originalKeys = new Set(Object.keys(originalOperations)); + for (const method of Object.values(OpenAPIV3.HttpMethods)) { + if (isKeyInObject(operations, method)) { + if (!originalKeys.has(method)) { + throw new TypeError( + `Supplement document contains extra operation \`${method}\` on path \`${path}\`.` + ); + } + + if (isKeyInObject(operations[method], 'tags')) { + throw new TypeError( + `Cannot use \`tags\` in supplement document on path \`${path}\` and operation \`${method}\`. Define tags in the document root instead.` + ); + } + } + } + } +}; + +/** + * Check if the supplement document only contains operations (path + method) and tags that are in + * the original document. + * + * @throws {TypeError} if the supplement data contains extra operations that are not in the + * original data. + */ +export const validateSupplement = ( + original: OpenAPIV3.Document, + supplement: Record +) => { + if (supplement.tags) { + const supplementTags = z.array(z.object({ name: z.string() })).parse(supplement.tags); + const originalTags = new Set(original.tags?.map((tag) => tag.name)); + + for (const { name } of supplementTags) { + if (!originalTags.has(name)) { + throw new TypeError(`Supplement document contains extra tag \`${name}\`.`); + } + } + } + + if (supplement.paths) { + validateSupplementPaths( + new Map(Object.entries(original.paths)), + new Map(Object.entries(supplement.paths)) + ); + } +}; diff --git a/packages/core/src/routes/well-known.openapi.json b/packages/core/src/routes/well-known.openapi.json index 1291aa233..e9e7f9d8a 100644 --- a/packages/core/src/routes/well-known.openapi.json +++ b/packages/core/src/routes/well-known.openapi.json @@ -1,22 +1,11 @@ { "tags": [ { - "name": "Well-Known", + "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/endpoints/{tenantId}": { - "get": { - "summary": "Get tenant endpoint", - "description": "Get the endpoint for the specified tenant.", - "responses": { - "200": { - "description": "The tenant endpoint." - } - } - } - }, "/api/.well-known/sign-in-exp": { "get": { "summary": "Get full sign-in experience",