The router
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.
'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
const app = createApp({
name: 'serials',
healthExtra: () => ({ entities: Object.keys(ENTITIES) }),
settings: { defaults: { warranty_months: 24 } },
});
| Option | Purpose |
|---|---|
name | The app's name. Identifies the app in the health response and in log lines. |
healthExtra | A function whose return value is merged into the health response. |
settings.defaults | The 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
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.
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.
| Member | Is |
|---|---|
c.ctx | The raw invocation context, if you need something the router does not surface. |
c.params | Path parameters, by name. c.params.id for /serials/{id}. |
c.query | Query parameters, by name. |
c.body | The parsed JSON request body, or null. |
c.tenant | The 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. |
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 is201, and the capability'sresponsesmust 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.
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:
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, a404also 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 onlyGET,PUTandDELETEare mounted is a405, 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
- CRUD —
mountCrudand what it mounts. - Errors — throwing the right status from a handler.
- Capabilities — declaring the routes you just wrote.
- App SDK — installing
@revenexx/app-sdk.