mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
chore(core): enforce summary and description for Management APIs
This commit is contained in:
parent
495f4d5e80
commit
b05fb2960d
7 changed files with 142 additions and 21 deletions
5
.changeset/calm-ladybugs-doubt.md
Normal file
5
.changeset/calm-ladybugs-doubt.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/core": patch
|
||||
---
|
||||
|
||||
add summary and description to APIs
|
|
@ -12,26 +12,27 @@
|
|||
"description": "Get all custom phrases for all languages.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A list of custom phrases."
|
||||
"description": "An array of custom phrases."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/custom-phrases/{languageTag}": {
|
||||
"get": {
|
||||
"summary": "Get custom phrases by language tag",
|
||||
"summary": "Get custom phrases",
|
||||
"description": "Get custom phrases for the specified language tag.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Custom phrases for the specified language tag."
|
||||
},
|
||||
"404": {
|
||||
"description": "Custom phrases not found for the specified language tag."
|
||||
"description": "Custom phrases not found."
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Upsert custom phrases by language tag",
|
||||
"summary": "Upsert custom phrases",
|
||||
"description": "Upsert custom phrases for the specified language tag. Upsert means that if the custom phrases already exist, they will be updated. Otherwise, they will be created.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
|
@ -45,7 +46,7 @@
|
|||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Custom phrases created/updated successfully."
|
||||
"description": "Custom phrases created or updated successfully."
|
||||
},
|
||||
"422": {
|
||||
"description": "Invalid translation structure."
|
||||
|
@ -53,16 +54,17 @@
|
|||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "Delete custom phrases by language tag",
|
||||
"summary": "Delete custom phrase",
|
||||
"description": "Delete custom phrases for the specified language tag.",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Custom phrases deleted successfully."
|
||||
},
|
||||
"404": {
|
||||
"description": "Custom phrases not found for the specified language tag."
|
||||
"description": "Custom phrases not found."
|
||||
},
|
||||
"409": {
|
||||
"description": "Cannot delete default language."
|
||||
"description": "Cannot delete the default language."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"tags": [
|
||||
{
|
||||
"name": "Swagger.json",
|
||||
"description": "Endpoints for the Swagger JSON document."
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/swagger.json": {
|
||||
"get": {
|
||||
|
|
|
@ -25,6 +25,7 @@ import {
|
|||
findSupplementFiles,
|
||||
normalizePath,
|
||||
validateSupplement,
|
||||
validateSwaggerDocument,
|
||||
} from './utils/general.js';
|
||||
import {
|
||||
buildParameters,
|
||||
|
@ -220,19 +221,20 @@ 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
|
||||
);
|
||||
|
||||
if (EnvSet.values.isUnitTest) {
|
||||
consoleLog.warn('Skip validating swagger document in unit test.');
|
||||
} else {
|
||||
for (const document of supplementDocuments) {
|
||||
validateSupplement(baseDocument, document);
|
||||
}
|
||||
validateSwaggerDocument(data);
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
...data,
|
||||
tags: data.tags?.slice().sort((tagA, tagB) => tagA.name.localeCompare(tagB.name)),
|
||||
|
|
|
@ -6,6 +6,9 @@ import { isKeyInObject, type Optional } from '@silverhand/essentials';
|
|||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { consoleLog } from '#src/utils/console.js';
|
||||
|
||||
const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
|
||||
|
||||
/**
|
||||
|
@ -142,3 +145,55 @@ export const validateSupplement = (
|
|||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given OpenAPI document is valid for being served as the swagger document:
|
||||
*
|
||||
* - Every path + method combination must have a tag, summary, and description.
|
||||
* - Every tag must have a description.
|
||||
*
|
||||
* @throws {TypeError} if the document is invalid.
|
||||
*/
|
||||
export const validateSwaggerDocument = (document: OpenAPIV3.Document) => {
|
||||
for (const [path, operations] of Object.entries(document.paths)) {
|
||||
if (!EnvSet.values.isProduction && path.startsWith('/api/interaction')) {
|
||||
consoleLog.warn(`Path \`${path}\` is not documented. Do something!`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!operations) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
|
||||
const operation = operations[method];
|
||||
|
||||
if (!operation) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(operation)) {
|
||||
throw new TypeError(
|
||||
`Path \`${path}\` and operation \`${method}\` must be an object, not an array.`
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
operation.tags?.length,
|
||||
`Path \`${path}\` and operation \`${method}\` must have at least one tag.`
|
||||
);
|
||||
assert(
|
||||
operation.summary,
|
||||
`Path \`${path}\` and operation \`${method}\` must have a summary.`
|
||||
);
|
||||
assert(
|
||||
operation.description,
|
||||
`Path \`${path}\` and operation \`${method}\` must have a description.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const tag of document.tags ?? []) {
|
||||
assert(tag.description, `Tag \`${tag.name}\` must have a description.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -120,14 +120,33 @@ const isObjectArray = (value: unknown): value is Array<Record<string, unknown>>
|
|||
* Merge two arrays. If the two arrays are both object arrays, merge them with the following
|
||||
* rules:
|
||||
*
|
||||
* - If the source array has an item with `name` and `in` properties, and the destination array
|
||||
* - If the source array has an item with `name` properties, and the destination array
|
||||
* also has an item with the same `name` and `in` properties, merge the two items with
|
||||
* `deepmerge`.
|
||||
* `deepmerge`. It is assumed that the two items are using `name` for identifying the same
|
||||
* parameter, and may use `in` to distinguish the same parameter in different places.
|
||||
* - Otherwise, append the item to the destination array (the default behavior of
|
||||
* `deepmerge`).
|
||||
*
|
||||
* Otherwise, use `deepmerge` to merge the two arrays.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* mergeParameters(
|
||||
* [{ name: 'foo', in: 'query', required: true }, { name: 'bar', in: 'query', required: true }],
|
||||
* [{ name: 'foo', in: 'query', required: false }]
|
||||
* ); // [{ name: 'foo', in: 'query', required: false }, { name: 'bar', in: 'query', required: true }]
|
||||
*
|
||||
* mergeParameters(
|
||||
* [{ name: 'foo', required: true }, { name: 'bar', required: true }],
|
||||
* [{ name: 'foo', in: 'query', required: false }]
|
||||
* );
|
||||
* // [
|
||||
* // { name: 'foo', required: true },
|
||||
* // { name: 'bar', required: true },
|
||||
* // { name: 'foo', in: 'query', required: false }
|
||||
* // ]
|
||||
* ```
|
||||
*
|
||||
* @param destination The destination array.
|
||||
* @param source The source array.
|
||||
* @returns The merged array.
|
||||
|
@ -140,7 +159,7 @@ export const mergeParameters = (destination: unknown[], source: unknown[]) => {
|
|||
const result = destination.slice();
|
||||
|
||||
for (const item of source) {
|
||||
if (!('name' in item) || !('in' in item)) {
|
||||
if (!('name' in item)) {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
result.push(item);
|
||||
continue;
|
||||
|
|
32
packages/core/src/routes/user-assets.openapi.json
Normal file
32
packages/core/src/routes/user-assets.openapi.json
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"tags": [
|
||||
{
|
||||
"name": "User assets",
|
||||
"description": "Endpoints for managing user uploaded assets."
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/user-assets/service-status": {
|
||||
"get": {
|
||||
"summary": "Get service status",
|
||||
"description": "Get user assets service status.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "An object containing the service status and metadata."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/user-assets": {
|
||||
"post": {
|
||||
"summary": "Upload asset",
|
||||
"description": "Upload a user asset.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "An object containing the uploaded asset metadata."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue