Skip to content

New: Route loading utility for declarative routes.json support#58

Merged
taylortom merged 13 commits intomasterfrom
copilot/add-route-loading-utility
Feb 26, 2026
Merged

New: Route loading utility for declarative routes.json support#58
taylortom merged 13 commits intomasterfrom
copilot/add-route-loading-utility

Conversation

Copy link
Contributor

Copilot AI commented Feb 25, 2026

Route definitions are currently spread across imperative code in API/auth modules. This adds a loadRouteConfig utility to the server module that reads a declarative routes.json file, validates it against base JSON schemas via the app's jsonschema module (with full $merge/$ref composition support), and resolves handler strings to bound methods — giving AbstractApiModule and AbstractAuthModule a shared foundation to build on.

New

  • schema/routes.schema.json — base JSON Schema for the top-level routes.json structure (requires root as a string, routes as an array); consumer modules extend this via $merge to add their own fields
  • schema/routeitem.schema.json — base JSON Schema for individual route entries (requires route and handlers, validates HTTP method names, validates handler values are strings); includes permissions field supporting scope arrays or null for unsecured routes; consumer modules extend this via $merge
  • loadRouteConfig(rootDir, target, options?) — reads routes.json from a module root, validates it against a named schema via app.waitForModule('jsonschema') (inheriting custom keywords, caching, and auto-discovered consumer schemas), resolves handler strings against target, returns null if no file exists, throws descriptive errors for schema violations or unresolvable handlers
import { loadRouteConfig } from 'adapt-authoring-server'

// In AbstractApiModule.addRoutes():
const config = await loadRouteConfig(this.rootDir, this, {
  schema: 'apiroutes',   // consumer schema name (defaults to 'routes'); auto-registered by jsonschema module
  handlerAliases: {
    default: this.requestHandler(),
    query: this.queryHandler()
  }
})
// config.root, config.routes (handlers already bound), plus any consumer-specific fields
// Returns null if no routes.json — caller falls back to current behaviour

// In AbstractAuthModule.init():
const config = await loadRouteConfig(this.rootDir, this, { schema: 'authroutes' })

Handler strings are resolved as target[str].bind(target); handlerAliases lets callers map special strings (e.g. "default", "query") to pre-resolved values. Consumer-specific top-level fields (e.g. schemaName) are preserved in the returned config. Route item validation is handled by the schema itself (consumer schemas define routes.items constraints via $merge). Consumer schemas (apiroutes, authroutes) are auto-discovered from each module's schema/ directory at startup by the jsonschema module — no manual registration needed.

Testing

  1. node --test tests/utils-loadRouteConfig.spec.js — 13 tests covering: missing file returns null, config fields preserved, consumer-specific fields preserved after validation, handler binding, alias override, clear error on unresolvable handler, schema validation rejects missing required fields and wrong types, route item validation via consumer $merge schema, consumer-provided schema validation, permissions: null accepted for unsecured routes, and invalid HTTP method keys in permissions rejected
Original prompt

This section details on the original issue you should resolve

<issue_title>New: Route loading utility for declarative routes.json support</issue_title>
<issue_description>## Context

Route definitions are currently spread across imperative code in API modules (setValues(), addRoutes()) and auth modules (init()), plus 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 — and a route loading utility in the server module to read and process these files. Server already owns the Router class and all route registration mechanics, so it's the natural home for the shared loading logic.

Both AbstractApiModule (api) and AbstractAuthModule (auth) will consume this utility and layer on their own semantics (CRUD defaults, permissions, secure/unsecure).

Part of the broader static docs refactor: adapt-authoring-docs#47.
Related: adapt-authoring-api#74, adapt-authoring-auth#67.


What server owns

1. The routes.json format definition

A single JSON file in each module's root (alongside adapt-authoring.json):

{
  "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": { "..." : "..." } }
        }
      }
    }
  ]
}

Auth modules use the same format with additional fields (parentRouter, unsecured, internal, type).

2. Route loading utility

A utility function (or method on Router) that:

  1. Reads routes.json from a module's root directory
  2. Resolves handler strings against a target object — e.g. "insertRecursive" becomes target.insertRecursive.bind(target)
  3. Returns structured route config objects ready for router.addRoute()

The utility does NOT know about:

  • CRUD default routes (that's API's concern)
  • Permission application / secureRoute / unsecureRoute (that's Auth's concern)
  • API middleware (that's API's concern)

It purely handles: file reading + handler string resolution.

Proposed API

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

// In AbstractApiModule.addRoutes():
const config = await loadRouteConfig(this.rootDir, this)
// config.root, config.routes (with resolved handlers), config.schemaName, etc.

// In AbstractAuthModule.init():
const config = await loadRouteConfig(this.rootDir, this)
// config.type, config.parentRouter, config.routes (with resolved handlers), etc.

Parameters:

  • rootDir — path to the module's root (where routes.json lives)
  • target — the object to resolve handler strings against (the module instance)

Returns null if no routes.json exists (caller falls back to current behaviour).

Handler resolution rules

Handler string Resolved to
"insertRecursive" target.insertRecursive.bind(target)
"default" target.requestHandler() (API convention — caller handles this)
"query" target.queryHandler() (API convention — caller handles this)

Note: "default" and "query" are API-specific conventions. The server utility resolves them literally (looks for target.default); API's consumer code maps them to requestHandler()/queryHandler() before or after calling the utility. This keeps server generic.

Alternatively, the utility could accept a handler map for special values:

const config = await loadRouteConfig(this.rootDir, this, {
  handlerAliases: {
    'default': this.requestHandler(),
    'query': this.queryHandler()
  }
})

Key files

File Change
lib/Router.js or new lib/utils/loadRouteConfig.js New route loading utility

Testing

  1. Test with a fixture routes.json — verify file reading and handler resolution
  2. Test with missing routes.json — returns null
  3. Test handler resolution for various string values
  4. Test that unresolvable handler strings throw clear errors</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

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

@copilot please amend according to the latest instructions in the body of the issue

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

Copilot AI commented Feb 26, 2026

@copilot please amend according to the latest instructions in the body of the issue

Done in 14f416d. Changes per the updated issue:

  • Added schema/routes.schema.json and schema/routeitem.schema.json base schemas
  • loadRouteConfig now validates the top-level config against the base routes schema (or a consumer-provided schema via options.schema/options.schemaDir), and each route item against routeitem.schema.json
  • Updated tests/data/routes.json to the new base format (only root, routes, internal, meta — removed consumer-specific fields)
  • Added 5 new tests covering schema validation: missing required fields, wrong types, invalid HTTP methods, missing route item fields, and consumer schema validation

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

Critical: Custom validator doesn't support $merge/$ref

loadRouteConfig.js implements a hand-rolled validateSchema() that only understands basic JSON Schema keywords (type, required, properties, propertyNames). It has no support for $merge, $ref, or the project's schema composition system. This means:

  • Consumer schemas (apiroutes, authroutes) are never actually built/resolved — the $merge declarations are ignored
  • Route item schema overrides (items.$ref) are never applied
  • The API and auth schemas are effectively decorative

The utility must use the project's existing schema system from _adapt-schemas to build and validate against composed schemas.

Route item validation ignores consumer schema

Lines 119-126 always validate route items against the base routeitem.schema.json from SCHEMA_DIR, regardless of what schema the consumer passed. Even if the consumer's top-level schema overrides routes.items to reference their own route item schema, this hard-coded validation bypasses it.

permissions should be on the base routeitem schema

Per updated issue: permissions is used by both API and auth routes, so it belongs on the base routeitem.schema.json, not duplicated on consumer schemas. Additionally, permission values should support null to indicate unsecured routes (replacing the need for a separate unsecured boolean on auth routes):

"permissions": {
  "type": "object",
  "propertyNames": { "enum": ["get", "post", "put", "patch", "delete"] },
  "additionalProperties": {
    "oneOf": [
      { "type": "array", "items": { "type": "string" } },
      { "type": "null" }
    ]
  }
}

Minor: schemaDir option

The schemaDir option exists as a workaround for testing because the hand-rolled validator can't resolve $merge/$ref. With the project's schema system this wouldn't be needed — schemas are resolved by name, not file path.

