mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #5045 from logto-io/gao-validate-supplement-document
This commit is contained in:
commit
d8d420c812
11 changed files with 133 additions and 36 deletions
|
@ -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",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,11 +15,17 @@ 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';
|
||||
|
||||
import { buildTag, findSupplementFiles, normalizePath } from './utils/general.js';
|
||||
import {
|
||||
buildTag,
|
||||
findSupplementFiles,
|
||||
normalizePath,
|
||||
validateSupplement,
|
||||
} from './utils/general.js';
|
||||
import {
|
||||
buildParameters,
|
||||
paginationParameters,
|
||||
|
@ -214,6 +220,14 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
|||
tags: [...tags].map((tag) => ({ name: tag })),
|
||||
};
|
||||
|
||||
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(
|
||||
(document, supplement) => deepmerge(document, supplement, { arrayMerge: mergeParameters }),
|
||||
baseDocument
|
||||
|
|
|
@ -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<string, Optional<OpenAPIV3.PathItemObject>>,
|
||||
supplementPaths: Map<string, unknown>
|
||||
) => {
|
||||
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<string, unknown>
|
||||
) => {
|
||||
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))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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==}
|
||||
|
|
Loading…
Reference in a new issue