hazo_api
v1.1.1
Published
Foundation-layer toolkit for hazo_* HTTP APIs: response envelopes, error codes, Zod→OpenAPI generation, Swagger UI, and request context.
Maintainers
Readme
hazo_api
Foundation-layer toolkit for
hazo_*HTTP APIs: response envelopes, error codes, Zod→OpenAPI generation, Swagger UI, and request context.
Install
npm install hazo_api zod
# Required peers:
npm install hazo_logs hazo_config hazo_connect hazo_ui react react-dom
# Optional peers:
npm install hazo_auth # for withApiKey({bind_user:true})
npm install hazo_debug # for hazo_debug request panelQuick start
1. Wrap your handler with the request context
// app/api/v1/runs/route.ts
import { ok, fail, withRequestContext, zodIssuesToDetails } from 'hazo_api';
import { z } from 'zod';
const SubmitRunSchema = z.object({
site_slug: z.string(),
run_date: z.string().date(),
status: z.enum(['success', 'partial', 'failed']),
});
export const POST = withRequestContext(async (req) => {
const parsed = SubmitRunSchema.safeParse(await req.json());
if (!parsed.success) {
return fail('VALIDATION_FAILED', 'Bad body', {
details: zodIssuesToDetails(parsed.error.issues),
});
}
// ...business logic...
return ok({ run_id: 'run_xxx' }, { status: 201 });
});Every response gets a meta block:
{
"ok": true,
"data": { "run_id": "run_xxx" },
"meta": { "version": "v1", "request_id": "req_abc123", "elapsed_ms": 12 }
}2. Register routes for OpenAPI
// lib/routes.ts
import { defineRoute } from 'hazo_api';
import { z } from 'zod';
export const submitRunRoute = defineRoute({
method: 'POST',
path: '/api/v1/runs',
summary: 'Submit a daily run',
request: { body: SubmitRunSchema },
responses: {
201: { description: 'Run accepted' },
400: { description: 'Validation failed' },
},
});3. Serve OpenAPI JSON
// app/api/v1/docs/route.ts
import { generateOpenAPI } from 'hazo_api';
import { submitRunRoute, /* ... */ } from '@/lib/routes';
export const GET = () =>
Response.json(
generateOpenAPI({
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'http://localhost:3000' }],
routes: [submitRunRoute],
}),
);4. Serve Swagger UI
// app/api/v1/docs/ui/route.ts
import { swaggerUiHtml } from 'hazo_api/client';
export const GET = () =>
new Response(
swaggerUiHtml({ spec_url: '/api/v1/docs' }),
{ headers: { 'Content-Type': 'text/html' } },
);Exports
Server entry (hazo_api)
ok(data, opts?)/fail(code, message, opts?)— build nativeResponse.ErrorCodes,ErrorCode— frozen enum + HTTP status map.withRequestContext(handler),getRequestContext()— ALS-backed request id and timing.defineRoute(opts),getAllRoutes(),resetRouteRegistry()— route registration for OpenAPI.generateOpenAPI(opts)— produces an OpenAPI 3.1 document.zodIssuesToDetails(issues)— flattens Zod errors into{ field, issue }.resolveApiConfig(),ApiConfigDefaults— INI-backed config.createApiKeyService({ getHazoConnect })— issue/list/revoke/validate API keys.withApiKey({ service, require_scopes?, bind_user? })— auth middleware (parses Bearer then X-Api-Key).createApiKeyRoutes({ service })— list/create/revoke route factories.createRateLimitService({ getHazoConnect })— token-bucket service.withRateLimit({ service, bucket_key, limit, window_sec, cost? })— rate-limit middleware (fail-open).okStream(iterable, { format: 'sse' | 'ndjson' })— streaming envelopes.- Additional types:
IssuedApiKey,ApiKeyService,RateLimitService,RateLimitConsumeInput,RateLimitResult,WithApiKeyOptions,WithRateLimitOptions. - Types:
ApiKey,ApiKeyValidator,AuthMode,ResponseMeta,RouteDefinition, ...
Client entry (hazo_api/client)
swaggerUiHtml(opts)— self-contained HTML page loading swagger-ui-dist from CDN.ApiKeyManagerReact component — full issue/list/reveal-once/revoke UI built onhazo_uiprimitives.- Shared types only.
Configuration
config/hazo_api_config.ini:
[api]
version = v1
mode = internal
[logging]
enabled = true
level = info
[debug]
enabled = false
[swagger_ui]
cdn = unpkg
version = 5.17.14See config/hazo_api_config.ini.sample for the full template.
v1.1.0 features
API key auth
import { createApiKeyService, withApiKey, createApiKeyRoutes } from 'hazo_api';
const apiKeys = createApiKeyService({ getHazoConnect: () => myAdapter });
// Protect a route:
export const GET = withRequestContext(
withApiKey({ service: apiKeys, require_scopes: ['runs:write'] },
async (_req, key) => ok({ greeting: `Hello, ${key.label}` }),
),
);
// Mount admin routes (wrap with your own session/role check):
const routes = createApiKeyRoutes({ service: apiKeys });
export const POST = withRequestContext(routes.create);Rate limiting
import { createRateLimitService, withRateLimit } from 'hazo_api';
const rateLimit = createRateLimitService({ getHazoConnect: () => myAdapter });
export const GET = withRequestContext(
withRateLimit({
service: rateLimit,
bucket_key: (req) => `ip:${req.headers.get('x-forwarded-for') ?? 'local'}`,
limit: 60, window_sec: 60,
}, async () => ok({ ok: true })),
);Token-bucket. Fail-open on adapter errors. Returns 429 + Retry-After header on block.
Streaming (SSE / NDJSON)
import { okStream } from 'hazo_api';
export const GET = withRequestContext(async () =>
okStream(myAsyncIterable, { format: 'sse' }), // or 'ndjson'
);Admin UI
'use client';
import { ApiKeyManager } from 'hazo_api/client';
export default function KeysPage() {
return <ApiKeyManager api_base="/api/admin/keys" />;
}Customizing the scopes input
By default the create dialog renders a comma-separated text input for scopes. For end-user-facing admin UIs you usually want friendlier copy or a curated catalog instead. Pass scopes_field:
import { ApiKeyManager, type ScopesFieldConfig } from 'hazo_api/client';
const CATALOG: ScopesFieldConfig = {
mode: 'catalog',
label: 'What can this key do?',
options: [
{ value: 'runs:write', label: 'Submit runs', description: 'Start new model runs.' },
{ value: 'actions:read', label: 'Read actions', description: 'Inspect available actions.' },
],
};
<ApiKeyManager api_base="/api/admin/keys" scopes_field={CATALOG} />;Modes:
{ mode: 'text' }— comma-separated text input (default).{ mode: 'catalog', options, label? }— checkbox list backed by a fixed catalog.{ mode: 'hidden' }— no scopes UI; the server fills them in.{ mode: 'custom', render }— fully bring-your-own (segmented control, multi-select, etc.). Receives{ selected, onChange }.
Binding to a user
The dialog never asks the end user for a UUID. Pass user_id from your session and the issued key gets bound to that user:
<ApiKeyManager api_base="/api/admin/keys" user_id={session.user.id} />Omit user_id to issue an unbound key (e.g. service tokens).
Future (v1.2.0)
- API-key rotation (re-issue raw under same id).
- Per-route automatic rate-limit via INI.
- Webhook signing helpers.
- Background bucket compaction worker.
- AbortSignal threading into
okStream. - Optional
Content-Encoding: gzipfor streaming envelopes. withApiKeyaudit log.
Design
See design/2026-05-16-hazo_api-v1.0.0-design.md (v1.0.0) and
design/2026-05-17-hazo_api-v1.1.0-design.md (v1.1.0) for full specs.
