The router

createApp and the App SDK router — declaring routes, the request context, how a path is matched, the built-in health route, and the difference between a 404 and a 405.

Your app is invoked as a function, with one request in and one response out. The router turns that into declarative routes so you are not parsing paths by hand.

src/main.js
'use strict';

const { createApp, mountCrud, notFound } = require('@revenexx/app-sdk/router');
const { createDb, ENTITIES } = require('./db.generated');

function buildApp(db) {
  const app = createApp({ name: 'serials' });

  mountCrud(app, db.device_serials, {
    path: '/serials',
    columns: ENTITIES.device_serials.columns,
  });

  app.post('/serials/{id}/sold', async (c) => {
    const row = await db.device_serials.get(c.params.id);
    if (!row) throw notFound();
    return c.json(await db.device_serials.update(row.id, {
      status: 'sold',
      sold_at: new Date().toISOString(),
    }));
  });

  return app;
}

module.exports = async (context) => buildApp(createDb({ adapter: 'runtime', context })).handler()(context);
module.exports.buildApp = buildApp;

Building the app from an injected db rather than constructing one inside it is what lets your tests run the same routes against a mock adapter.

createApp

src/main.js
const app = createApp({
  name: 'serials',
  healthExtra: () => ({ entities: Object.keys(ENTITIES) }),
  settings: { defaults: { warranty_months: 24 } },
});
OptionPurpose
nameThe app's name. Identifies the app in the health response and in log lines.
healthExtraA function whose return value is merged into the health response.
settings.defaultsThe offline fallback for c.settings() when the settings service cannot be reached.

app.handler() returns the function the platform invokes; call it with the invocation context.

Declaring routes

src/main.js
app.get('/serials', handler);
app.get('/serials/{id}', handler);
app.post('/serials', handler);
app.put('/serials/{id}', handler);
app.patch('/serials/{id}', handler);
app.delete('/serials/{id}', handler);

Path parameters use {braces}, the same syntax a capability's route.path uses — so a route and its declaration are copy-paste identical. (cockpit.json view routes use :param instead; that is a different file with a different convention.)

Paths are lowercase, and every route lives under the app's own prefix. That namespacing is how the gateway routes to your app, and how two apps avoid claiming the same URL.

A route in your handler is not public until a capability declares it. A route with no capability is dead code from the outside; a capability with no route is a 404 at runtime. Keep the generator that writes manifest.capabilities.json in step with the routes you mount.

Literal segments outrank parameters

When two routes could match the same path, the more specific one wins: a literal segment beats a parameter at the same position, regardless of declaration order.

src/main.js
app.get('/serials/{id}', readOne);
app.get('/serials/summary', summary);   // wins for GET /serials/summary

GET /serials/summary reaches summary, not readOne with id: 'summary'. You do not have to order your declarations defensively, and you do not need a guard inside readOne for the literal case.

This is worth knowing when you name a collection-level action. /serials/summary, /serials/defaults and /serials/export all coexist with /serials/{id} safely. What does not work is a literal that a real id could equal — so do not name an action after something that could plausibly be a primary key value.

The request context

Every handler receives one argument, conventionally c.

MemberIs
c.ctxThe raw invocation context, if you need something the router does not surface.
c.paramsPath parameters, by name. c.params.id for /serials/{id}.
c.queryQuery parameters, by name.
c.bodyThe parsed JSON request body, or null.
c.tenantThe calling tenant, from the invocation context.
c.header(name)One request header, case-insensitive.
c.log(message)Writes a log line for this execution.
c.json(body, status?)Builds the JSON response. status defaults to 200.
c.settings()The app's settings, resolved for this request's tenant and market. Async.
src/main.js
app.post('/serials', async (c) => {
  const settings = await c.settings();
  const source = c.header('x-source') ?? 'api';
  c.log(`registering ${c.body?.serial} for ${c.tenant} from ${source}`);

  const row = await db.device_serials.create({
    ...c.body,
    warranty_months: settings.warranty_months,
  });

  return c.json(row, 201);
});

Three habits worth forming:

  • Return c.json(...), always. Every path through a handler ends in a response, including the ones you consider impossible.
  • Set the status explicitly when it is not 200. A create is 201, and the capability's responses must say so too.
  • Read the tenant from c.tenant, never from the body. The context is a fact from the gateway; a body is a claim from the caller.

The health route

createApp mounts a health route at / for you. It answers without a tenant identity, which is what makes it usable as a liveness check, and it reports the app's name and whatever healthExtra returns.

Request
curl https://api.revenexx.com/v1/serials/

Because it needs no tenant, it is also the one route that must not touch data. Do not put a database read in healthExtra — listing the entity names the app knows about is the right amount of information.

Guarding what the router does not

The router dispatches; it does not decide policy. Two guards belong in your exported handler, before app.handler() sees the request:

src/main.js
module.exports = async (context) => {
  const { req, res } = context;
  const headers = req.headers || {};
  const trigger = headers['x-revenexx-trigger'] ?? '';
  const schedule = headers['x-revenexx-schedule'] ?? '';

  // Event deliveries and cron ticks carry no path your routes would match.
  if (trigger === 'event') return res.json(await handleEvent(context, headers));
  if (schedule) return res.json(await handleSchedule(schedule, context));

  return buildApp(createDb({ adapter: 'runtime', context })).handler()(context);
};

An event delivery or a scheduled tick is an invocation of the same function with no meaningful path, so branch on the header first or your router will answer a 404 to the platform.

405 versus 404

The router distinguishes the two, and so should your reading of a failed call:

  • 404 — no route matches that path at all. Either the path is wrong, or the capability was never installed. On the gateway, a 404 also means no capability matches that path for this tenant — usually because the app is not installed, or the install step did not run.
  • 405 — the path matches a declared route, but not with that method. POST /serials/{id} when only GET, PUT and DELETE are mounted is a 405, and the response says which methods the path does accept.

So a 405 tells you the route exists and you used the wrong verb; a 404 tells you the route does not exist for you. They point at different fixes, which is why the router does not collapse them into one.

Next steps

  • CRUDmountCrud and what it mounts.
  • Errors — throwing the right status from a handler.
  • Capabilities — declaring the routes you just wrote.
  • App SDK — installing @revenexx/app-sdk.
Was this page helpful?