DEV Community

Cover image for Building Modern Backends with Kaapi: API Documentation Generation
ShyGyver
ShyGyver

Posted on

Building Modern Backends with Kaapi: API Documentation Generation

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();
Enter fullscreen mode Exit fullscreen mode

Once this is running, visit:

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',
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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',
        },
      },
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

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)
`);
Enter fullscreen mode Exit fullscreen mode


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,
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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
              }),
          }),
        },
      },
    },
  },
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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'],
                  }
                }
              )
            ),
          }),
        },
      },
    },
  },
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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'],
    },
  });
Enter fullscreen mode Exit fullscreen mode

Register them globally:

import {
  groupResponses,
  Kaapi
} from '@kaapi/kaapi';

const app = new Kaapi({
  // ...

  docs: {
    // ...

    schemas: [errorSchema], // ๐Ÿ‘ˆ register schemas
    responses: groupResponses(badRequestResponse), // ๐Ÿ‘ˆ register responses
  },
});
Enter fullscreen mode Exit fullscreen mode

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
            ),
          }),
        },
      },
    },
  },
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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'],
Enter fullscreen mode Exit fullscreen mode

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'));
        }
      });
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”— Learn more: github.com/demingongo/kaapi/wiki


Top comments (0)