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:
Jakob Wennberg
2026-06-26 15:30:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 55ba66908b
commit fc2b4d1e23
14 changed files with 96 additions and 60 deletions
@@ -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'
+2 -4
View File
@@ -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',
+46
View File
@@ -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'