0
Fork 0
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:
Gao Sun 2023-12-03 19:01:15 +08:00
parent 495f4d5e80
commit b05fb2960d
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
7 changed files with 142 additions and 21 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/core": patch
---
add summary and description to APIs

View file

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

View file

@ -1,4 +1,10 @@
{
"tags": [
{
"name": "Swagger.json",
"description": "Endpoints for the Swagger JSON document."
}
],
"paths": {
"/api/swagger.json": {
"get": {

View file

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

View file

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

View file

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

View 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."
}
}
}
}
}
}