From e77d2d94140bf1f903759070bdf3f04d634f96d4 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 23 Jul 2021 23:10:54 +0800 Subject: [PATCH] feat: auto-gen api doc --- packages/core/README.md | 16 ++++- packages/core/package.json | 5 +- .../RequestError/collection/swagger-errors.ts | 7 +++ .../core/src/errors/RequestError/message.ts | 2 + .../core/src/errors/RequestError/types.ts | 9 ++- packages/core/src/init/router.ts | 14 +++-- packages/core/src/middleware/koa-guard.ts | 8 ++- packages/core/src/proxies/ui.ts | 2 +- packages/core/src/routes/register.ts | 2 +- packages/core/src/routes/sign-in.ts | 2 +- packages/core/src/routes/swagger.ts | 63 +++++++++++++++++++ packages/core/src/utils/string.ts | 10 +++ packages/core/src/utils/zod.ts | 50 +++++++++++++++ packages/core/yarn.lock | 5 ++ 14 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/errors/RequestError/collection/swagger-errors.ts create mode 100644 packages/core/src/routes/swagger.ts create mode 100644 packages/core/src/utils/string.ts create mode 100644 packages/core/src/utils/zod.ts diff --git a/packages/core/README.md b/packages/core/README.md index 3d8c2022c..f445dd5e8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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/). diff --git a/packages/core/package.json b/packages/core/package.json index 211d7aeb6..1863c8c35 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" diff --git a/packages/core/src/errors/RequestError/collection/swagger-errors.ts b/packages/core/src/errors/RequestError/collection/swagger-errors.ts new file mode 100644 index 000000000..ace565af7 --- /dev/null +++ b/packages/core/src/errors/RequestError/collection/swagger-errors.ts @@ -0,0 +1,7 @@ +export enum SwaggerErrorCode { + InvalidZodType = 'swagger.invalid_zod_type', +} + +export const swaggerErrorMessage: Record = { + [SwaggerErrorCode.InvalidZodType]: 'Invalid Zod type, please check route guard config.', +}; diff --git a/packages/core/src/errors/RequestError/message.ts b/packages/core/src/errors/RequestError/message.ts index 541119002..266e2c66c 100644 --- a/packages/core/src/errors/RequestError/message.ts +++ b/packages/core/src/errors/RequestError/message.ts @@ -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 = { ...guardErrorMessage, ...oidcErrorMessage, ...registerErrorMessage, + ...swaggerErrorMessage, }; diff --git a/packages/core/src/errors/RequestError/types.ts b/packages/core/src/errors/RequestError/types.ts index 0cd591740..0f11a023e 100644 --- a/packages/core/src/errors/RequestError/types.ts +++ b/packages/core/src/errors/RequestError/types.ts @@ -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; diff --git a/packages/core/src/init/router.ts b/packages/core/src/init/router.ts index 14284478b..93ad3841a 100644 --- a/packages/core/src/init/router.ts +++ b/packages/core/src/init/router.ts @@ -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; } diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index aeba6097d..51793fa13 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -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 = { @@ -34,6 +35,11 @@ export type WithGuardConfig< config: GuardConfig; }; +export const isGuardMiddleware = ( + function_: Type +): function_ is WithGuardConfig => + function_.name === 'guardMiddleware' && has(function_, 'config'); + export default function koaGuard< StateT, ContextT extends IRouterParamContext, diff --git a/packages/core/src/proxies/ui.ts b/packages/core/src/proxies/ui.ts index efb2816c9..0fd0c4056 100644 --- a/packages/core/src/proxies/ui.ts +++ b/packages/core/src/proxies/ui.ts @@ -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, diff --git a/packages/core/src/routes/register.ts b/packages/core/src/routes/register.ts index e7497d629..a9d71afd0 100644 --- a/packages/core/src/routes/register.ts +++ b/packages/core/src/routes/register.ts @@ -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( diff --git a/packages/core/src/routes/sign-in.ts b/packages/core/src/routes/sign-in.ts index a81acf206..2c8f87e02 100644 --- a/packages/core/src/routes/sign-in.ts +++ b/packages/core/src/routes/sign-in.ts @@ -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( diff --git a/packages/core/src/routes/swagger.ts b/packages/core/src/routes/swagger.ts new file mode 100644 index 000000000..bb5d0e044 --- /dev/null +++ b/packages/core/src/routes/swagger.ts @@ -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 => + 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(); +} diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts new file mode 100644 index 000000000..754d86326 --- /dev/null +++ b/packages/core/src/utils/string.ts @@ -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, ' '); +} diff --git a/packages/core/src/utils/zod.ts b/packages/core/src/utils/zod.ts new file mode 100644 index 000000000..172fc3961 --- /dev/null +++ b/packages/core/src/utils/zod.ts @@ -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); +}; diff --git a/packages/core/yarn.lock b/packages/core/yarn.lock index 277554851..9f61dc370 100644 --- a/packages/core/yarn.lock +++ b/packages/core/yarn.lock @@ -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"