Skip to content

New: Support routes.json for declarative route definitions#77

Merged
taylortom merged 14 commits intomasterfrom
copilot/support-routes-json-definitions
Feb 26, 2026
Merged

New: Support routes.json for declarative route definitions#77
taylortom merged 14 commits intomasterfrom
copilot/support-routes-json-definitions

Conversation

Copy link
Contributor

Copilot AI commented Feb 26, 2026

Route definitions are currently spread across imperative setValues() / addRoutes() calls and separate apidefs.js files. This adds routes.json support to AbstractApiModule so modules can declare routes, handlers, permissions, and OpenAPI metadata in a single static file.

New

  • schema/apiroutes.schema.json — extends server's routes base schema with schemaName, collectionName, useDefaultRoutes, and typed routes items ($ref: routeitem)
  • lib/default-routes.json — declarative default CRUD route template; defines the standard POST/GET /, all methods on /:_id, POST /query, and GET /schema routes using "default", "query", and "serveSchema" handler aliases with {scope} permission placeholders expanded at runtime; lives in lib/ to avoid confusion with consumer-module routes.json files
  • AbstractApiModule#_applyRouteConfig(config) — async method that applies a loaded routes.json config: sets root/schemaName/collectionName and builds the routes array
  • AbstractApiModule#_getDefaultRoutes() — async method that loads the default CRUD route template from lib/default-routes.json via readJson, resolves handler aliases, and expands {scope} permission placeholders with this.permissionsScope || this.root

Update

  • AbstractApiModule#addRoutes() now uses a static import * as server from 'adapt-authoring-server' and calls server.loadRouteConfig?.() (optional chaining, no typeof guard) with schema: 'apiroutes' and handlerAliases so the string tokens "default" and "query" resolve to requestHandler() and queryHandler() respectively; local waitForModule('server') result renamed to serverMod to avoid variable shadowing; falls back to the existing imperative path if loadRouteConfig is not yet available on the server module
  • readJson from adapt-authoring-core now used for JSON loading (replaces createRequire), keeping JSON loading consistent with the rest of the codebase
  • generateApiMetadata() marked @deprecated — modules with routes.json should define metadata inline in each route's meta field instead
  • adapt-authoring-server listed in both peerDependencies (production requirement) and devDependencies (required locally for the static top-level import in tests)

Testing

  1. Module with routes.json — verify root, schemaName, collectionName, and routes are populated from the file; custom routes appear after default CRUD routes
  2. useDefaultRoutes: true (or omitted) — verify the four default route paths (/, /:_id, /query, /schema) are prepended from the lib/default-routes.json template
  3. useDefaultRoutes: false — verify only routes defined in the module's routes.json are registered
  4. "default" / "query" handler aliases resolve to the correct bound functions
  5. Module without routes.json — verify existing setValues() + useDefaultRouteConfig() behaviour is unchanged
Original prompt

This section details on the original issue you should resolve

<issue_title>New: Support routes.json for declarative route definitions</issue_title>
<issue_description>## Context

Route definitions are currently spread across imperative code (setValues(), addRoutes(), useDefaultRouteConfig()) and separate apidefs.js files for OpenAPI metadata. This means the API surface of each module can only be determined by running the application.

