3447da027a
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill EndpointDefinition.example is required and every one of the 125 v1 endpoints populates example.response, but generateOpenApiSpec() never emitted it. The examples reached only the docs markdown builder, so /api/v1/openapi.json carried none and the generated skills/accounted-api had zero json blocks in all 12 reference files: every agent reading the spec or installing the skill got schemas with no concrete body. Emit example on the application/json media types (request body and 200 response) and teach the portable renderOperationMd to print it as a fenced json block. 178 worked examples now reach the skill. SKILL.md is unchanged: the examples land in the on-demand reference files, not the entry file. Attached to JSON media types only, so a multipart body and a binary application/pdf response do not advertise an example they cannot send. Adds the one missing example.request (currency-revaluation) so the new exhaustive coverage assertions hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): emit Retry-After on a v1 429 so the documented contract is real The published accounted-api skill has told agents to honor Retry-After on a 429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to pace against and had to back off blindly. 60 seconds is an exact upper bound rather than a guess: the rate limiter is a fixed one-minute tumbling window per key row and the limited branch does not slide it. The value moves into an exported constant next to that limiter, so the MCP server's hardcoded '60' now reads from the same place. Also corrects the withApiV1 doc comment, which claimed step 8 stamps X-RateLimit-Limit. It never did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(mcp): guard the tools/list payload for the namespace new installs get The payload ratchet only ever serialized the gnubok_* projection. The accounted_* projection is inherently larger (every tool reference gains 3 chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP installs at exactly that namespace, so the payload a new user's client receives was never measured. It had already drifted ~90 tokens past the 63.4K ceiling while the guarded number sat comfortably under it. Measure both and assert on the larger. The ceiling moves to 63.6K to cover the real worst case; this buys no new catalog surface. A second test pins the direction of the delta so Math.max cannot silently stop describing reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
101 lines
4.3 KiB
TypeScript
101 lines
4.3 KiB
TypeScript
/**
|
|
* The registry's worked `example` must reach the OpenAPI spec.
|
|
*
|
|
* `EndpointDefinition.example` has always been required, and every endpoint
|
|
* populates `example.response`, but `generateOpenApiSpec()` never emitted it.
|
|
* The examples therefore reached only the docs markdown builder
|
|
* (lib/docs/content/reference.ts); the spec carried none, so spec consumers
|
|
* (skills/accounted-api, client generators, any agent reading
|
|
* /api/v1/openapi.json) saw schemas without a single concrete body.
|
|
*
|
|
* A condensed schema states the shape of a field. An example states the
|
|
* conventions the shape cannot express, which is the half agents get wrong.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { generateOpenApiSpec, listEndpoints } from '../registry'
|
|
// Side-effect import: populates the ENDPOINTS registry from every route file.
|
|
import '../load-routes'
|
|
|
|
type MediaType = { schema?: unknown; example?: unknown }
|
|
type OperationObject = {
|
|
requestBody?: { content: Record<string, MediaType> }
|
|
responses: Record<string, { content?: Record<string, MediaType> }>
|
|
}
|
|
|
|
const spec = generateOpenApiSpec('https://unit.test')
|
|
|
|
function operation(path: string, method: string): OperationObject {
|
|
const op = (spec.paths[path] as Record<string, OperationObject> | undefined)?.[method]
|
|
expect(op, `${method.toUpperCase()} ${path} missing from spec`).toBeDefined()
|
|
return op as OperationObject
|
|
}
|
|
|
|
describe('generateOpenApiSpec examples', () => {
|
|
it('attaches the registry response example to the JSON success media type', () => {
|
|
const op = operation('/api/v1/companies/{companyId}/customers', 'post')
|
|
const example = op.responses['200']?.content?.['application/json']?.example as
|
|
| { data?: unknown }
|
|
| undefined
|
|
expect(example).toBeDefined()
|
|
expect(example).toHaveProperty('data')
|
|
})
|
|
|
|
it('attaches the registry request example to the JSON request body', () => {
|
|
const op = operation('/api/v1/companies/{companyId}/customers', 'post')
|
|
const example = op.requestBody?.content['application/json']?.example as
|
|
| Record<string, unknown>
|
|
| undefined
|
|
expect(example).toBeDefined()
|
|
expect(example).toHaveProperty('name')
|
|
})
|
|
|
|
it('emits a response example on every JSON success response', () => {
|
|
const missing: string[] = []
|
|
for (const [path, item] of Object.entries(spec.paths)) {
|
|
for (const [method, op] of Object.entries(item as Record<string, OperationObject>)) {
|
|
const json = op.responses['200']?.content?.['application/json']
|
|
// 204 endpoints and binary (application/pdf) responses carry no JSON body.
|
|
if (!json) continue
|
|
if (json.example === undefined) missing.push(`${method.toUpperCase()} ${path}`)
|
|
}
|
|
}
|
|
expect(missing).toEqual([])
|
|
})
|
|
|
|
it('emits a request example on every JSON request body', () => {
|
|
// `example.request` is optional on EndpointDefinition, but a registered
|
|
// JSON body with no worked example is the gap this test exists to hold
|
|
// shut: an agent reading the spec would get a shape and no conventions.
|
|
const missing: string[] = []
|
|
for (const [path, item] of Object.entries(spec.paths)) {
|
|
for (const [method, op] of Object.entries(item as Record<string, OperationObject>)) {
|
|
const json = op.requestBody?.content['application/json']
|
|
if (!json) continue
|
|
if (json.example === undefined) missing.push(`${method.toUpperCase()} ${path}`)
|
|
}
|
|
}
|
|
expect(missing).toEqual([])
|
|
})
|
|
|
|
it('does not attach a JSON example to a binary response', () => {
|
|
// A PDF endpoint's registry example describes the JSON envelope it does
|
|
// not send; attaching it to the binary media type would be a lie.
|
|
for (const [path, item] of Object.entries(spec.paths)) {
|
|
for (const [method, op] of Object.entries(item as Record<string, OperationObject>)) {
|
|
for (const [contentType, media] of Object.entries(op.responses['200']?.content ?? {})) {
|
|
if (contentType === 'application/json') continue
|
|
expect(media.example, `${method.toUpperCase()} ${path} ${contentType}`).toBeUndefined()
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('keeps every registered endpoint carrying a response example in the registry', () => {
|
|
const missing = listEndpoints()
|
|
.filter((def) => !def.example?.response)
|
|
.map((def) => def.operation)
|
|
expect(missing).toEqual([])
|
|
})
|
|
})
|