Good

  • Schema files (routes.schema.json, routeitem.schema.json) are structurally correct
  • Handler resolution logic is clean — aliases, binding, error messages
  • ENOENT fallback returning null is correct
  • Test coverage is solid for the handler resolution side

Copilot AI and others added 2 commits February 26, 2026 00:33
Co-authored-by: taylortom <1059083+taylortom@users.noreply.github.com>
…s to routeitem schema

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

Copilot AI commented Feb 26, 2026

Review

Critical: Custom validator doesn't support $merge/$ref...

Done in 0bf9727. Changes:

  • Replaced hand-rolled validateSchema with Schemas/SchemaError from adapt-schemas — full $merge/$ref composition support
  • options.schemaDir removed; replaced with options.schemaFile (absolute path to consumer schema file, resolved by name via the schema registry)
  • Hard-coded route item validation loop removed — route item validation is now the schema's responsibility (consumer schemas add routes.items constraints via $merge)
  • permissions added to routeitem.schema.json with null support for unsecured routes
  • adapt-schemas added as a dependency in package.json
  • Tests updated: consumer schema test uses $merge and schemaFile; route item validation test uses a consumer schema with inline items constraint

taylortom

This comment was marked as duplicate.

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-0bf9727)

The switch from the hand-rolled validator to adapt-schemas is a good step, but the approach should go one further — use the jsonschema module instead of importing adapt-schemas directly.

Use jsonschema module, not adapt-schemas

