mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
feat(core): append additional yaml responses to swagger.json (#1407)
This commit is contained in:
parent
d75dc24e33
commit
100bffbc6a
5 changed files with 248 additions and 291 deletions
|
@ -50,6 +50,7 @@
|
||||||
"iconv-lite": "0.6.3",
|
"iconv-lite": "0.6.3",
|
||||||
"inquirer": "^8.2.2",
|
"inquirer": "^8.2.2",
|
||||||
"jose": "^4.0.0",
|
"jose": "^4.0.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
"koa": "^2.13.1",
|
"koa": "^2.13.1",
|
||||||
"koa-body": "^5.0.0",
|
"koa-body": "^5.0.0",
|
||||||
"koa-compose": "^4.1.0",
|
"koa-compose": "^4.1.0",
|
||||||
|
@ -81,6 +82,7 @@
|
||||||
"@types/etag": "^1.8.1",
|
"@types/etag": "^1.8.1",
|
||||||
"@types/inquirer": "^8.2.1",
|
"@types/inquirer": "^8.2.1",
|
||||||
"@types/jest": "^27.4.1",
|
"@types/jest": "^27.4.1",
|
||||||
|
"@types/js-yaml": "^4.0.5",
|
||||||
"@types/koa": "^2.13.3",
|
"@types/koa": "^2.13.3",
|
||||||
"@types/koa-compose": "^3.2.5",
|
"@types/koa-compose": "^3.2.5",
|
||||||
"@types/koa-logger": "^3.1.1",
|
"@types/koa-logger": "^3.1.1",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { load } from 'js-yaml';
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
@ -7,9 +8,13 @@ import koaGuard from '@/middleware/koa-guard';
|
||||||
import koaPagination from '@/middleware/koa-pagination';
|
import koaPagination from '@/middleware/koa-pagination';
|
||||||
import { AnonymousRouter } from '@/routes/types';
|
import { AnonymousRouter } from '@/routes/types';
|
||||||
|
|
||||||
import swaggerRoutes, { paginationParameters } from './swagger';
|
import swaggerRoutes, { defaultResponses, paginationParameters } from './swagger';
|
||||||
|
|
||||||
const createSwaggerRequest = (
|
jest.mock('js-yaml', () => ({
|
||||||
|
load: jest.fn().mockReturnValue({ paths: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const createSwaggerRequest = (
|
||||||
allRouters: Array<Router<unknown, any>>,
|
allRouters: Array<Router<unknown, any>>,
|
||||||
swaggerRouter: AnonymousRouter = new Router()
|
swaggerRouter: AnonymousRouter = new Router()
|
||||||
) => {
|
) => {
|
||||||
|
@ -233,4 +238,60 @@ describe('GET /swagger.json', () => {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should use correct responses', () => {
|
||||||
|
it('should use "defaultResponses" if there is no custom "responses" from the additional swagger', async () => {
|
||||||
|
(load as jest.Mock).mockReturnValueOnce({
|
||||||
|
paths: { '/api/mock': { delete: {} } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const swaggerRequest = createSwaggerRequest([mockRouter]);
|
||||||
|
const response = await swaggerRequest.get('/swagger.json');
|
||||||
|
expect(response.body.paths).toMatchObject({
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
'/api/mock': expect.objectContaining({
|
||||||
|
delete: expect.objectContaining({ responses: defaultResponses }),
|
||||||
|
}),
|
||||||
|
/* eslint-enable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom "responses" from the additional swagger if it exists', async () => {
|
||||||
|
(load as jest.Mock).mockReturnValueOnce({
|
||||||
|
paths: {
|
||||||
|
'/api/mock': {
|
||||||
|
get: {
|
||||||
|
responses: {
|
||||||
|
'204': { description: 'No Content' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
patch: {
|
||||||
|
responses: {
|
||||||
|
'202': { description: 'Accepted' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const swaggerRequest = createSwaggerRequest([mockRouter]);
|
||||||
|
const response = await swaggerRequest.get('/swagger.json');
|
||||||
|
expect(response.body.paths).toMatchObject({
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
'/api/mock': {
|
||||||
|
get: expect.objectContaining({
|
||||||
|
responses: {
|
||||||
|
'204': { description: 'No Content' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
patch: expect.objectContaining({
|
||||||
|
responses: {
|
||||||
|
'202': { description: 'Accepted' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
/* eslint-enable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
import { toTitle } from '@silverhand/essentials';
|
import { toTitle } from '@silverhand/essentials';
|
||||||
|
import { load } from 'js-yaml';
|
||||||
import Router, { IMiddleware } from 'koa-router';
|
import Router, { IMiddleware } from 'koa-router';
|
||||||
import { OpenAPIV3 } from 'openapi-types';
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
import { ZodObject, ZodOptional } from 'zod';
|
import { ZodObject, ZodOptional } from 'zod';
|
||||||
|
|
||||||
import { isGuardMiddleware, WithGuardConfig } from '@/middleware/koa-guard';
|
import { isGuardMiddleware, WithGuardConfig } from '@/middleware/koa-guard';
|
||||||
import { isPaginationMiddleware, fallbackDefaultPageSize } from '@/middleware/koa-pagination';
|
import { fallbackDefaultPageSize, isPaginationMiddleware } from '@/middleware/koa-pagination';
|
||||||
import assertThat from '@/utils/assert-that';
|
import assertThat from '@/utils/assert-that';
|
||||||
import { zodTypeToSwagger } from '@/utils/zod';
|
import { zodTypeToSwagger } from '@/utils/zod';
|
||||||
|
|
||||||
|
@ -62,7 +65,7 @@ const buildParameters = (
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildTag(path: string) {
|
const buildTag = (path: string) => {
|
||||||
const root = path.split('/')[1];
|
const root = path.split('/')[1];
|
||||||
|
|
||||||
if (root?.startsWith('.')) {
|
if (root?.startsWith('.')) {
|
||||||
|
@ -70,13 +73,29 @@ function buildTag(path: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return toTitle(root ?? 'General');
|
return toTitle(root ?? 'General');
|
||||||
}
|
};
|
||||||
|
|
||||||
const buildOperation = (stack: IMiddleware[], path: string): OpenAPIV3.OperationObject => {
|
export const defaultResponses: OpenAPIV3.ResponsesObject = {
|
||||||
|
'200': {
|
||||||
|
description: 'OK',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildOperation = (
|
||||||
|
stack: IMiddleware[],
|
||||||
|
path: string,
|
||||||
|
customResponses?: OpenAPIV3.ResponsesObject
|
||||||
|
): OpenAPIV3.OperationObject => {
|
||||||
const guard = stack.find((function_): function_ is WithGuardConfig<IMiddleware> =>
|
const guard = stack.find((function_): function_ is WithGuardConfig<IMiddleware> =>
|
||||||
isGuardMiddleware(function_)
|
isGuardMiddleware(function_)
|
||||||
);
|
);
|
||||||
|
const pathParameters = buildParameters(guard?.config.params, 'path');
|
||||||
|
|
||||||
const hasPagination = stack.some((function_) => isPaginationMiddleware(function_));
|
const hasPagination = stack.some((function_) => isPaginationMiddleware(function_));
|
||||||
|
const queryParameters = [
|
||||||
|
...buildParameters(guard?.config.query, 'query'),
|
||||||
|
...(hasPagination ? paginationParameters : []),
|
||||||
|
];
|
||||||
|
|
||||||
const body = guard?.config.body;
|
const body = guard?.config.body;
|
||||||
const requestBody = body && {
|
const requestBody = body && {
|
||||||
|
@ -88,21 +107,11 @@ const buildOperation = (stack: IMiddleware[], path: string): OpenAPIV3.Operation
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const pathParameters = buildParameters(guard?.config.params, 'path');
|
|
||||||
const queryParameters = [
|
|
||||||
...buildParameters(guard?.config.query, 'query'),
|
|
||||||
...(hasPagination ? paginationParameters : []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tags: [buildTag(path)],
|
tags: [buildTag(path)],
|
||||||
parameters: [...pathParameters, ...queryParameters],
|
parameters: [...pathParameters, ...queryParameters],
|
||||||
requestBody,
|
requestBody,
|
||||||
responses: {
|
responses: customResponses ?? defaultResponses,
|
||||||
'200': {
|
|
||||||
description: 'OK',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -111,16 +120,29 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
||||||
allRouters: R[]
|
allRouters: R[]
|
||||||
) {
|
) {
|
||||||
router.get('/swagger.json', async (ctx, next) => {
|
router.get('/swagger.json', async (ctx, next) => {
|
||||||
|
// Use `as` here since we'll check typing with integration tests
|
||||||
|
const additionalSwagger = load(
|
||||||
|
await readFile('static/yaml/additional-swagger.yaml', { encoding: 'utf-8' })
|
||||||
|
) as OpenAPIV3.Document;
|
||||||
|
|
||||||
const routes = allRouters.flatMap<RouteObject>((router) =>
|
const routes = allRouters.flatMap<RouteObject>((router) =>
|
||||||
router.stack.flatMap<RouteObject>(({ path, stack, methods }) =>
|
router.stack.flatMap<RouteObject>(({ path: routerPath, stack, methods }) =>
|
||||||
methods
|
methods
|
||||||
// There is no need to show the HEAD method.
|
// There is no need to show the HEAD method.
|
||||||
.filter((method) => method !== 'HEAD')
|
.filter((method) => method !== 'HEAD')
|
||||||
.map((method) => ({
|
.map((method) => {
|
||||||
path: `/api${path}`,
|
const path = `/api${routerPath}`;
|
||||||
method: method.toLowerCase() as OpenAPIV3.HttpMethods,
|
const httpMethod = method.toLowerCase() as OpenAPIV3.HttpMethods;
|
||||||
operation: buildOperation(stack, path),
|
|
||||||
}))
|
const additionalPathItem = additionalSwagger.paths[path] ?? {};
|
||||||
|
const additionalResponses = additionalPathItem[httpMethod]?.responses;
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
method: httpMethod,
|
||||||
|
operation: buildOperation(stack, routerPath, additionalResponses),
|
||||||
|
};
|
||||||
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
60
packages/core/static/yaml/additional-swagger.yaml
Normal file
60
packages/core/static/yaml/additional-swagger.yaml
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
# The structure of `paths` SHOULD follow OpenAPI 3.0 Specification.
|
||||||
|
# See https://swagger.io/docs/specification/paths-and-operations/
|
||||||
|
paths:
|
||||||
|
/api/applications/:id:
|
||||||
|
delete:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/connectors/:id/test:
|
||||||
|
delete:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/me/password:
|
||||||
|
patch:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/resources/:id:
|
||||||
|
delete:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/session/sign-in/passwordless/sms/send-passcode:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/session/sign-in/passwordless/email/send-passcode:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/session/register/passwordless/sms/send-passcode:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/session/register/passwordless/email/send-passcode:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/status:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/users/:userId:
|
||||||
|
delete:
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
/api/.well-known/sign-in-exp:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
'304':
|
||||||
|
description: No Modified
|
348
pnpm-lock.yaml
generated
348
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue