Kaapi: A flexible, extensible backend framework for modern APIs with messaging, documentation, and type safety built right in.
This series is written for backend developers who love TypeScript and can appreciate Hapiโs design philosophy.
Documentation?
(Yes, we actually read itโฆ sometimes.)
A good API lives or dies by its documentation.
Whether you have a public API, a private API, or an "API that only Chad from DevOps knows how to use", someone eventually needs to understand it.
Kaapi does this for you by auto-generating docs in two formats, because why settle for one?
- OpenAPI v3.1.1: for a complete, machine-readable definition
- Postman Collection v2.1: for folks on Postmanโs free tier (๐ hi, we see you)
Before we jump straight into documentation generation, we need... configuration. So letโs start there.
Configuration
A Kaapi app starts like a regular Hapi server:
import { Kaapi } from '@kaapi/kaapi';
const app = new Kaapi({
// ServerOptions
port: 3000,
host: 'localhost',
});
const start = async () => {
app.route({
method: 'GET',
path: '/hello',
options: {
description: 'Another Hello World.',
},
handler: () => `Hello World!`,
});
await app.listen();
app.log('๐ Server running at:', app.base().info.uri);
};
start();
Once this is running, visit:
- Swagger UI โ http://localhost:3000/docs/api
- OpenAPI schema โ /docs/api/schema
- Postman collection โ /docs/api/schema?format=postman
Boom. Instant documentation. Zero guilt.
Now letโs customize the documentation settings using the docs option:
import { Kaapi } from '@kaapi/kaapi';
const app = new Kaapi({
// ServerOptions
port: 3000,
host: 'localhost',
// DocsConfig
docs: {
disabled: false, // disable documentation generation, default: false
path: '/docs/api', // path to documentation, default: /docs/api
title: 'My First App',
version: '1.0.1',
ui: {
swagger: {
customCss: '.swagger-ui .topbar { display: none; }',
customSiteTitle: 'My First App',
},
},
},
});
Visit /docs/api again, and voilร ! Your Swagger UI now has a personalized title, a missing top bar and shows a new API version and title.
Petstore Example
Because no API documentation article is complete without animals,
Time to recreate the legendary Swagger Petstore with request data validation.
Petstore Configuration
Weโre turning the configuration dial up to Fancy Mode:
const app = new Kaapi({
// LoggerOptions
loggerOptions: {
level: 'debug',
},
port: 3000,
host: 'localhost',
docs: {
disabled: false,
path: '/docs/api',
title: 'Petstore',
license: {
name: 'MIT',
url: 'https://raw.githubusercontent.com/shygyver/kaapi-monorepo-playground/refs/heads/main/LICENSE',
},
version: '1.0.12',
ui: {
swagger: {
customCss: '.swagger-ui .topbar { display: none; }',
customSiteTitle: 'Swagger UI - Petstore',
},
},
host: {
url: '{baseUrl}',
variables: {
baseUrl: {
default: 'http://localhost:3000',
enum: ['http://localhost:3000'],
},
},
},
tags: [
{
name: 'pet',
description: 'Everything about your Pets',
externalDocs: {
url: 'https://swagger.io/',
description: 'Find out more',
},
},
],
},
});
Why define docs.host?
Useful when deploying to production so Postman and external clients resolve URLs correctly. For local development, we simply set the internal uri http://localhost:3000. Using a variable like {baseUrl} also keeps things clean when switching between environments.
Why define docs.tags?
This is optional, but helpful if you want to enrich tag metadata with descriptions or external links.
Now letโs also sprinkle some extra documentation seasoning using .setDescription(), .setContact(), .setExternalDoc(), and other goodies.
// info for OpenAPI specification
app.openapi
.setDescription(
`This is an OpenAPI 3.1.1 specification of a sample Pet Store Server build with Kaapi. You can find out more about Kaapi at [https://www.npmjs.com/package/@kaapi/kaapi](https://www.npmjs.com/package/@kaapi/kaapi).
Some useful links:
- [The original Pet Store repository](https://github.com/swagger-api/swagger-petstore)
- [OpenAPI Specification v3.1.1](https://spec.openapis.org/oas/v3.1.1.html)
`
)
.setTermsOfService('https://raw.githubusercontent.com/shygyver/kaapi-monorepo-playground/refs/heads/main/LICENSE')
.setContact({
url: 'https://github.com/shygyver',
})
.setExternalDoc({
url: 'https://github.com/demingongo/kaapi/wiki',
description: 'Find out more about Kaapi',
});
// info for Postman collection
app.postman
.setDescription(`This is a Postman 2.1.0 collection format of a sample Pet Store Server build with Kaapi. You can find out more about Kaapi at [https://www.npmjs.com/package/@kaapi/kaapi](https://www.npmjs.com/package/@kaapi/kaapi).
Some useful links:
- [The original Pet Store repository](https://github.com/swagger-api/swagger-petstore)
- [Postman Collection Format v2.1.0](https://schema.postman.com/collection/json/v2.1.0/draft-07/docs/index.html)
`);
Petstore - Update an existing pet (Payload)
Weโll use Joi for validation.
We also extend Joi to be nicer with application/x-www-form-urlencoded:
import Joi from 'joi';
// validator ajustments for application/x-www-form-urlencoded
const customJoi: Joi.Root = Joi.extend(
(joi) => ({
type: 'array',
base: joi.array(),
coerce: {
from: 'string',
method(value) {
try {
if (typeof value === 'string') {
if (value.startsWith('[') && value.endsWith(']')) {
return { value: JSON.parse(value) };
} else {
return { value: value.split(',') };
}
}
} catch (err) {
app.log.error(err);
}
return { value };
},
},
}),
(joi) => ({
type: 'object',
base: joi.object(),
coerce: {
from: 'string',
method(value) {
try {
if (typeof value === 'string' && value.startsWith('{')) {
return { value: JSON.parse(value) };
}
} catch (err) {
app.log.error(err);
}
return { value };
},
},
})
);
// 'Update an existing pet' endpoint
app.route<{
Payload: {
id: number;
name: string;
category?: {
id?: 1;
name?: string;
};
photoUrls: string[];
tags?: [
{
id?: 0;
name?: 'string';
},
];
status?: 'available';
};
}>({
path: '/pet',
method: 'PUT',
options: {
tags: ['pet'],
description: 'Update an existing pet.',
notes: ['Update an existing pet by Id.'],
payload: {
allow: ['application/json', 'application/x-www-form-urlencoded'],
},
validate: {
payload: Joi.object({
id: Joi.number().integer().example(10).meta({ format: 'int64' }).required(),
name: Joi.string().example('doggie').required(),
category: customJoi
.object({
id: Joi.number().integer().example(1).meta({ format: 'int64' }),
name: Joi.string().example('Dogs'),
}),
photoUrls: customJoi
.array()
.items(Joi.string().meta({ xml: { name: 'photoUrl' } }))
.meta({ xml: { wrapped: true } })
.required(),
tags: customJoi
.array()
.items(
Joi.object({
id: Joi.number().integer().meta({ format: 'int64' }),
name: Joi.string(),
}).meta({
xml: { name: 'tag' },
})
)
.meta({ xml: { wrapped: true } }),
status: Joi.string().description('pet status in the store').valid('available', 'pending', 'sold'),
})
.meta({
xml: { name: 'pet' },
})
.required(),
},
},
handler: ({ payload }) => payload,
});
Boom:
You now have automatic validation and automatic documentation.
Swagger UI will even show examples, which means fewer Slack messages from coworkers asking,
โWhat does this endpoint expect again?โ
Petstore - Update an existing pet (Responses)
Because your API should tell people what it actually does.
Here, if the request is valid, the endpoint simply returns the payload.
Instead of rewriting your response schemas everywhere (aka the โCopy/Paste Olympicsโ), Kaapi lets you reference components via Joi metadata:
app.route({
// ...
options: {
// ...
validate: {
payload: Joi.object({
id: Joi.number().integer().example(10).meta({ format: 'int64' }).required(),
name: Joi.string().example('doggie').required(),
category: customJoi
.object({
id: Joi.number().integer().example(1).meta({ format: 'int64' }),
name: Joi.string().example('Dogs'),
})
.meta({
ref: '#/components/schemas/Category', // ๐ here
}),
photoUrls: customJoi
.array()
.items(Joi.string().meta({ xml: { name: 'photoUrl' } }))
.meta({ xml: { wrapped: true } })
.required(),
tags: customJoi
.array()
.items(
Joi.object({
id: Joi.number().integer().meta({ format: 'int64' }),
name: Joi.string(),
}).meta({
xml: { name: 'tag' },
ref: '#/components/schemas/Tag', // ๐ here
})
)
.meta({ xml: { wrapped: true } }),
status: Joi.string().description('pet status in the store').valid('available', 'pending', 'sold'),
})
.meta({
xml: { name: 'pet' },
ref: '#/components/schemas/Pet', // ๐ here
})
.required(),
},
},
handler: ({ payload }) => payload,
});
Adding meta({ ref: '#/components/schemas/Pet' }) registers the schema Pet into #/components/schemas, if it was not already registered. We do the same for Tag and Category.
Once registered, documenting responses with modifiers, part of Kaapiโs built-in plugins, becomes easy:
import { ResponseDocsModifier } from '@kaapi/kaapi';
app.route({
// ...
options: {
// ...
validate: {
payload: Joi.object({
// ...
})
.meta({
xml: { name: 'pet' },
ref: '#/components/schemas/Pet', // ๐ creates schema reference
})
.required(),
},
plugins: {
kaapi: {
docs: {
modifiers: () => ({
responses: new ResponseDocsModifier()
.setCode(200)
.setDescription('Successful operation')
.addMediaType('application/json', {
schema: { $ref: '#/components/schemas/Pet' }, // ๐ use reference
}),
}),
},
},
},
},
// ...
});
You can also group responses:
import { groupResponses, ResponseDocsModifier } from '@kaapi/kaapi';
app.route({
// ...
options: {
// ...
plugins: {
kaapi: {
docs: {
modifiers: () => ({
responses: groupResponses(
new ResponseDocsModifier()
.setCode(200)
.setDescription('Bad Request')
.addMediaType('application/json', {
schema: { $ref: '#/components/schemas/Pet' },
}),
new ResponseDocsModifier()
.setCode(400)
.setDescription('Bad Request')
.addMediaType('application/json', {
schema: {
type: 'object',
properties: {
statusCode: {
type: 'number',
enum: [400],
},
error: { type: 'string', enum: ['Bad Request'] },
message: { type: 'string' },
},
required: ['statusCode', 'error'],
},
}),
new ResponseDocsModifier().setCode(404).setDescription('Pet not found'),
new ResponseDocsModifier().setCode(415).setDescription('Unsupported Media Type'),
new ResponseDocsModifier()
.setDefault(true)
.setDescription('Unexpected error')
.addMediaType('application/json', {
schema: {
type: 'object',
properties: {
statusCode: {
type: 'number',
},
error: { type: 'string' },
message: { type: 'string' },
},
required: ['statusCode', 'error'],
}
}
)
),
}),
},
},
},
},
// ...
});
Reusing Shared Responses
Since weโll probably reuse some responses (like 400 Bad Request) across multiple routes, itโs better to define them once:
import {
ResponseDocsModifier,
SchemaModifier
} from '@kaapi/kaapi';
// Error schema
const errorSchema = new SchemaModifier('Error', {
type: 'object',
properties: {
statusCode: {
type: 'number',
},
error: { type: 'string' },
message: { type: 'string' },
},
required: ['statusCode', 'error'],
});
// BadRequestResponse response
const badRequestResponse = new ResponseDocsModifier('BadRequestResponse')
.setDescription('Bad Request')
.addMediaType('application/json', {
schema: {
type: 'object',
properties: {
statusCode: {
type: 'number',
enum: [400],
},
error: { type: 'string', enum: ['Bad Request'] },
message: { type: 'string' },
},
required: ['statusCode', 'error'],
},
});
Register them globally:
import {
groupResponses,
Kaapi
} from '@kaapi/kaapi';
const app = new Kaapi({
// ...
docs: {
// ...
schemas: [errorSchema], // ๐ register schemas
responses: groupResponses(badRequestResponse), // ๐ register responses
},
});
Then reuse them:
import {
groupResponses,
MediaTypeModifier,
ResponseDocsModifier,
} from '@kaapi/kaapi';
// endpoint
app.route({
// ...
options: {
// ...
plugins: {
kaapi: {
docs: {
modifiers: () => ({
responses: groupResponses(
new ResponseDocsModifier()
.setCode(200)
.setDescription('Successful operation')
.addMediaType('application/json', {
schema: { $ref: '#/components/schemas/Pet' },
}),
badRequestResponse.withContext().setCode(400), // ๐ use response in this route context
new ResponseDocsModifier().setCode(404).setDescription('Pet not found'),
new ResponseDocsModifier().setCode(415).setDescription('Unsupported Media Type'),
new ResponseDocsModifier()
.setDefault(true)
.setDescription('Unexpected error')
.addMediaType('application/json', new MediaTypeModifier({ schema: errorSchema })) // ๐ use schema
),
}),
},
},
},
},
// ...
});
Less schema duplication. More happiness.
A Note on XML Support
So... we are done for this route but hereโs the unfortunate truth:
allow: ['application/json', 'application/x-www-form-urlencoded'],
Unlike the original Swagger Petstore API, our example does not support application/xml, even though our schemas include XML metadata.
So why not do it? Because by default, payloads are parsed into JSON for validation.
Disabling parsing means disabling Joi validation and auto-generated docs.
In short:
- disabling JSON parsing = disabling Joi
- disabling Joi = sadness or disabling auto-generated docs
But donโt worry, it is possible to disable parsing and manually document payloads, but to keep the example simple, we skipped it.
Instead, let's check it out in the next example.
Petstore - Uploads an image
File uploads should not be parsed into JSON (and they can't be anyway). For this route we will:
- disable automatic parsing
- manually validate the uploaded file type (PNG/JPEG)
- describe the request body manually using
RequestBodyDocsModifier
Here is the result:
import Boom from '@hapi/boom';
import {
groupResponses,
Kaapi,
MediaTypeModifier,
RequestBodyDocsModifier,
ResponseDocsModifier,
SchemaModifier
} from '@kaapi/kaapi';
import Joi from 'joi';
import Stream, { PassThrough } from 'node:stream';
// ...
app.route<{ Params: { petId: number }; Payload: Stream.Readable; Query: { additionalMetadata?: string } }>({
path: '/pet/{petId}/uploadImage',
method: 'POST',
options: {
tags: ['pet'],
description: 'Uploads an image.',
notes: ['Upload image of the pet.'],
payload: {
allow: ['application/octet-stream'],
output: 'stream',
maxBytes: 1024 * 2000,
multipart: false,
parse: false, // ๐ disable auto parsing
},
validate: {
params: Joi.object({
petId: Joi.number().integer().description('ID of pet to update').meta({ format: 'int64' }).required(),
}),
query: Joi.object({
additionalMetadata: Joi.string().description('Additional Metadata'),
}),
},
plugins: {
kaapi: {
docs: {
modifiers: () => ({
// ๐ payload schema definition
requestBody: new RequestBodyDocsModifier().addMediaType('application/octet-stream', {
schema: {
type: 'string',
contentMediaType: 'application/octet-stream',
},
}),
responses: groupResponses(
new ResponseDocsModifier().setCode(200).setDescription('successful operation'),
badRequestResponse.withContext().setCode(400),
new ResponseDocsModifier().setCode(404).setDescription('Pet not found'),
new ResponseDocsModifier()
.setDefault(true)
.setDescription('Unexpected error')
.addMediaType('application/json', new MediaTypeModifier({ schema: errorSchema }))
),
}),
},
},
},
},
handler: async ({ params: { petId }, payload, query: { additionalMetadata } }, h) => {
app.log.debug(`[uploadFile] petId: ${petId}`);
app.log.debug(`[uploadFile] additionalMetadata: ${additionalMetadata}`);
// validate file signature (PNG/JPG) and stream it back
return new Promise((resolve, reject) => {
// Collect first few bytes for validation
let buffer = Buffer.alloc(0);
let validated = false;
const pass = new PassThrough();
payload.on('data', (chunk) => {
if (!validated) {
buffer = Buffer.concat([buffer, chunk]);
if (buffer.length >= 8) {
const header = buffer.subarray(0, 8);
// PNG/JPG check
const pngSig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const jpegSig = Buffer.from([0xff, 0xd8]);
if (header.equals(pngSig) || header.subarray(0, 2).equals(jpegSig)) {
validated = true;
resolve(h.response(pass).type(header.equals(pngSig) ? 'image/png' : 'image/jpeg'));
} else {
payload.unpipe(pass);
// Drain the rest of the stream so client doesn't hang
payload.resume();
return reject(Boom.badRequest('Invalid file type'));
}
}
}
pass.write(chunk);
});
payload.on('end', () => {
app.log.debug('[uploadFile] Done parsing payload!');
pass.end();
if (buffer.length < 8) {
return reject(Boom.badRequest('No file uploaded'));
}
});
});
},
});
With that, weโve covered the core of API documentation in Kaapi.
Source Code
Where all the magic came from (and where you can go judge my code):
๐ github.com/shygyver/kaapi-monorepo-playground
The app demonstrated here is named documentation-app.
See the README for instructions on running it.
Stay tuned for more! Kaapi still has more features to show off!
๐ฆ Get started now
npm install @kaapi/kaapi
๐ Learn more: github.com/demingongo/kaapi/wiki







Top comments (0)