0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(core): validate supplement openapi document

This commit is contained in:
Gao Sun 2023-12-03 14:30:33 +08:00
parent f539b0851d
commit f1150eca30
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
8 changed files with 119 additions and 30 deletions

View file

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

View file

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

View file

@ -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": {

View file

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

View file

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

View file

@ -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<T extends AnonymousRouter, R extends Route
tags: [...tags].map((tag) => ({ name: tag })),
};
for (const document of supplementDocuments) {
validateSupplement(baseDocument, document);
}
const data = supplementDocuments.reduce(
(document, supplement) => deepmerge(document, supplement, { arrayMerge: mergeParameters }),
baseDocument

View file

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

View file

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