25524e1df4
* fix(suppliers): stop requiring standardkonto that was never meant to be required The supplier form initializes every optional field to '' and sent them as-is, while CreateSupplierSchema validates default_expense_account with the 4-digit account rule behind .optional(): an empty string is a present string, so saving a supplier with the field untouched failed with "Kontonummer måste vara 4 siffror" even though the field carries no required mark (reported by Björn with a screen recording; the edit page failed the same way for any supplier without a default account). Schemas now own the normalization, split by verb: on create '' becomes undefined (key dropped, column NULL), on update '' becomes null, because update routes pass fields straight into .update() where undefined means "leave unchanged" and clearing must actually write NULL. Email gets the same treatment and the form's old client-side email strip is removed; stripping empty strings client-side would break exactly the clear path. The free-text Standardkonto input is replaced with the shared AccountCombobox (browsable list filtered to cost classes 4-7, the same rule the agent-path expenseAccountField enforces), with the selected account name shown under the field and a clear button when set. Standardkonto itself stays optional: it only prefills supplier-invoice lines and the ledger-context suggestion covers the empty case. Verified end to end against the running app: saving a supplier without a default account succeeds on the update path, and the combobox search/select/clear cycle works inside the create dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api-spec): render preprocess pipes by output side, required-ness by undefined-acceptance The minimal Zod-to-JSON-schema walker described every pipe by its input side. For .transform() that is right (the caller sends the input), but z.preprocess() is the mirror image: the callable sits on the input side, so the supplier schemas' new empty-string normalization rendered email and default_expense_account as required untyped fields in the OpenAPI spec and the generated accounted-api skill. Describe the output side when the input is a transform. Required-ness now derives from schema.safeParse(undefined) instead of a top-level discriminator check: a field may be omitted exactly when the schema accepts undefined. Besides the preprocess pipes, this corrects several fields the old check misrendered as required (z.unknown() bodies, union-with-empty-string settings fields, preprocessed personal_number), so the regenerated skill references only flip required to optional where runtime validation already allowed omission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
499 lines
19 KiB
TypeScript
499 lines
19 KiB
TypeScript
/**
|
|
* Single source of truth for the v1 REST surface.
|
|
*
|
|
* Every endpoint registers its Zod request/response schemas + agent-facing
|
|
* metadata (description, use-when, do-not-use-for, pitfalls, example) +
|
|
* OpenAPI `x-*` extensions (`x-action-risk`, `x-idempotent`, `x-reversible`,
|
|
* `x-dry-run-supported`).
|
|
*
|
|
* Three artefacts are derived from this registry:
|
|
* 1. The OpenAPI 3.1 spec at /api/v1/openapi.json (this file).
|
|
* 2. The MCP tool list (future: Phase 5).
|
|
* 3. Runtime validators (Zod itself, used by handlers).
|
|
*
|
|
* Phase 1 ships a minimal Zod→JSON-Schema converter. Phase 2 will swap in
|
|
* `@asteasolutions/zod-to-openapi` once the schema surface justifies the
|
|
* dependency. The registry shape stays stable across that change.
|
|
*/
|
|
|
|
import { z } from 'zod'
|
|
import type { ZodTypeAny } from 'zod'
|
|
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
|
import { API_V1_VERSION } from './version'
|
|
|
|
/**
|
|
* The audit block surfaced inline on write responses (see `AuditBlock` in
|
|
* `lib/api/v1/response.ts`) so an agent gets the voucher number / audit-trail
|
|
* URL without a second round-trip. Every field is optional.
|
|
*/
|
|
const ResponseAuditSchema = z.object({
|
|
voucher_number: z.string().optional(),
|
|
voucher_url: z.string().optional(),
|
|
audit_trail_url: z.string().optional(),
|
|
immutable_at: z.string().optional(),
|
|
})
|
|
|
|
/**
|
|
* The `meta` block echoed in every v1 response envelope (see
|
|
* `lib/api/v1/response.ts`). List endpoints additionally populate
|
|
* `next_cursor`; it is absent on the final page. Writes may surface an
|
|
* `audit` block, and soft-degraded `?expand=` responses a `partial_expansions`
|
|
* list: both optional, so reads and lists omit them.
|
|
*/
|
|
export const ResponseMetaSchema = z.object({
|
|
request_id: z.string(),
|
|
api_version: z.string(),
|
|
next_cursor: z.string().nullable().optional(),
|
|
audit: ResponseAuditSchema.optional(),
|
|
partial_expansions: z.array(z.string()).optional(),
|
|
})
|
|
|
|
/**
|
|
* The `{ data, meta }` envelope that every list endpoint actually returns via
|
|
* `paginated()`. Declare a list endpoint's `response.success` with this so the
|
|
* OpenAPI contract matches the runtime body.
|
|
*
|
|
* Previously each list endpoint declared a bare `{ <name>: [...] }` success
|
|
* object (e.g. `{ companies: [...] }`) that no handler ever emits: the
|
|
* generated spec advertised a shape the API never returns. See issue #781.
|
|
*/
|
|
export function listEnvelope<T extends ZodTypeAny>(item: T) {
|
|
return z.object({
|
|
data: z.array(item),
|
|
meta: ResponseMetaSchema,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The `{ data, meta }` envelope for an endpoint that returns a single OBJECT
|
|
* under `data` (via `ok()`), rather than a bare array under `data`.
|
|
*
|
|
* Most list endpoints return `{ data: [...] }` (use {@link listEnvelope}). A
|
|
* few (`accounts`, `fiscal-periods`, `webhooks`) deliberately wrap their
|
|
* array in a named key (`{ data: { accounts: [...] } }`); their handlers and
|
|
* route tests lock that shape in. Declare those with
|
|
* `dataEnvelope(z.object({ <name>: z.array(Item) }))` so the OpenAPI contract
|
|
* matches what they actually return.
|
|
*/
|
|
export function dataEnvelope<T extends ZodTypeAny>(data: T) {
|
|
return z.object({
|
|
data,
|
|
meta: ResponseMetaSchema,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Sentinel `response.success` for endpoints that return 204 No Content with an
|
|
* empty body: e.g. DELETE handlers calling `noContent()`. The OpenAPI
|
|
* generator emits a bare `204` response (no schema) for these instead of a
|
|
* `200 { data, meta }`, and the envelope contract test exempts them.
|
|
*
|
|
* Identified by REFERENCE equality, so every 204 route MUST import this exact
|
|
* constant rather than declaring its own `z.object({})`: that is what lets the
|
|
* generator and the contract test recognise the "no body" intent.
|
|
*/
|
|
export const NoBodyResponse = z.object({})
|
|
|
|
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
|
|
|
export type ActionRisk = 'low' | 'medium' | 'high'
|
|
|
|
export interface EndpointDefinition {
|
|
/** HTTP method + path pattern, e.g. 'GET /api/v1/companies'. */
|
|
operation: string
|
|
method: HttpMethod
|
|
path: string
|
|
|
|
/** One-sentence summary; first sentence of the OpenAPI description. */
|
|
summary: string
|
|
|
|
/** Longer prose for the docs and the registered MCP tool description. */
|
|
description: string
|
|
|
|
/** Positive trigger: when should an agent reach for this endpoint? */
|
|
useWhen: string
|
|
|
|
/** Negative trigger: what looks similar but isn't this. */
|
|
doNotUseFor: string
|
|
|
|
/** Common pitfalls. Bullet-list style; agents see this in tool docs. */
|
|
pitfalls: string[]
|
|
|
|
/** One worked example (used by the contract-test suite). */
|
|
example: {
|
|
request?: Record<string, unknown>
|
|
response: Record<string, unknown>
|
|
}
|
|
|
|
/** Required scope; null for public endpoints. */
|
|
scope: ApiKeyScope | null
|
|
|
|
/** Action risk: informs whether the agent should confirm before calling. */
|
|
risk: ActionRisk
|
|
|
|
/** True for GET requests and well-known idempotent writes. */
|
|
idempotent: boolean
|
|
|
|
/** True for writes that can be undone by a single subsequent call (e.g. credit invoice). */
|
|
reversible: boolean
|
|
|
|
/** True for write endpoints that accept ?dry_run=true. */
|
|
dryRunSupported: boolean
|
|
|
|
/** Optional Zod schemas. */
|
|
request?: {
|
|
/** Path params (companyId, id, ...). */
|
|
params?: ZodTypeAny
|
|
/** Query params. */
|
|
query?: ZodTypeAny
|
|
/** Request body. */
|
|
body?: ZodTypeAny
|
|
/**
|
|
* Body content-type. Defaults to 'application/json' when omitted.
|
|
* Set to 'multipart/form-data' for upload endpoints (Phase 4 PR-3:
|
|
* documents). The OpenAPI generator emits the appropriate schema
|
|
* (`{ type: 'string', format: 'binary' }` for the file part) so
|
|
* code generators produce correct multipart clients.
|
|
*/
|
|
contentType?: 'application/json' | 'multipart/form-data'
|
|
}
|
|
response: {
|
|
/** Successful response body. */
|
|
success: ZodTypeAny
|
|
/** Stable error codes this endpoint can emit (cross-referenced with the docs). */
|
|
errorCodes?: string[]
|
|
/**
|
|
* Override the default 'application/json' content type for non-JSON
|
|
* responses (e.g. binary downloads). When set to 'application/pdf', the
|
|
* OpenAPI generator emits a `{ type: 'string', format: 'binary' }` schema
|
|
* instead of deriving from `success`. The `success` schema is still
|
|
* required (use `z.unknown()` as a marker) so existing registry consumers
|
|
* don't need to handle a missing field.
|
|
*/
|
|
contentType?: string
|
|
}
|
|
}
|
|
|
|
const ENDPOINTS = new Map<string, EndpointDefinition>()
|
|
|
|
/**
|
|
* Register an endpoint. Called from the route file at module load time:
|
|
*
|
|
* registerEndpoint({
|
|
* operation: 'companies.list',
|
|
* method: 'GET',
|
|
* path: '/api/v1/companies',
|
|
* ...
|
|
* })
|
|
*
|
|
* The wrapper does not depend on registration: scope resolution lives in
|
|
* `lib/auth/scopes.ts` so a missing register() call only affects docs, not
|
|
* runtime auth. CI test asserts every wrapped route appears in the registry.
|
|
*/
|
|
export function registerEndpoint(def: EndpointDefinition): void {
|
|
const key = `${def.method} ${def.path}`
|
|
if (ENDPOINTS.has(key)) {
|
|
// Duplicate registration is a bug: log loudly. Throwing during a route
|
|
// module's top-level eval would break unrelated routes; warn instead.
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`[api/v1/registry] duplicate endpoint registration: ${key}`)
|
|
}
|
|
ENDPOINTS.set(key, def)
|
|
}
|
|
|
|
export function listEndpoints(): EndpointDefinition[] {
|
|
return Array.from(ENDPOINTS.values())
|
|
}
|
|
|
|
export function getEndpoint(method: HttpMethod, path: string): EndpointDefinition | undefined {
|
|
return ENDPOINTS.get(`${method} ${path}`)
|
|
}
|
|
|
|
/**
|
|
* Resolve the registered endpoint for a CONCRETE request path (e.g.
|
|
* `/api/v1/companies/abc/customers`) by matching it against the registered
|
|
* `:param` patterns. Used by the wrapper to read an endpoint's `dryRunSupported`
|
|
* flag at request time: the route module being served has already run its
|
|
* `registerEndpoint()` call, so its pattern is present. Returns undefined when
|
|
* no pattern matches (the wrapper treats that as "cannot be simulated").
|
|
*/
|
|
export function getEndpointByConcretePath(
|
|
method: string,
|
|
concretePath: string,
|
|
): EndpointDefinition | undefined {
|
|
for (const def of ENDPOINTS.values()) {
|
|
if (def.method !== method) continue
|
|
const regex = new RegExp('^' + def.path.replace(/:[^/]+/g, '[^/]+') + '$')
|
|
if (regex.test(concretePath)) return def
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Minimal Zod → JSON Schema converter
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Phase 1 only registers a handful of endpoints with simple schemas. We
|
|
// implement just enough to cover them: object, string, number, boolean,
|
|
// uuid, array, optional, enum, literal, date-string. When the registry
|
|
// surface grows past Phase 2, swap this for @asteasolutions/zod-to-openapi.
|
|
|
|
interface JsonSchema {
|
|
type?: string | string[]
|
|
properties?: Record<string, JsonSchema>
|
|
required?: string[]
|
|
items?: JsonSchema
|
|
enum?: unknown[]
|
|
const?: unknown
|
|
format?: string
|
|
description?: string
|
|
additionalProperties?: boolean | JsonSchema
|
|
}
|
|
|
|
function zodToJsonSchema(schema: ZodTypeAny): JsonSchema {
|
|
const def = (schema as unknown as { _def: { typeName?: string; type?: string } })._def
|
|
|
|
// Zod 4 uses string discriminators on _def.type ('string', 'object', etc.).
|
|
// Fall back to the legacy typeName for cross-version safety.
|
|
const discriminator = def.type ?? def.typeName ?? ''
|
|
|
|
switch (discriminator) {
|
|
case 'string':
|
|
case 'ZodString':
|
|
return { type: 'string' }
|
|
case 'number':
|
|
case 'ZodNumber':
|
|
return { type: 'number' }
|
|
case 'boolean':
|
|
case 'ZodBoolean':
|
|
return { type: 'boolean' }
|
|
case 'array':
|
|
case 'ZodArray': {
|
|
const inner = (def as { element?: ZodTypeAny; type?: ZodTypeAny }).element
|
|
?? (def as { type?: ZodTypeAny }).type
|
|
return { type: 'array', items: inner ? zodToJsonSchema(inner) : {} }
|
|
}
|
|
case 'optional':
|
|
case 'ZodOptional':
|
|
case 'nullable':
|
|
case 'ZodNullable':
|
|
// A `.default()` field accepts the inner type on input; the object case
|
|
// below additionally treats it as not-required.
|
|
case 'default':
|
|
case 'ZodDefault': {
|
|
const inner = (def as { innerType: ZodTypeAny }).innerType
|
|
return zodToJsonSchema(inner)
|
|
}
|
|
case 'record':
|
|
case 'ZodRecord': {
|
|
const valueType = (def as { valueType?: ZodTypeAny }).valueType
|
|
return {
|
|
type: 'object',
|
|
additionalProperties: valueType ? zodToJsonSchema(valueType) : true,
|
|
}
|
|
}
|
|
// `.transform()` / `.pipe()` wrappers: describe the INPUT side, which is
|
|
// what an API caller must send. `z.preprocess()` is the mirror image:
|
|
// its input side IS the callable (a ZodTransform, no describable type),
|
|
// and the schema the cleaned value must satisfy sits on the output side.
|
|
case 'pipe':
|
|
case 'ZodPipeline': {
|
|
const pipeDef = def as { in?: ZodTypeAny; out?: ZodTypeAny }
|
|
const inDef = (pipeDef.in as unknown as { _def?: { type?: string; typeName?: string } } | undefined)?._def
|
|
const inDisc = inDef?.type ?? inDef?.typeName ?? ''
|
|
const side = ['transform', 'ZodEffects'].includes(inDisc) ? pipeDef.out : pipeDef.in
|
|
return side ? zodToJsonSchema(side) : {}
|
|
}
|
|
case 'effects':
|
|
case 'ZodEffects': {
|
|
const inner = (def as { schema?: ZodTypeAny }).schema
|
|
return inner ? zodToJsonSchema(inner) : {}
|
|
}
|
|
case 'object':
|
|
case 'ZodObject': {
|
|
const shape = (schema as unknown as { shape: Record<string, ZodTypeAny> }).shape
|
|
const properties: Record<string, JsonSchema> = {}
|
|
const required: string[] = []
|
|
for (const [key, value] of Object.entries(shape)) {
|
|
properties[key] = zodToJsonSchema(value)
|
|
// A field may be omitted exactly when the schema accepts undefined:
|
|
// covers optional and defaulted fields, and wrappers that only carry
|
|
// optionality inside (e.g. a preprocess pipe over `.optional()`),
|
|
// which a top-level discriminator check misclassifies as required.
|
|
const mayOmit = value.safeParse(undefined).success
|
|
if (!mayOmit) {
|
|
required.push(key)
|
|
}
|
|
}
|
|
return {
|
|
type: 'object',
|
|
properties,
|
|
...(required.length > 0 ? { required } : {}),
|
|
additionalProperties: false,
|
|
}
|
|
}
|
|
case 'enum':
|
|
case 'ZodEnum': {
|
|
const enumDef = def as { values?: unknown[]; entries?: Record<string, unknown> }
|
|
const values =
|
|
enumDef.values ??
|
|
(enumDef.entries ? Object.values(enumDef.entries) : [])
|
|
return { type: 'string', enum: values }
|
|
}
|
|
case 'literal':
|
|
case 'ZodLiteral': {
|
|
const value = (def as { value?: unknown; values?: unknown[] }).value
|
|
?? (def as { values?: unknown[] }).values?.[0]
|
|
return { const: value }
|
|
}
|
|
case 'union':
|
|
case 'ZodUnion': {
|
|
// Best-effort: emit a oneOf with each member converted. No top-level
|
|
// `type` constraint: the individual branches carry their own types
|
|
// (valid JSON Schema for a union).
|
|
const options = (def as { options?: ZodTypeAny[] }).options ?? []
|
|
return { oneOf: options.map(zodToJsonSchema) } as unknown as JsonSchema
|
|
}
|
|
default:
|
|
// Unknown construct → empty schema, accept anything.
|
|
return {}
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// OpenAPI 3.1 spec generation
|
|
// ──────────────────────────────────────────────────────────────────
|
|
|
|
interface OpenApiSpec {
|
|
openapi: '3.1.0'
|
|
info: { title: string; version: string; description: string }
|
|
servers: Array<{ url: string }>
|
|
components: { securitySchemes: Record<string, unknown> }
|
|
security: Array<Record<string, unknown[]>>
|
|
paths: Record<string, Record<string, unknown>>
|
|
}
|
|
|
|
const SCHEME_NAME = 'ApiKey'
|
|
|
|
export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
|
|
const paths: OpenApiSpec['paths'] = {}
|
|
|
|
for (const def of ENDPOINTS.values()) {
|
|
// OpenAPI path syntax: {param} instead of :param.
|
|
const openApiPath = def.path.replace(/:([^/]+)/g, '{$1}')
|
|
|
|
// Binary responses (e.g. application/pdf) declare a `format: binary`
|
|
// schema rather than deriving from the Zod success type.
|
|
const successContent = def.response.contentType && def.response.contentType !== 'application/json'
|
|
? { [def.response.contentType]: { schema: { type: 'string', format: 'binary' } } }
|
|
: { 'application/json': { schema: zodToJsonSchema(def.response.success) } }
|
|
|
|
// 204 No Content endpoints (DELETEs returning noContent()) carry no body:
|
|
// emit a bare 204 instead of a 200 { data, meta } so the spec stops
|
|
// advertising a response shape these handlers never send.
|
|
const successResponse = def.response.success === NoBodyResponse
|
|
? { '204': { description: 'No Content' } }
|
|
: { '200': { description: 'Success', content: successContent } }
|
|
|
|
// Path parameters, derived from the `:param` pattern itself so every
|
|
// templated segment is declared even though routes don't register a
|
|
// params schema. All v1 path params are string ids.
|
|
const parameters = [...def.path.matchAll(/:([^/]+)/g)].map(([, name]) => ({
|
|
name,
|
|
in: 'path',
|
|
required: true,
|
|
schema: { type: 'string' },
|
|
}))
|
|
|
|
// Request body from the registered Zod schema. In multipart bodies a
|
|
// part registered as `z.unknown()` is by convention the binary file part
|
|
// (see documents.upload); the converter turns it into an empty schema,
|
|
// which is rewritten here to `format: binary` so client generators
|
|
// produce correct multipart uploads.
|
|
let requestBody: Record<string, unknown> | undefined
|
|
if (def.request?.body) {
|
|
const contentType = def.request.contentType ?? 'application/json'
|
|
let bodySchema = zodToJsonSchema(def.request.body)
|
|
if (contentType === 'multipart/form-data' && bodySchema.properties) {
|
|
bodySchema = {
|
|
...bodySchema,
|
|
properties: Object.fromEntries(
|
|
Object.entries(bodySchema.properties).map(([key, prop]) => [
|
|
key,
|
|
Object.keys(prop).length === 0
|
|
? { type: 'string', format: 'binary' }
|
|
: prop,
|
|
]),
|
|
),
|
|
}
|
|
}
|
|
requestBody = {
|
|
required: true,
|
|
content: { [contentType]: { schema: bodySchema } },
|
|
}
|
|
}
|
|
|
|
const operationDef: Record<string, unknown> = {
|
|
operationId: def.operation,
|
|
summary: def.summary,
|
|
description: [
|
|
def.description,
|
|
'',
|
|
`**Use when:** ${def.useWhen}`,
|
|
`**Do not use for:** ${def.doNotUseFor}`,
|
|
...(def.pitfalls.length > 0 ? ['', '**Pitfalls:**', ...def.pitfalls.map((p) => `- ${p}`)] : []),
|
|
].join('\n'),
|
|
'x-action-risk': def.risk,
|
|
'x-idempotent': def.idempotent,
|
|
'x-reversible': def.reversible,
|
|
'x-dry-run-supported': def.dryRunSupported,
|
|
...(def.scope ? { 'x-required-scope': def.scope } : {}),
|
|
...(parameters.length > 0 ? { parameters } : {}),
|
|
...(requestBody ? { requestBody } : {}),
|
|
responses: {
|
|
...successResponse,
|
|
'400': { description: 'Validation error', $ref: '#/components/responses/Error' },
|
|
'401': { description: 'Unauthorized', $ref: '#/components/responses/Error' },
|
|
'403': { description: 'Insufficient scope', $ref: '#/components/responses/Error' },
|
|
'404': { description: 'Not found', $ref: '#/components/responses/Error' },
|
|
'429': { description: 'Rate limited', $ref: '#/components/responses/Error' },
|
|
'500': { description: 'Internal error', $ref: '#/components/responses/Error' },
|
|
},
|
|
}
|
|
|
|
if (!paths[openApiPath]) paths[openApiPath] = {}
|
|
paths[openApiPath][def.method.toLowerCase()] = operationDef
|
|
}
|
|
|
|
return {
|
|
openapi: '3.1.0',
|
|
info: {
|
|
title: 'Accounted API',
|
|
version: API_V1_VERSION,
|
|
description:
|
|
'Public REST API for Accounted: Swedish double-entry bookkeeping. ' +
|
|
'Every write supports dry-run via `?dry_run=true`. Every request must include ' +
|
|
'`Authorization: Bearer gnubok_sk_...`. See /docs/api for the cookbook.',
|
|
},
|
|
servers: [{ url: serverUrl }],
|
|
components: {
|
|
securitySchemes: {
|
|
[SCHEME_NAME]: {
|
|
type: 'http',
|
|
scheme: 'bearer',
|
|
bearerFormat: 'gnubok_sk_<live|test>_<random>',
|
|
},
|
|
},
|
|
},
|
|
security: [{ [SCHEME_NAME]: [] }],
|
|
paths,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test-only escape hatch. Clears the registry: used in unit tests so a test
|
|
* that registers a fake endpoint doesn't leak into the next test.
|
|
*/
|
|
export function _resetRegistryForTests(): void {
|
|
ENDPOINTS.clear()
|
|
}
|