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
|
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",
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "rm -rf build/ && tsc",
|
||||||
"lint": "eslint --format pretty --ext .ts src",
|
"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"
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -50,6 +50,7 @@
|
||||||
"eslint-formatter-pretty": "^4.1.0",
|
"eslint-formatter-pretty": "^4.1.0",
|
||||||
"husky": "^6.0.0",
|
"husky": "^6.0.0",
|
||||||
"lint-staged": "^11.0.0",
|
"lint-staged": "^11.0.0",
|
||||||
|
"openapi-types": "^9.1.0",
|
||||||
"prettier": "^2.3.2",
|
"prettier": "^2.3.2",
|
||||||
"tsc-watch": "^4.4.0",
|
"tsc-watch": "^4.4.0",
|
||||||
"typescript": "^4.3.5"
|
"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 { guardErrorMessage } from './collection/guard-errors';
|
||||||
import { oidcErrorMessage } from './collection/oidc-errors';
|
import { oidcErrorMessage } from './collection/oidc-errors';
|
||||||
import { registerErrorMessage } from './collection/register-errors';
|
import { registerErrorMessage } from './collection/register-errors';
|
||||||
|
import { swaggerErrorMessage } from './collection/swagger-errors';
|
||||||
|
|
||||||
export const requestErrorMessage: Record<RequestErrorCode, string> = {
|
export const requestErrorMessage: Record<RequestErrorCode, string> = {
|
||||||
...guardErrorMessage,
|
...guardErrorMessage,
|
||||||
...oidcErrorMessage,
|
...oidcErrorMessage,
|
||||||
...registerErrorMessage,
|
...registerErrorMessage,
|
||||||
|
...swaggerErrorMessage,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import { GuardErrorCode } from './collection/guard-errors';
|
import { GuardErrorCode } from './collection/guard-errors';
|
||||||
import { OidcErrorCode } from './collection/oidc-errors';
|
import { OidcErrorCode } from './collection/oidc-errors';
|
||||||
import { RegisterErrorCode } from './collection/register-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 = {
|
export type RequestErrorMetadata = {
|
||||||
code: RequestErrorCode;
|
code: RequestErrorCode;
|
||||||
|
|
|
@ -1,21 +1,23 @@
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import { Provider } from 'oidc-provider';
|
import { Provider } from 'oidc-provider';
|
||||||
import createSignInRoutes from '@/routes/sign-in';
|
import signInRoutes from '@/routes/sign-in';
|
||||||
import createUIProxy from '@/proxies/ui';
|
import registerRoutes from '@/routes/register';
|
||||||
import createRegisterRoutes from '@/routes/register';
|
import uiProxy from '@/proxies/ui';
|
||||||
|
import swaggerRoutes from '@/routes/swagger';
|
||||||
|
|
||||||
const createRouter = (provider: Provider): Router => {
|
const createRouter = (provider: Provider): Router => {
|
||||||
const router = new Router({ prefix: '/api' });
|
const router = new Router({ prefix: '/api' });
|
||||||
|
|
||||||
router.use(createSignInRoutes(provider));
|
router.use(signInRoutes(provider));
|
||||||
router.use(createRegisterRoutes());
|
router.use(registerRoutes());
|
||||||
|
router.use(swaggerRoutes());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function initRouter(app: Koa, provider: Provider): Router {
|
export default function initRouter(app: Koa, provider: Provider): Router {
|
||||||
const router = createRouter(provider);
|
const router = createRouter(provider);
|
||||||
app.use(router.routes()).use(createUIProxy()).use(router.allowedMethods());
|
app.use(router.routes()).use(uiProxy()).use(router.allowedMethods());
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import RequestError, { GuardErrorCode } from '@/errors/RequestError';
|
import RequestError, { GuardErrorCode } from '@/errors/RequestError';
|
||||||
|
import { has } from '@logto/essentials';
|
||||||
import { Middleware } from 'koa';
|
import { Middleware } from 'koa';
|
||||||
import koaBody from 'koa-body';
|
import koaBody from 'koa-body';
|
||||||
import { IRouterParamContext } from 'koa-router';
|
import { IMiddleware, IRouterParamContext } from 'koa-router';
|
||||||
import { ZodType } from 'zod';
|
import { ZodType } from 'zod';
|
||||||
|
|
||||||
export type GuardConfig<QueryT, BodyT, ParametersT> = {
|
export type GuardConfig<QueryT, BodyT, ParametersT> = {
|
||||||
|
@ -34,6 +35,11 @@ export type WithGuardConfig<
|
||||||
config: GuardConfig<GuardQueryT, GuardBodyT, GuardParametersT>;
|
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<
|
export default function koaGuard<
|
||||||
StateT,
|
StateT,
|
||||||
ContextT extends IRouterParamContext,
|
ContextT extends IRouterParamContext,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import proxy from 'koa-proxies';
|
import proxy from 'koa-proxies';
|
||||||
|
|
||||||
// CAUTION: this is for testing only
|
// CAUTION: this is for testing only
|
||||||
export default function createUIProxy() {
|
export default function uiProxy() {
|
||||||
return proxy(/^\/(?!api|oidc).*$/, {
|
return proxy(/^\/(?!api|oidc).*$/, {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|
|
@ -22,7 +22,7 @@ const generateUserId = async (maxRetries = 500) => {
|
||||||
throw new Error('Cannot generate user ID in reasonable retries');
|
throw new Error('Cannot generate user ID in reasonable retries');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function createRegisterRoutes() {
|
export default function registerRoutes() {
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { conditional } from '@logto/essentials';
|
||||||
import koaGuard from '@/middleware/koa-guard';
|
import koaGuard from '@/middleware/koa-guard';
|
||||||
import { OidcErrorCode } from '@/errors/RequestError';
|
import { OidcErrorCode } from '@/errors/RequestError';
|
||||||
|
|
||||||
export default function createSignInRoutes(provider: Provider) {
|
export default function signInRoutes(provider: Provider) {
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.post(
|
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"
|
resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
|
||||||
integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=
|
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:
|
optionator@^0.9.1:
|
||||||
version "0.9.1"
|
version "0.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
|
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
|
||||||
|
|
Loading…
Add table
Reference in a new issue