km-api
v0.4.1
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,
operationId - All HTTP methods —
get,post,put,delete,head,options,patch,trace(case-insensitive) - Open status-code unions — any status code as a
responsekey, any Zod type as the value (includingz.void()) - 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.4.x | 5.9+ | 4.x | 14+ | | 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'],
operationId: 'getUserById',
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: get, post, put, delete, head, options, patch, trace (each in lowercase, UPPERCASE, Capitalized) |
| pathShape | IPath | ✅ | Path starting with / |
| request.body | ZodType (any) | ✅ | 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> | ✅ | Open status-code union: any status code (number or string key), any Zod value including z.void() |
| 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 (availability / application policy; independent of deprecated) |
| deprecated | 'YES' \| 'NO' | – | API lifecycle status: endpoint exists but should no longer be preferred (independent of disable) |
| summary | string | – | Short summary (1–2 sentences) |
| description | string | – | Detailed description (Markdown) |
| operationId | string | – | OpenAPI operation identifier (unique across the API document) |
| examples | IEndpointExamples | – | OpenAPI 3.0 examples for docs: request (free-form map) + response (keyed by HTTP status) |
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. Since v0.4.0 the map is an open status-code
union — any status code may be used as a key (number or string form),
including non-standard codes like 499. Every value accepts any Zod type,
including z.void() for no-content responses:
response: {
200: z.object({ id: z.string(), name: z.string() }),
201: z.object({ id: z.string() }), // alternate success
204: z.void(), // no content
400: z.object({ message: z.string() }),
'404': z.object({ message: z.string() }), // string key also works
499: z.void(), // non-standard code (v0.4.0)
500: z.object({ error: z.string() }),
}Note: this is a type-level contract. The runtime schema httpStatusCodeSchema
still validates only the standard 2xx–5xx numeric codes.
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');
}Deprecating endpoints
deprecated and disable are separate fields:
deprecateddescribes API lifecycle status.disabledescribes endpoint availability or application policy. Setting one does not automatically set or change the other.
An endpoint can be deprecated (still available, but no longer preferred) without being disabled:
const legacyListUsers = makeApiConfig({
method: 'GET',
pathShape: '/legacy/users',
tags: ['#legacy'],
operationId: 'listLegacyUsers',
auth: 'NO',
responseContentType: 'application/json',
summary: 'List users (legacy)',
deprecated: 'YES',
disable: 'NO', // still available — deprecation does not disable
description: 'Deprecated in v2. Use /users instead.',
request: {
body: z.any(),
params: z.object({}),
query: z.object({}),
headers: z.object({}),
cookies: z.object({}),
},
response: {
200: z.array(z.object({ id: z.string() })),
},
});
if (legacyListUsers.deprecated === 'YES') {
console.warn('This endpoint is deprecated — prefer /users');
}
// legacyListUsers.disable is still 'NO': the endpoint works.The value is preserved at runtime exactly as given — deprecated: 'YES'
never becomes disable: 'YES' (and vice versa). The reusable
deprecatedSchema (z.enum(['YES', 'NO'])) is exported from the package
root if you want to validate the value independently.
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.4.0 → v0.4.1
No breaking changes. All v0.4.x code keeps working. v0.4.1 adds:
deprecated— optional'YES' | 'NO'field on every endpoint config, describing API lifecycle status (the endpoint exists but should no longer be preferred). It is independent ofdisable, which describes endpoint availability or application policy — setting one never changes the other.deprecatedSchema— new exported schema:z.enum(['YES', 'NO']).IDeprecatedStatus— new exported type alias ('YES' | 'NO').
// New in v0.4.1
makeApiConfig({
method: 'GET',
pathShape: '/legacy-users',
deprecated: 'YES',
disable: 'NO', // unchanged — no conversion happens
// ...
});Migration: v0.3.3 → v0.4.0
No breaking changes. All v0.3.x code keeps working. v0.4.0 adds:
TRACEmethod —method: 'TRACE' | 'trace' | 'Trace'is now accepted, completing the canonical orderget, post, put, delete, head, options, patch, trace.operationId— optionalstringfield on every endpoint config (OpenAPI 3.0 operation identifier).- Open status-code union — the
responsemap (andexamples.response) accepts any status code as a key, including non-standard codes like499. Response values already accepted any Zod type (includingz.void()); that contract is now explicitly tested and documented.
// New in v0.4.0
makeApiConfig({
method: 'TRACE',
pathShape: '/health',
operationId: 'traceHealth',
request: { /* ... */ },
response: {
200: z.string(),
499: z.void(), // non-standard status code
},
});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
