km-api
v0.3.3
Published
Type-safe API schema builder for TypeScript — define HTTP endpoints with Zod validation, full OpenAPI 3.0 compatibility, and adapters for Axios, Fetch, and Alova.
Maintainers
Readme
km-api
Type-safe API schema builder for TypeScript. Define HTTP endpoints with Zod validation, full OpenAPI 3.0 compatibility, and adapters for Axios, Fetch, and Alova.
Features
- Type-safe API definitions — full IntelliSense for every field
- OpenAPI 3.0 compatible — path, methods, parameters, responses, examples
- Dual path syntax — Express
:paramand OpenAPI{param}both work - Zod schema validation — runtime-safe request and response data
- HTTP client adapters — Axios, Fetch, Alova (UniApp, XHR, Taro)
- Request body conversion — auto-converts JSON, form-data, multipart, binary
- Response shape builders — standardised single-item and paginated-list wrappers
- OpenAPI examples — optional per-endpoint request/response examples
Installation
npm install km-api zod
# or
yarn add km-api zod
# or
pnpm add km-api zod
# or
bun add km-api zodCompatibility
| km-api | TypeScript | Zod | Node.js | |---------|------------|------|---------| | 0.3.x | 5.9+ | 4.x | 14+ | | 0.2.x | 5.9+ | 4.x | 14+ | | 0.1.x | 5.x | 3.x | 14+ |
Quick Start
import { z } from 'zod';
import { makeApiConfig } from 'km-api';
const getUser = makeApiConfig({
method: 'GET',
pathShape: '/users/{id}',
tags: ['#users'],
auth: 'YES',
responseContentType: 'application/json',
summary: 'Get user by ID',
description: 'Retrieves a single user by their unique identifier.',
request: {
body: z.any(),
params: z.object({ id: z.string().uuid() }),
query: z.object({ include: z.enum(['profile', 'settings']).optional() }),
headers: z.object({ 'x-api-key': z.string().optional() }),
cookies: z.object({ sessionId: z.string().optional() }),
},
response: {
200: z.object({ id: z.string(), name: z.string(), email: z.string() }),
404: z.object({ message: z.string() }),
},
});
// Resolve path
getUser.makeFullPath({ id: '550e8400-e29b-41d4-a716-446655440000' });
// → '/users/550e8400-e29b-41d4-a716-446655440000'
// OpenAPI path format
getUser.makeOpenApiPathShape();
// → '/users/{id}'
// Express path format
getUser.makeExpressPathShape();
// → '/users/:id'
// HTTP client adapter config
getUser.convertResponseType('axios'); // { responseType: 'json' }
getUser.convertResponseType('fetch'); // { responseMethod: 'json' }API Reference
makeApiConfig(config)
Creates a typed endpoint configuration with helper methods attached.
Configuration fields
| Field | Type | Required | Description |
|----------------------|----------------------------|----------|-------------|
| method | IMethod | ✅ | HTTP method (case-insensitive) |
| pathShape | IPath | ✅ | Path starting with / |
| request.body | ZodType | ✅ | Request body Zod schema |
| request.params | ZodObject | ✅ | Path parameters Zod schema |
| request.query | ZodObject | ✅ | Query parameters Zod schema |
| request.headers | ZodObject | ✅ | Custom headers Zod schema |
| request.cookies | ZodObject | ✅ | Cookie parameters Zod schema |
| response | Record<statusCode, ZodType> | ✅ | Response schemas keyed by status code |
| tags | string[] | – | Tags prefixed with # |
| auth | 'YES' \| 'NO' | – | Authentication requirement |
| responseContentType| IResponseContentType | – | Response MIME type |
| requestContentType | IRequestContentType | – | Request body MIME type |
| disable | 'YES' \| 'NO' | – | Mark endpoint as disabled |
| summary | string | – | Short summary (1–2 sentences) |
| description | string | – | Detailed description (Markdown) |
| examples | IEndpointExamples | – | OpenAPI 3.0 examples for docs |
Returned helper methods
| Method | Signature | Description |
|--------|-----------|-------------|
| makeBody | (data) => data | Returns type-safe request body |
| makeParams | (params) => params | Returns type-safe path parameters |
| makeQueries | (queries) => queries | Returns type-safe query parameters |
| makeHeaders | (headers) => headers | Returns type-safe headers |
| makeCookies | (cookies) => cookies | Returns type-safe cookies |
| makeFullPath | (params) => string | Resolves the path template to a URL |
| makeOpenApiPathShape | () => string | Converts path to OpenAPI {param} format |
| makeExpressPathShape | () => string | Converts path to Express :param format |
| convertResponseType | (adapter) => AdapterConfig | Returns adapter-specific response config |
Path syntax
Both path parameter styles are supported and can be mixed:
// Express-style
pathShape: '/users/:id/posts/:postId'
// OpenAPI-style
pathShape: '/users/{id}/posts/{postId}'
// Mixed (valid)
pathShape: '/users/:id/posts/{postId}'config.makeFullPath({ id: '1', postId: '42' }); // '/users/1/posts/42'
config.makeOpenApiPathShape(); // '/users/{id}/posts/{postId}'
config.makeExpressPathShape(); // '/users/:id/posts/:postId'Response schemas
Map status codes to Zod schemas. Both number and string keys are accepted:
response: {
200: z.object({ id: z.string(), name: z.string() }),
201: z.object({ id: z.string() }), // alternate success
400: z.object({ message: z.string() }),
'404': z.object({ message: z.string() }), // string key also works
500: z.object({ error: z.string() }),
}Response shape builders
Use makeResponseSuccessShape to create consistent wrappers for item and list responses.
Single item
import { makeResponseSuccessShape } from 'km-api';
const userSchema = z.object({ id: z.string(), name: z.string() });
const shape = makeResponseSuccessShape(userSchema, 'user');
const itemSchema = shape.item();
// Validates: { user: { id: '1', name: 'Alice' } }Paginated list
import { makeResponseSuccessShape, paginationSchema } from 'km-api';
const listSchema = makeResponseSuccessShape(userSchema, 'users')
.list(paginationSchema());
// Validates:
// {
// users: [{ id: '1', name: 'Alice' }, ...],
// currentPage: 1,
// totalItems: 50,
// itemsPerPage: 20,
// totalPages: 3 (optional)
// }Custom metadata
const meta = z.object({ page: z.number(), total: z.number(), nextCursor: z.string().optional() });
const cursorListSchema = makeResponseSuccessShape(userSchema, 'users').list(meta);HTTP client adapters
Response type conversion
const config = makeApiConfig({ responseContentType: 'application/pdf', ... });
// Axios
config.convertResponseType('axios'); // { responseType: 'blob' }
// Fetch API
config.convertResponseType('fetch'); // { responseMethod: 'blob' }
// Alova variants
config.convertResponseType('alova-axios'); // { responseType: 'blob' }
config.convertResponseType('alova-uniapp'); // { responseType: 'arraybuffer' }
config.convertResponseType('alova-xhr'); // { responseType: 'blob' }
config.convertResponseType('alova-taro'); // { responseType: 'arraybuffer', dataType: 'arraybuffer' }Request body conversion
import { convertRequestBody, safeConvertRequestBody } from 'km-api';
// JSON
convertRequestBody({ name: 'Alice' }, 'application/json');
// → '{"name":"Alice"}'
// Form URL-encoded
convertRequestBody({ q: 'search term' }, 'application/x-www-form-urlencoded');
// → URLSearchParams { 'q' => 'search term' }
// Multipart
convertRequestBody({ file: myBlob, title: 'photo' }, 'multipart/form-data');
// → FormData instance
// safeConvertRequestBody — only converts if needed
safeConvertRequestBody('{"name":"Alice"}', 'application/json'); // returned unchanged
safeConvertRequestBody({ name: 'Alice' }, 'application/json'); // converted to stringOpenAPI examples (optional)
Add examples to any endpoint for documentation tools that support OpenAPI 3.0 examples:
const createUser = makeApiConfig({
method: 'POST',
pathShape: '/users',
requestContentType: 'application/json',
responseContentType: 'application/json',
request: {
body: z.object({ name: z.string(), email: z.string().email() }),
params: z.object({}),
query: z.object({}),
headers: z.object({}),
cookies: z.object({}),
},
response: {
201: z.object({ id: z.string(), name: z.string() }),
422: z.object({ message: z.string() }),
},
examples: {
request: {
alice: {
summary: 'Create Alice',
value: { name: 'Alice', email: '[email protected]' },
},
},
response: {
'201': {
created: {
summary: 'User created successfully',
value: { id: 'uuid-here', name: 'Alice' },
},
},
'422': {
invalidEmail: {
summary: 'Invalid email address',
value: { message: 'email: Invalid email' },
},
},
},
},
});Each example follows the OpenAPI 3.0 Example Object:
| Field | Type | Description |
|-----------------|-----------|-------------|
| summary | string | Short description of the example |
| description | string | Long description, Markdown supported |
| value | unknown | The example value (mutually exclusive with externalValue) |
| externalValue | string | URL to an external example file |
Authentication and headers
const protectedEndpoint = makeApiConfig({
method: 'GET',
pathShape: '/admin/users',
auth: 'YES',
request: {
body: z.any(),
params: z.object({}),
query: z.object({}),
headers: z.object({
Authorization: z.string(),
'x-tenant-id': z.string().uuid(),
}),
cookies: z.object({}),
},
response: {
200: z.array(z.object({ id: z.string() })),
401: z.object({ message: z.string() }),
403: z.object({ message: z.string() }),
},
});
const headers = protectedEndpoint.makeHeaders({
Authorization: 'Bearer token123',
'x-tenant-id': '550e8400-e29b-41d4-a716-446655440000',
});Disabling endpoints
const legacyEndpoint = makeApiConfig({
method: 'GET',
pathShape: '/v1/users',
disable: 'YES',
description: 'Deprecated. Use /v2/users instead.',
request: { ... },
response: { ... },
});
if (legacyEndpoint.disable === 'YES') {
console.warn('This endpoint is disabled');
}Complete blog API example
import { z } from 'zod';
import { makeApiConfig, makeResponseSuccessShape, paginationSchema } from 'km-api';
const postSchema = z.object({
id: z.string().uuid(),
title: z.string(),
content: z.string(),
authorId: z.string().uuid(),
createdAt: z.string().datetime(),
});
const errorSchema = z.object({ message: z.string(), code: z.number().int() });
const blogApi = {
listPosts: makeApiConfig({
method: 'GET',
pathShape: '/posts',
auth: 'NO',
responseContentType: 'application/json',
summary: 'List all posts',
request: {
body: z.any(),
params: z.object({}),
query: z.object({
page: z.number().int().min(1).optional(),
limit: z.number().int().max(100).optional(),
tag: z.string().optional(),
}),
headers: z.object({}),
cookies: z.object({}),
},
response: {
200: makeResponseSuccessShape(postSchema, 'posts').list(paginationSchema()),
},
}),
getPost: makeApiConfig({
method: 'GET',
pathShape: '/posts/{slug}',
auth: 'NO',
responseContentType: 'application/json',
summary: 'Get post by slug',
request: {
body: z.any(),
params: z.object({ slug: z.string() }),
query: z.object({}),
headers: z.object({}),
cookies: z.object({}),
},
response: {
200: makeResponseSuccessShape(postSchema, 'post').item(),
404: errorSchema,
},
}),
createPost: makeApiConfig({
method: 'POST',
pathShape: '/posts',
auth: 'YES',
requestContentType: 'application/json',
responseContentType: 'application/json',
summary: 'Create a new post',
tags: ['#posts', '#write'],
request: {
body: z.object({
title: z.string().min(3).max(200),
content: z.string().min(10),
tags: z.array(z.string()).optional(),
}),
params: z.object({}),
query: z.object({}),
headers: z.object({ Authorization: z.string() }),
cookies: z.object({}),
},
response: {
201: makeResponseSuccessShape(postSchema, 'post').item(),
400: errorSchema,
401: errorSchema,
},
}),
deletePost: makeApiConfig({
method: 'DELETE',
pathShape: '/posts/{id}',
auth: 'YES',
summary: 'Delete a post',
request: {
body: z.any(),
params: z.object({ id: z.string().uuid() }),
query: z.object({}),
headers: z.object({ Authorization: z.string() }),
cookies: z.object({}),
},
response: {
204: z.object({}),
403: errorSchema,
404: errorSchema,
},
}),
};
// Usage
const listQuery = blogApi.listPosts.makeQueries({ page: 2, limit: 10 });
const postPath = blogApi.getPost.makeFullPath({ slug: 'hello-world' });
const axiosCfg = blogApi.createPost.convertResponseType('axios');Testing with Vitest
Install test dependencies:
npm install --save-dev vitest
# or
bun add -d vitestConfigure Vitest (vitest.config.ts):
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});Write tests against your endpoint configs:
import { describe, it, expect } from 'vitest';
import { getUser } from './api/users';
describe('getUser', () => {
it('resolves path correctly', () => {
expect(getUser.makeFullPath({ id: '123' })).toBe('/users/123');
});
it('converts to OpenAPI path', () => {
expect(getUser.makeOpenApiPathShape()).toBe('/users/{id}');
});
it('converts to Express path', () => {
expect(getUser.makeExpressPathShape()).toBe('/users/:id');
});
it('returns axios config for JSON', () => {
expect(getUser.convertResponseType('axios')).toEqual({ responseType: 'json' });
});
it('validates params schema', () => {
const validParams = getUser.request.params.parse({ id: '550e8400-e29b-41d4-a716-446655440000' });
expect(validParams).toEqual({ id: '550e8400-e29b-41d4-a716-446655440000' });
});
});Migration: v0.3.2 → v0.3.3
No API changes. This release replaces Zod types in all exported generic constraints and return types with local structural types, eliminating the TypeScript Language Server hang that some IDEs experienced on import.
If you were casting the result of makeResponseSuccessShape().item() or
.list() to ZodObject, replace it with the structural output type instead:
// v0.3.2 — inferred as ZodObject<{...}>
const schema = makeResponseSuccessShape(userSchema, 'user').item();
// v0.3.3 — inferred as { readonly _zod: { readonly output: { user: ... } } }
// runtime value is still a real ZodObject — call .parse() freely
const schema = makeResponseSuccessShape(userSchema, 'user').item();
schema.parse({ user: { id: '1', name: 'Alice' } }); // still works at runtimeSee rules.md for the full set of constraints that govern
Zod usage in exported signatures.
Migration: v0.3.0 → v0.3.1
Renamed helper method
makeOpenAPIPath() has been renamed to makeOpenApiPathShape() for naming consistency.
// OLD (v0.3.0)
config.makeOpenAPIPath();
// NEW (v0.3.1)
config.makeOpenApiPathShape();New helper method
makeExpressPathShape() converts any path to Express :param format:
// pathShape: '/users/{id}/posts/{postId}'
config.makeExpressPathShape(); // '/users/:id/posts/:postId'
// pathShape: '/users/:id' (already Express-style — unchanged)
config.makeExpressPathShape(); // '/users/:id'Migration: v0.2.x → v0.3.x
Import style
v0.2.x used a deeply nested namespace:
// OLD (v0.2.x)
import { kmApi } from 'km-api';
kmApi.apiConfig.makeApiConfig({ ... });v0.3.x uses flat exports:
// NEW (v0.3.x)
import { makeApiConfig } from 'km-api';
makeApiConfig({ ... });All names are the same — only the import path changed.
Removed dependency
km-type is no longer required. Remove it from your package.json if you installed it separately.
Migration: v0.1.x → v0.2.x
Breaking changes
path renamed to pathShape
// v0.1.x
{ path: '/users/{id}' }
// v0.2.x+
{ pathShape: '/users/{id}' }Response object restructured
// v0.1.x
response: {
success: z.object({ ... }),
error: z.object({ ... }),
}
// v0.2.x+
response: {
200: z.object({ ... }),
400: z.object({ ... }),
}Removed functions
makeParamsOrderedList()— removedmakeParamsString()— removedmakeFullPathShape()— removedmakeFullPath(params, orderList)→makeFullPath(params)(no second argument)
Contributing
- Fork the repo
- Create your branch:
git checkout -b feature/my-feature - Run tests:
npm test - Submit a PR
License
MIT © komeilm76
