Home - demingongo/kaapi GitHub Wiki
The kaapi project is a flexible, extensible backend framework with messaging and documentation features.
- Modularity: Designed for extensibility and modularity, which is great for evolving business needs.
- Messaging: Built-in abstractions for messaging (Kafka, custom), ideal for distributed systems.
- Documentation: OpenAPI/Postman support.
- Type Safety: TypeScript to ensure better maintainability and fewer runtime errors.
Install Kaapi from the npm registry:
npm install @kaapi/kaapiKaapi is built on top of Hapi, giving you full access to the Hapi ecosystem - including plugins, extensions, and familiar APIs.
Hereβs a minimal example to start a server:
import { Kaapi } from '@kaapi/kaapi';
const init = async () => {
const app = new Kaapi({
port: 3000,
host: 'localhost'
});
await app.listen();
const server = app.base(); // Access the underlying Hapi server
console.log('Server running on %s', server.info.uri);
};
process.on('unhandledRejection', (err) => {
console.log(err);
process.exit(1);
});
init();-
appis an instance ofKaapi. - Use
await app.listen()to start the server. - Access the underlying
Hapi.Serverviaapp.base()if you need lower-level control.
Kaapi routing is inspired by the simplicity of Hapi's routing. You can define routes using app.route() by specifying the HTTP method and path, along with a handler function.
app.route({
method: 'GET',
path: '/',
}, (request, h) => 'Hello World!');You can return strings, objects, or use h.response() to customize status codes, headers, and more.
To provide a custom response when no route matches, simply register a fallback route without any method or path - this acts as a "catch-all" route.
import { Kaapi } from '@kaapi/kaapi';
const init = async () => {
const app = new Kaapi({
port: 3000,
host: 'localhost'
});
// Register a fallback route (for unmatched requests)
app.route({}, (request, h) => {
return h.response('404 Error! Page Not Found!').code(404);
});
await app.listen();
const server = app.base();
console.log('Server running on %s', server.info.uri);
};
init();βΉοΈ The empty route config
{}matches any method and any path, so it effectively acts as a global 404 handler.
Kaapi provides built-in logging support via Winston but it also allows you to plug in your own logger implementation if needed.
By default, Kaapi uses Winston as its logging engine. You can configure Winston by passing loggerOptions during initialization.
import winston from 'winston';
import { Kaapi } from '@kaapi/kaapi';
const loggerOpts: winston.LoggerOptions = {
level: 'debug'
};
const app = new Kaapi({
port: 3000,
host: 'localhost',
loggerOptions: loggerOpts
});Once initialized, you can log using Kaapi's built-in logger:
app.log('Hello'); // Defaults to 'info'
app.log.silly('Very detailed log');
app.log.debug('Debugging info');
app.log.verbose('Verbose output');
app.log.info('General info');
app.log.warn('Warnings');
app.log.warning('Alias for warn');
app.log.err('Alias for error');
app.log.error('Errors');You can override the default logger by providing a custom implementation that conforms to the ILogger interface.
interface ILogger {
(...args: unknown[]): void
silly: (...args: unknown[]) => void;
debug: (...args: unknown[]) => void;
verbose: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
warning: (...args: unknown[]) => void;
err: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
β οΈ When a customloggeris provided, theloggerOptionssetting is ignored.
import myLogger from './myLogger';
const app = new Kaapi({
port: 3000,
host: 'localhost',
logger: myLogger
});
app.log.verbose('Wordy');You can use the createLogger utility from Kaapi to easily create a logger based on Winston that adheres to the ILogger interface:
import winston from 'winston';
import { createLogger } from '@kaapi/kaapi';
const myLogger = createLogger({
level: 'info',
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.splat(),
winston.format.simple()
),
}),
],
});
myLogger.info('Logger is working');Winston allows powerful customization through formatters and transports.
import { Kaapi } from '@kaapi/kaapi';
import winston from 'winston';
const app = new Kaapi({
port: 3000,
host: 'localhost',
loggerOptions: {
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
],
}
});new winston.transports.File({
filename: 'logs/app.log',
level: 'info',
});Since Winston supports a wide range of transports, you can easily integrate your logger with external logging platforms such as Grafana Loki, Logstash, or Elastic Stack.
To do this, simply add the appropriate Winston transport for your target platform.
π§ Check out the official documentation or packages for your preferred log platform to learn how to integrate it with Winston.
Examples:
For production, we recommend structured JSON logs and using external agents to forward logs reliably.
Kaapi follows the Hapi-style authentication model, with support for schemes, strategies, and per-route auth modes.
If you're familiar with Hapi Authentication, you'll feel right at home.
By default, Kaapi allows you to define a global auth strategy and override it per route using auth: true.
app.base().auth.strategy('session', 'cookie', {
name: 'sid-example',
password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6',
isSecure: false
});
app.base().auth.default({
strategies: [ 'session' ],
mode: 'try' // default
});
app.route(
{
method: 'GET',
path: '/profile',
auth: true, // Shorthand for: { mode: 'required' }
},
({ auth: { credentials: { user } } }) =>
`Hello ${user && 'name' in user ? user.name : 'World'}!`
);β Useful when some routes should require authentication, while others can optionally use the credentials if provided.
This is useful if some paths of your app require to be authorized and others don't but could still use the user's credentials if authenticated.
Kaapi supports a plugin system for authentication, called Auth Designs.
Each design is a class that extends AuthDesign, and can be added via the extend option.
Weβll look at some built-in designs next.
import { Kaapi, BearerAuthDesign } from '@kaapi/kaapi';
const bearerAuthDesign = new BearerAuthDesign({
strategyName: 'bearer-auth-design',
auth: {
async validate(request, token) {
if (token !== 'secret') {
return { isValid: false, message: '' };
// Or: return Boom.unauthorized('Invalid token');
}
return {
isValid: true,
credentials: {
user: { name: 'admin' },
},
artifacts: {},
};
},
},
});
const app = new Kaapi({
port: 3000,
host: 'localhost',
extend: [ bearerAuthDesign ],
routes: {
auth: {
strategy: bearerAuthDesign.getStrategyName(),
mode: 'try'
}
}
});-
strategyName- optional custom strategy name (default:'bearer-auth-design') -
auth.validate(request, token)- returns{ isValid, credentials, artifacts } - Returning a
messageprevents further auth attempts
import { Kaapi, BasicAuthDesign } from '@kaapi/kaapi';
const basicAuthDesign = new BasicAuthDesign({
strategyName: 'basic-auth-design',
auth: {
async validate(request, username, password) {
if (username == 'admin' && password == 'password') {
return {
isValid: true,
credentials: {
user: {
name: 'admin'
}
}
};
}
return { isValid: false };
}
}
});
const app = new Kaapi({
port: 3000,
host: 'localhost',
extend: [ basicAuthDesign ],
routes: {
auth: {
strategy: basicAuthDesign.getStrategyName(),
mode: 'try'
}
}
});-
strategyName- optional custom strategy name (default:'basic-auth-design') -
auth.validate(request, username, password)- returns{ isValid, credentials, artifacts }
import { Kaapi, APIKeyAuthDesign } from '@kaapi/kaapi';
const apiKeyAuthDesign = new APIKeyAuthDesign({
strategyName: 'api-key-auth-design',
auth: {
async validate(request, token) {
if (token === 'secret') {
return {
isValid: true,
credentials: {
user: { name: 'admin' },
},
};
}
return { isValid: false };
},
headerTokenType: 'Session',
},
key: 'authorization',
})
.inHeader() // Read token from header (default)
// .inQuery() // Alternatively, read token from query string
// .inCookie() // Or read token from a cookie
.setDescription('Session token type');
const app = new Kaapi({
port: 3000,
host: 'localhost',
extend: [ apiKeyAuthDesign ],
routes: {
auth: {
strategy: apiKeyAuthDesign.getStrategyName(),
mode: 'try'
}
}
});-
strategyName- custom strategy name (default:'api-key-auth-design') -
key- name of the header / query param / cookie to extract the token from -
auth.validate(request, token)- async function that validates the token and returns{ isValid, credentials, artifacts } -
auth.headerTokenType- optional token prefix in the header (e.g.,'Bearer','Session') -
.inHeader()- read token from header (default behavior) -
.inQuery()- read token from query string -
.inCookie()- read token from cookie -
.setDescription()- optional description (for API docs)
Once an auth strategy is configured as the default, you can control its behavior per route.
If you configure a global auth strategy (e.g., try mode), you can require authentication for specific routes by setting auth: true.
app.base().auth.default({
strategy: 'api-key-auth-design',
mode: 'try', // Global default: optional
});
app.route(
{
method: 'GET',
path: '/profile',
auth: true, // Override to: { mode: 'required' }
},
({ auth: { credentials: { user } } }) =>
`Hello ${user && 'name' in user ? user.name : 'World'}!`
);π
auth: trueis a shorthand for{ mode: 'required' }using the default strategy.
You can also define a different strategy and mode directly in the route's options:
app.route(
{
method: 'GET',
path: '/profile',
options: {
auth: {
strategy: 'api-key-auth-design',
mode: 'required',
},
},
},
({ auth: { credentials: { user } } }) =>
`Hello ${user && 'name' in user ? user.name : 'World'}!`
);π This is useful when you have multiple strategies and want to apply them selectively.
Kaapi uses the same auth modes as Hapi. Here's how each mode behaves:
| Mode | Description |
|---|---|
required |
Authentication must succeed. The request will be rejected if not authenticated. |
try |
Attempts authentication. If it fails, the route still continues - useful when credentials are optional. |
optional |
Skips authentication if credentials are not provided. Only authenticates when explicitly present. |
π See Hapiβs official docs β route auth modes for further reference.
Kaapi includes built-in API documentation powered by OpenAPI (Swagger) β enabled by default.
Once your server is running, documentation is available at:
-
/docs/apiβ Swagger UI -
/docs/api/schemaβ OpenAPI JSON schema -
/docs/api/schema?format=postmanβ Postman collection
You can customize the documentation's behavior, appearance, or path by updating the docs configuration.
Hereβs how to customize the OpenAPI/Postman metadata and Swagger UI appearance:
import { Kaapi } from '@kaapi/kaapi'
const app = new Kaapi({
docs: {
disabled: false, // enable or disable docs entirely
path: '/docs/api', // change the base path
host: {
url: 'http://{domain}:{port}',
description: 'development',
variables: {
domain: {
default: 'localhost',
enum: [
'localhost',
'127.0.0.1'
],
description: 'Dev domain'
},
port: {
default: '3000'
}
}
},
title: 'Kaapi',
license: {
name: 'ISC',
url: 'https://opensource.org/license/isc-license-txt'
},
ui: {
swagger: {
customCssUrl: '/public/swagger-ui.css',
customSiteTitle: 'Kaapi documentation'
}
}
}
});- All routes registered via
app.route()are automatically included in the docs. - You can refresh the docs manually using
app.refreshDocs()if you've registered routes directly to the Hapi server (viaapp.base()):
app.refreshDocs(); // Refresh schema and sort routes alphabeticallyTo hide a route from the documentation, use:
app.route({
method: 'GET',
path: '/hidden/path',
options: {
plugins: {
kaapi: {
docs: false // or { disabled: true }
}
}
}
}, () => 'You found me!');Kaapi uses Joi validation to generate documentation for:
headersqueryparamspayload
Hereβs a route with Joi-based query validation:
import { Kaapi } from '@kaapi/kaapi';
import Joi from 'joi';
const app = new Kaapi({
port: 3000,
host: 'localhost'
});
app.route<{ Query: { name?: string } }>({
method: 'GET',
path: '/',
options: {
description: 'Greet someone',
tags: ['Index'],
validate: {
query: Joi.object({
name: Joi.string().description('The name of the person to greet').trim()
})
}
}
}, ({ query: { name } }) => `Hello ${name || 'World'}!`);
app.listen();To document and handle file uploads via multipart/form-data, Kaapi uses Joi tagging and stream handling.
import { Kaapi } from '@kaapi/kaapi';
import inert from '@hapi/inert';
import Joi from 'joi';
import fs from 'node:fs/promises';
import Stream from 'node:stream';
import path from 'node:path';
const init = async () => {
const app = new Kaapi({
port: 3000,
host: 'localhost'
});
await app.base().register(inert); // Needed for file handling
app.route<{
Payload: {
picture: {
_data: Stream,
hapi: {
filename: string,
headers: {
'content-type': string
}
}
}
}
}>({
method: 'POST',
path: '/upload',
options: {
description: 'Upload a picture',
tags: ['Index'],
validate: {
payload: Joi.object({
picture: Joi.object().required().tag('files') // π tag required for docs
})
},
payload: {
output: 'stream',
parse: true,
allow: 'multipart/form-data',
multipart: { output: 'stream' },
maxBytes: 1024 * 3000 // 3MB
}
}
}, async (req) => {
const { picture } = req.payload;
// Save the uploaded file
await fs.writeFile(
path.join(__dirname, '..', 'uploads', picture.hapi.filename),
picture._data
);
return 'ok';
});
await app.listen();
console.log('Server running on %s', app.base().info.uri);
};
init();π Tagging the field with
.tag('files')is necessary for Kaapi to identify it as a file and document it properly.
The Messaging interface is a core abstraction in the framework that enables asynchronous communication between different parts of your application, or with external systems, via various messaging protocols (e.g., Kafka, MQTT, etc.).
This interface allows developers to integrate messaging systems into their applications with minimal coupling. By defining a standard contract (publish and subscribe), any messaging protocol can be supported through a custom implementation.
The Messaging interface expects two main methods:
publish<T = unknown>(topic: string, message: T): Promise<void>
Publishes a message to a specific topic or channel.
-
Parameters:
-
topic(string) - The destination topic or channel. -
message(T | unknown) - The payload to send (can be object, string, etc).
-
- Returns: A Promise that resolves when the message is successfully sent.
subscribe<T = unknown>(topic: string, handler: (message: T, context: IMessagingContext) => Promise<void> | void, conf?: IMessagingSubscribeConfig): Promise<void>
Subscribes to a specific topic and registers a handler to process incoming messages.
-
Type Parameter:
-
T- The expected type of the message payload (defaults tounknown).
-
-
Parameters:
-
topic(string) - The topic or channel to listen to. -
handler(function) - An async or sync function that receives:-
message(T) - The deserialized message payload. -
context(IMessagingContext) - An object providing context about the sender or transport (e.g., to reply or acknowledge).
-
-
conf(IMessagingSubscribeConfig, optional) - Protocol-specific subscription configuration. For example, in a Kafka implementation, this can include options like:groupIdallowAutoTopicCreationsessionTimeout- And other consumer settings.
-
- Returns: A Promise that resolves when the subscription is successfully established.
Additional Optional Methods:
shutdown?(): Promise<unknown>
Initiates a graceful shutdown of the service. This method is invoked when the application is stopping via app.stop().
You can create custom implementations of the Messaging interface for different protocols, such as:
- KafkaMessaging - Integration with Apache Kafka
- MQTTMessaging - Integration with MQTT brokers
- AMQPMessaging - Support for RabbitMQ (AMQP)
Each implementation should conform to the interface and handle the underlying protocol-specific logic internally.
// Example: Using a Kafka implementation
import { Kaapi, createLogger } from '@kaapi/kaapi';
import { KafkaMessaging, KafkaMessagingContext, KafkaMessagingSubscribeConfig } from '@kaapi/kafka-messaging';
import { PartitionAssigners } from 'kafkajs';
import winston from 'winston';
/**
* Define the expected message structure
*/
interface SignupMessage {
id: number;
name: string;
}
/**
* Kafka consumer configuration
*/
const SUBSCRIBE_CONFIG: KafkaMessagingSubscribeConfig = {
fromBeginning: false,
allowAutoTopicCreation: false,
groupId: 'my-group',
heartbeatInterval: 3000,
maxBytes: 10 * 1024 * 1024,
maxBytesPerPartition: 1 * 1024 * 1024,
maxWaitTimeInMs: 5000,
metadataMaxAge: 300_000,
minBytes: 1,
partitionAssigners: [PartitionAssigners.roundRobin],
readUncommitted: true,
rebalanceTimeout: 60_000,
retry: { retries: 5 },
sessionTimeout: 30_000,
};
/**
* Initialize Kafka messaging
*/
const messaging = new KafkaMessaging({
brokers: ['localhost:9094'],
name: 'examples-kaapi-messaging',
logger: createLogger({
level: 'debug',
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.splat(),
winston.format.simple()
),
}),
],
}),
});
/**
* Initialize the Kaapi app with messaging
*/
const app = new Kaapi({
port: 3000,
host: 'localhost',
messaging,
});
/**
* Demonstrates how to subscribe and publish a message
*/
async function runExample(): Promise<void> {
/**
* Option 1: Use messaging directly (standalone)
*/
await messaging.subscribe<SignupMessage>('user-signup', (msg, context: KafkaMessagingContext) => {
console.log('π Received signup message (via messaging):', msg);
console.log('π¦ Context info:', context);
}, SUBSCRIBE_CONFIG);
await messaging.publish<SignupMessage>('user-signup', { id: 123, name: 'Alice' });
/**
* Option 2: Use Kaapi app (recommended in app lifecycle)
*/
await app.subscribe<SignupMessage>(
'user-signup',
(msg, context: KafkaMessagingContext) => {
console.log('π Received signup message (via app):', msg);
console.log('π¦ Context info:', context);
},
SUBSCRIBE_CONFIG
);
await app.publish<SignupMessage>('user-signup', { id: 456, name: 'Bob' });
}
runExample().catch((err) => {
console.error('β Messaging example failed:', err);
});Use the Messaging interface when you need:
- Loose coupling between services/modules
- Event-driven or message-based communication
- Protocol-agnostic communication thatβs easy to extend or swap
Kaapi allows you to extend its functionality by creating plugins. Plugins integrate with Kaapi using the extend() method and provide custom routes, authentication schemes, or other features.
You can add one or multiple plugins to your app with:
await app.extend(pluginOrPlugins);-
pluginOrPluginscan be a single plugin or an array of plugins. - Using
awaitensures all plugin logic (especially async operations) completes before your app continues.
Alternatively, you can declare plugins at initialization:
const app = new Kaapi({
port: 3000,
host: 'localhost',
extend: pluginOrPlugins
});
β οΈ Note: Usingextendat init is convenient, but it does not wait for the plugin'sintegrate()method to finish before returning. If your plugin performs async setup (e.g., connecting to a database), prefer usingawait app.extend()after app creation.
A plugin is an object implementing the KaapiPlugin interface:
export interface KaapiPlugin {
integrate(tools: KaapiTools): void | Promise<void>;
}- The core of a plugin is its
integratemethod. -
integratereceivesKaapiToolsto interact with the Kaapi app.
Inside integrate, you use KaapiTools to add routes, auth schemes, or access the underlying Hapi server:
interface KaapiTools {
readonly log: ILogger;
route<Refs = Hapi.ReqRefDefaults>(
serverRoute: KaapiServerRoute<Refs>,
handler?: Hapi.HandlerDecorations | Hapi.Lifecycle.Method<Refs, Hapi.Lifecycle.ReturnValue<Refs>>
): this;
openapi?: KaapiOpenAPI;
postman?: KaapiPostman;
scheme<Refs = Hapi.ReqRefDefaults, Options extends object = {}>(
name: string,
scheme: Hapi.ServerAuthScheme<Options, Refs>
): void;
strategy(
name: string,
scheme: string,
options?: object
): void;
server: Hapi.Server;
}-
Add routes: Use
route()to register new API endpoints. -
Add authentication schemes and strategies: Use
scheme()andstrategy()for custom auth flows. -
Access documentation generators:
openapiandpostmancan help extend API docs. -
Log: Use
logfor plugin-specific logging. -
Access Hapi server: Full power of Hapi is available via
server.
import { KaapiPlugin } from '@kaapi/kaapi';
const MyPlugin: KaapiPlugin = {
async integrate(tools) {
tools.log.info('Integrating MyPlugin');
// Add a simple route
tools.route(
{
method: 'GET',
path: '/hello',
options: {
description: 'Returns a greeting'
}
},
() => 'Hello from MyPlugin!'
);
// Add a custom auth scheme (optional)
tools.scheme('custom-scheme', (server, options) => {
return {
authenticate: async (request, h) => {
// Implement authentication logic here
return h.authenticated({ credentials: { user: 'custom' } });
}
};
});
// Register an auth strategy using the scheme
tools.strategy('custom-strategy', 'custom-scheme');
tools.log.info('MyPlugin integrated successfully');
}
};import { Kaapi } from '@kaapi/kaapi';
const app = new Kaapi({
port: 3000,
host: 'localhost'
});
await app.extend(MyPlugin);
await app.listen();Or at initialization:
const app = new Kaapi({
port: 3000,
host: 'localhost',
extend: MyPlugin
});β Use
await app.extend()if your plugin performs async setup or depends on external services.
- Implement the
KaapiPlugininterface with anintegrate(tools: KaapiTools)method. - Use
KaapiToolsto add routes, auth schemes/strategies, and access server features. - Register your plugin via
app.extend()or during app initialization. - Prefer the
awaitform when plugin setup must complete before the app starts.