@noego/dinner
v0.0.7
Published
Dinner turns your OpenAPI file into a running Express app. You describe your API once (paths, params, schemas) and Dinner wires up routing, validation, and middleware to your controllers — so you write business logic, not glue code.
Readme
@noego/dinner
Dinner turns your OpenAPI file into a running Express app. You describe your API once (paths, params, schemas) and Dinner wires up routing, validation, and middleware to your controllers — so you write business logic, not glue code.
Why Dinner?
- OpenAPI as the source of truth: one spec drives routes, validation, and docs.
- Zero‑boilerplate routing: easily map paths to your controller methods.
- Built‑in validation using JSON Schema directly from your OpenAPI spec.
- Composable middleware system, compose and order them, and inherit at module/global levels.
- Module‑first URL design: organize endpoints in modules with
basePathfor clean, predictable URLs across large APIs. - Built on Express: battle‑tested with a rich ecosystem and familiar middleware. Faster routing via static‑prefix bucketing and rich URL parameter design.
- Dev‑friendly: quick iteration with watch mode; minimal framework magic.
- Forking of server for better CPU usage.
Installation
# Install the framework and its peer dependency
npm install @noego/dinner express
# TypeScript (recommended)
npm install -D typescript ts-node @types/node @types/expressRequires Node.js 18+.
Peer dependency: Express
Dinner is built on Express and declares express as a peer dependency. This lets your app control the exact Express version and configuration you use. Make sure you install a compatible Express version (v5 recommended) in your application:
npm install express@^5Quick Start
Design your routing logic using OpenAPI spec format.
- Create an OpenAPI file:
# openapi.yaml
openapi: 3.0.0
info:
title: My API
version: 1.0.0
paths:
/hello:
get:
x-controller: hello.controller
x-action: hello
responses:
'200':
description: OK- Create a controller:
// controllers/hello.controller.ts
/**
* Ensure the controller is the default export from the module.
*/
export default class HelloController {
hello() {
return { message: 'Hello, world!' };
}
}- Bootstrap Express with Dinner:
// server.ts
import path from 'path';
import express from 'express';
import { Server } from '@noego/dinner';
async function start() {
const app = express();
/**
* This is a basic express application. Add express.json for json support.
*/
app.use(express.json());
await Server.createServer({
openapi_path: path.join(__dirname, 'openapi.yaml'), //path to openapi file
controllers_base_path: path.join(__dirname, 'controllers'), //path to all controller files
server: app, //express server
/**
* Your default export from your controller class is passed to the controller builder.
* You can design how you instanciate your controller. We suggest using an IOC library
* to instanciate your controller.
*/
controller_builder: async (Controller) => new Controller(),
/**
* The req, res are passed to this function. This allows you to customize what is passed to your controller
* You can add any global settings inside of here. These will be modified before each controller
* receives a request.
*/
controller_args_provider: async (req, res) => ({ req, res })
});
/**
* Listen to your app as a normal express application. Feel free to add whatever you need on top.
*/
app.listen(3000, () => console.log('http://localhost:3000'));
}
start();- Run in dev:
npx ts-node server.tsCore Concepts
Core extensions
x-controller: controller module to load (e.g.,hello.controller→controllers/hello.controller.ts)x-action: method to invoke on the controller instance (e.g.,hello)x-middleware: middleware identifiers to apply (resolved undermiddleware_path)
Parameters (OpenAPI style)
Use {id} in your path to declare a path parameter. To constrain what matches, add a regex in the path itself — this is what the router uses for parsing/validation at match time (OpenAPI schemas are still useful for documentation and body/query validation).
paths:
/users/{id}: # This will accept anything
get:
x-controller: controllers/user.controller
x-action: get
parameters:
- name: id
in: path
required: true
schema: { type: integer }
paths:
# Bracket Notation
/users/{id:\\d+}: # This will only accept numbers
get:
x-controller: controllers/user.controller
x-action: get
# Colon Notation
/users/:id(\\d+): # This is the same filter just different syntax
get:
x-controller: controllers/user.controller
x-action: getRequest body and responses
Define requestBody.content['application/json'].schema and responses per OpenAPI. Dinner compiles JSON Schemas and validates requests before calling your controller.
The schema you add to the endpoint will be validated before it every reaches your controller. This will prevent unvalidated request from reaching your application.
This also stops you from having to do validation in your application. The only validation required after this will be business logic.
paths:
"/user/create":
post:
x-controller: controllers/post.controller
x-action: create
summary: Create a new Post
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
content:
type: stringYou can use $ref to better define and separate your application validation. This provides a cleaner definitions inside your route definitions.
paths:
"/user/create":
post:
x-controller: controllers/post.controller
x-action: create
summary: Create a new Post
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewPost"
components:
schemas:
NewPost:
type: object
properties:
title:
type: string
content:
type: string
required:
- title
- contentGlobal and module base paths
- A root-level
basePathprefixes allpaths. - Within a
module, itsbasePathprefixes the module’spaths. - The parser combines them:
/<basePath>/<module.basePath>/<module.paths…>.
Root Example
basePath: /api
paths:
/ping:
get:
x-controller: controllers/ping.controller
x-action: pingThis will create /api/ping as a route.
Module Example
- Instead of repeating prefixes, declare a
modulesection. The parser (framework/openapi/parser) flattens module paths into the top-levelpathsmap.
module:
users:
basePath: /users
paths:
'/':
get:
x-controller: controllers/user.controller
x-action: getAll
'/{id}':
get:
x-controller: controllers/user.controller
x-action: getThis will expand to /users/ and /users/{id} allowing you to group modules under the same base url.
Middleware
Adding middleware allows you to do additional validation prior to a request reaching your controller. You can define your middleware path inside of the ServerOptions
// server.ts
import path from 'path';
import express from 'express';
import { Server } from '@noego/dinner';
async function start() {
const app = express();
/**
* This is a basic express application. Add express.json for json support.
*/
app.use(express.json());
await Server.createServer({
openapi_path: ...,
controllers_base_path: ...,
server: app,
controller_builder: ...,
controller_args_provider: ...,
/**
* Adding a middleware path allows you to add additional
* middleware definitions to your application
*/
middleware_path: path.join(__dirname, "middleware"),
});
app.listen(3000, () => console.log('http://localhost:3000'));
}
start();// middleware/core/auth.ts
/**
* These are familiar express middleware that is just exported from a module.
*/
export default function UserAuth(req:any,reply:any,next:any){
// Auth Logic
next();
}
export function validate_session(req,res,next){
// Logic
next()
}Defining middleware names in spec
There are a couple of syntax to define middleware. Having this flexibility gives the user a greater amount of ways to group middleware functions.
module:
users:
basePath: /users
paths:
'/':
get:
x-controller: controllers/user.controller
x-action: getAll
x-middleware:
- core.auth # This will load middleware/core/auth.ts and load the default function
- core.auth:validate_session # This will load middleware/core/auth.ts and load the validate_session function if it's exported.
- core.auth:* # This will load all the exported functions from the middleware (no order gauranteed)
- core.auth:validate_session,default # This will load the specified functions and run in the order that they are listedMiddleware inheritance
- If you specify
module.users.x-middleware: [...], the parser applies those entries to each route within the module (in addition to the route’s ownx-middleware).
URL transformation and matching
- The parser normalizes your URL templates for routing. It supports both OpenAPI-style
{id}parameters and advanced path patterns (see “Route Path Patterns”). - Matching is done from the start to the end of the path; query strings are ignored for matching.
- The router pre-indexes routes by static prefixes for performance and uses path-to-regex to verify and extract params.
/user/{id}:
get:
x-controller: controllers/user.controller
x-action: get
x-middleware:
- user.auth
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200': { description: Success }Modules
Group related endpoints with a module block and a basePath to avoid repetition:
module:
post:
basePath: '/posts'
paths:
'/':
get:
x-controller: controllers/post.controller
x-action: getAll
'/create':
post:
x-controller: controllers/post.controller
x-action: create
'/{id}':
get:
x-controller: controllers/post.controller
x-action: getRoute Path Patterns (path-to-regex)
Dinner supports advanced URL matching using path-to-regex patterns in your path strings. Use these to express optional parts, wildcards, and custom regex captures.
Key patterns
:key– named segment (single path part):key?– optional named segment:key*– zero or more segments (split by/), returns an array:key(…)– custom regex for the segment (e.g.,(\d+),(.*))- Repeated names –
:key … :keyreturns arrays (multiple captures)
Notes
- Matching is case-sensitive by default.
- By default, matching is from the start and to the end of the URL path.
- The separator is
/.
Examples
Single and multiple params
paths:
/user/:id:
get:
x-controller: controllers/user.controller
x-action: get
/user/:foo/:bar:
get:
x-controller: controllers/user.controller
x-action: pairOptional segments
paths:
/foo/:bar?:
get:
x-controller: controllers/foo.controller
x-action: maybeBarNumeric constraint
paths:
/foo/:bar(\d+):
get:
x-controller: controllers/foo.controller
x-action: onlyNumbersWildcard segments vs catch-all
paths:
# :path* captures multiple segments, split by '/'
/assets/:path*:
get:
x-controller: controllers/assets.controller
x-action: serve
# (.*) captures everything, including '/'
/report/:slug(.*)/download:
get:
x-controller: controllers/report.controller
x-action: downloadRepeated names (arrays)
paths:
/foo/:bar/:bar:
get:
x-controller: controllers/foo.controller
x-action: repeatedSimple and advanced params
- For simple path params, use either
{id}(OpenAPI style) or:id(colon style) — both are supported. - For advanced matching (regex and modifiers), you can use either style. The URL transformer converts
{name[:regex][modifier]}into the equivalent colon form for matching.
paths:
# Colon forms
/order/:id(\\d+): { get: { x-controller: controllers/order.controller, x-action: get } }
/files/:path*: { get: { x-controller: controllers/files.controller, x-action: list } }
# Brace forms (equivalent)
/order/{id:\\d+}: { get: { x-controller: controllers/order.controller, x-action: get } }
/files/{path:(.*)}: { get: { x-controller: controllers/files.controller, x-action: list } }Path patterns: updated guidance
Dinner transforms brace parameters to colon form before compiling with path-to-regex. Use these forms for reliable matching:
- Brace grammar:
{name[:pattern][modifier]}withmodifier(?,*,+) only when nopatternis present. - Multi‑segment capture: use
(.*)(e.g.,{slug:(.*)}→:slug(.*)). - Non‑greedy capture: use
.*?(e.g.,{a:.*?}-x/{b:.*}→:a(.*?)-x/:b(.*)). - Literal text in patterns must escape regex characters (e.g.,
{ext:\\.tar\\.gz}). - Note: in this package,
:name*and:name+are not multi‑segment globs — prefer explicit(.*).
Examples
paths:
/order/{id:\\d+}: { get: { x-controller: controllers.order, x-action: get } }
/report/{slug:(.*)}/download: { get: { x-controller: controllers.report, x-action: download } }
/a/{foo?}: { get: { x-controller: controllers.a, x-action: get } }
/file/{ext:\\.tar\\.gz}: { get: { x-controller: controllers.file, x-action: get } }You can also set a global basePath to prefix everything:
basePath: '/api'Controllers
Dinner loads controllers dynamically based on x-controller and calls the method in x-action.
Location
x-controller is resolved relative to controllers_base_path. Omit the file extension.
# openapi.yaml
paths:
/users/{id}:
get:
x-controller: controllers/user.controller
x-action: getResolved file:
<controllers_base_path>/controllers/user.controller.ts
# Omit the file extension in x-controllerExport
Controllers must be the default export of the module.
// src/controllers/user.controller.ts
export default class UserController {
async get({ params }: { params: { id: string } }) {
return { id: params.id };
}
}Instantiation
Use controller_builder to create controller instances — plug in DI or plain constructors. Dinner passes you the raw controller class (constructor). You decide how to instantiate it: directly with new, through a DI container, as a singleton, or with per‑request state.
// server setup
await Server.createServer({
// ...
controller_builder: async (Controller) => new Controller(),
});DI container example
import createContainer from '@noego/ioc';
const container = createContainer();
await Server.createServer({
// ...
controller_builder: async (Controller) => {
// Resolve via your container
return container.instance(Controller);
},
});Singleton (per class) example
const singletons = new WeakMap<Function, any>();
await Server.createServer({
// ...
controller_builder: async (Controller) => {
if (!singletons.has(Controller)) {
singletons.set(Controller, new Controller());
}
return singletons.get(Controller);
},
});Factory using context
await Server.createServer({
// ...
controller_builder: async (Controller, context) => new Controller(context),
context_builder: async (req) => ({ requestId: req.headers['x-request-id'] }),
});Arguments
Use controller_args_provider to shape the single argument passed to your controller action.
// server setup
await Server.createServer({
// ...
controller_args_provider: async (req, res, context) => ({
req,
res,
context,
body: req.body,
params: req.params,
query: req.query,
}),
});Context
Optionally provide a context_builder to compute per‑request context (e.g., requestId, auth info) before controller_args_provider runs.
// server setup
await Server.createServer({
// ...
context_builder: async (req, res) => ({
requestId: String(req.headers['x-request-id'] || ''),
user: (req as any).user || null,
}),
});Recommended minimal setup
import express from 'express';
import { Server } from '@noego/dinner';
await Server.createServer({
openapi_path: 'openapi.yaml',
controllers_base_path: 'src',
server: express(),
controller_builder: async (Controller) => new Controller(),
controller_args_provider: async (req, res, context) => ({
req,
res,
context,
body: req.body,
params: req.params,
query: req.query,
}),
context_builder: async (req, res) => ({ requestId: req.headers['x-request-id'] }),
});Controller example
// src/controllers/user.controller.ts
export default class UserController {
async get({ params }: { params: { id: string } }) {
return { id: params.id };
}
}Validation & Params
Dinner compiles JSON Schemas from your OpenAPI and validates requests with Ajv before your controller action runs. Invalid requests get a 400 response.
Define parameters (path, query), request bodies, and responses in YAML:
paths:
/users/{id}:
get:
x-controller: controllers/user.controller
x-action: get
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: include
in: query
required: false
schema:
type: string
enum: [profile, posts]
responses:
'200':
description: OK
/users:
post:
x-controller: controllers/user.controller
x-action: create
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NewUser'
responses:
'201': { description: Created }
components:
schemas:
NewUser:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
minLength: 8Provider mapping
- Path params →
req.paramsby name (e.g.,{ id: '123' }). - Query params →
req.query(strings unless you coerce types yourself). - Body →
req.body(JSON content-type recommended).
Ajv formats
- Enable built‑in formats (e.g.,
email,uri) by settingajv_formats:
await Server.createServer({
// ...
ajv_formats: true, // all default formats
// or choose specific ones
// ajv_formats: ['email', 'uri']
});Error handling
- If the request body doesn’t match the schema, Dinner returns 400 before your action runs.
- For path/query params, define schemas as shown; Dinner uses them to build route validation.
Split Your Spec (with @noego/stitch)
Use a small "stitch config" to merge module files before Dinner processes them. The stitch file can list modules as an array or object.
stitch.yaml
stitch:
- ./spec/users.yaml
- ./spec/posts.yamlspec/users.yaml
openapi: 3.0.0
module:
users:
basePath: /users
paths:
'/':
get:
x-controller: controllers/user.controller
x-action: getAll
'/{id}':
get:
x-controller: controllers/user.controller
x-action: getThen point Dinner at the stitch file instead of a raw OpenAPI file:
await Server.createServer({
openapi_path: path.join(__dirname, 'stitch.yaml'),
controllers_base_path: path.join(__dirname, 'controllers'),
server: app,
controller_builder: async (C) => new C(),
controller_args_provider: async (req, res) => ({ req, res }),
});Dinner detects a stitch document, uses @noego/stitch to merge your YAML files, resolves $refs, and then builds routes. Note: Stitch itself only merges/validates; any basePath handling happens in your OpenAPI content (and Dinner’s processing), not in stitch.yaml.
Stitch: Detailed Examples
Project structure example
project/
├── stitch.yaml
├── spec/
│ ├── users.yaml
│ ├── posts.yaml
│ └── components/
│ └── schemas.yaml
└── controllers/
├── user.controller.ts
└── post.controller.tsSample stitch.yaml
stitch:
- ./spec/base.yaml
- ./spec/paths.yamlGlob patterns
Stitch can expand globs in stitch.yaml. Globs are resolved relative to the stitch.yaml directory, directories are ignored, and matches are sorted alphabetically for deterministic merging.
stitch:
- ./spec/base.yaml
- ./spec/paths/*.yaml # include all path fragments
- ./spec/components/*.yaml # include component fragmentsVSCode YAML schema integration
npx stitch install schema.json --target "**/*.yaml"Middleware Patterns
Middleware entries use a compact syntax to reference files and exported functions.
- Dot notation → file path:
folder.fileresolves tofolder/file.tsundermiddleware_path(orcontrollers_base_pathfallback). - Default export:
authloads the default export frommiddleware/auth.ts. - Named exports:
auth:cookie,bearerloads only the named exportscookieandbearer(in order). - All exports:
auth:*loads all exported functions from the module. - Explicit default:
auth:defaultis equivalent toauth.
Resolution rules
- Base dir is
middleware_pathif provided. Otherwise Dinner falls back tocontrollers_base_path, thenprocess.cwd(). - Dots are converted to slashes:
security.auth.session→security/auth/session.ts. - Errors are thrown for missing modules/functions or non-function exports.
paths:
/user/{id}:
get:
x-middleware:
- auth # default export from middleware/auth.ts
- auth:cookie,bearer # only these named exports (in order)
- auth:* # all exported functions
- security.auth.session # dot notation → security/auth/session.tsExample middleware file:
// middleware/auth.ts
export default function authenticate(req, res, next) { next(); }
export function cookie(req, res, next) { next(); }
export function bearer(req, res, next) { next(); }Execution order
- Middleware run sequentially in the order listed in
x-middleware. - For comma-separated names (
name1,name2), functions run in that order.
Controllers & DI
Controllers are plain classes. Use dependency injection with [@noego/ioc] if you prefer:
import { Component, Inject } from '@noego/ioc';
import { UserService } from '../services/userService';
@Component()
export default class UserController {
constructor(@Inject(UserService) private userService: UserService) {}
async getAll() {
const users = await this.userService.getUsers();
return { users, total: users.length };
}
}Provide your own controller lifecycle via controller_builder and request args via controller_args_provider:
const controller_builder = async (Controller) => new Controller();
const controller_args_provider = async (req, res) => ({ req, res });Configuration
type ServerOptions = {
openapi_path: string; // absolute path to OpenAPI file
controllers_base_path: string; // absolute base path for controllers
middleware_path?: string; // absolute base path for middleware
controller_builder?: (Controller, ctx?) => Promise<any>;
controller_args_provider?: (req, res, ctx?) => Promise<any>;
context_builder?: (req, res) => Promise<any>;
server?: import('express').Application; // existing Express app
ajv_formats?: string[] | true; // configure AJV formats
}
``;
Notes:
- Dinner dynamically imports controllers; ensure your build/runtime can resolve your controller modules by path.
## TypeScript
Recommended minimal `tsconfig.json` settings:
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "Node",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "dist",
"strict": true
},
"include": ["./**/*.ts"]
}Examples & Docs
- Examples: see the
example/folder for controllers, middleware, and OpenAPI usage - Middleware guide:
docs/middleware.md - Advanced validation:
docs/advanced_validation.md
Benefits:
- Development: Automatic restart on file changes for faster iteration
- Production: Main process manages workers for improved reliability
- Scalability: Can be extended to support multiple workers for load balancing
Performance Benchmarks
- What it does: Exercises in-memory HTTP calls (Supertest) against two apps: one built with Dinner, one with plain Express. Measures requests/sec and average latency. No ports are opened.
- Where it lives:
scripts/benchmark.js
Run a quick benchmark
npm run benchTune what’s measured (env vars)
BENCH_ROUTESETS: CSV of route counts to test (default:10,100,500,1000)BENCH_ROUTES: Single route count (overridesBENCH_ROUTESETS)BENCH_SUBROUTES: Subroutes per base route (default:5)BENCH_REPEATS: Passes through all URLs per iteration (default:2)BENCH_ITERS: Iterations per size for mean±sd (default:5)BENCH_ENGINES: Which engines to run, comma-separated (dinner,expressby default). Examples:dinnerorexpress.BENCH_REQ_TIMEOUT_MS: Per-request timeout (default:60000,0to disable)BENCH_ITER_TIMEOUT_MS: Per-iteration timeout (default:120000)BENCH_PROGRESS_EVERY: Print progress every N requests (default: off)BENCH_PAUSE_MS: Sleep between iterations (default:0)
GC and stability
- The
benchscript runs Node with--expose-gcand triggersglobal.gc()between warmups and iterations to reduce memory pressure variability. SetBENCH_LOG_GC=1to log GC checkpoints.
Examples
# Default multi-size run (10,100,500,1000 routes)
npm run bench
# Single size, more iterations
BENCH_ROUTES=500 BENCH_ITERS=10 npm run bench
# Heavier URL matrix
BENCH_ROUTESETS=100,1000 BENCH_SUBROUTES=10 BENCH_REPEATS=3 npm run bench
# Inspect GC pauses (verbose)
npm run bench:gc
# or with NODE_OPTIONS
NODE_OPTIONS="--expose-gc --trace-gc --trace-gc-ignore-scavenger" npm run benchProfiling
# Generate a flamegraph with Clinic (installs Clinic if needed)
npm run profile
# Output is written under .clinic/*.html — open in your browser
# Analyze async/await flow (Bubbleprof)
npm run profile:async
# Output under .clinic/*.html — look for slow async edges, long timers, or blocked chainsOutputs
- Console tables: per-size summary showing
Req/s (mean±sd)andAvg ms/req (mean±sd)plus speedup. - Markdown block: ready-to-paste tables for the README (includes environment and config).
Tip
- Ensure a build exists before benchmarking. The script runs
npm run buildautomatically via thebenchnpm script.
Testing
Integration Testing
Test your API with Supertest:
import express, { Application } from "express";
import request from "supertest";
import path from "path";
import { Server, ServerOptions } from "@noego/dinner";
import createContainer from "@noego/ioc";
import { ModuleSetup, build_loader_function, build_provider_function } from "./setup/module_setup";
describe('API Tests', () => {
let app: Application;
beforeAll(async () => {
const container = createContainer();
ModuleSetup(container);
const appInstance = express();
appInstance.use(express.json());
const options: ServerOptions = {
openapi_path: path.resolve(__dirname, "./openapi.yaml"),
controllers_base_path: path.resolve(__dirname),
loader_function: build_loader_function(container),
provider_function: build_provider_function(container),
server: appInstance
};
const server = await Server.createServer(options);
app = server.implementation.server as Application;
});
it('should return hello world', async () => {
const response = await request(app).get('/hello');
expect(response.status).toBe(200);
expect(response.body).toEqual({
message: "Successful request. Hello World!",
error: false
});
});
});Service Pattern
Use the dependency injection to create and use services in your controllers:
// services/example.service.ts
import { Component } from "@noego/ioc";
@Component()
export class ExampleService {
async performAction(data: any) {
// Service implementation
return { result: true, data };
}
}// controllers/example.controller.ts
import { Component, Inject } from "@noego/ioc";
import { ExampleService } from "../services/example.service";
@Component()
export default class ExampleController {
constructor(@Inject(ExampleService) private service: ExampleService) {}
async doSomething({ req, reply }) {
const result = await this.service.performAction(req.body);
return result;
}
}Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
ISC License — see the LICENSE file for details.