Every other module in the project accesses schema functionality via app.waitForModule('jsonschema'). No module imports adapt-schemas directly — it's an internal implementation detail of the jsonschema module. The current PR:

  • Adds adapt-schemas as a direct dependency in package.json (pinned to a branch) — this shouldn't be needed
  • Manually rebuilds a Schemas registry on every call via buildSchemas() — expensive and redundant since the jsonschema module already auto-discovers and registers all schema/*.schema.json files from dependencies at startup
  • Misses custom keywords — things like isObjectId are added at runtime to the jsonschema module. A standalone adapt-schemas import won't have them
  • Misses caching/config — the jsonschema module handles schema compilation caching and app configuration

The utility should simplify to:

import { App } from 'adapt-authoring-core'

export async function loadRouteConfig (rootDir, target, options = {}) {
  // ... file reading ...

  const schemaName = options.schema || 'routes'
  const jsonschema = await App.instance.waitForModule('jsonschema')
  const schema = await jsonschema.getSchema(schemaName)
  await schema.validate(config)

  // ... handler resolution ...
}

This eliminates buildSchemas(), the schemaFile option, and the adapt-schemas dependency entirely. The base schemas (routes, routeitem) and consumer schemas (apiroutes, authroutes) are registered automatically from each module's schema/ directory during app startup.

Other observations on the latest diff

  • No test for permissions: null validation — the routeitem schema now supports null permission values but there's no test verifying { "permissions": { "post": null } } passes validation or that invalid values like { "permissions": { "post": 42 } } are rejected
  • Schema registry rebuilt every call — even setting aside the adapt-schemas vs jsonschema point, buildSchemas() is called on every loadRouteConfig invocation with no caching between calls

… import

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

Copilot AI commented Feb 26, 2026

Follow-up review (post-0bf9727)

The switch from the hand-rolled validator to adapt-schemas is a good step, but the approach should go one further — use the jsonschema module instead of imp...

Done in b4744be. Changes:

  • loadRouteConfig.js now uses App.instance.waitForModule('jsonschema')jsonschema.getSchema(schemaName)schema.validate(config). No direct adapt-schemas import.
  • adapt-schemas moved from dependencies to devDependencies — only used in the test mock.
  • buildSchemas() and schemaFile option removed entirely. Consumer schemas must be pre-registered in the app's jsonschema module (auto-discovered from schema/ dirs at startup, as with all other schemas).
  • Tests mock App.instance.waitForModule with a Schemas instance pre-loaded with the server's base schemas. Consumer schema tests pre-register schemas in the shared registry before calling loadRouteConfig, matching how the real app works.
  • Two permissions tests added in a nested describe: one confirming { permissions: { post: null } } is accepted and the null value preserved in the returned config; another confirming { permissions: { invalidMethod: null } } is rejected via propertyNames.enum. Note: value-type errors (e.g. 42) are silently stripped by AJV's removeAdditional: 'all' rather than rejected — the invalid-key test is the meaningful validation boundary here.

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-b4744be)

This is in good shape now. The core design issues are resolved — uses the jsonschema module, schemas are correct, handler resolution is clean.

1. adapt-schemas still in devDependencies

The adapt-schemas dependency was moved from production to devDependencies, which is correct since the utility now uses the jsonschema module. However, the tests import Schemas directly from adapt-schemas to build the mock. This is fine for now, but worth noting — if the test approach changes to use the real jsonschema module in integration tests, this devDependency could be dropped entirely.

2. Permissions tests duplicate the schema inline

The permissions test block defines a perm-routes consumer schema that re-declares the permissions property inline rather than referencing routeitem. Since routeitem.schema.json already defines permissions, a test that validates against a schema using "items": { "$ref": "routeitem" } would be more representative of real consumer usage and would catch regressions in the base schema itself.

3. schema.validate() — check if async

The code calls schema.validate(config) without await. Looking at the adapt-schemas source, validate() may be synchronous, but worth verifying — if it returns a Promise the error handling would silently miss validation failures.

4. Minor: process.exitCode = 0 in test setup

The before() hook resets process.exitCode after App.init() fails. This works but is fragile — if something else legitimately sets exitCode during the test run, it would be masked. A comment explaining why it's there is helpful (already partially there).

Good

  • loadRouteConfig is clean and focused — 60 lines, clear separation of file reading, validation, handler resolution
  • Schema files are correct and minimal
  • Test coverage is comprehensive — missing file, valid config, handler binding, aliases, unresolvable handlers, schema validation failures, consumer schemas with $merge, permissions with null
  • Tests properly clean up registered schemas via deregisterSchema in finally/after blocks
  • No unnecessary dependencies in production

In real consumer schemas (apiroutes, authroutes), route items reference
routeitem via $ref which is resolved by the jsonschema module at startup.
In tests, AJV throws anchor conflicts when $ref targets an already-registered
schema, so we inline the constraints instead.
Consumers can pass a defaults path to prepend resolved default routes
from a template file, eliminating duplicate _getDefaultRoutes() methods
across API and auth modules.
The test after() hook already cleans up the tmp directory. A leftover
from an interrupted run is better surfaced by git status than hidden.
@taylortom taylortom marked this pull request as ready for review February 26, 2026 13:22
@taylortom taylortom merged commit c535b3e into master Feb 26, 2026
2 checks passed
@taylortom taylortom deleted the copilot/add-route-loading-utility branch February 26, 2026 13:22
github-actions bot pushed a commit that referenced this pull request Feb 26, 2026
# [2.1.0](v2.0.0...v2.1.0) (2026-02-26)

### Chore

* Add comments explaining inline test schemas vs $ref ([2620c74](2620c74))
* Import loadRouteConfig via barrel file in tests ([f7dc358](f7dc358))
* Remove tests/tmp/ from .gitignore (refs #58) ([47a0dd1](47a0dd1)), closes [#58](#58)
* Use npm version for adapt-schemas instead of GitHub branch ref ([1027922](1027922))
* Use readJson from core barrel instead of readFile + JSON.parse ([8dfb2b4](8dfb2b4))

### Fix

* remove lockfile and peerDependenciesMeta, add .npmrc ([40bed7e](40bed7e))
* replace hand-rolled validator with adapt-schemas, add permissions to routeitem schema ([0bf9727](0bf9727))
* use app.waitForModule jsonschema instead of direct adapt-schemas import ([b4744be](b4744be))

### New

* Add defaults option to loadRouteConfig for default route templates ([ad081c9](ad081c9))
* add loadRouteConfig utility for declarative routes.json support ([c3db63a](c3db63a))
* add schema validation to loadRouteConfig, add base JSON schemas ([14f416d](14f416d))
* Route loading utility for declarative routes.json support (#58) ([c535b3e](c535b3e)), closes [#58](#58)

### WIP

* planning adapt-schemas integration ([0ee0a80](0ee0a80))
@github-actions
Copy link

🎉 This PR is included in version 2.1.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: Route loading utility for declarative routes.json support

2 participants