mirror of
https://github.com/logto-io/logto.git
synced 2025-01-20 21:32:31 -05:00
feat: auto-gen api doc
This commit is contained in:
parent
2c0aa72bd8
commit
e77d2d9414
14 changed files with 179 additions and 16 deletions
|
@ -10,6 +10,18 @@ Copy proper `.env` to project root. (TBD: design the config process)
|
|||
yarn && yarn dev
|
||||
```
|
||||
|
||||
## Notes
|
||||
## OpenAPI Doc
|
||||
|
||||
Upgrade `xo` after [this issue](https://github.com/SamVerschueren/vscode-linter-xo/issues/91) is solved.
|
||||
OpenAPI (Swagger) json is available on `http(s)://your-domain/api/swagger.json`. If you are running locally, the default URL will be `http://localhost:3001/api/swagger.json`. Consume it in the way you like.
|
||||
|
||||
### Using ReDoc
|
||||
|
||||
The doc website can be served by [redoc-cli](https://github.com/Redocly/redoc/blob/master/cli/README.md) in an extremely easy way:
|
||||
|
||||
```bash
|
||||
npx redoc-cli serve http://localhost:3001/api/swagger.json
|
||||
```
|
||||
|
||||
### Using Swagger Editor
|
||||
|
||||
Copy the API output and paste it in the [Swagger Editor](https://editor.swagger.io/).
|
||||
|
|
|
@ -7,9 +7,9 @@
|
|||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "rm -rf build/ && tsc",
|
||||
"lint": "eslint --format pretty --ext .ts src",
|
||||
"dev": "tsc-watch --onSuccess \"node ./build/index.js\"",
|
||||
"dev": "rm -rf build/ && tsc-watch --onSuccess \"node ./build/index.js\"",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -50,6 +50,7 @@
|
|||
"eslint-formatter-pretty": "^4.1.0",
|
||||
"husky": "^6.0.0",
|
||||
"lint-staged": "^11.0.0",
|
||||
"openapi-types": "^9.1.0",
|
||||
"prettier": "^2.3.2",
|
||||
"tsc-watch": "^4.4.0",
|
||||
"typescript": "^4.3.5"
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export enum SwaggerErrorCode {
|
||||
InvalidZodType = 'swagger.invalid_zod_type',
|
||||
}
|
||||
|
||||
export const swaggerErrorMessage: Record<SwaggerErrorCode, string> = {
|
||||
[SwaggerErrorCode.InvalidZodType]: 'Invalid Zod type, please check route guard config.',
|
||||
};
|
|
@ -2,9 +2,11 @@ import { RequestErrorCode } from './types';
|
|||
import { guardErrorMessage } from './collection/guard-errors';
|
||||
import { oidcErrorMessage } from './collection/oidc-errors';
|
||||
import { registerErrorMessage } from './collection/register-errors';
|
||||
import { swaggerErrorMessage } from './collection/swagger-errors';
|
||||
|
||||
export const requestErrorMessage: Record<RequestErrorCode, string> = {
|
||||
...guardErrorMessage,
|
||||
...oidcErrorMessage,
|
||||
...registerErrorMessage,
|
||||
...swaggerErrorMessage,
|
||||
};
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
import { GuardErrorCode } from './collection/guard-errors';
|
||||
import { OidcErrorCode } from './collection/oidc-errors';
|
||||
import { RegisterErrorCode } from './collection/register-errors';
|
||||
import { SwaggerErrorCode } from './collection/swagger-errors';
|
||||
|
||||
export { GuardErrorCode, OidcErrorCode, RegisterErrorCode };
|
||||
export { GuardErrorCode, OidcErrorCode, SwaggerErrorCode, RegisterErrorCode };
|
||||
|
||||
export type RequestErrorCode = GuardErrorCode | OidcErrorCode | RegisterErrorCode;
|
||||
export type RequestErrorCode =
|
||||
| GuardErrorCode
|
||||
| OidcErrorCode
|
||||
| RegisterErrorCode
|
||||
| SwaggerErrorCode;
|
||||
|
||||
export type RequestErrorMetadata = {
|
||||
code: RequestErrorCode;
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
import { Provider } from 'oidc-provider';
|
||||
import createSignInRoutes from '@/routes/sign-in';
|
||||
import createUIProxy from '@/proxies/ui';
|
||||
import createRegisterRoutes from '@/routes/register';
|
||||
import signInRoutes from '@/routes/sign-in';
|
||||
import registerRoutes from '@/routes/register';
|
||||
import uiProxy from '@/proxies/ui';
|
||||
import swaggerRoutes from '@/routes/swagger';
|
||||
|
||||
const createRouter = (provider: Provider): Router => {
|
||||
const router = new Router({ prefix: '/api' });
|
||||
|
||||
router.use(createSignInRoutes(provider));
|
||||
router.use(createRegisterRoutes());
|
||||
router.use(signInRoutes(provider));
|
||||
router.use(registerRoutes());
|
||||
router.use(swaggerRoutes());
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
export default function initRouter(app: Koa, provider: Provider): Router {
|
||||
const router = createRouter(provider);
|
||||
app.use(router.routes()).use(createUIProxy()).use(router.allowedMethods());
|
||||
app.use(router.routes()).use(uiProxy()).use(router.allowedMethods());
|
||||
return router;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import RequestError, { GuardErrorCode } from '@/errors/RequestError';
|
||||
import { has } from '@logto/essentials';
|
||||
import { Middleware } from 'koa';
|
||||
import koaBody from 'koa-body';
|
||||
import { IRouterParamContext } from 'koa-router';
|
||||
import { IMiddleware, IRouterParamContext } from 'koa-router';
|
||||
import { ZodType } from 'zod';
|
||||
|
||||
export type GuardConfig<QueryT, BodyT, ParametersT> = {
|
||||
|
@ -34,6 +35,11 @@ export type WithGuardConfig<
|
|||
config: GuardConfig<GuardQueryT, GuardBodyT, GuardParametersT>;
|
||||
};
|
||||
|
||||
export const isGuardMiddleware = <Type extends IMiddleware>(
|
||||
function_: Type
|
||||
): function_ is WithGuardConfig<Type> =>
|
||||
function_.name === 'guardMiddleware' && has(function_, 'config');
|
||||
|
||||
export default function koaGuard<
|
||||
StateT,
|
||||
ContextT extends IRouterParamContext,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import proxy from 'koa-proxies';
|
||||
|
||||
// CAUTION: this is for testing only
|
||||
export default function createUIProxy() {
|
||||
export default function uiProxy() {
|
||||
return proxy(/^\/(?!api|oidc).*$/, {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
|
|
|
@ -22,7 +22,7 @@ const generateUserId = async (maxRetries = 500) => {
|
|||
throw new Error('Cannot generate user ID in reasonable retries');
|
||||
};
|
||||
|
||||
export default function createRegisterRoutes() {
|
||||
export default function registerRoutes() {
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
|
|
|
@ -8,7 +8,7 @@ import { conditional } from '@logto/essentials';
|
|||
import koaGuard from '@/middleware/koa-guard';
|
||||
import { OidcErrorCode } from '@/errors/RequestError';
|
||||
|
||||
export default function createSignInRoutes(provider: Provider) {
|
||||
export default function signInRoutes(provider: Provider) {
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
|
|
63
packages/core/src/routes/swagger.ts
Normal file
63
packages/core/src/routes/swagger.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import Router, { IMiddleware } from 'koa-router';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { isGuardMiddleware, WithGuardConfig } from '@/middleware/koa-guard';
|
||||
import { toTitle } from '@/utils/string';
|
||||
import { zodTypeToSwagger } from '@/utils/zod';
|
||||
|
||||
export default function swaggerRoutes() {
|
||||
const router = new Router();
|
||||
|
||||
router.get('/swagger.json', async (ctx) => {
|
||||
const routes = ctx.router.stack.map(({ path, stack, methods }) => {
|
||||
const guard = stack.find((function_): function_ is WithGuardConfig<IMiddleware> =>
|
||||
isGuardMiddleware(function_)
|
||||
);
|
||||
return { path, methods, guard };
|
||||
});
|
||||
|
||||
const paths = Object.fromEntries(
|
||||
routes.map<[string, OpenAPIV3.PathItemObject]>(({ path, methods, guard }) => {
|
||||
const trimmedPath = path.slice(4);
|
||||
const body = guard?.config.body;
|
||||
|
||||
return [
|
||||
trimmedPath,
|
||||
Object.fromEntries(
|
||||
methods.map<[string, OpenAPIV3.OperationObject]>((method) => [
|
||||
method.toLowerCase(),
|
||||
{
|
||||
tags: [toTitle(trimmedPath.split('/')[1] ?? 'General')],
|
||||
requestBody: body && {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: zodTypeToSwagger(body),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'OK',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const document: OpenAPIV3.Document = {
|
||||
openapi: '3.0.1',
|
||||
info: {
|
||||
title: 'Logto Core',
|
||||
version: '0.1.0',
|
||||
},
|
||||
paths,
|
||||
};
|
||||
|
||||
ctx.body = document;
|
||||
});
|
||||
|
||||
return router.routes();
|
||||
}
|
10
packages/core/src/utils/string.ts
Normal file
10
packages/core/src/utils/string.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export function toTitle(string: string) {
|
||||
if (typeof string !== 'string') {
|
||||
throw new TypeError('Expected a string');
|
||||
}
|
||||
|
||||
return string
|
||||
.toLowerCase()
|
||||
.replace(/(?:^|\s|-)\S/g, (x) => x.toUpperCase())
|
||||
.replace(/-/g, ' ');
|
||||
}
|
50
packages/core/src/utils/zod.ts
Normal file
50
packages/core/src/utils/zod.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { ZodArray, ZodBoolean, ZodNumber, ZodObject, ZodOptional, ZodString } from 'zod';
|
||||
import RequestError, { SwaggerErrorCode } from '@/errors/RequestError';
|
||||
import { conditional } from '@logto/essentials';
|
||||
|
||||
export const zodTypeToSwagger = (config: unknown): OpenAPIV3.SchemaObject => {
|
||||
if (config instanceof ZodOptional) {
|
||||
return zodTypeToSwagger(config._def.innerType);
|
||||
}
|
||||
|
||||
if (config instanceof ZodObject) {
|
||||
const entries = Object.entries(config.shape);
|
||||
const required = entries
|
||||
.filter(([, value]) => !(value instanceof ZodOptional))
|
||||
.map(([key]) => key);
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
required: conditional(required.length > 0 && required),
|
||||
properties: Object.fromEntries(entries.map(([key, value]) => [key, zodTypeToSwagger(value)])),
|
||||
};
|
||||
}
|
||||
|
||||
if (config instanceof ZodArray) {
|
||||
return {
|
||||
type: 'array',
|
||||
items: zodTypeToSwagger(config._def.type),
|
||||
};
|
||||
}
|
||||
|
||||
if (config instanceof ZodString) {
|
||||
return {
|
||||
type: 'string',
|
||||
};
|
||||
}
|
||||
|
||||
if (config instanceof ZodNumber) {
|
||||
return {
|
||||
type: 'number',
|
||||
};
|
||||
}
|
||||
|
||||
if (config instanceof ZodBoolean) {
|
||||
return {
|
||||
type: 'boolean',
|
||||
};
|
||||
}
|
||||
|
||||
throw new RequestError(SwaggerErrorCode.InvalidZodType, config);
|
||||
};
|
|
@ -3320,6 +3320,11 @@ only@~0.0.2:
|
|||
resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
|
||||
integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=
|
||||
|
||||
openapi-types@^9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-9.1.0.tgz#a2ab0acc5198c1725f83d7cbe063efd1bcd0479e"
|
||||
integrity sha512-mhXh8QN8sbErlxfxBeZ/pzgvmDn443p8CXlxwGSi2bWANZAFvjLPI0PoGjqHW+JdBbXg6uvmvM81WXaweh/SVA==
|
||||
|
||||
optionator@^0.9.1:
|
||||
version "0.9.1"
|
||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
|
||||
|
|
Loading…
Add table
Reference in a new issue