Skip to content

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

@taylortom

Description

@taylortom

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 base format

A single JSON file in each module's root (alongside adapt-authoring.json). Server defines the base format — only the fields that are generic to all route definitions. Consumer-specific fields (API's schemaName, auth's parentRouter, etc.) are defined by their respective modules.

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

2. Schemas

Server owns two base schemas. Consumer modules (API, auth) extend these via $merge to add their own fields.

schema/routes.schema.json — top-level route config

Defines the top-level structure of routes.json. The routes array is typed but has no items constraint — each consumer overrides this property with a $ref to their own route item schema.

{
  "$anchor": "routes",
  "type": "object",
  "properties": {
    "root": {
      "type": "string",
      "description": "Router root path"
    },
    "routes": {
      "type": "array",
      "description": "Route definitions"
    }
  },
  "required": ["root"]
}

schema/routeitem.schema.json — base route item

Defines the base fields for a single route entry. Consumer modules $merge from this to add their own per-route fields. Includes permissions as a base field since both API and auth modules use it.

A permission value of null for a given method means the route is explicitly unsecured for that method. This replaces the need for a separate unsecured boolean.

{
  "$anchor": "routeitem",
  "type": "object",
  "properties": {
    "route": {
      "type": "string",
      "description": "Express-style route path"
    },
    "handlers": {
      "type": "object",
      "description": "Keys are HTTP methods, values are handler name strings",
      "propertyNames": { "enum": ["get", "post", "put", "patch", "delete"] },
      "additionalProperties": { "type": "string" }
    },
    "permissions": {
      "type": "object",
      "description": "Keys are HTTP methods. Values are arrays of permission scope strings, or null to mark the method as unsecured.",
      "propertyNames": { "enum": ["get", "post", "put", "patch", "delete"] },
      "additionalProperties": {
        "oneOf": [
          { "type": "array", "items": { "type": "string" } },
          { "type": "null" }
        ]
      }
    },
    "internal": {
      "type": "boolean",
      "description": "Restrict route to localhost",
      "default": false
    },
    "meta": {
      "type": "object",
      "description": "Keys are HTTP methods, values are OpenAPI operation objects",
      "propertyNames": { "enum": ["get", "post", "put", "patch", "delete"] }
    }
  },
  "required": ["route", "handlers"]
}

3. Route loading utility — lib/utils/loadRouteConfig.js

A utility function that:

  1. Reads routes.json from a module's root directory
  2. Validates against the appropriate schema using the project's schema system (_adapt-schemas), which supports $merge and $ref for schema composition
  3. Resolves handler strings against a target object — e.g. "insertRecursive" becomes target.insertRecursive.bind(target)
  4. Returns structured route config objects ready for router.addRoute()

The utility does NOT know about:

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

It purely handles: file reading + schema validation + handler string resolution.

Proposed API

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

// In AbstractApiModule.addRoutes():
const config = await loadRouteConfig(this.rootDir, this, {
  schema: 'apiroutes',
  handlerAliases: {
    'default': this.requestHandler(),
    'query': this.queryHandler()
  }
})

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

Parameters:

  • rootDir — path to the module's root (where routes.json lives)
  • target — the object to resolve handler strings against (the module instance)
  • options.schema — schema name to validate against (defaults to 'routes' base schema)
  • options.handlerAliases — map of special handler strings to pre-resolved functions

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

Validation behaviour

Validation must use the project's existing schema system from _adapt-schemas, which supports $merge and $ref for schema composition. This ensures consumer schemas (e.g. apiroutes which $merges from routes) are properly built and validated at runtime.

Validation happens in two stages with distinct error classes:

  1. Schema validation — validates the parsed JSON against the specified schema. Throws with the file path and validation errors if it fails (structural problem).
  2. Handler resolution — resolves handler strings against the target object. Throws with the handler name and target type if a handler string cannot be resolved and is not in handlerAliases (runtime problem).

Handler resolution rules

Handler string Resolved to
"insertRecursive" target.insertRecursive.bind(target)
String in handlerAliases The pre-resolved value from the aliases map
Unresolvable string Throws error

Note: "default" and "query" are API-specific conventions. Server's utility does not know about them — the API module passes them via handlerAliases. This keeps server generic.


Key files

File Change
schema/routes.schema.json New base top-level route config schema
schema/routeitem.schema.json New base route item schema (includes permissions with null support)
lib/utils/loadRouteConfig.js New route loading utility (must use project schema system for validation)
lib/utils.js Export loadRouteConfig

Testing

  1. Test with a fixture routes.json — verify file reading, schema validation, and handler resolution
  2. Test with missing routes.json — returns null
  3. Test handler resolution for various string values including aliases
  4. Test that unresolvable handler strings throw clear errors
  5. Test that schema validation rejects invalid routes.json (missing required fields, wrong types, invalid HTTP methods)
  6. Test that a consumer-provided schema name is used for validation

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions