From f1150eca30b215881aeb05f1056dd5c34606a1ab Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 3 Dec 2023 14:30:33 +0800 Subject: [PATCH 1/3] 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", From 5fa377dd2452172f5a20a8c737bbe23754b990ba Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 3 Dec 2023 18:28:22 +0800 Subject: [PATCH 2/3] chore(core): skip supplement validation in unit test --- packages/core/src/routes/swagger/index.test.ts | 6 +++++- packages/core/src/routes/swagger/index.ts | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/core/src/routes/swagger/index.test.ts b/packages/core/src/routes/swagger/index.test.ts index eb17eb5ea..769a64fe5 100644 --- a/packages/core/src/routes/swagger/index.test.ts +++ b/packages/core/src/routes/swagger/index.test.ts @@ -72,6 +72,7 @@ describe('GET /swagger.json', () => { const testTagRouter = new Router(); testTagRouter.get('/mock', () => ({})); testTagRouter.put('/.well-known', () => ({})); + testTagRouter.put('/sso-connectors', () => ({})); const swaggerRequest = createSwaggerRequest([testTagRouter]); const response = await swaggerRequest.get('/swagger.json'); @@ -80,7 +81,10 @@ describe('GET /swagger.json', () => { get: { tags: ['Mock'] }, }, '/api/.well-known': { - put: { tags: ['Well known'] }, + put: { tags: ['Well-known'] }, + }, + '/api/sso-connectors': { + put: { tags: ['SSO connectors'] }, }, }); }); diff --git a/packages/core/src/routes/swagger/index.ts b/packages/core/src/routes/swagger/index.ts index 3eb8bfb7d..90367af59 100644 --- a/packages/core/src/routes/swagger/index.ts +++ b/packages/core/src/routes/swagger/index.ts @@ -15,6 +15,7 @@ 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 assertThat from '#src/utils/assert-that.js'; +import { consoleLog } from '#src/utils/console.js'; import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js'; import type { AnonymousRouter } from '../types.js'; @@ -219,8 +220,12 @@ export default function swaggerRoutes ({ name: tag })), }; - for (const document of supplementDocuments) { - validateSupplement(baseDocument, document); + if (EnvSet.values.isUnitTest) { + consoleLog.warn('Skip validating supplement documents in unit test.'); + } else { + for (const document of supplementDocuments) { + validateSupplement(baseDocument, document); + } } const data = supplementDocuments.reduce( From 07e025ddb906377602a0209ac985eba3cea66620 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 3 Dec 2023 19:05:12 +0800 Subject: [PATCH 3/3] chore: add openapi-types to deps --- packages/core/package.json | 2 +- pnpm-lock.yaml | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 5a02f5bd5..c210fdf5c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -74,6 +74,7 @@ "lru-cache": "^10.0.0", "nanoid": "^5.0.1", "oidc-provider": "^8.2.2", + "openapi-types": "^12.1.3", "otplib": "^12.0.1", "p-retry": "^6.0.0", "pg-protocol": "^1.6.0", @@ -119,7 +120,6 @@ "nock": "^13.3.1", "node-mocks-http": "^1.12.1", "nodemon": "^3.0.0", - "openapi-types": "^12.1.3", "prettier": "^3.0.0", "sinon": "^17.0.0", "supertest": "^6.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b0419154..9ab3aa59d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3271,6 +3271,9 @@ importers: oidc-provider: specifier: ^8.2.2 version: 8.2.2 + openapi-types: + specifier: ^12.1.3 + version: 12.1.3 otplib: specifier: ^12.0.1 version: 12.0.1 @@ -3401,9 +3404,6 @@ importers: nodemon: specifier: ^3.0.0 version: 3.0.0 - openapi-types: - specifier: ^12.1.3 - version: 12.1.3 prettier: specifier: ^3.0.0 version: 3.0.0 @@ -17080,7 +17080,6 @@ packages: /openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - dev: true /optionator@0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==}