diff --git a/assets/skills/koapp-router/SKILL.md b/assets/skills/koapp-router/SKILL.md index 7e0817a..cfdd104 100644 --- a/assets/skills/koapp-router/SKILL.md +++ b/assets/skills/koapp-router/SKILL.md @@ -1,11 +1,12 @@ --- name: koapp-router -description: Define routes, path parameters, validators, and nested routers with the @axiosleo/koapp Router class. Use when declaring HTTP/WebSocket/TCP routes in koapp, adding path params like /users/{:id}, composing nested routers, attaching per-route middlewares or after-handlers, validating params/query/body with validatorjs rules, or using shortcut helpers (get/post/put/patch/delete/any). +description: Define routes, path parameters, validators, and nested routers with the @axiosleo/koapp Router class. Use when declaring HTTP/WebSocket/TCP routes in koapp, adding path params like /users/{:id}, composing nested routers, attaching per-route middlewares or after-handlers, validating params/query/body with validatorjs rules, using shortcut helpers (get/post/put/patch/delete/any), or registering routes dynamically at runtime (e.g. routes loaded from a database) with registerRouters / app.registerRouters. --- # @axiosleo/koapp Router -Source: [`src/router.js`](../../../src/router.js). +Source: [`src/router.js`](../../../src/router.js) (Router class) and +[`src/core.js`](../../../src/core.js) (`registerRouters` for runtime registration). `Router` is the single route-definition primitive for all three application types (`KoaApplication`, `SocketApplication`, `WebSocketApplication`). A @@ -191,6 +192,45 @@ For a request `PUT /users/42`: 3. If nothing matches, follow the nearest `/***` fallback 4. If even that misses, the framework lets the next Koa middleware run (typically 404) +## Runtime route registration + +Routers passed via `config.routers` are resolved once at startup. To inject +additional routes **while the app is running** (e.g. route info stored in a +database instead of declared with the `Router` class), use `registerRouters`: + +```javascript +const { registerRouters } = require('@axiosleo/koapp'); + +// register a single route: pass one object +registerRouters(app.routes, { + path: '/dynamic/{:id}', // alias of prefix; must start with "/" + method: 'get', // normalized to uppercase; 'GET|POST' and 'ANY' work too + handlers: async (context) => { // a single function is wrapped into an array + // handle request + } +}); + +// register multiple routes: pass an array +registerRouters(app.routes, [routeA, routeB]); +``` + +Every application instance also exposes it as a chainable method: + +```javascript +app.registerRouters({ path: '/dynamic', method: 'ANY', handlers: [handler] }); +``` + +Notes: + +- Each item may be a `Router` instance or a plain object with `path` (or + `prefix`), `method`, `handlers`, plus optional `middlewares`, `afters`, + `validators`, and nested `routers`. +- Routes are inserted into the live route tree, so they take effect for the + next request immediately - no restart needed. +- Registering the same path + method again appends a new entry; lookup + returns the **first** registered one, so existing routes are not overridden. +- A route without `method` never matches; use `'ANY'` to accept all methods. + ## Common pitfalls - Leaving `method: ''` (or unset) on a `router.push` creates a route that diff --git a/assets/skills/koapp-router/examples.md b/assets/skills/koapp-router/examples.md index 202e1f0..f166037 100644 --- a/assets/skills/koapp-router/examples.md +++ b/assets/skills/koapp-router/examples.md @@ -183,3 +183,49 @@ const api = new Router('/api/v1'); registerCrud(api, 'users', usersController); registerCrud(api, 'posts', postsController); ``` + +## Runtime registration from database + +Routes stored in a database (not declared with the `Router` class) can be +injected while the app is running. Map each record to a plain route object +and register it - new routes serve the very next request, no restart needed: + +```javascript +const { KoaApplication, success } = require('@axiosleo/koapp'); + +const app = new KoaApplication({ + port: 8080, + routers: [staticRouter] // routes known at startup +}); +await app.start(); + +// later, while the app is running: +// e.g. rows like { path: '/pages/about', method: 'GET', template: '...' } +const rows = await db.query('SELECT path, method, template FROM dynamic_routes'); + +app.registerRouters(rows.map((row) => ({ + path: row.path, // must start with "/" + method: row.method, // 'get' works too, normalized to uppercase + handlers: async (context) => { // single function is auto-wrapped + success({ html: render(row.template, context.params) }); + } +}))); + +// registering a single route works the same way, no array needed +app.registerRouters({ + path: '/pages/{:slug}', + method: 'ANY', + handlers: async (context) => { + const page = await db.findPage(context.params.slug); + success(page); + } +}); +``` + +The standalone function does the same against any route tree: + +```javascript +const { registerRouters } = require('@axiosleo/koapp'); + +registerRouters(app.routes, { path: '/health', method: 'GET', handlers: [ping] }); +``` diff --git a/index.d.ts b/index.d.ts index c8461a9..e086190 100644 --- a/index.d.ts +++ b/index.d.ts @@ -791,6 +791,38 @@ export interface RouterOptions = KoaContext> validators?: RouterValidator; } +/** + * Options for registering routes dynamically at runtime + * (e.g. route info loaded from a database, not declared with the Router class) + * @template T Context type extending AppContext + * + * @example + * ```typescript + * const route: DynamicRouterOptions = { + * path: '/dynamic/{:id}', // alias of prefix, must start with "/" + * method: 'get', // normalized to uppercase; supports 'GET|POST' and 'ANY' + * handlers: async (context) => { // single function will be wrapped into an array + * // handle request + * } + * }; + * + * registerRouters(app.routes, route); // register a single route + * registerRouters(app.routes, [route, other]); // register multiple routes + * ``` + */ +export interface DynamicRouterOptions< + T extends AppContext = KoaContext, +> extends Omit, "handlers" | "routers"> { + /** Route path like '/dynamic/{:id}', alias of prefix */ + path?: string; + /** Route path prefix, used when path is not provided */ + prefix?: string; + /** Route handlers: a single function or an array of functions */ + handlers?: ContextHandler | ContextHandler[]; + /** Nested sub-routers */ + routers?: Array> | DynamicRouterOptions>; +} + /** * Router class for defining API routes and middleware * @template T Context type extending AppContext (can be KoaContext, SocketContext, etc.) @@ -1250,6 +1282,19 @@ export declare abstract class Application extends EventEmitter { constructor(config: AppConfiguration); + /** + * Register one or more routers at runtime. + * Takes effect immediately for subsequent requests. + * @param routers Single router or an array of routers (Router instances or plain route objects) + * @returns The application instance for chaining + */ + registerRouters( + routers: + | Router + | DynamicRouterOptions + | Array | DynamicRouterOptions>, + ): this; + /** * Start the application * @returns Promise that resolves when application is started @@ -1489,6 +1534,38 @@ export declare class Model { // Utility Functions // ======================================== +/** + * Register one or more routers into an existing route tree at runtime. + * Accepts Router instances or plain objects (e.g. route info loaded from a database). + * Registered routes take effect immediately for subsequent requests. + * @template T Context type extending AppContext + * @param routes Route tree resolved at startup (e.g. app.routes) + * @param routers Single router or an array of routers + * @returns The route tree + * + * @example + * ```typescript + * // register a single route (plain object, e.g. loaded from database) + * registerRouters(app.routes, { + * path: '/dynamic/{:id}', + * method: 'GET', + * handlers: async (context) => { ... } + * }); + * + * // register multiple routes at once + * registerRouters(app.routes, [routeA, routeB]); + * ``` + */ +export function registerRouters< + T extends AppContext = KoaContext, +>( + routes: any, + routers: + | Router + | DynamicRouterOptions + | Array | DynamicRouterOptions>, +): any; + /** * Initialize application context * @template T Application type diff --git a/index.js b/index.js index 15bdf18..667e781 100644 --- a/index.js +++ b/index.js @@ -5,7 +5,7 @@ const { Router } = require('./src/router'); const response = require('./src/response'); const Model = require('./src/model'); const { KoaSSEMiddleware } = require('./src/middlewares/sse'); -const { initContext } = require('./src/core'); +const { initContext, registerRouters } = require('./src/core'); const session = require('koa-session'); const { SocketClient } = require('./src/utils'); const { @@ -33,6 +33,7 @@ module.exports = { // functions ...response, initContext, + registerRouters, SocketClient }; diff --git a/src/apps/app.js b/src/apps/app.js index f72bc43..e229995 100644 --- a/src/apps/app.js +++ b/src/apps/app.js @@ -3,7 +3,7 @@ const EventEmitter = require('events'); const { v4 } = require('uuid'); const { Configuration } = require('@axiosleo/cli-tool'); -const { resolveRouters } = require('../core'); +const { resolveRouters, registerRouters } = require('../core'); class Application extends EventEmitter { constructor(config) { @@ -19,6 +19,16 @@ class Application extends EventEmitter { this.emit('starting', this); } + /** + * Register one or more routers at runtime. + * Takes effect immediately for subsequent requests. + * @param {object|object[]} routers single router or an array of routers + */ + registerRouters(routers) { + registerRouters(this.routes, routers); + return this; + } + async start() { throw new Error('not implemented'); } diff --git a/src/core.js b/src/core.js index 11617f8..34e197d 100644 --- a/src/core.js +++ b/src/core.js @@ -85,12 +85,41 @@ const recur = (tree, prefix, router, middlewares = [], afters = []) => { } }; -const resolveRouters = (routers = []) => { +const normalizeRouter = (router) => { + const item = Object.assign({}, router); + if (!item.prefix && item.path) { + item.prefix = item.path; + } + if (item.method) { + item.method = `${item.method}`.toUpperCase(); + } + if (is.func(item.handlers)) { + item.handlers = [item.handlers]; + } + if (is.array(item.routers) && item.routers.length) { + item.routers = item.routers.map(normalizeRouter); + } + return item; +}; + +/** + * Register one or more routers into an existing route tree. + * Accepts Router instances or plain objects (e.g. route info loaded from database), + * so routes can be injected dynamically while the app is running. + * @param {object} routes route tree resolved by resolveRouters (e.g. app.routes) + * @param {object|object[]} routers single router or an array of routers + */ +const registerRouters = (routes, routers = []) => { if (!is.array(routers)) { routers = [routers]; } + routers.forEach(item => recur(routes, '', normalizeRouter(item), [])); + return routes; +}; + +const resolveRouters = (routers = []) => { const tree = {}; - routers.forEach(item => recur(tree, '', item, [])); + registerRouters(tree, routers); return tree; }; @@ -212,4 +241,5 @@ module.exports = { initContext, getRouteInfo, resolveRouters, + registerRouters, }; diff --git a/tests/core.tests.js b/tests/core.tests.js index fdf4644..c54a066 100644 --- a/tests/core.tests.js +++ b/tests/core.tests.js @@ -1,7 +1,7 @@ 'use strict'; const { expect } = require('chai'); -const { resolveRouters, getRouteInfo, initContext } = require('../src/core'); +const { resolveRouters, getRouteInfo, initContext, registerRouters } = require('../src/core'); const { Router } = require('../src/router'); describe('core', () => { @@ -186,4 +186,137 @@ describe('core', () => { expect(info).to.be.null; }); }); + + describe('registerRouters()', () => { + it('should register a single plain object route into a resolved tree', () => { + const router = new Router('/api'); + router.get('/users', async () => {}); + const tree = resolveRouters(router); + + registerRouters(tree, { + path: '/api/dynamic', + method: 'GET', + handlers: [async () => {}] + }); + + // new route takes effect + expect(getRouteInfo(tree, '/api/dynamic', 'GET')).to.not.be.null; + // existing routes still work + expect(getRouteInfo(tree, '/api/users', 'GET')).to.not.be.null; + }); + + it('should register into an empty tree', () => { + const tree = {}; + registerRouters(tree, { + path: '/hello', + method: 'GET', + handlers: [async () => {}] + }); + expect(getRouteInfo(tree, '/hello', 'GET')).to.not.be.null; + }); + + it('should support path params in plain object routes', () => { + const tree = resolveRouters([]); + registerRouters(tree, { + path: '/dynamic/{:id}/detail/{:field}', + method: 'GET', + handlers: [async () => {}] + }); + const info = getRouteInfo(tree, '/dynamic/42/detail/name', 'GET'); + expect(info).to.not.be.null; + expect(info.params.id).to.equal('42'); + expect(info.params.field).to.equal('name'); + }); + + it('should normalize lowercase method to uppercase', () => { + const tree = {}; + registerRouters(tree, { + path: '/lower', + method: 'post', + handlers: [async () => {}] + }); + expect(getRouteInfo(tree, '/lower', 'POST')).to.not.be.null; + expect(getRouteInfo(tree, '/lower', 'GET')).to.be.null; + }); + + it('should wrap a single handler function into an array', () => { + const handler = async () => {}; + const tree = {}; + registerRouters(tree, { + path: '/single-handler', + method: 'GET', + handlers: handler + }); + const info = getRouteInfo(tree, '/single-handler', 'GET'); + expect(info).to.not.be.null; + expect(info.handlers).to.be.an('array').that.includes(handler); + }); + + it('should support prefix field as alias of path', () => { + const tree = {}; + registerRouters(tree, { + prefix: '/by-prefix', + method: 'GET', + handlers: [async () => {}] + }); + expect(getRouteInfo(tree, '/by-prefix', 'GET')).to.not.be.null; + }); + + it('should register multiple routes at once', () => { + const tree = resolveRouters([]); + registerRouters(tree, [ + { path: '/multi/a', method: 'GET', handlers: [async () => {}] }, + { path: '/multi/b', method: 'POST', handlers: [async () => {}] }, + { path: '/multi/c', method: 'ANY', handlers: [async () => {}] } + ]); + expect(getRouteInfo(tree, '/multi/a', 'GET')).to.not.be.null; + expect(getRouteInfo(tree, '/multi/b', 'POST')).to.not.be.null; + expect(getRouteInfo(tree, '/multi/c', 'DELETE')).to.not.be.null; + }); + + it('should accept Router instances', () => { + const tree = resolveRouters([]); + const router = new Router('/from-router'); + router.get('/test', async () => {}); + registerRouters(tree, router); + expect(getRouteInfo(tree, '/from-router/test', 'GET')).to.not.be.null; + }); + + it('should register middlewares and afters of plain object routes', () => { + const mw = async () => {}; + const afterFn = async () => {}; + const tree = {}; + registerRouters(tree, { + path: '/with-mw', + method: 'GET', + handlers: [async () => {}], + middlewares: [mw], + afters: [afterFn] + }); + const info = getRouteInfo(tree, '/with-mw', 'GET'); + expect(info).to.not.be.null; + expect(info.middlewares).to.include(mw); + expect(info.afters).to.include(afterFn); + }); + + it('should support nested routers in plain objects', () => { + const tree = {}; + registerRouters(tree, { + path: '/parent', + routers: [ + { path: '/child', method: 'get', handlers: async () => {} } + ] + }); + expect(getRouteInfo(tree, '/parent/child', 'GET')).to.not.be.null; + }); + + it('should throw for invalid path not starting with "/"', () => { + const tree = {}; + expect(() => registerRouters(tree, { + path: 'invalid-path', + method: 'GET', + handlers: [async () => {}] + })).to.throw('Invalid route path'); + }); + }); });