fix(api): declare the real { data, meta } envelope for v1 list endpoints (#802)
The OpenAPI success schemas for v1 list endpoints declared a bare
{ <name>: [...] } object that no handler returns, so the published spec
advertised a shape the API never emits (#781, item 2). response.success is
doc-only (feeds zodToJsonSchema for /openapi.json; not validated at runtime),
so this is a documentation fix with no behaviour change.
Add listEnvelope() ({ data: [...], meta }) and dataEnvelope() ({ data, meta })
plus a shared ResponseMetaSchema. Ten endpoints that return paginated() now use
listEnvelope; the three that deliberately wrap their array under a named key via
ok() (accounts, fiscal-periods, webhooks — a shape their route tests lock in)
use dataEnvelope. Also corrects the accounts/fiscal-periods examples, which
showed an unwrapped data: [...] that contradicted their handlers.
Refs #781.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
55ba66908b
commit
fc2b4d1e23
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
|
||||
@@ -26,7 +26,7 @@ const Account = z.object({
|
||||
sort_order: z.number().int(),
|
||||
})
|
||||
|
||||
const AccountsResponse = z.object({ accounts: z.array(Account) })
|
||||
const AccountsResponse = dataEnvelope(z.object({ accounts: z.array(Account) }))
|
||||
|
||||
const ACCOUNT_COLUMNS =
|
||||
'account_number, account_name, account_class, account_group, account_type, ' +
|
||||
@@ -51,16 +51,18 @@ registerEndpoint({
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
account_number: '1930',
|
||||
account_name: 'Företagskonto',
|
||||
account_class: 1,
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
accounts: [
|
||||
{
|
||||
account_number: '1930',
|
||||
account_name: 'Företagskonto',
|
||||
account_class: 1,
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateCustomerSchema } from '@/lib/api/schemas'
|
||||
@@ -46,9 +46,7 @@ const CustomerSummary = z.object({
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const CustomersListResponse = z.object({
|
||||
customers: z.array(CustomerSummary),
|
||||
})
|
||||
const CustomersListResponse = listEnvelope(CustomerSummary)
|
||||
|
||||
// Explicit projection — never SELECT *. Schema migrations adding columns
|
||||
// must update this list before the field becomes visible on the public API.
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateEmployeeSchema } from '@/lib/api/schemas'
|
||||
@@ -53,7 +53,7 @@ const EmployeeSummary = z.object({
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const EmployeesListResponse = z.object({ employees: z.array(EmployeeSummary) })
|
||||
const EmployeesListResponse = listEnvelope(EmployeeSummary)
|
||||
|
||||
// Explicit projection — never SELECT *. Schema migrations adding columns
|
||||
// must update this list before the field becomes visible on the public API.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse } from '@/lib/api/v1/errors'
|
||||
|
||||
@@ -25,7 +25,7 @@ const FiscalPeriod = z.object({
|
||||
exceeds_18_months: z.boolean(),
|
||||
})
|
||||
|
||||
const FiscalPeriodsResponse = z.object({ fiscal_periods: z.array(FiscalPeriod) })
|
||||
const FiscalPeriodsResponse = dataEnvelope(z.object({ fiscal_periods: z.array(FiscalPeriod) }))
|
||||
|
||||
const FISCAL_PERIOD_COLUMNS =
|
||||
'id, name, period_start, period_end, is_closed, closed_at, locked_at, ' +
|
||||
@@ -49,16 +49,18 @@ registerEndpoint({
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
id: 'fp_2026',
|
||||
name: 'Räkenskapsår 2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
fiscal_periods: [
|
||||
{
|
||||
id: 'fp_2026',
|
||||
name: 'Räkenskapsår 2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { parseExpand } from '@/lib/api/v1/expand'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateInvoiceSchema } from '@/lib/api/schemas'
|
||||
@@ -62,9 +62,7 @@ const InvoiceSummary = z.object({
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const InvoicesListResponse = z.object({
|
||||
invoices: z.array(InvoiceSummary),
|
||||
})
|
||||
const InvoicesListResponse = listEnvelope(InvoiceSummary)
|
||||
|
||||
const ALLOWED_EXPAND = ['customer', 'items'] as const
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
@@ -49,7 +49,7 @@ const JournalEntrySummary = z.object({
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const JournalEntriesListResponse = z.object({ journal_entries: z.array(JournalEntrySummary) })
|
||||
const JournalEntriesListResponse = listEnvelope(JournalEntrySummary)
|
||||
|
||||
const JournalEntryLine = z.object({
|
||||
id: z.string().uuid(),
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateSalaryRunSchema } from '@/lib/api/schemas'
|
||||
@@ -48,7 +48,7 @@ const SalaryRunSummary = z.object({
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const SalaryRunsListResponse = z.object({ salary_runs: z.array(SalaryRunSummary) })
|
||||
const SalaryRunsListResponse = listEnvelope(SalaryRunSummary)
|
||||
|
||||
const SALARY_RUN_SUMMARY_COLUMNS =
|
||||
'id, period_year, period_month, payment_date, status, voucher_series, total_gross, total_tax, total_net, total_avgifter, total_employer_cost, agi_generated_at, agi_submitted_at, approved_at, paid_at, booked_at, created_at'
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
@@ -71,9 +71,7 @@ const SupplierInvoiceSummary = z.object({
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const SupplierInvoicesListResponse = z.object({
|
||||
supplier_invoices: z.array(SupplierInvoiceSummary),
|
||||
})
|
||||
const SupplierInvoicesListResponse = listEnvelope(SupplierInvoiceSummary)
|
||||
|
||||
// Explicit projection.
|
||||
const SI_SUMMARY_COLUMNS =
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateSupplierSchema } from '@/lib/api/schemas'
|
||||
@@ -47,9 +47,7 @@ const SupplierSummary = z.object({
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const SuppliersListResponse = z.object({
|
||||
suppliers: z.array(SupplierSummary),
|
||||
})
|
||||
const SuppliersListResponse = listEnvelope(SupplierSummary)
|
||||
|
||||
// Explicit projection — never SELECT *. Schema migrations adding columns
|
||||
// must update this list before the field becomes visible on the public API.
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
|
||||
@@ -33,9 +33,7 @@ const TransactionSummary = z.object({
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const TransactionListResponse = z.object({
|
||||
transactions: z.array(TransactionSummary),
|
||||
})
|
||||
const TransactionListResponse = listEnvelope(TransactionSummary)
|
||||
|
||||
// Explicit projection — no SELECT *. created_at is required for cursor
|
||||
// stability (see ordering rationale in the GET handler).
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
import { paginated } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { decodeDefaultCursor, encodeDefaultCursor, parsePaginationParams } from '@/lib/api/v1/pagination'
|
||||
@@ -76,7 +76,7 @@ registerEndpoint({
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: z.array(DeliverySummary) },
|
||||
response: { success: listEnvelope(DeliverySummary) },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { z } from 'zod'
|
||||
import { created, ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { generateWebhookSecret } from '@/lib/webhooks/signing'
|
||||
@@ -83,9 +83,7 @@ const WebhookCreated = WebhookSummary.extend({
|
||||
description: z.string().nullable(),
|
||||
})
|
||||
|
||||
const WebhooksListResponse = z.object({
|
||||
webhooks: z.array(WebhookSummary),
|
||||
})
|
||||
const WebhooksListResponse = dataEnvelope(z.object({ webhooks: z.array(WebhookSummary) }))
|
||||
|
||||
const WEBHOOK_LIST_COLUMNS =
|
||||
'id, name, event_type, webhook_url, active, api_version_pinned, disabled_at, disabled_reason, created_at'
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
parsePaginationParams,
|
||||
decodeDefaultCursor,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse } from '@/lib/api/v1/errors'
|
||||
|
||||
@@ -29,9 +29,7 @@ const Company = z.object({
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const CompaniesListResponse = z.object({
|
||||
companies: z.array(Company),
|
||||
})
|
||||
const CompaniesListResponse = listEnvelope(Company)
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'companies.list',
|
||||
|
||||
@@ -16,10 +16,56 @@
|
||||
* 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 `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.
|
||||
*/
|
||||
export const ResponseMetaSchema = z.object({
|
||||
request_id: z.string(),
|
||||
api_version: z.string(),
|
||||
next_cursor: z.string().nullable().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,
|
||||
})
|
||||
}
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
|
||||
export type ActionRisk = 'low' | 'medium' | 'high'
|
||||
|
||||
Reference in New Issue
Block a user