This change introduces a routes.json convention — a single declarative file per module that defines routes, handlers, permissions, and OpenAPI metadata. The server module owns the routes.json base format, base schemas (routes, routeitem), and the shared loading utility (adapt-authoring-server#57). This issue covers the API module's role as a consumer — extending the base schemas, calling server's route loader, and layering on CRUD-specific semantics.

Part of the broader static docs refactor: adapt-authoring-docs#47.
Related: adapt-authoring-server#57 (route loader + base schemas), adapt-authoring-auth#67 (auth consumer).


Schemas

The API module defines two schemas that extend the server base schemas via $merge.

schema/apirouteitem.schema.json — API route item

Extends the base routeitem schema with API-specific per-route fields.

{
  "$anchor": "apirouteitem",
  "$merge": {
    "source": { "$ref": "routeitem" },
    "with": {
      "properties": {
        "permissions": {
          "type": "object",
          "description": "Keys are HTTP methods, values are arrays of permission scope strings",
          "propertyNames": { "enum": ["get", "post", "put", "patch", "delete"] },
          "additionalProperties": {
            "type": "array",
            "items": { "type": "string" }
          }
        }
      }
    }
  }
}

schema/apiroutes.schema.json — API route config

Extends the base routes schema with API-specific top-level fields. Overrides the routes property to reference apirouteitem for per-route validation.

{
  "$anchor": "apiroutes",
  "$merge": {
    "source": { "$ref": "routes" },
    "with": {
      "properties": {
        "schemaName": {
          "type": "string",
          "description": "Schema name for the module's data model"
        },
        "collectionName": {
          "type": "string",
          "description": "MongoDB collection name"
        },
        "useDefaultRoutes": {
          "type": "boolean",
          "description": "Whether to generate default CRUD routes",
          "default": true
        },
        "routes": {
          "type": "array",
          "items": { "$ref": "apirouteitem" }
        }
      }
    }
  }
}

These schemas are validated by server's loadRouteConfig utility when passed schema: 'apiroutes'.


routes.json format for API modules

{
  "root": "content",
  "schemaName": "content",
  "collectionName": "content",
  "useDefaultRoutes": true,
  "routes": [
    {
      "route": "/insertrecursive",
      "handlers": { "post": "insertRecursive" },
      "permissions": { "post": ["write:content"] },
      "meta": {
        "post": {
          "summary": "Insert hierarchical content data",
          "requestBody": { "..." : "..." },
          "responses": { "201": { "..." : "..." } }
        }
      }
    }
  ]
}

Location: Module root, alongside adapt-authoring.json.

Backward compatibility: Modules without routes.json work exactly as before — AbstractApiModule falls back to the current setValues() + addRoutes() pattern. Migration is incremental.


Changes to AbstractApiModule

Use server's route loader in addRoutes()

Call server's route loading utility with the apiroutes schema and handler aliases. Then apply API-specific concerns on top:

async addRoutes () {
  const config = await loadRouteConfig(this.rootDir, this, {
    schema: 'apiroutes',
    handlerAliases: {
      'default': this.requestHandler(),
      'query': this.queryHandler()
    }
  })
  if (config) {
    // Use declarative config
    this.root = config.root
    this.schemaName = config.schemaName
    this.collectionName = config.collectionName
    this.routes = config.routes
    // Merge default CRUD routes if requested
    if (config.useDefaultRoutes) { ... }
  }
  // Then proceed with existing addRoutes() logic (router creation, middleware, permission application)
}

Fall back to current behaviour if no routes.json exists (backward compatibility).

Resolve special handler aliases

The server utility resolves handler strings generically ("foo"target.foo.bind(target)). API modules have two special conventions ...


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

- Add schema/apirouteitem.schema.json extending routeitem with permissions
- Add schema/apiroutes.schema.json extending routes with schemaName, collectionName, useDefaultRoutes
- Update addRoutes() to call server's loadRouteConfig utility with handlerAliases (default/query)
- Add _applyRouteConfig() to apply config from routes.json (root, schemaName, collectionName, routes)
- Add _getDefaultRoutes() returning standard CRUD routes when useDefaultRoutes is true
- Deprecate generateApiMetadata() for modules with routes.json
- Add tests for _applyRouteConfig() and _getDefaultRoutes()

Co-authored-by: taylortom <1059083+taylortom@users.noreply.github.com>
Copilot AI changed the title [WIP] Add support for routes.json for declarative route definitions New: Support routes.json for declarative route definitions Feb 26, 2026
Copy link
Collaborator

@taylortom taylortom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Missing schema: 'apiroutes' option

The loadRouteConfig call in addRoutes() doesn't pass schema: 'apiroutes':

const config = await serverModule.loadRouteConfig(this.rootDir, this, {
  handlerAliases: { ... }
})

It validates against the base routes schema only, so API-specific fields (schemaName, collectionName, useDefaultRoutes) are never validated. Should be:

const config = await loadRouteConfig(this.rootDir, this, {
  schema: 'apiroutes',
  handlerAliases: { ... }
})

Use static import, not dynamic

The PR uses await import('adapt-authoring-server') with a ERR_MODULE_NOT_FOUND catch. Server is a peer dependency that should always be present — use a static import at the top of the file:

import { loadRouteConfig } from 'adapt-authoring-server'

This fails fast at load time if the dependency is missing, rather than silently swallowing the error at runtime.

No routes.json for the module's own routes

The PR adds infrastructure to consume routes.json but doesn't include one. The default CRUD routes are still built imperatively in _getDefaultRoutes(). Adding a routes.json to the API module root is the proof that the declarative system works end-to-end.

apirouteitem schema is unnecessary

Per updated issue: permissions now lives on the base routeitem schema (with null support for unsecured routes). API routes don't need any additional per-route fields beyond what routeitem provides, so apirouteitem.schema.json can be removed. The apiroutes.schema.json should reference routeitem directly:

"routes": {
  "type": "array",
  "items": { "$ref": "routeitem" }
}

Good

  • _applyRouteConfig() and _getDefaultRoutes() logic is clean
  • Test coverage for config application and default route generation is thorough
  • @deprecated tag on generateApiMetadata is correct

- Add schema: 'apiroutes' to loadRouteConfig call for proper validation
- Switch from dynamic import to static namespace import of adapt-authoring-server
- Add adapt-authoring-server as devDependency
- Create routes.json with declarative default CRUD route templates (handler aliases + {scope} permission templates)
- Replace imperative _getDefaultRoutes() with template-driven implementation loading from routes.json
- Remove apirouteitem.schema.json (unnecessary); update apiroutes.schema.json to reference routeitem directly

Co-authored-by: taylortom <1059083+taylortom@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Feb 26, 2026

Review

Missing schema: 'apiroutes' option...

All four points addressed in 27494e0:

  • schema: 'apiroutes' — added to the loadRouteConfig options so API-specific fields are validated against the correct schema
  • Static import — replaced the await import() + try/catch inside addRoutes() with import * as server from 'adapt-authoring-server' at the top of the file; used namespace import rather than named import because the current published server (v2.0.1) doesn't yet export loadRouteConfig (that's added in server PR Bump semantic-release from 25.0.2 to 25.0.3 #58), and import { loadRouteConfig } would throw a SyntaxError at load time; also added adapt-authoring-server as a devDependency so tests keep passing
  • routes.json — created at the module root with the default CRUD route templates using "default", "query", and "serveSchema" handler aliases; _getDefaultRoutes() now loads this JSON via createRequire and expands {scope} permission placeholders at runtime (JSON import attributes like with { type: 'json' } are not supported by Standard.js v17)
  • apirouteitem.schema.json removed — deleted; apiroutes.schema.json now references routeitem directly

Copy link
Collaborator

@taylortom taylortom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review (post-27494e0)

Good progress — schema: 'apiroutes' is now passed, routes.json is added for default CRUD templates, apirouteitem schema is removed. A few remaining issues:

1. import * as server + typeof guard is unnecessary

import * as server from 'adapt-authoring-server'
// ...
if (typeof server.loadRouteConfig === 'function') {

This is a static import — if the module isn't found, the file fails to load entirely. The typeof check is dead code. Use a named import:

import { loadRouteConfig } from 'adapt-authoring-server'

2. Replace createRequire with readJson from core

The PR uses createRequire(import.meta.url) to load the default routes template. Core already exports readJson which the API module already depends on:

import { AbstractModule, Hook, readJson, stringifyValues } from 'adapt-authoring-core'

// in _getDefaultRoutes() or at init time:
const template = await readJson(new URL('../routes.json', import.meta.url).pathname)

This removes the createRequire import and keeps JSON loading consistent with the rest of the codebase.

3. Rename routes.json to avoid confusion

The default route template at the API module root is called routes.json but doesn't conform to the routes schema (no root field — it's a template, not a module route config). This will be confusing since routes.json in every other module root means "this module's declarative route config". Consider default-routes.json or moving it to lib/default-routes.json.

4. Variable shadowing

server is imported at module scope (import * as server) but also declared locally in addRoutes():

const server = await this.app.waitForModule('server')

This shadows the import. If you switch to a named import of loadRouteConfig (point 1), this goes away.

5. adapt-authoring-server in devDependencies

It's listed under devDependencies but used in production code (AbstractApiModule.js). Should be a peerDependency to match the project's convention for inter-module dependencies.

Good

  • routes.json with {scope} placeholder pattern is a clean approach
  • _getDefaultRoutes() resolving handlers and permissions from the template is well-structured
  • apiroutes.schema.json correctly references routeitem directly
  • Test coverage for _applyRouteConfig and _getDefaultRoutes is thorough

Copy link
Collaborator

@taylortom taylortom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review (post-27494e0)

Good progress — schema: 'apiroutes' is now passed, routes.json is added for default CRUD templates, apirouteitem schema is removed. A few remaining issues:

1. import * as server + typeof guard is unnecessary

import * as server from 'adapt-authoring-server'
// ...
if (typeof server.loadRouteConfig === 'function') {

This is a static import — if the module isn't found, the file fails to load entirely. The typeof check is dead code. Use a named import:

import { loadRouteConfig } from 'adapt-authoring-server'

2. Replace createRequire with readJson from core

The PR uses createRequire(import.meta.url) to load the default routes template. Core already exports readJson which the API module already depends on:

import { AbstractModule, Hook, readJson, stringifyValues } from 'adapt-authoring-core'

// in _getDefaultRoutes() or at init time:
const template = await readJson(new URL('../routes.json', import.meta.url).pathname)

This removes the createRequire import and keeps JSON loading consistent with the rest of the codebase.

3. Rename routes.json to avoid confusion

The default route template at the API module root is called routes.json but doesn't conform to the routes schema (no root field — it's a template, not a module route config). This will be confusing since routes.json in every other module root means "this module's declarative route config". Consider default-routes.json or moving it to lib/default-routes.json.

4. Variable shadowing

server is imported at module scope (import * as server) but also declared locally in addRoutes():

const server = await this.app.waitForModule('server')

This shadows the import. If you switch to a named import of loadRouteConfig (point 1), this goes away.

5. adapt-authoring-server in devDependencies

It's listed under devDependencies but used in production code (AbstractApiModule.js). Should be a peerDependency to match the project's convention for inter-module dependencies.

Good

  • routes.json with {scope} placeholder pattern is a clean approach
  • _getDefaultRoutes() resolving handlers and permissions from the template is well-structured
  • apiroutes.schema.json correctly references routeitem directly
  • Test coverage for _applyRouteConfig and _getDefaultRoutes is thorough

Copilot AI and others added 2 commits February 26, 2026 01:10
Co-authored-by: taylortom <1059083+taylortom@users.noreply.github.com>
- Replace createRequire with readJson from adapt-authoring-core
- Make _getDefaultRoutes() and _applyRouteConfig() async; update tests
- Rename routes.json to lib/default-routes.json to avoid confusion with consumer routes.json files
- Fix variable shadowing: rename server (waitForModule) to serverMod in addRoutes()
- Use optional chaining server.loadRouteConfig?.() instead of typeof guard
- Restore adapt-authoring-core to dependencies (removed accidentally in previous commit)
- Keep adapt-authoring-server in both peerDependencies and devDependencies (standard pattern for peer deps also needed in dev)

Co-authored-by: taylortom <1059083+taylortom@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Feb 26, 2026

Follow-up review (post-27494e0)

Good progress — schema: 'apiroutes' is now passed, routes.json is added for default CRUD templates, apirouteitem schema is removed. A few remaining issues...

All five points addressed in 63e7c66:

  1. typeof guard removed — replaced with optional chaining server.loadRouteConfig?.(...). Kept import * as server (namespace import) rather than a named import because the current published server (v2.0.1) does not yet export loadRouteConfig — a named import import { loadRouteConfig } would throw SyntaxError at module link time with that version, breaking tests. Optional chaining achieves the same "no explicit typeof check" goal while remaining compatible until server PR Bump semantic-release from 25.0.2 to 25.0.3 #58 lands.

  2. createRequirereadJsonreadJson imported from adapt-authoring-core; _getDefaultRoutes() and _applyRouteConfig() are now async; tests updated accordingly.

  3. Renamed to lib/default-routes.json — avoids confusion with consumer-module routes.json files.

  4. Variable shadowing fixedwaitForModule('server') result renamed to serverMod in addRoutes().

  5. adapt-authoring-server in peerDependencies + devDependencies — the static top-level import requires the package to be installed in dev as well; keeping it in both is the standard pattern for peer deps also needed locally (see npm docs).

@taylortom taylortom marked this pull request as ready for review February 26, 2026 14:16
@taylortom taylortom merged commit 776e621 into master Feb 26, 2026
2 checks passed
@taylortom taylortom deleted the copilot/support-routes-json-definitions branch February 26, 2026 14:16
github-actions bot pushed a commit that referenced this pull request Feb 26, 2026
# [2.2.0](v2.1.4...v2.2.0) (2026-02-26)

### chore

* planning follow-up review changes ([1791c19](1791c19))

### Chore

* Bump server peerDependency to ^2.1.0, untrack package-lock (refs #77) ([07c399d](07c399d)), closes [#77](#77)
* Drop underscore prefix from internal methods (refs #77) ([7fa7665](7fa7665)), closes [#77](#77)
* Fix applyRouteConfig tests for server v2.1.0 (refs #77) ([467239b](467239b)), closes [#77](#77)
* Fix no-template-curly-in-string lint warnings (refs #77) ([4f12d5f](4f12d5f)), closes [#77](#77)
* Use ${scope} placeholder syntax to match template literals (refs #77) ([76a789f](76a789f)), closes [#77](#77)

### feat

* support routes.json for declarative route definitions ([bc1797b](bc1797b))

### fix

* address follow-up review feedback ([63e7c66](63e7c66))
* address review feedback on routes.json support ([27494e0](27494e0))

### Fix

* Add missing schema permissions, $schema declaration, deprecate validateValues ([a5aea67](a5aea67))

### New

* Support routes.json for declarative route definitions (fixes #74) (#77) ([776e621](776e621)), closes [#74](#74) [#77](#77)

### Update

* Move routes.json loading into setValues for proper validation ordering ([c1ea549](c1ea549))
* Use loadRouteConfig defaults option for default route merging (refs #77) ([3ca33ae](3ca33ae)), closes [#77](#77)
* Use named import for loadRouteConfig from server module ([211cdc6](211cdc6))
@github-actions
Copy link

🎉 This PR is included in version 2.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New: Support routes.json for declarative route definitions

2 participants