Home - demingongo/kaapi GitHub Wiki

Welcome to the kaapi 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.

Installation

Install Kaapi from the npm registry:

npm install @kaapi/kaapi

Getting Started

Kaapi 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();

Notes

  • app is an instance of Kaapi.
  • Use await app.listen() to start the server.
  • Access the underlying Hapi.Server via app.base() if you need lower-level control.

Routing

Overview

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.

Example

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.

Handling 404s (Not Found)

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.

Example: Custom 404 Page

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.


Logger

Kaapi provides built-in logging support via Winston but it also allows you to plug in your own logger implementation if needed.

Default Logger (Winston)

By default, Kaapi uses Winston as its logging engine. You can configure Winston by passing loggerOptions during initialization.

Example:

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');

Custom Logger Support

You can override the default logger by providing a custom implementation that conforms to the ILogger interface.

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 custom logger is provided, the loggerOptions setting is ignored.

Example with a Custom Logger:

import myLogger from './myLogger';

const app = new Kaapi({
    port: 3000,
    host: 'localhost',
    logger: myLogger
});

app.log.verbose('Wordy');

Creating a Custom Winston-Based Logger

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');

Custom Formatting & Transports

Winston allows powerful customization through formatters and transports.

Example: JSON Format (for external ingestion)

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(),
        ],
    }
});

Example: Log to File

new winston.transports.File({
  filename: 'logs/app.log',
  level: 'info',
});

Integrating with External Log Platforms

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.


Authorization

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.

Minimal Setup

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.


Auth Designs

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.

Bearer Token Authorization

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'
        }
    }
});

Options

  • strategyName - optional custom strategy name (default: 'bearer-auth-design')
  • auth.validate(request, token) - returns { isValid, credentials, artifacts }
  • Returning a message prevents further auth attempts

Basic Authorization

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'
        }
    }
});

Options

  • strategyName - optional custom strategy name (default: 'basic-auth-design')
  • auth.validate(request, username, password) - returns { isValid, credentials, artifacts }

API Key Authorization

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'
        }
    }
});

Options

  • 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)

Using Auth in Routes

Once an auth strategy is configured as the default, you can control its behavior per route.

Use Default Strategy + mode: required

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: true is a shorthand for { mode: 'required' } using the default strategy.

Use a Specific Strategy per Route

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.

Auth Modes Summary (from Hapi)

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.


API Documentation

Kaapi includes built-in API documentation powered by OpenAPI (Swagger) β€” enabled by default.

Once your server is running, documentation is available at:

You can customize the documentation's behavior, appearance, or path by updating the docs configuration.

Customizing Docs

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'
            }
        }
    }
});

Route 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 (via app.base()):
app.refreshDocs(); // Refresh schema and sort routes alphabetically

Excluding Routes from Docs

To 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!');

Joi-based Schema Generation

Kaapi uses Joi validation to generate documentation for:

  • headers
  • query
  • params
  • payload

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();

File Uploads in Docs

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.


Messaging

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.).

Purpose

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.

Interface Definition

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 to unknown).
  • 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:
      • groupId
      • allowAutoTopicCreation
      • sessionTimeout
      • 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().

Implementations

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 Usage

// 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);
});

When to Use

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

Extending

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.

How to Extend

You can add one or multiple plugins to your app with:

await app.extend(pluginOrPlugins);
  • pluginOrPlugins can be a single plugin or an array of plugins.
  • Using await ensures 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: Using extend at init is convenient, but it does not wait for the plugin's integrate() method to finish before returning. If your plugin performs async setup (e.g., connecting to a database), prefer using await app.extend() after app creation.

Plugin Interface

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 integrate method.
  • integrate receives KaapiTools to interact with the Kaapi app.

KaapiTools: Your Integration Toolbox

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;
}

What You Can Do with KaapiTools

  • Add routes: Use route() to register new API endpoints.
  • Add authentication schemes and strategies: Use scheme() and strategy() for custom auth flows.
  • Access documentation generators: openapi and postman can help extend API docs.
  • Log: Use log for plugin-specific logging.
  • Access Hapi server: Full power of Hapi is available via server.

Example: Creating a Simple Plugin

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');
    }
};

Using Your Plugin

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.

Summary

  • Implement the KaapiPlugin interface with an integrate(tools: KaapiTools) method.
  • Use KaapiTools to add routes, auth schemes/strategies, and access server features.
  • Register your plugin via app.extend() or during app initialization.
  • Prefer the await form when plugin setup must complete before the app starts.
⚠️ **GitHub.com Fallback** ⚠️