From fc2b4d1e239330390ca210aace007e06b22b05ad Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:30:48 +0200 Subject: [PATCH] fix(api): declare the real { data, meta } envelope for v1 list endpoints (#802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAPI success schemas for v1 list endpoints declared a bare { : [...] } 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) --- .../companies/[companyId]/accounts/route.ts | 26 ++++++----- .../companies/[companyId]/customers/route.ts | 6 +-- .../companies/[companyId]/employees/route.ts | 4 +- .../[companyId]/fiscal-periods/route.ts | 26 ++++++----- .../companies/[companyId]/invoices/route.ts | 6 +-- .../[companyId]/journal-entries/route.ts | 4 +- .../[companyId]/salary-runs/route.ts | 4 +- .../[companyId]/supplier-invoices/route.ts | 6 +-- .../companies/[companyId]/suppliers/route.ts | 6 +-- .../[companyId]/transactions/route.ts | 6 +-- .../webhooks/[id]/deliveries/route.ts | 4 +- .../companies/[companyId]/webhooks/route.ts | 6 +-- app/api/v1/companies/route.ts | 6 +-- lib/api/v1/registry.ts | 46 +++++++++++++++++++ 14 files changed, 96 insertions(+), 60 deletions(-) diff --git a/app/api/v1/companies/[companyId]/accounts/route.ts b/app/api/v1/companies/[companyId]/accounts/route.ts index 80466568..cb087d9e 100644 --- a/app/api/v1/companies/[companyId]/accounts/route.ts +++ b/app/api/v1/companies/[companyId]/accounts/route.ts @@ -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' }, }, }, diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index f417dcb4..4cf47a4c 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -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. diff --git a/app/api/v1/companies/[companyId]/employees/route.ts b/app/api/v1/companies/[companyId]/employees/route.ts index f7476618..5c132cdb 100644 --- a/app/api/v1/companies/[companyId]/employees/route.ts +++ b/app/api/v1/companies/[companyId]/employees/route.ts @@ -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. diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/route.ts index 197a49e7..17328e44 100644 --- a/app/api/v1/companies/[companyId]/fiscal-periods/route.ts +++ b/app/api/v1/companies/[companyId]/fiscal-periods/route.ts @@ -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' }, }, }, diff --git a/app/api/v1/companies/[companyId]/invoices/route.ts b/app/api/v1/companies/[companyId]/invoices/route.ts index 2396039a..9735192b 100644 --- a/app/api/v1/companies/[companyId]/invoices/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/journal-entries/route.ts b/app/api/v1/companies/[companyId]/journal-entries/route.ts index 391b36c1..8c6c0987 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/route.ts @@ -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(), diff --git a/app/api/v1/companies/[companyId]/salary-runs/route.ts b/app/api/v1/companies/[companyId]/salary-runs/route.ts index 22a911b7..25427b04 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/route.ts @@ -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' diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts index d29adaf5..cbf6a0ac 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts @@ -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 = diff --git a/app/api/v1/companies/[companyId]/suppliers/route.ts b/app/api/v1/companies/[companyId]/suppliers/route.ts index 30f361d5..94dea686 100644 --- a/app/api/v1/companies/[companyId]/suppliers/route.ts +++ b/app/api/v1/companies/[companyId]/suppliers/route.ts @@ -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. diff --git a/app/api/v1/companies/[companyId]/transactions/route.ts b/app/api/v1/companies/[companyId]/transactions/route.ts index f96bad57..6b071c96 100644 --- a/app/api/v1/companies/[companyId]/transactions/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/route.ts @@ -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). diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route.ts index d6108bbc..ee86aba9 100644 --- a/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route.ts @@ -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 }> }>( diff --git a/app/api/v1/companies/[companyId]/webhooks/route.ts b/app/api/v1/companies/[companyId]/webhooks/route.ts index 4bf0f356..4a3c2129 100644 --- a/app/api/v1/companies/[companyId]/webhooks/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/route.ts @@ -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' diff --git a/app/api/v1/companies/route.ts b/app/api/v1/companies/route.ts index 35e70134..cc1d3b64 100644 --- a/app/api/v1/companies/route.ts +++ b/app/api/v1/companies/route.ts @@ -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', diff --git a/lib/api/v1/registry.ts b/lib/api/v1/registry.ts index 91472de6..374002e4 100644 --- a/lib/api/v1/registry.ts +++ b/lib/api/v1/registry.ts @@ -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 `{ : [...] }` 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(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({ : z.array(Item) }))` so the OpenAPI contract + * matches what they actually return. + */ +export function dataEnvelope(data: T) { + return z.object({ + data, + meta: ResponseMetaSchema, + }) +} + export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' export type ActionRisk = 'low' | 'medium' | 'high'