hor-restfulapi-architecture · git:20261016.fb3deed · 2026-10-16 · sha256 d07b0c29bfcd09c3
hor-restfulapi-architecture git:20261016.fb3deedA
Immutable. This exact content never changes and is served at /api/v1/blob/d07b0c29bfcd09c3.
---
name: hor-restfulapi-architecture
description: >
Understand and build the RESTful API layer of a renchan-style backend: the renderer architecture
under server/restfulapi/. Use this skill whenever the user asks how the REST API is structured,
where a REST endpoint lives, or how to add or edit one — a GET / POST / PUT / PATCH / DELETE
Renderer, its route, version and server directory, the render() entry point, the response and
error hashes, the per-request context and share, the engine's auth filter, and flushers.
---
# RESTful API Architecture
A skill for understanding the **REST API layer** of a renchan-style backend — the code under
`server/restfulapi/`. Structurally it **mirrors the GraphQL layer**: there is a per-request
**context**, a per-process **share**, an **engine** that wires everything to the server, and an error
hash declared up front. The difference is the surface: instead of a resolver returning a plain
object, a **renderer** returns a **`RestfulApiResponse`** carrying an HTTP **status code** plus
`content` or `error`, and a **flusher** writes it to the response.
> This skill is self-contained. Support classes (`BaseRestfulApiServerEngine`,
> `BaseGetRenderer` / `BasePostRenderer`, `RestfulApiResponse`, `BaseRestfulApiContext` /
> `BaseRestfulApiShare`, `BaseRestfulApiResponseFlusher`) come from the framework
> (`@openreachtech/renchan`). Examples use generic entities (`Article`) and generic routes;
> substitute your own. Some conventions below are the **target** shape — where the current repo
> diverges (notably input validation, §5), the improved form is described.
## Core principle: a renderer is a resolver with an HTTP envelope
One request → one **renderer**. A renderer does the same job a GraphQL resolver does — validate,
touch the DB / an external system, shape a result — but it speaks HTTP:
- Its return value is a **`RestfulApiResponse`** (`statusCode` + `content` / `error`), not a bare
object. Errors are **returned**, not thrown: `return this.errorResponseHash.Xxx.createAsError()`.
- It is bound to an **HTTP method + route** (`GET /v1/articles`), discovered from a **versioned
directory** (`renderers/v1/get/…`), not from a schema name.
- Everything else — the per-request **context** (`now`, `share`, `env`, the authenticated user),
the **error hash** declared up front, an optional **DI factory** — is the same idea as the
GraphQL side.
Three rules matter most:
1. **Return responses, never throw.** Every exit is a `RestfulApiResponse` — success via
`RestfulApiResponse.create({ statusCode, content })`, failure via
`this.errorResponseHash.Xxx.createAsError()`. HTTP always needs a status code, so an error
without one is meaningless here (§4).
2. **The engine owns cross-cutting concerns; the renderer owns one endpoint.** Auth (the visa
filter), middleware, versioning, and the standard error envelopes live on the engine (§3). The
renderer only knows its route, its errors, and its `render()` (§2).
3. **Validate at the boundary, then delegate.** REST input arrives split across `body` / `query` /
path params and is all string-like; normalize and validate it before touching the DB (§5).
- **Comments: English for code, the surrounding language for domain notes.** Match the neighbors.
## Layer map
```
HTTP request
│
┌────────────────────▼─────────────────────┐
│ Engine (BaseRestfulApiServerEngine) │ app-wide: routes, middleware,
│ config: pathPrefix + renderersPath │ auth filter (visa), Share/Context,
│ generateFilterHandler(), visaIssuers │ standardErrorEnvelopHash
└────────────────────┬─────────────────────┘
│ builds per request
┌────────────────────▼─────────────────────┐
│ Context (per request) + Share (per proc) │ now, env, share, user,
│ hasAuthenticated / hasAuthorized / … │ hasPathPermission (§3)
└────────────────────┬─────────────────────┘
│ render input = { body, query, context, request }
┌────────────────────▼─────────────────────┐
│ Renderer (BaseGetRenderer/BasePostRenderer)│ routePath, errorStructureHash,
│ passesFilter, render() → RestfulApiResponse│ passesFilter, render() (§1,§2)
└────────────────────┬─────────────────────┘
│ returns RestfulApiResponse (status + content/error) (§4)
┌────────────────────▼─────────────────────┐
│ Flusher (BaseRestfulApiResponseFlusher) │ writes status + headers + body
│ Json (default) / Html / Csv / Redirect │ contentType, flushResponseBody (§4)
└────────────────────┬─────────────────────┘
▼
HTTP response
```
## Request lifecycle
For each incoming request the framework's routes builder:
1. Builds the **render input** `{ body, query, context, request }` — `body` is the multer-fulfilled
request body, `query` is `expressRequest.query`, `context` is created async (resolves the user +
visa), `request` wraps the express request (path-param proxy).
2. If the renderer's **`passesFilter`** is `true`, skips the filter; otherwise runs the engine's
**filter handler** (auth). If the filter returns an error response, that short-circuits `render()`.
3. Calls **`renderer.render(input)`**, which returns a `RestfulApiResponse`.
4. Any **thrown** error is caught and converted to a `500` `RestfulApiResponse` (Unknown) — so
renderers should still return their own typed errors rather than relying on this.
5. The renderer's **flusher** writes the response (status, headers, body).
## 1. Directory, versioning, and route binding
Renderers live under `server/restfulapi/renderers/`, split **by version then by HTTP method**:
```
server/restfulapi/
AppRestfulApiServerEngine.js # the engine (§3)
contexts/
AppRestfulApiContext.js # per-request context (§3)
AppRestfulApiShare.js # per-process share (§3)
flushers/ # custom response flushers (§4)
renderers/
v1/
get/ <Name>Renderer.js # one file per endpoint
post/ <Name>Renderer.js
v2/
get/ …
post/ …
```
- **`renderers/<version>/<method>/<Name>Renderer.js`** — the directory encodes the **version**
(`v1`, `v2`) and the **HTTP method** (`get`, `post`, …). Keep the method out of the class name and
let the folder carry it (the repo has both `FooRenderer` and `FooGetRenderer` — prefer the
folder-only form for new files).
- **The engine selects the version.** `config.renderersPath` points at **one** version directory
(`renderers/v1/`) and `config.pathPrefix` (`/v1`) is prepended to every `routePath`. So
`routePath = '/articles'` in `renderers/v1/get/` serves **`GET /v1/articles`**. Serve another
version by pointing an engine at `renderers/v2/` with prefix `/v2`.
- **The route is `pathPrefix + routePath`, method = the renderer's `method`.** The framework
discovers every renderer under `renderersPath`, reads its `routePath` and `method`, and registers
the express route — you never wire routes by hand.
- **Several RESTful API servers: one directory per server.** When a project runs more than one
server (for example one public and one reachable only inside a VPN), each server's routes live
under `renderers/<server>/v1/`, and each server's engine points `renderersPath` at its own
`renderers/<server>/v1/`. A single `renderers/v1/` shared by two engines would mount every route
on both servers, so the VPN-only routes would answer on the public one. A project with one
server keeps `renderers/v1/`.
```
server/restfulapi/renderers/
public/
v1/
get/ <Name>Renderer.js
vpn/
v1/
get/ <Name>Renderer.js
```
Detail in [renderers.md](./references/renderers.md).
## 2. The renderer
A renderer extends the method base (`BaseGetRenderer` / `BasePostRenderer` / `BasePutRenderer` / …)
and implements a small, fixed surface:
```js
import {
BaseGetRenderer,
RestfulApiResponse,
} from '@openreachtech/renchan'
export default class ArticleGetRenderer extends BaseGetRenderer {
/** @override */
static get routePath () {
return '/articles'
}
/** @override */
static get errorStructureHash () {
return {
ArticleNotFound: {
statusCode: 404,
errorMessage: 'Article not found',
},
}
}
/** @override */
get passesFilter () {
return true // public endpoint → skip the engine auth filter
}
/**
* @override
* @param {RestfulApiType.RenderInput<*, *>} input
* @returns {Promise<RestfulApiType.RenderResponse>}
*/
async render ({
query,
context,
request,
}) {
const article = await this.findArticle({
articleId: request.pathParameterHashProxy.articleId,
})
if (!article) {
return this.errorResponseHash.ArticleNotFound.createAsError()
}
return RestfulApiResponse.create({
statusCode: 200,
content: this.formatResponseContent({
article,
}),
})
}
// ... findArticle(), formatResponseContent() ...
}
```
- **`render({ body, query, context, request })`** is the entry point. `body` = parsed request body
(POST/PUT/PATCH), `query` = query string params, `context` = the per-request context (`now`,
`share`, `env`, user — §3), `request` = the wrapped express request (`expressRequest`, and a
`pathParameterHashProxy` that returns `null` for missing path params).
- **`static get routePath()`** — the route (prefixed by the engine, §1).
- **`get passesFilter()`** — `true` skips the engine's auth filter (public); default `false` runs it.
- **Dependency injection** — add `constructor` + `static create()` + `static createXxx()` only when
a tool must be substitutable in tests (same factory pattern as the GraphQL side).
Method-verb subclasses, `render()` input shape, DI, and file uploads (multer via
`fileFieldsConfigHash`) are in [renderers.md](./references/renderers.md).
## 3. Engine, context, and share (the app-wide layer)
These three set up the whole REST surface, mirroring the GraphQL engine/context/share:
- **Engine** (`AppRestfulApiServerEngine extends BaseRestfulApiServerEngine`) — declares `config`
(`pathPrefix` / `renderersPath` / `staticPath`), wires `static get Share()` / `static get
Context()`, the **`standardErrorEnvelopHash`** (cross-cutting errors: `Unknown`,
`Unauthenticated`, `Unauthorized`, `Database`, …), the express **middleware**
(`collectMiddleware()`), the **auth filter** (`generateFilterHandler()`), and the **visa issuers**
(`visaIssuers`: `hasAuthenticated` / `hasAuthorized` / `hasPathPermission`).
- **Context** (`AppRestfulApiContext extends BaseRestfulApiContext`) — built per request. Override
`static async findUser()` to resolve the authenticated user from the access-token header. Exposes
`now`, `userId`, `userEntity`, `share`, `env`, and the visa predicates
(`hasAuthenticated()` / `hasAuthorized()` / `hasPathPermission()`).
- **Share** (`AppRestfulApiShare extends BaseRestfulApiShare`) — the per-process object (holds `env`;
put shared clients/providers here). Often a `// noop` subclass.
Detail in [engine-and-context.md](./references/engine-and-context.md).
## 4. Response, error hash, and flusher
- **`RestfulApiResponse`** is the return type. Success:
`RestfulApiResponse.create({ statusCode, headers?, content })`. Error: from the built error hash,
`this.errorResponseHash.Xxx.createAsError()`.
- **Error hash** — like the GraphQL error code hash, a renderer declares its errors up front in
`static get errorStructureHash()`. **Unlike** GraphQL (code only), each REST entry also carries a
`statusCode` and `errorMessage`, because an HTTP response needs a status. The framework turns the
hash into constructable `RestfulApiResponse` subclasses on `this.errorResponseHash`. Cross-cutting
errors live on the engine's `standardErrorEnvelopHash`.
- **Extending the response** — when you need a non-default body shape or behavior, subclass
`RestfulApiResponse` in the app and return that.
- **Flusher** — a `BaseRestfulApiResponseFlusher` writes the response (status → headers → body).
Default is JSON (`{ content, error }`); the framework also ships Html and Csv; a renderer selects
one via `static get FlusherCtor()`, and the app can define custom flushers (e.g. a redirect).
Detail in [response-and-flusher.md](./references/response-and-flusher.md).
## 5. Input validation (the target shape)
The current repo validates REST input **ad hoc** inside `render()` (an inline `isValidRequestBody()`).
The **target** is to validate with the **same validator structure as GraphQL**: a
`BaseInputValidator` subclass whose `generateValidationEntries()` returns `[predicate, ErrorCtor]`
pairs and whose `validateInput()` returns the first failing error (or `null`).
The catch is the input shape. A GraphQL validator receives a single typed `input` object; REST input
arrives split across **`body` / `query` / path params**, all strings. So a REST renderer needs an
**adapter**: a small class that reads `{ body, query, request }` and produces the normalized `input`
object the validator expects. The renderer flow becomes **adapter → input → validator → (on failure)
map to the renderer's `errorResponseHash` entry**.
There is also a **type-mismatch** to bridge: a GraphQL-style validator yields a code-only error,
while REST needs a **status-bearing** `RestfulApiResponse`. The wiring must map each validation
failure to the matching `errorStructureHash` entry (status + message).
The adapter, the validator contract, and the error mapping are in
[validation.md](./references/validation.md).
## Detail files
- [renderers.md](./references/renderers.md) — the renderer file: method-verb subclasses, directory
& versioning, `routePath`, `render()` input (`body` / `query` / `context` / `request`),
`passesFilter`, the DI factory, and multipart file uploads (§1, §2)
- [engine-and-context.md](./references/engine-and-context.md) — the engine (`config`,
`renderersPath` / `pathPrefix`, `Share` / `Context`, `standardErrorEnvelopHash`, middleware,
filter handler, visa issuers) and the per-request context / per-process share (§3)
- [response-and-flusher.md](./references/response-and-flusher.md) — `RestfulApiResponse`
(success / `createAsError`), `errorStructureHash` → `errorResponseHash`, extending the response,
and flushers (JSON / Html / Csv / custom, `FlusherCtor`, `contentType`) (§4)
- [validation.md](./references/validation.md) — the target validator (`BaseInputValidator`,
`generateValidationEntries`, `validateInput`), the REST-input → `input` adapter, and mapping a
validation failure to a status-bearing response (§5)