From e8f46cfbdafd4f90f1119ec9a399ceefe6bb0d71 Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Mon, 8 Dec 2025 04:10:32 -0500 Subject: [PATCH] chore: remove unneccessary code Signed-off-by: Okiki Ojo --- utils/endpoint/definitions.ts | 82 ---- utils/endpoint/schemas.ts | 133 ------- utils/query/fields.ts | 371 ------------------ utils/query/filtering.ts | 692 --------------------------------- utils/query/index.ts | 54 --- utils/query/pagination.ts | 607 ----------------------------- utils/query/query.ts | 165 -------- utils/query/schemas.ts | 380 ------------------ utils/query/sorting.ts | 275 ------------- utils/response/errors.ts | 636 ------------------------------ utils/response/index.ts | 41 -- utils/response/schemas.ts | 487 ----------------------- utils/response/status-codes.ts | 210 ---------- utils/response/success.ts | 404 ------------------- 14 files changed, 4537 deletions(-) delete mode 100644 utils/endpoint/definitions.ts delete mode 100644 utils/endpoint/schemas.ts delete mode 100644 utils/query/fields.ts delete mode 100644 utils/query/filtering.ts delete mode 100644 utils/query/index.ts delete mode 100644 utils/query/pagination.ts delete mode 100644 utils/query/query.ts delete mode 100644 utils/query/schemas.ts delete mode 100644 utils/query/sorting.ts delete mode 100644 utils/response/errors.ts delete mode 100644 utils/response/index.ts delete mode 100644 utils/response/schemas.ts delete mode 100644 utils/response/status-codes.ts delete mode 100644 utils/response/success.ts diff --git a/utils/endpoint/definitions.ts b/utils/endpoint/definitions.ts deleted file mode 100644 index 62f9659..0000000 --- a/utils/endpoint/definitions.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ValidationTargets } from 'hono' -import type { FormValue, ParsedFormValue } from 'hono/types' -import type z from 'zod' - -/** - * Schema accepting input type I, producing any output - * - * No structural constraints - accepts: - * - Plain objects: z.object({ ... }) - * - Pipes: z.object({ ... }).pipe(...) - * - Transforms: z.object({ ... }).transform(...) - * - Unions, intersections, etc. - * - * Use this when you need input type safety but want to allow transformations. - */ -export type SchemaFor = z.ZodType - -/** - * Helper to normalize record types for compatibility checking - * - * Converts Record to { [key: string]: V } to allow index signature - * schemas to satisfy specific key union requirements. - * - * This enables: z.object({}).catchall(z.string()) to satisfy - * RecordSchemaFor> - */ -type NormalizedRecordInput = - T extends Record - ? K extends string - ? { [key: string]: V } - : T - : T - -/** - * Schema with record-shaped input - * - * Accepts schemas with index signatures that can parse the expected record type. - * This allows catchall schemas to satisfy specific key union requirements. - * - * @example - * // ✓ This works - catchall accepts any string keys - * const schema = z.object({}).catchall(z.string()) - * satisfies RecordSchemaFor> - */ -export type RecordSchemaFor, Output = any> = - SchemaFor, Output> - -/** - * @deprecated Use SchemaFor instead - allows transformations - */ -export type AnySchemaFor = SchemaFor - -/** - * Validation schemas for each input source - * - * Each schema must accept the raw input type from its source: - * - Query: Record - * - Form: Record - * - Json: any - * - Param: Record - * - Header: Record - * - Cookie: Record - * - * Schemas can transform to any output type (pipes/transforms allowed). - */ -export type EndpointDefinitionSchemas = { - [K in keyof ValidationTargets as Capitalize]?: - ValidationTargets[K] extends Record - ? RecordSchemaFor[K]> - : SchemaFor[K]> -} - -// Endpoint definition contract -export type EndpointDefinition = { - Name: string - Route: string - Description?: string - Methods: readonly ('GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH')[] - Input: z.ZodType - Output: z.ZodType - Schemas: EndpointDefinitionSchemas -} diff --git a/utils/endpoint/schemas.ts b/utils/endpoint/schemas.ts deleted file mode 100644 index d73d5ad..0000000 --- a/utils/endpoint/schemas.ts +++ /dev/null @@ -1,133 +0,0 @@ -// schemas.ts -import type { FormValue, ParsedFormValue } from 'hono/types'; -import type { EndpointDefinitionSchemas } from './definitions.ts'; -import { z } from 'zod'; - -/** - * Zod "string | string[]" union (mirrors Hono's `query` values). - * Useful for normalizing query params that may be repeated. - */ -export const ZStringOrStringArray = z.union([z.string(), z.array(z.string())]); - -/** - * ### Base JSON Schema - * - * - Accepts any JSON-like value. - * - Tighten per-endpoint with `.refine(...)`, `.transform(...)`, or `.pipe(...)`. - */ -export const BaseJsonSchema = z.any() satisfies NonNullable; - -/** - * ### Base Form Schema - * - * - **Input:** `Record`, where `T` defaults to `ParsedFormValue` (`string | File`). - * - Uses `z.object({}).catchall(...)` so it remains a **ZodObject** (giving you `.shape` and `.extend(...)`). - * - Extend per endpoint to restrict keys, change accepted types, or add transforms. - * - * @example - * // Restrict to a specific form key and coerce a file name: - * const UploadForm = makeBaseFormSchema().extend({ - * file: z.custom(), - * note: z.union([z.string(), z.array(z.string())]).transform(v => Array.isArray(v) ? v[0] ?? '' : v), - * }); - */ -export function makeBaseFormSchema() { - const value = z.union([z.custom(), z.array(z.custom())]); - return z.object({}).catchall(value) satisfies NonNullable['Form']>; -} - -/** - * @see {@link makeBaseFormSchema} - */ -export const BaseFormSchema = makeBaseFormSchema(); - -/** - * ### Base Query Schema - * - * - **Input:** `Record`. - * - Accepts arbitrary keys with `string | string[]` values. - * - Extend per endpoint with strongly-typed keys and transforms. - * - * @example - * const Query = BaseQuerySchema.extend({ - * page: z.coerce.number().int().min(1).default(1), - * sort: ZStringOrStringArray.transform(v => Array.isArray(v) ? v[0] ?? '' : v), - * }); - */ -export const BaseQuerySchema = z - .object({}) - .catchall(ZStringOrStringArray) satisfies NonNullable; - -/** - * ### Base Header Schema (extendable; no transforms) - * - * - **Input:** `Record` - * - Kept as a `ZodObject` so you can `.extend(Other.shape)` or spread shapes. - * - Apply normalization via `makeHeaderSchema(...)` when you’re done composing. - */ -export const BaseHeaderSchema = z - .object({}) - .catchall(z.string()) satisfies NonNullable; - -/** - * ### makeHeaderSchema - * - * Finalize a header schema by applying the **key-normalization transform** (lowercase keys). - * - * - Accepts any **object** schema compatible with the `header` input target - * (e.g., `BaseHeaderSchema`, or an extended version of it). - * - Returns a **piped** schema (`ZodPipe`) whose **output** is `Record` with - * lowercase keys. This is what you should actually **parse** with at the boundary. - * - * @example - * // 1) Compose while the schema is still a ZodObject - * const StrictHeader = BaseHeaderSchema.extend({ - * authorization: z.string().min(1), - * }); - * - * // 2) Only at the end, finalize with the transform - * const Header = makeHeaderSchema(StrictHeader); - * - * // Now parse incoming headers - * const parsed = Header.parse(reqHeaders); - * - * @example - * // If you don't need to customize headers, just: - * const Header = makeHeaderSchema(); // uses BaseHeaderSchema by default - */ -export function makeHeaderSchema< - // Infer the Input side from whatever object schema you pass in - S extends typeof BaseHeaderSchema ->(schema?: S) { - const base = (schema ?? BaseHeaderSchema) as S; - // Apply the transform at the very end; result is a ZodPipe (no .shape/.extend) - return base.transform((rec) => { - type K = (keyof typeof rec); - const out: Record = Object.create(null); - for (const k in rec) { - if (typeof k === "string") { - out[(k as string).toLowerCase()] = rec[k]; - } - } - return out; - }); -} - -/** - * ### Base Cookie Schema - * - * - **Input:** `Record`. - * - Simple string map; extend per endpoint to constrain or transform specific cookie keys. - */ -export const BaseCookieSchema = z - .object({}) - .catchall(z.string()) satisfies NonNullable; - - -/** - * ### Base Params Schema - */ -export const BaseParamSchema = z - .object({}) - .catchall(z.string()) satisfies NonNullable - diff --git a/utils/query/fields.ts b/utils/query/fields.ts deleted file mode 100644 index 623530d..0000000 --- a/utils/query/fields.ts +++ /dev/null @@ -1,371 +0,0 @@ -// utils/query/fields.ts -/** - * Field selection (sparse fieldsets) with multi-source support - * - * Features: - * - JSON:API syntax: fields[type]=a,b,c - * - Simple syntax: fields=a,b,c - * - Field allowlists via registry - * - De-duplication - * - Wildcard handling - * - Query/JSON/FormData source adapters - */ - -import { z } from 'zod' - -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '../endpoint/schemas.ts' -import { - FieldSelectionNormalizedSchema, - type FieldsConfig, - type FieldSelectionNormalized -} from './schemas.ts' - -// ============================================================================ -// WIRE SCHEMAS (raw incoming data) -// ============================================================================ - -/** - * Query parameter wire schema (raw incoming) - * Supports both: - * - Simple: ?fields=a,b,c - * - JSON:API: ?fields[products]=a,b,c&fields[categories]=x,y - */ -export const FieldsQueryWire = BaseQuerySchema - -/** - * JSON body wire schema (raw incoming) - * Expects: { fields: { type: 'simple', fields: ['a', 'b'] } } - */ -export const FieldsJsonWire = BaseJsonSchema.pipe( - z.object({ - fields: FieldSelectionNormalizedSchema.nullable().default(null) - }) -) - -/** - * FormData wire schema (raw incoming) - */ -export const FieldsFormWire = BaseFormSchema - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/** - * Extract string value from ZStringOrStringArray - */ -const getString = (val: unknown): string | undefined => { - if (Array.isArray(val)) return val[0] - if (typeof val === 'string') return val - return undefined -} - -/** - * Detect JSON:API vs simple syntax from query/form data - * - * Detection priority: - * 1. Check for JSON:API syntax: fields[type]=... - * 2. Fall back to simple syntax: fields=... - * 3. Return null if neither found - * - * @param data Raw query or form data - * @returns Normalized field selection or null - * - * @example - * // JSON:API syntax - * detectFieldSyntax({ 'fields[products]': 'id,name', 'fields[categories]': 'name' }) - * // => { type: 'jsonapi', fields: { products: ['id', 'name'], categories: ['name'] } } - * - * // Simple syntax - * detectFieldSyntax({ fields: 'id,name,price' }) - * // => { type: 'simple', fields: ['id', 'name', 'price'] } - * - * // No field selection - * detectFieldSyntax({ page: '1' }) - * // => null - */ -function detectFieldSyntax(data: Record): FieldSelectionNormalized | null { - // Check for JSON:API syntax: fields[type]=... - const jsonApiFields: Record = {} - - for (const [key, value] of Object.entries(data)) { - const match = key.match(/^fields\[([^\]]+)\]$/) - if (match) { - const type = match[1] - - // Extract value (handle arrays from ZStringOrStringArray) - const stringValue = String(getString(value) ?? '') - const fields = stringValue - .split(',') - .map(f => f.trim()) - .filter(f => f) - - if (fields.length > 0) { - jsonApiFields[type] = fields - } - } - } - - if (Object.keys(jsonApiFields).length > 0) { - return { type: 'jsonapi', fields: jsonApiFields } - } - - // Fall back to simple syntax: fields=... - const fieldsValue = data.fields - if (fieldsValue) { - // Extract value (handle arrays from ZStringOrStringArray) - const stringValue = String(getString(fieldsValue) ?? '') - const fields = stringValue - .split(',') - .map(f => f.trim()) - .filter(f => f) - - if (fields.length > 0) { - return { type: 'simple', fields } - } - } - - return null -} - -/** - * Encode field selection back to wire format - * Used for round-trip serialization - * - * @param selection Normalized field selection - * @returns Wire format object - * - * @example - * encodeFieldSelection({ type: 'simple', fields: ['id', 'name'] }) - * // => { fields: 'id,name' } - * - * encodeFieldSelection({ - * type: 'jsonapi', - * fields: { products: ['id', 'name'], categories: ['name'] } - * }) - * // => { 'fields[products]': 'id,name', 'fields[categories]': 'name' } - */ -function encodeFieldSelection(selection: FieldSelectionNormalized | null): Record { - if (!selection) return {} - - if (selection.type === 'simple') { - return { fields: selection.fields.join(',') } - } - - // JSON:API format - const result: Record = {} - for (const [type, fields] of Object.entries(selection.fields)) { - result[`fields[${type}]`] = fields.join(',') - } - return result -} - -// ============================================================================ -// SOURCE ADAPTERS -// ============================================================================ - -/** - * Query parameter adapter (uses z.codec) - * Supports: ?fields=a,b,c or ?fields[products]=a,b,c - * - * @example - * const adapter = createFieldsQueryAdapter() - * - * // Simple syntax - * const simple = adapter.decode({ fields: 'id,name,price' }) - * // => { type: 'simple', fields: ['id', 'name', 'price'] } - * - * // JSON:API syntax - * const jsonapi = adapter.decode({ - * 'fields[products]': 'id,name', - * 'fields[categories]': 'name' - * }) - * // => { type: 'jsonapi', fields: { products: ['id', 'name'], categories: ['name'] } } - */ -export function createFieldsQueryAdapter() { - return z.codec( - FieldsQueryWire, // Input (wire) - FieldSelectionNormalizedSchema.nullable(), // Output (normalized) - { - decode: (raw) => { - return detectFieldSyntax(raw) - }, - encode: (normalized) => { - return encodeFieldSelection(normalized) - } - } - ) -} - -/** - * JSON body adapter (uses z.codec) - * Expects: { fields: { type: 'simple', fields: ['a', 'b'] } } - * - * @example - * const adapter = createFieldsJsonAdapter() - * const normalized = adapter.decode({ - * fields: { type: 'simple', fields: ['id', 'name'] } - * }) - * // => { type: 'simple', fields: ['id', 'name'] } - */ -export function createFieldsJsonAdapter() { - return z.codec( - FieldsJsonWire, // Input (wire) - FieldSelectionNormalizedSchema.nullable(), // Output (normalized) - { - decode: (raw) => { - return raw.fields as FieldSelectionNormalized | null - }, - encode: (normalized) => { - return { fields: normalized } - } - } - ) -} - -/** - * FormData adapter (uses z.codec) - * Supports: fields=a,b,c or fields[products]=a,b,c - * - * @example - * const adapter = createFieldsFormAdapter() - * const formData = new FormData() - * formData.append('fields', 'id,name,price') - * const normalized = adapter.decode(formData) - * // => { type: 'simple', fields: ['id', 'name', 'price'] } - */ -export function createFieldsFormAdapter() { - return z.codec( - FieldsFormWire, // Input (wire) - FieldSelectionNormalizedSchema.nullable(), // Output (normalized) - { - decode: (raw): FieldSelectionNormalized | null => { - // Convert FormValue to plain record for detectFieldSyntax - const plainObj: Record = {} - - for (const [key, value] of Object.entries(raw)) { - plainObj[key] = String(getString(value) ?? '') - } - - return detectFieldSyntax(plainObj) - }, - encode: (normalized) => { - return encodeFieldSelection(normalized) - } - } - ) -} - -// ============================================================================ -// SCHEMA COMPOSITION WITH VALIDATION -// ============================================================================ - -/** - * Create endpoint-specific fields schema with validation - * All validation happens in .superRefine() so middleware handles errors - * - * @param config Configuration for field selection validation - * @param config.source Input source type ('query' | 'json' | 'form') - * @param config.allowedFields Array of selectable field names (allowlist) - * @param config.resourceType Resource type for JSON:API validation - * @param config.defaultFields Default fields when none provided - * - * @example - * const schema = createFieldsSchema({ - * source: 'query', - * allowedFields: ['id', 'name', 'price', 'created_at'], - * defaultFields: ['id', 'name'] - * }) - * - * // Parse and validate - * const fields = schema.parse({ fields: 'id,name,price' }) - * // => { type: 'simple', fields: ['id', 'name', 'price'] } - * - * // With wildcard - * const allFields = schema.parse({ fields: '*' }) - * // => { type: 'simple', fields: ['id', 'name', 'price', 'created_at'] } - * - * // Invalid field rejected - * schema.parse({ fields: 'id,invalid_field' }) - * // => Throws validation error - */ -export function createFieldsSchema(config: { - source: 'query' | 'json' | 'form' -} & FieldsConfig) { - // When disabled, always return null - if (config.disabled) { - return z.null() - } - - const adapter = - config.source === 'query' ? createFieldsQueryAdapter() : - config.source === 'json' ? createFieldsJsonAdapter() : - createFieldsFormAdapter() - - return adapter - .transform((selection): FieldSelectionNormalized | null => { - // Apply defaults when no selection provided - if (!selection && config.defaults && config.defaults.length > 0) { - return { - type: 'simple', - fields: [...config.defaults] // Copy to avoid mutation - } - } - return selection - }) - .superRefine((selection, ctx) => { - // No selection - use default or allow null - if (!selection) { return; } - - // Get fields to validate based on selection type - const fieldsToValidate = - selection.type === 'simple' - ? selection.fields - : config.resourceType - ? selection.fields[config.resourceType] ?? [] - : Object.values(selection.fields).flat() - - // De-duplicate fields - const uniqueFields = Array.from(new Set(fieldsToValidate)) - - // Handle wildcard - const hasWildcard = uniqueFields.includes('*') - if (hasWildcard) { - // Wildcard with no restrictions - if (!config.allowedFields) return - - // Replace wildcard with all allowed fields - if (selection.type === 'simple') { - selection.fields = [...config.allowedFields] - } else if (config.resourceType) { - selection.fields[config.resourceType] = [...config.allowedFields] - } - - return - } - - // Validate against allowlist - if (config.allowedFields && config.allowedFields.length > 0) { - const invalidFields = uniqueFields.filter(f => !config.allowedFields!.includes(f)) - - if (invalidFields.length > 0) { - ctx.addIssue({ - code: "custom", - path: selection.type === 'simple' ? ['fields'] : ['fields', config.resourceType ?? '*'], - message: `Invalid fields: ${invalidFields.join(', ')}. Allowed fields: ${config.allowedFields.join(', ')}` - }) - return - } - } - - // De-duplicate fields in the selection - if (selection.type === 'simple') { - selection.fields = uniqueFields - } else { - // For JSON:API, de-duplicate each resource type - for (const [type, fields] of Object.entries(selection.fields)) { - selection.fields[type] = Array.from(new Set(fields)) - } - } - }) -} \ No newline at end of file diff --git a/utils/query/filtering.ts b/utils/query/filtering.ts deleted file mode 100644 index 26532d7..0000000 --- a/utils/query/filtering.ts +++ /dev/null @@ -1,692 +0,0 @@ -// utils/query/filtering.ts - -/** - * Query Filtering — URL shapes, parsing, validation, and why `arrayOperators` exist. - * - * ## The two client-facing representations - * - * 1) **Bracket notation (URL-friendly; recommended for GET)** - * Structure: `filter[][]=` - * - * - Operator is optional; missing operator implies `eq`. - * Example: - * `/api/posts?filter[status]=published` - * → `status eq 'published'` (operator defaults to `eq`). - * - * - Multiple filters AND together: - * `/api/products?filter[category]=electronics&filter[price][gte]=50&filter[price][lte]=200` - * → `category = 'electronics' AND price >= 50 AND price <= 200`. - * - * - Null checks use keywords (not booleans): - * `/api/posts?filter[deleted_at]=null` → `IS NULL` - * `/api/posts?filter[published_at]=not_null` → `IS NOT NULL`. - * - * - Set operators use comma-separated lists in the URL: - * `/api/issues?filter[status][in]=open,in_review,blocked` - * `/api/issues?filter[status][nin]=wontfix,duplicate`. - * - * Parsing flow: - * - `parseBracketNotation(...)` walks query keys with `/^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/`, - * extracts `{ field, operator? }`, defaults operator to `eq`, and turns `null` / `not_null` - * into `is_null` / `is_not_null`. The rest pass through as `{ field, operator, value }`. - * This produces a `FiltersNormalized[]` array for downstream validation. - * - * 2) **Structured JSON filters (body POST/PUT), or URL-encoded JSON as a single param** - * Shape: - * ```json - * { "filters": [ - * { "field": "category", "operator": "eq", "value": "electronics" }, - * { "field": "price", "operator": "gte","value": "50" } - * ] } - * ``` - * - Use `createFiltersJsonAdapter()` to parse the request body into `FiltersNormalized`. - * If you must keep GET-only, you *may* URL-encode the JSON into a single `filters` param - * and parse it prior to validation, then feed this same adapter. - * - * ## Where `arrayOperators` matter - * - * - Instead of hardcoding that `'in'`/`'nin'` always expect arrays, each field’s registry entry - * can *declare* which operators consume arrays via `arrayOperators`. This allows per-field policy - * (e.g., `status` allows `in`; `title` does not), and future operators like `between`, `overlaps`, - * or geo/json containment to be opt-in for the fields that support them. - * - * - In bracket notation, clients still send comma-separated lists; validation reads the registry - * to decide whether to split/coerce into arrays. In JSON, clients send actual arrays for `value`. - * Either way, we normalize to the *same* `FiltersNormalized` shape. - * - * ## Validation layer (what this module enforces) - * - * - **Allowlist fields & operators per resource**: We require a `FilterRegistry` describing - * what’s filterable and with which operators, including the expected scalar type (string/number/ - * boolean/date/enum/uuid) and allowed enum values. - * - * - **Type-aware coercion**: The validator converts values based on the field type and operator: - * - numbers for `gt/gte/lt/lte/eq/ne` (rejects NaN), - * - booleans for `eq/ne`, - * - dates to ISO strings for `gt/gte/lt/lte/eq/ne` (rejects invalid dates), - * - enum membership checks, - * - UUID format checks, etc. - * - * - **Null operators** never require a `value` (`is_null`, `is_not_null`). - * - * - **Array operators** (as declared per field) require arrays; URL commas split into arrays. - * (We also recommend adding per-endpoint caps on list lengths via `LimitsConfig` to avoid abuse.) - * - * - **DoS protection**: We cap the **number of filters** per request (default 20; configurable) - * and fail fast if exceeded. - * - * ## Security & performance notes - * - * - **Security**: Always allowlist fields/operators and type-check values before building queries. - * Don’t ever string-concatenate SQL; use parameterized calls. - * - * - **Performance**: Index columns you filter/sort on; prefer compound indexes that align to - * common filter+sort patterns. This is especially important for high-cardinality fields and - * for cursor-based pagination where tiebreakers (e.g., `created_at,id`) must be indexed. - * - * ## Worked examples (URL) - * - * - Equality (implicit operator): - * `/api/posts?filter[status]=published` → `{ field:'status', operator:'eq', value:'published' }` - * - * - Ranged price: - * `/api/products?filter[price][gte]=50&filter[price][lte]=200` → two normalized filters; numbers coerced. - * - * - Null checks: - * `/api/posts?filter[deleted_at]=null&filter[published_at]=not_null` → `is_null` / `is_not_null` (no values). - * - * - Set membership (array operator via registry): - * `/api/issues?filter[status][in]=open,in_review,blocked` - * → value coerced to `['open','in_review','blocked']` when `status` declares `in` under `arrayOperators`. - * - * ## Worked examples (JSON) - * - * ```json - * { - * "filters": [ - * { "field": "category", "operator": "eq", "value": "electronics" }, - * { "field": "price", "operator": "gte", "value": "50" }, - * { "field": "status", "operator": "in", "value": ["open","in_review","blocked"] } - * ] - * } - * ``` - * - Parsed via `createFiltersJsonAdapter()` into the same `FiltersNormalized[]`; array handling - * and type coercion follow the registry+validation rules above. - * - * --- - * - * Implementation notes: - * - The adapters (`createFiltersQueryAdapter`, `createFiltersJsonAdapter`, `createFiltersFormAdapter`) - * only handle source parsing: they normalize different input formats (query strings, JSON bodies, form data) - * into a consistent internal shape. - * - * - The actual business rules live in `createFiltersSchema(...).superRefine(...)`: - * • Enforce allowlists (only certain fields/operators allowed) - * • Match operators to correct value types - * • Split arrays consistently (e.g. "a,b,c" → ["a","b","c"]) - * • Apply caps/limits (e.g. max filters) - * - * - This separation keeps parsing concerns **independent from** validation rules, - * making the system easier to extend and reason about. - */ - -import { z } from 'zod' - -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '../endpoint/schemas.ts' -import { - FiltersNormalizedSchema, - type FilterNormalized, - type FiltersNormalized, - type FilterOperator, - type FilterRegistry, - type FiltersConfig, -} from './schemas.ts' - -// ============================================================================ -// WIRE SCHEMAS (raw incoming data) -// ============================================================================ - -/** - * Query parameter wire schema (raw incoming) - * Supports: ?filter[category][eq]=electronics&filter[price][gte]=50 - */ -export const FiltersQueryWire = BaseQuerySchema - -/** - * JSON body wire schema (raw incoming) - * Expects: { filters: [{ field: 'category', operator: 'eq', value: 'electronics' }] } - */ -export const FiltersJsonWire = BaseJsonSchema.pipe( - z.object({ - filters: FiltersNormalizedSchema.default([]) - }) -) - -/** - * FormData wire schema (raw incoming) - */ -export const FiltersFormWire = BaseFormSchema - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/** - * Parse bracket notation from query parameters or form data - * Syntax: filter[field][operator]=value - * - * @param data Raw query or form data - * @returns Array of normalized filters - * - * @example - * parseBracketNotation({ - * 'filter[status]': 'published', - * 'filter[price][gte]': '50' - * }) - * // => [ - * // { field: 'status', operator: 'eq', value: 'published' }, - * // { field: 'price', operator: 'gte', value: '50' } - * // ] - */ -function parseBracketNotation(data: Record): FiltersNormalized { - const filters: FilterNormalized[] = [] - - for (const [key, rawValue] of Object.entries(data)) { - const match = key.match(/^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/) - if (!match) continue - - const field = match[1] - const operator = (match[2] || 'eq') as FilterOperator - - // Extract value (handle arrays from ZStringOrStringArray) - let value: string - if (Array.isArray(rawValue)) { - value = String(rawValue[0] ?? '') - } else { - value = String(rawValue ?? '') - } - - // Handle null keywords - if (value === 'null') { - filters.push({ field, operator: 'is_null' }) - } else if (value === 'not_null') { - filters.push({ field, operator: 'is_not_null' }) - } else { - filters.push({ field, operator, value }) - } - } - - return filters -} - -/** - * Encode filters back to bracket notation - * - * @param filters Normalized filters array - * @returns Wire format object - * - * @example - * encodeBracketNotation([ - * { field: 'status', operator: 'eq', value: 'published' }, - * { field: 'price', operator: 'gte', value: 50 } - * ]) - * // => { - * // 'filter[status]': 'published', - * // 'filter[price][gte]': '50' - * // } - */ -function encodeBracketNotation(filters: FiltersNormalized): Record { - const result: Record = {} - - for (const filter of filters) { - // Handle null operators (no value) - if (filter.operator === 'is_null') { - result[`filter[${filter.field}]`] = 'null' - continue - } - - if (filter.operator === 'is_not_null') { - result[`filter[${filter.field}]`] = 'not_null' - continue - } - - // Handle value-based operators - const value = filter.value - const stringValue = Array.isArray(value) - ? value.join(',') - : String(value ?? '') - - // Use bracket notation for non-eq operators - const key = filter.operator === 'eq' - ? `filter[${filter.field}]` - : `filter[${filter.field}][${filter.operator}]` - - result[key] = stringValue - } - - return result -} - -// ============================================================================ -// SOURCE ADAPTERS -// ============================================================================ - -/** - * Query parameter adapter (uses z.codec) - * Supports: ?filter[category][eq]=electronics&filter[price][gte]=50 - * - * @example - * const adapter = createFiltersQueryAdapter() - * const normalized = adapter.decode({ - * 'filter[status]': 'published', - * 'filter[price][gte]': '50' - * }) - * // => [ - * // { field: 'status', operator: 'eq', value: 'published' }, - * // { field: 'price', operator: 'gte', value: '50' } - * // ] - */ -export function createFiltersQueryAdapter() { - return z.codec( - FiltersQueryWire, // Input (wire) - FiltersNormalizedSchema, // Output (normalized) - { - decode: (raw) => { - return parseBracketNotation(raw) - }, - encode: (normalized) => { - return encodeBracketNotation(normalized) - } - } - ) -} - -/** - * JSON body adapter (uses z.codec) - * Expects: { filters: [{ field: 'category', operator: 'eq', value: 'electronics' }] } - * - * @example - * const adapter = createFiltersJsonAdapter() - * const normalized = adapter.decode({ - * filters: [{ field: 'category', operator: 'eq', value: 'electronics' }] - * }) - */ -export function createFiltersJsonAdapter() { - return z.codec( - FiltersJsonWire, // Input (wire) - FiltersNormalizedSchema, // Output (normalized) - { - decode: (raw) => { - return raw.filters as FiltersNormalized - }, - encode: (normalized) => { - return { filters: normalized } - } - } - ) -} - -/** - * FormData adapter (uses z.codec) - * Supports same bracket notation as query adapter - * - * @example - * const adapter = createFiltersFormAdapter() - * const formData = new FormData() - * formData.append('filter[status]', 'published') - * const normalized = adapter.decode(formData) - */ -export function createFiltersFormAdapter() { - return z.codec( - FiltersFormWire, // Input (wire) - FiltersNormalizedSchema, // Output (normalized) - { - decode: (raw): FiltersNormalized => { - // Convert FormValue to plain record for parseBracketNotation - return parseBracketNotation(raw) - }, - encode: (normalized) => { - return encodeBracketNotation(normalized) - } - } - ) -} - -// ============================================================================ -// VALIDATION HELPERS -// ============================================================================ - -/** - * Validate and coerce operator value based on field type - * Mutates the filter object with coerced value - * - * @param filter Filter to validate and coerce - * @param fieldDef Field definition from registry - * @param ctx Zod refinement context for error reporting - * @param path Path for error reporting - */ -function validateAndCoerceOperatorValue( - filter: FilterNormalized, - fieldDef: FilterRegistry[string], - ctx: z.RefinementCtx, - path: (string | number)[] -) { - const { operator, value, field } = filter - - // Null operators don't need values - if (operator === 'is_null' || operator === 'is_not_null') { - return - } - - // Between operator needs exactly 2 values - if (operator === 'between') { - let arrayValue: unknown[] - if (Array.isArray(value)) { - arrayValue = value - } else if (typeof value === 'string') { - arrayValue = value.split(',').map(v => v.trim()).filter(Boolean) - } else { - arrayValue = [value] - } - - if (arrayValue.length !== 2) { - ctx.addIssue({ - code: 'custom', - input: ctx.value, - path: [...path, 'value'], - message: `Operator 'between' on field '${field}' requires exactly 2 values (min,max). Got ${arrayValue.length}.` - }) - return - } - - // Coerce based on field type - if (fieldDef.type === 'number') { - const [min, max] = arrayValue.map(Number) - if (isNaN(min) || isNaN(max)) { - ctx.addIssue({ - code: 'custom', - input: ctx.value, - path: [...path, 'value'], - message: `Field '${field}' requires numeric values for 'between' operator` - }) - return - } - - if (min > max) { - ctx.addIssue({ - code: 'custom', - input: ctx.value, - path: [...path, 'value'], - message: `Field '${field}' 'between' operator requires min <= max. Got min=${min}, max=${max}.` - }) - return - } - filter.value = [min, max] - } else if (fieldDef.type === 'date') { - const [minDate, maxDate] = arrayValue.map(v => new Date(String(v))) - if (isNaN(minDate.getTime()) || isNaN(maxDate.getTime())) { - ctx.addIssue({ - code: 'custom', - input: ctx.value, - path: [...path, 'value'], - message: `Field '${field}' requires valid dates for 'between' operator` - }) - return - } - - if (minDate > maxDate) { - ctx.addIssue({ - code: 'custom', - input: ctx.value, - path: [...path, 'value'], - message: `Field '${field}' 'between' operator requires start date <= end date.` - }) - return - } - - filter.value = [minDate.toISOString(), maxDate.toISOString()] - } else { - ctx.addIssue({ - code: 'custom', - input: ctx.value, - path: [...path, 'operator'], - message: `Operator 'between' not supported for field type '${fieldDef.type}' on field '${field}'` - }) - } - return - } - - // Array operators need special handling - const isArrayOperator = fieldDef.arrayOperators?.includes(operator) ?? false - if (isArrayOperator) { - // Ensure value is an array - let arrayValue: unknown[] - if (Array.isArray(value)) { - arrayValue = value - } else if (typeof value === 'string') { - arrayValue = value.split(',').map(v => v.trim()).filter(Boolean) - } else { - arrayValue = [value] - } - - if (arrayValue.length === 0) { - ctx.addIssue({ - code: 'too_small', // v4 literal (not the enum) - minimum: 1, - origin: 'array', - path: [...path, 'value'], - message: `Operator '${operator}' on field '${field}' requires an array or comma-separated value with at least 1 value.` - }); - return; // stop further processing for this filter - } - - filter.value = arrayValue - return - } - - // Non-array operators - coerce based on field type - switch (fieldDef.type) { - case 'number': - if (['gt', 'gte', 'lt', 'lte', 'eq', 'ne'].includes(operator)) { - const numValue = Number(value) - if (isNaN(numValue)) { - ctx.addIssue({ - code: "custom", - input: ctx.value, - path: [...path, 'value'], - message: `Field '${field}' requires numeric value for operator '${operator}'` - }) - return - } - - filter.value = numValue - } - break - - case 'boolean': - if (operator === 'eq' || operator === 'ne') { - const boolValue = value === 'true' || value === '1' || value === 1 - filter.value = boolValue - } - break - - case 'date': - if (['gt', 'gte', 'lt', 'lte', 'eq', 'ne'].includes(operator)) { - const dateValue = new Date(String(value)) - if (isNaN(dateValue.getTime())) { - ctx.addIssue({ - code: "custom", - input: ctx.value, - path: [...path, 'value'], - message: `Field '${field}' requires valid date for operator '${operator}'` - }) - return - } - - filter.value = dateValue.toISOString() - } - break - - case 'enum': - if (operator === 'eq' || operator === 'ne') { - const strValue = String(value) - if (fieldDef.values && !fieldDef.values.includes(strValue)) { - ctx.addIssue({ - code: "custom", - input: ctx.value, - path: [...path, 'value'], - message: `Invalid value '${strValue}' for enum field '${field}'. Allowed values: ${fieldDef.values.join(', ')}` - }) - - return - } - } - break - - case 'uuid': - if (operator === 'eq' || operator === 'ne') { - const uuidCheck = z.uuid().safeParse(String(value)) - if (!uuidCheck.success) { - const uuidIssue = uuidCheck.error.issues[0]; - ctx.addIssue({ - ...uuidIssue, - path: [...path, 'value'], - message: `Field '${field}' requires valid UUID for operator '${operator}'. ${uuidIssue.message}` - }) - return - } - } - break - - case 'string': - // String operators accept any value, coerce to string - filter.value = String(value) - break - } -} - -// ============================================================================ -// SCHEMA COMPOSITION WITH VALIDATION -// ============================================================================ - -/** - * Create endpoint-specific filters schema with validation - * All validation happens in .superRefine() so middleware handles errors - * - * @param config Configuration for filter validation - * @param config.source Input source type ('query' | 'json' | 'form') - * @param config.registry Filter registry with field definitions - * @param config.limits Optional limits configuration (maxFilters) - * - * @example - * const schema = createFiltersSchema({ - * source: 'query', - * registry: { - * price: { - * operators: ['gte', 'lte'], - * type: 'number' - * }, - * status: { - * operators: ['eq', 'in'], - * type: 'enum', - * values: ['draft', 'published'], - * arrayOperators: ['in'] - * } - * }, - * limits: { maxFilters: 10 } - * }) - * - * // Parse and validate - * const filters = schema.parse({ - * 'filter[price][gte]': '50', - * 'filter[status][in]': 'draft,published' - * }) - */ -export function createFiltersSchema(config: { - source: 'query' | 'json' | 'form' -} & FiltersConfig) { - // When disabled, always return null - if (config.disabled) { - return z.null() - } - - // Select appropriate adapter based on source - const adapter = - config.source === 'query' ? createFiltersQueryAdapter() : - config.source === 'json' ? createFiltersJsonAdapter() : - createFiltersFormAdapter() - - const maxFilters = config.limits?.maxFilters ?? 20 - const mergeDefaults = config.mergeDefaults ?? true - - const registry = config.registry ?? {} - const allowedFields = Object.keys(registry) - - return adapter - .transform((filters): FiltersNormalized => { - // Apply default filters - if (config.defaults && config.defaults.length > 0) { - if (filters.length === 0) { - // No user filters - use defaults - return [...config.defaults] - } else if (mergeDefaults) { - // Merge defaults with user filters - // Defaults come first (applied before user filters) - return [...config.defaults, ...filters] - } - } - - return filters - }) - .superRefine((filters, ctx) => { - // Check filter count (DoS protection) - if (filters.length > maxFilters) { - ctx.addIssue({ - code: "too_big", - maximum: maxFilters, - origin: 'array', - path: [], - message: `Too many filters: maximum ${maxFilters} allowed, got ${filters.length}` - }) - return // Don't continue validating individual filters - } - - // No registry = skip field/operator validation - if (!config.registry) { - return - } - - // Validate each filter - filters.forEach((filter, idx) => { - const { field, operator = 'eq', value } = filter - - // Check if field is filterable - const fieldDef = registry[field] - if (!fieldDef && allowedFields.length > 0) { - ctx.addIssue({ - code: "custom", - input: ctx.value, - path: [idx, 'field'], - message: `Field '${field}' is not filterable. Allowed fields: ${allowedFields.join(', ')}` - }) - return // Skip further validation for this filter - } - - // If field is in registry, validate operator - if (fieldDef) { - if (!fieldDef.operators.includes(operator)) { - ctx.addIssue({ - code: "custom", - input: ctx.value, - path: [idx, 'operator'], - message: `Operator '${operator}' not allowed for field '${field}'. Allowed operators: ${fieldDef.operators.join(', ')}` - }) - return - } - - // Validate operator-value type compatibility and coerce - validateAndCoerceOperatorValue(filter, fieldDef, ctx, [idx]) - } - }) - }) -} \ No newline at end of file diff --git a/utils/query/index.ts b/utils/query/index.ts deleted file mode 100644 index c728107..0000000 --- a/utils/query/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -// utils/query/index.ts -/** - * Public surface for query utilities. - * - Adapters and schema factories - * - Cursor helpers and executor - */ - -export * from './schemas.ts' -export * from './filtering.ts' -export * from './sorting.ts' -export * from './fields.ts' -export * from './pagination.ts' -export * from './query.ts' - -/** - * Example (Hono-like) handler usage - * - * import { createEndpoindQuerySpec } from '@platform/backend/query/schemas.ts' - * import { createValidator } from '#shared/middleware/validation.ts' // your existing middleware factory - * - * import { executeListQuery } from '#shared/query/execution/supabase.ts' - * import { paginate, gone, badRequest } from '@platform/backend/response/index.ts' - * - * const QueryValidator = createValidator('query', createEndpoindQuerySpec({ - * filters: { registry: { age: { operators: ['gte', 'lte'], valueType: 'number' } } }, - * sorts: { allowedFields: ['created_at','title'] }, - * fields: { allowlist: ['*','id','title','created_at','posts.*'] }, - * pagination: { cursorSecret: Deno.env.get('CURSOR_SECRET') ?? '' }, - * })) - * - * app.get('/items', QueryValidator, async (c) => { - * const spec = c.req.valid('query') // -> QuerySpec - * - * const result = await executeListQuery( - * { secret: Deno.env.get('CURSOR_SECRET') ?? undefined, ttlSec: 3600 }, - * spec, - * async ({ filters, sorts, fields, pagination }) => { - * // TODO: translate filters/sorts/fields/pagination → your DB query - * return { rows: [], count: 0 } - * } - * ) - * - * if (result.errorType === 'cursor_expired') { - * return c.json(...gone(c.req.path, 'Cursor expired')) - * } - * if (result.errorType === 'cursor_invalid') { - * return c.json(...badRequest(c.req.path, 'Invalid cursor')) - * } - * - * // Use your existing paginate() response helper (data + meta + headers in tuple) - * // Example expects you to compute pagination meta (next/prev cursors) alongside rows. - * return c.json(...paginate(c.req.url, result.data ?? [], { count: result.count ?? 0 })) - * }) - */ diff --git a/utils/query/pagination.ts b/utils/query/pagination.ts deleted file mode 100644 index 1cf07db..0000000 --- a/utils/query/pagination.ts +++ /dev/null @@ -1,607 +0,0 @@ -// utils/query/pagination.ts -/** - * Cursor-based and offset-based pagination with multi-source support - * - * Features: - * - Auto-detects cursor vs offset from any source - * - HMAC-signed cursors with expiration - * - Configurable limits per endpoint - * - 410 Gone for expired cursors - * - Query/JSON/FormData source adapters - */ - -import type { CursorPaginationNormalized, OffsetPaginationNormalized, PaginationConfig, SortDirection } from './schemas.ts' -import type { Pagination, PaginationMetadata } from '../response/schemas.ts' -import type { QuerySpec } from './schemas.ts' - -import { z } from 'zod' -import { createHmac } from 'node:crypto' -import { Buffer } from 'node:buffer' - -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '../endpoint/schemas.ts' -import { - PaginationNormalizedSchema, - CursorDataSchema, - type CursorData, -} from './schemas.ts' -import { badRequest, ok, gone } from '../response/index.ts' -import { isSuccessResponse } from '../response/success.ts' - -// ============================================================================ -// CURSOR ENCODING/DECODING (existing, unchanged) -// ============================================================================ - -const EncodedCursorSchema = z.object({ - data: CursorDataSchema, - signature: z.hex().length(64) -}).strict() - -/** - * @based on `@mofax/sorted-stringify` https://jsr.io/@mofax/sorted-stringify/0.0.4/index.ts - * Recursively sorts the keys of an object or elements of an array. - * - * - If the input is not an object or array, the value is returned as is. - * - If the input is an array, it recursively sorts each element in the array. - * - If the input is an object, it sorts the object by its keys and recursively sorts the values. - * - * @param obj - The input object, array, or any other value to be sorted. - * @returns The sorted object, array, or the original value if it's not an object or array. - * - * @example - * ```typescript - * const obj = { - * z: 1, - * a: { c: 3, b: 2 }, - * array: [ { b: 2, a: 1 }, 3 ] - * }; - * const sortedObj = sortObj(obj); - * console.log(sortedObj); - * // Output: - * // { - * // a: { b: 2, c: 3 }, - * // array: [ { a: 1, b: 2 }, 3 ], - * // z: 1 - * // } - * ``` - */ -export function sortObject(obj: T): T { - if (obj instanceof Date) return obj.toISOString() as T; // normalize precision - if (obj == null || typeof obj !== "object") return obj; - if (ArrayBuffer.isView(obj) || obj instanceof ArrayBuffer) return obj as T; - if (Array.isArray(obj)) return obj.map(sortObject) as T; - - return Object.entries(obj) - .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) - .reduce((sortedObj: Record, [key, value]) => { - sortedObj[key] = sortObject(value); - return sortedObj; - }, {}) as T; -} - -/** - * Codec: string (base64url JSON) <-> { data, signature } - * - decode(): wire -> structured - * - encode(): structured -> wire - * - * Note: defaults/prefaults apply in forward (decode) direction, - * not in backward (encode) direction. Keep that in mind. - */ -export const Base64UrlJsonCursorCodec = z.codec( - z.string(), // wire type (Input) - EncodedCursorSchema, // domain type (Output) - { - decode: (token) => { - // base64url decode - const decoded = Buffer.from(token, 'base64url').toString('utf-8') - const parsed = JSON.parse(decoded) - return parsed; - }, - encode: (obj) => { - const json = JSON.stringify(obj) - return Buffer.from(json).toString('base64url'); - }, - } -); - -export function hmacSha256Hex(secret: string, payload: unknown): string { - // Important: canonicalize to avoid key-order signature drift. - // If you don't have a stable stringify util, ensure 'data' is serialized consistently. - // The order of the objects keys affects how the object is stringified - const data = sortObject(payload); - const json = JSON.stringify(data); - return createHmac("sha256", secret) - .update(json) - .digest("hex"); -} - -/** - * Decode + verify a cursor token. Return domain result or throw mapped HTTP errors. - * - 400 if format/signature invalid - * - 410 if expired - */ -export function decodeAndVerifyCursor( - token: string, - secret: string, - ttlSeconds = 86_400, // 24h -) { - // 1) Decode token -> { data, signature } - const decoded = Base64UrlJsonCursorCodec.decode(token); - const parsed = EncodedCursorSchema.safeParse(decoded); - - if (!parsed.success) { - // Map Zod issues to your error envelope at the HTTP boundary: - return badRequest(token, `Invalid cursor: ${parsed.error.issues?.[0]?.message}`) - } - - const { data, signature } = parsed.data; - - // 2) Verify HMAC - const expected = hmacSha256Hex(secret, data); - if (signature !== expected) { - return badRequest(token, "Cursor signature mismatch") - } - - // 3) Verify TTL (use epoch seconds; avoid Date#getDate bug) - const nowSec = Math.floor(Date.now() / 1000); - const createdSec = Math.floor(data.createdAt.getTime() / 1000); - const age = nowSec - createdSec; - - if (age > ttlSeconds) { - return gone(String(age), `Cursor has expired (${age - ttlSeconds})`, { - deltaSeconds: age - ttlSeconds, - }); - } - - // 4) Success: return normalized CursorData - return ok(data, 200); -} - -/** - * Create a new signed cursor token from CursorData. - */ -export function encodeCursor(data: CursorData, secret: string): string { - const envelope = Object.assign({}, { - data, - signature: hmacSha256Hex(secret, data), - }); - - return Base64UrlJsonCursorCodec.encode(envelope); -} - -// ============================================================================ -// SOURCE ADAPTERS -// ============================================================================ - -// Query wire schema (raw incoming) -export const PaginationQueryWire = BaseQuerySchema.extend({ - offset: ZStringOrStringArray.optional(), - limit: ZStringOrStringArray.optional(), - page: ZStringOrStringArray.optional(), - per_page: ZStringOrStringArray.optional(), - cursor: ZStringOrStringArray.optional() -}) - -// JSON wire schema (raw incoming) -export const PaginationJsonWire = BaseJsonSchema; // you already have this - -// Form wire schema (raw incoming) -export const PaginationFormWire = BaseFormSchema; - -// Extract first value if array -const getString = (val: unknown): string | undefined => { - if (Array.isArray(val)) return val[0] - if (typeof val === 'string') return val - return undefined -} - -// Extract first value of a specific key if array of string -const pickForm = (raw: Record, key: string): string | undefined => { - const val = getString(raw[key]) - if (val) return String(val) - return val -} - - -/** - * Query parameter adapter (extends BaseQuerySchema) - * Supports: ?offset=0&limit=20, ?page=1&per_page=20, ?cursor=abc&limit=20 - */ -export function createPaginationQueryAdapter(defaultLimit: number = 20) { - return z.codec( - PaginationQueryWire, // Input (wire) - PaginationNormalizedSchema, // Output (normalized) - { - decode: (raw) => { - const cursor = getString(raw.cursor) - const offset = getString(raw.offset) - const page = getString(raw.page) - const limit = getString(raw.limit) - const perPage = getString(raw.per_page) - - // Cursor-based - if (cursor !== undefined) { - return { - type: 'cursor', - cursor: cursor || undefined, - limit: parseInt(limit ?? String(defaultLimit), 10) - } as CursorPaginationNormalized - } - - // Page-based - if (page !== undefined) { - const pageNum = parseInt(page, 10) - const limitNum = parseInt(perPage ?? limit ?? String(defaultLimit), 10) - return { - type: 'offset', - offset: (pageNum - 1) * limitNum, - limit: limitNum - } as OffsetPaginationNormalized - } - - // Offset-based - return { - type: 'offset', - offset: parseInt(offset ?? '0', 10), - limit: parseInt(limit ?? String(defaultLimit), 10) - } as OffsetPaginationNormalized - }, - // optional if you want to emit back out to wire shape: - encode: (norm) => { - if (norm.type === "cursor") { - return { - cursor: norm.cursor, - limit: String(norm.limit), - } as z.input; - } - return { - offset: String(norm.offset), - limit: String(norm.limit), - } as z.input; - }, - }) -} - -/** - * JSON body adapter (uses BaseJsonSchema) - * Expects: { pagination: { type: 'cursor', cursor: '...', limit: 20 } } - */ -export function createPaginationJsonAdapter(defaultLimit: number = 20) { - const JsonEnvelope = z.object({ - pagination: PaginationNormalizedSchema.optional().default({ - type: 'offset', - offset: 0, - limit: defaultLimit - }) - }) - - return z.codec( - BaseJsonSchema, // Input wire (your BaseJsonSchema) - PaginationNormalizedSchema, // Output normalized - { - decode: (raw) => JsonEnvelope.parse(raw).pagination, - encode: (norm) => ({ pagination: norm }) as z.input, - } - ); -} - -/** - * FormData adapter (uses makeBaseFormSchema) - * Supports same params as query adapter - */ -export function createPaginationFormAdapter(defaultLimit: number = 20) { - return z.codec( - PaginationFormWire, // Input wire - PaginationNormalizedSchema, // Output normalized - { - decode: (raw) => { - const cursor = pickForm(raw, 'cursor') - const offset = pickForm(raw, 'offset') - const page = pickForm(raw, 'page') - const limit = pickForm(raw, 'limit') - const perPage = pickForm(raw, 'per_page') - - // Cursor-based - if (cursor !== undefined) { - return { - type: 'cursor', - cursor: cursor || undefined, - limit: parseInt(limit ?? String(defaultLimit), 10) - } as CursorPaginationNormalized - } - - // Page-based - if (page !== undefined) { - const pageNum = parseInt(page, 10) - const limitNum = parseInt(perPage ?? limit ?? String(defaultLimit), 10) - return { - type: 'offset', - offset: (pageNum - 1) * limitNum, - limit: limitNum - } as OffsetPaginationNormalized - } - - // Offset-based - return { - type: 'offset', - offset: parseInt(offset ?? '0', 10), - limit: parseInt(limit ?? String(defaultLimit), 10) - } as OffsetPaginationNormalized - }, - encode: (norm) => { - if (norm.type === "cursor") - return { - cursor: norm.cursor, - limit: String(norm.limit) - } as z.infer; - - return { - offset: String(norm.offset), - limit: String(norm.limit) - } as z.infer; - }, - }) -} - -// ============================================================================ -// SCHEMA COMPOSITION WITH VALIDATION -// ============================================================================ - -/** - * Create endpoint-specific pagination schema with validation - * All validation happens in .superRefine() so middleware handles errors - */ -export function createPaginationSchema(config: { - source: 'query' | 'json' | 'form' -} & PaginationConfig) { - const limits = config.limits ?? {} as NonNullable - const defaultLimit = config.limits?.defaultLimit ?? 20 - - const adapter = - config.source === 'query' ? createPaginationQueryAdapter(defaultLimit) : - config.source === 'json' ? createPaginationJsonAdapter(defaultLimit) : - createPaginationFormAdapter(defaultLimit) - - return adapter.superRefine((pagination, ctx) => { - // Validate limits - if (pagination.limit < limits.minLimit) { - ctx.addIssue({ - code: "too_small", - minimum: limits.minLimit, - origin: "number", - path: ['limit'], - message: `Limit must be between ${limits.minLimit} and ${limits.maxLimit} (exclusive), got ${pagination.limit}`, - input: ctx.value - }) - } - - if (pagination.limit > limits.maxLimit) { - ctx.addIssue({ - code: "too_big", - maximum: limits.maxLimit, - origin: "number", - path: ['limit'], - message: `Limit must be between ${limits.minLimit} and ${limits.maxLimit} (exclusive), got ${pagination.limit}`, - input: ctx.value - }) - } - - // Validate offset for DoS protection - if (pagination.type === 'offset' && pagination.offset > limits.maxOffset) { - ctx.addIssue({ - code: "too_big", - maximum: limits.maxOffset, - origin: "number", - path: ['offset'], - message: `Offset cannot exceed ${limits.maxOffset} (DoS protection), got ${pagination.offset}`, - input: ctx.value - }) - } - - // Decode cursor if present (execution-time error, not validation) - if (pagination.type === 'cursor' && pagination.cursor && config.cursorSecret) { - const result = decodeAndVerifyCursor(pagination.cursor, config.cursorSecret, limits.cursorTTL); - if (isSuccessResponse(result)) { - // Mutation is safe here - Zod creates new object per parse - const [decoded] = result - pagination.decodedCursor = decoded.data - } else { - const [error] = result - ctx.addIssue({ - code: "custom", - path: ['decodedCursor'], - message: error.detail || 'Invalid or expired cursor', - input: ctx.value - }) - } - } - }) -} - -// ============================================================================ -// RESPONSE GENERATION (existing, mostly unchanged) -// ============================================================================ - -/** - * Create CursorData from a DB row. - * Keep this close to the query code so it's obvious which fields drive the cursor. - * - * @example - * const head = rows[0], tail = rows[rows.length - 1] - * const nextData = cursorFromRow(tail, { sortField: "created_at", tiebreaker: "id", direction: "asc" }) - * const prevData = cursorFromRow(head, { sortField: "created_at", tiebreaker: "id", direction: "desc" }) - */ -export function cursorFromRow>(row: Row, cfg: { - sortField: string; - tiebreaker: string; - direction: SortDirection; -}): CursorData { - const sortValue = row[cfg.sortField]; - const tieValue = row[cfg.tiebreaker]; - - // Normalize known primitives; let CursorDataSchema enforce the rest. - const data = Object.assign({}, { - sortField: cfg.sortField, - sortValue: sortValue as unknown, - tiebreaker: cfg.tiebreaker, - tiebreakerValue: tieValue as string | number, - direction: cfg.direction, - createdAt: new Date(), // mint time the cursor was issued - }); - - // Validate to keep types honest - return CursorDataSchema.parse(data); -} - -/** - * Compute next/prev tokens for keyset pages. - * - `hasMoreForward`: whether there are more rows after the last one you returned. - * - `hasMoreBackward`: whether there are rows before the first one (optional; set if you probe backward). - */ -export function makeCursorTokens>(args: { - items: Row[]; - limit: number; - sortField: string; - tiebreaker: string; - secret: string; - direction: "asc" | "desc"; - hasMoreForward: boolean; - hasMoreBackward?: boolean; -}) { - const count = args.items.length; - if (count === 0) { - return Object.assign({}, { - next: undefined as string | undefined, - prev: undefined as string | undefined - }); - } - - const head = args.items[0]; - const tail = args.items[count - 1]; - - // For "next" we point past the last item we actually returned. - const nextData: CursorData | undefined = args.hasMoreForward - ? cursorFromRow(tail, { sortField: args.sortField, tiebreaker: args.tiebreaker, direction: args.direction }) - : undefined; - - // For "prev" we point before the first item; invert direction to walk back. - const prevData: CursorData | undefined = args.hasMoreBackward - ? cursorFromRow(head, { sortField: args.sortField, tiebreaker: args.tiebreaker, direction: args.direction === "asc" ? "desc" : "asc" }) - : undefined; - - return Object.assign({}, { - next: nextData ? encodeCursor(nextData, args.secret) : undefined, - prev: prevData ? encodeCursor(prevData, args.secret) : undefined, - }); -} - -/** - * Computes a page expiry timestamp (ISO) from: - * - The cursor TTL policy (if cursor pagination) - * - An optional global page TTL (offset pagination or policy override) - * - * Semantics: - * - If you minted a "next" cursor, its expiry governs page expiry. - * - If both "next" and "prev" exist, choose the earliest. - * - If neither cursor exists (e.g., empty result or offset mode), use a global TTL (optional). - */ -export function computeExpiresAt(opts: { - now?: Date; // for testability - ttlSecs?: number; // same units you pass to decode/verify - hasCursor?: boolean; -}): Date | undefined { - const now = opts.now ?? new Date() - if (opts.hasCursor && typeof opts.ttlSecs === 'number') { - const expiry = new Date(now.getTime() + opts.ttlSecs * 1000) - return expiry - } - return undefined -} - -/** - * Comprehensive pagination bridge. - * - Trims rows via limit+1 strategy - * - Computes hasMore - * - Generates next/prev cursor tokens (cursor mode) - * - Computes next/prev offsets (offset mode) - * - Optionally computes expiresAt from TTL - */ -export function buildPaginationMeta>(args: { - rows: Row[]; - query: QuerySpec; - sortField?: string; - tiebreaker?: string; - direction?: "asc" | "desc"; - secret?: string; - ttlSec?: number; // optional TTL for expiresAt - total?: number; - approxTotal?: number; -}): { items: Row[]; } & PaginationMetadata { - const { rows, query } = args; - const limit = query.pagination.limit; - - // finalizePage logic - const hasMore = rows.length > limit; - const items = hasMore ? rows.slice(0, limit) : rows; - - // common base - const base: Pagination = { - hasMore, - limit, - count: items.length, - total: args.total, - approxTotal: args.approxTotal, - }; - - if (query.pagination.type === "cursor") { - const { sortField = "id", tiebreaker = "id", direction = "asc", secret = "" } = args; - - const { next, prev } = makeCursorTokens({ - items, - limit, - sortField, - tiebreaker, - secret, - direction, - hasMoreForward: hasMore, - // hasMoreBackward could be probed separately if needed - }); - - // optional expiry - if (args.ttlSec && args.ttlSec > 0) { - const expiresAt = computeExpiresAt({ - ttlSecs: args.ttlSec, - hasCursor: query.pagination.type === "cursor" - }) - base.expiresAt = expiresAt; - } - - return { - items, - pagination: { - ...base, - nextCursor: next, - prevCursor: prev, - }, - query - }; - } - - if (query.pagination.type === "offset") { - const nextOffset = hasMore ? query.pagination.offset + limit : undefined; - const prevOffset = query.pagination.offset > 0 ? Math.max(0, query.pagination.offset - limit) : undefined; - - return { - items, - pagination: { - ...base, - offset: query.pagination.offset, - ...(nextOffset !== undefined ? { nextOffset } : {}), - ...(prevOffset !== undefined ? { prevOffset } : {}), - }, - query - }; - } - - // exhaustive guard - return { items, pagination: base }; -} diff --git a/utils/query/query.ts b/utils/query/query.ts deleted file mode 100644 index 4c42c77..0000000 --- a/utils/query/query.ts +++ /dev/null @@ -1,165 +0,0 @@ -// utils/query/query.ts -/** - * Composite schema factory for endpoint-specific query specs - * - * CHANGES: - * - createSingleSourceQuerySpec now respects disable flags - * - Disabled components return null instead of creating schemas - * - applyQuerySpec skips null components - * - * Features: - * - Source precedence (JSON > Query > Form) - * - Conflict detection - * - Complete QuerySpec type - * - Declarative endpoint configuration - * - Per-component enable/disable control - */ - -import { - EndpointQueryConfigSchema, - type EndpointQueryConfig, - type QuerySpec -} from './schemas.ts' - -import { createPaginationSchema } from './pagination.ts' -import { createFiltersSchema } from './filtering.ts' -import { createSortsSchema } from './sorting.ts' -import { createFieldsSchema } from './fields.ts' - -import { BaseFormSchema, BaseJsonSchema, BaseQuerySchema } from '../endpoint/schemas.ts' - -// Re-export QuerySpec type -export { QuerySpecSchema, type QuerySpec } from './schemas.ts' - -// ============================================================================ -// SINGLE-SOURCE QUERY SPEC FACTORY (UPDATED) -// ============================================================================ - -/** - * Build a single-source schema (query | json | form) applying business rules - * - * NEW: Respects disable flags in config: - * - disableFiltering: Always returns null for filters - * - disableSorting: Always returns null for sorts - * - disableFields: Always returns null for fields - * - Pagination always enabled (required for collections) - * - * @param source Input source type - * @param configInput Endpoint configuration - * @returns Zod schema that parses and validates complete query spec - * - * @example - * // Endpoint with fixed fields and sorting - * const schema = createSingleSourceQuerySpec('query', { - * fieldRegistry: { - * filterable: ['status'], - * sortable: ['created_at', 'id'], - * selectable: ['id', 'title'] - * }, - * disableFields: true, // Ignore field selection requests - * disableSorting: true, // Ignore sort requests - * defaultSort: [{ field: 'created_at', direction: 'desc' }] - * }) - * - * @example - * // Read-only endpoint (no filtering) - * const schema = createSingleSourceQuerySpec('query', { - * fieldRegistry: { - * filterable: [], - * sortable: ['created_at'], - * selectable: ['id', 'title', 'content'] - * }, - * disableFiltering: true - * }) - */ -export function createQuerySpec( - source: 'query' | 'json' | 'form', - configInput?: EndpointQueryConfig -) { - const config = EndpointQueryConfigSchema.parse(configInput) - - console.log({ - config - }) - - // Pagination is always enabled (required for collection endpoints) - const paginationSchema = createPaginationSchema({ - source, - ...config.pagination - }) - - const filtersSchema = !config?.filters.disabled ? createFiltersSchema({ - source, - ...config.filters - }) : null - - const sortsSchema = !config?.sorts.disabled ? createSortsSchema({ - source, - ...config.sorts - }) : null - - const fieldsSchema = !config?.fields.disabled ? createFieldsSchema({ - source, - ...config.fields - }) : null - - const BaseInputSchema = - source === 'query' ? BaseQuerySchema : - source === 'json' ? BaseJsonSchema : - BaseFormSchema - - return BaseInputSchema.transform((raw): QuerySpec => { - // Parse each component (disabled ones return null) - const pagination = paginationSchema.parse(raw) - const filters = filtersSchema ? filtersSchema.parse(raw) : null - const sorts = sortsSchema ? sortsSchema.parse(raw) : null - const fields = fieldsSchema ? fieldsSchema.parse(raw) : null - - return { - pagination, - filters, - sorts, - fields, - } - }) -} - -// ============================================================================ -// HELPER FACTORY -// ============================================================================ - -/** - * Simplified factory for common case (query source only) - * - * Most endpoints only need to parse query parameters, so this provides - * a convenient shorthand. - * - * @param config Endpoint configuration - * @returns Schema for query-based input - * - * @example - * // Standard endpoint with all features - * const QuerySchema = createEndpointQuerySchema({ - * fieldRegistry: { - * filterable: ['status'], - * sortable: ['created_at', 'id'], - * selectable: ['id', 'title', 'status'] - * }, - * tiebreaker: 'id' - * }) - * - * @example - * // Fixed-output endpoint (disable field selection) - * const QuerySchema = createEndpointQuerySchema({ - * fieldRegistry: { - * filterable: ['status'], - * sortable: ['created_at', 'id'], - * selectable: [] // Not used when disabled - * }, - * disableFields: true, - * tiebreaker: 'id' - * }) - */ -export function createEndpointQuerySchema(config: EndpointQueryConfig) { - return createQuerySpec('query', config) -} diff --git a/utils/query/schemas.ts b/utils/query/schemas.ts deleted file mode 100644 index e5d67d4..0000000 --- a/utils/query/schemas.ts +++ /dev/null @@ -1,380 +0,0 @@ -// utils/query/types.ts -/** - * Core types and schemas for query processing - * - * Provides: - * - Registry schemas for allowlists and validation - * - Normalized intermediary schemas - * - Zod schemas for all query features - * - QuerySpecSchema now has nullable filters and sorts for disable functionality - * - EndpointQueryConfig gains disableFiltering, disableSorting, disableFields flags - */ - -import { z } from 'zod' -import type { ValidationErrorDetail } from '../response/schemas.ts' - -// Re-export for convenience -export type { ValidationErrorDetail } - -// --------------------------------------------- -// Shared primitives -// --------------------------------------------- - -export const NonEmptyStringSchema = z.string().trim().min(1, 'Value cannot be empty') - -// ============================================================================ -// FILTER SCHEMAS -// ============================================================================ - -/** - * Available filter operators - */ -export const FilterOperatorSchema = z.enum([ - 'eq', 'ne', - 'gt', 'gte', - 'lt', 'lte', - 'between', // NEW - 'in', 'nin', - 'contains', 'icontains', - 'startswith', 'endswith', - 'is_null', 'is_not_null' -]) - -export type FilterOperator = z.infer - -/** - * Normalized filter shape (output from adapters) - */ -export const FilterNormalizedSchema = z.object({ - field: NonEmptyStringSchema, - operator: FilterOperatorSchema, - value: z.unknown().optional() -}) - -export type FilterNormalized = z.infer - -/** - * Array of normalized filters - */ -export const FiltersNormalizedSchema = z.array(FilterNormalizedSchema) - -export type FiltersNormalized = z.infer - -/** - * Normalized filter shape (output from adapters) - */ -export const BaseFilterNormalizedSchema = z.object({ - field: NonEmptyStringSchema, - operator: FilterOperatorSchema.optional(), - value: z.unknown().optional() -}) - -export type BaseFilterNormalized = z.infer - -/** - * Array of normalized filters - */ -export const BaseFiltersNormalizedSchema = z.array(BaseFilterNormalizedSchema) - -export type BaseFiltersNormalized = z.infer - -/** - * `OperatorDefinitionSchema` - * - * Describes *per-field* filtering rules: - * - `operators`: which operators are allowed for this field (allowlist). - * - `type`: the scalar type used for value coercion (`string|number|boolean|date|enum|uuid`). - * - `values` (optional): enum allowlist for `type: 'enum'`. - * - `arrayOperators` (optional): which operators for this field *expect arrays*. - * - * Why `arrayOperators`? - * - Avoids hardcoding that `'in'`/`'nin'` (or future operators like `between`, `overlaps`, geo/json operators) - * always consume lists. Instead, each field opts into multi-value semantics explicitly, enabling - * field-specific policy, schema-driven validation, and accurate auto-docs/UI hints. - * - * How it interacts with URLs: - * - Bracket notation clients send comma-separated values (e.g., `filter[tag][in]=a,b,c`), which the validator - * converts to arrays *only if* the field’s `arrayOperators` includes that operator. JSON clients send arrays - * directly for `value`. Either way, normalization ends at the same `FiltersNormalized` shape. - */ -export const OperatorDefinitionSchema = z.object({ - operators: z.array(FilterOperatorSchema).min(1), - type: z.enum(['string', 'number', 'boolean', 'date', 'enum', 'uuid']), - values: z.array(z.string()).optional(), // For enum types - arrayOperators: z.array(FilterOperatorSchema).optional() // Operators that require arrays -}) - -/** - * See {@link OperatorDefinitionSchema} for full schema details. - */ -export type OperatorDefinition = z.infer - -/** - * Filter registry for a resource - */ -export const FilterRegistrySchema = z.record( - z.string(), - OperatorDefinitionSchema -) - -export type FilterRegistry = z.infer - -// ============================================================================ -// SORT SCHEMAS -// ============================================================================ - -/** - * Sort direction - */ -export const SortDirectionSchema = z.enum(['asc', 'desc']) - -export type SortDirection = z.infer - -/** - * Normalized sort shape (output from adapters) - */ -export const SortNormalizedSchema = z.object({ - field: NonEmptyStringSchema, - direction: SortDirectionSchema, - tiebreaker: z.boolean().optional().default(false) -}) - -export type SortNormalized = z.infer - -/** - * Array of normalized sorts - */ -export const SortsNormalizedSchema = z.array(SortNormalizedSchema) - -export type SortsNormalized = z.infer - -// ============================================================================ -// FIELD SELECTION SCHEMAS -// ============================================================================ - -/** - * Simple field selection (fields=a,b,c) - */ -export const SimpleFieldSelectionSchema = z.object({ - type: z.literal('simple'), - fields: z.array(NonEmptyStringSchema).min(1) -}) - -/** - * JSON:API field selection (fields[type]=a,b,c) - */ -export const JsonApiFieldSelectionSchema = z.object({ - type: z.literal('jsonapi'), - fields: z.record(z.string(), z.array(NonEmptyStringSchema)) -}) - -/** - * Normalized field selection (output from adapters) - */ -export const FieldSelectionNormalizedSchema = z.discriminatedUnion('type', [ - SimpleFieldSelectionSchema, - JsonApiFieldSelectionSchema -]) - -export type FieldSelectionNormalized = z.infer - -// ============================================================================ -// PAGINATION SCHEMAS -// ============================================================================ - -/** - * Cursor data structure (internal representation) - */ -export const CursorDataSchema = z.object({ - sortField: NonEmptyStringSchema, - sortValue: z.union([ - z.string(), - z.number(), - z.coerce.date() - ]), - tiebreaker: z.string(), - tiebreakerValue: z.union([z.string(), z.number()]), - direction: SortDirectionSchema, - createdAt: z.coerce.date() -}) - -export type CursorData = z.infer - -/** - * Offset pagination (normalized) - */ -export const OffsetPaginationNormalizedSchema = z.object({ - type: z.literal('offset'), - limit: z.number().int().positive(), - offset: z.number().int().min(0), -}) - -export type OffsetPaginationNormalized = z.infer - -/** - * Cursor pagination (normalized) - */ -export const CursorPaginationNormalizedSchema = z.object({ - type: z.literal('cursor'), - limit: z.number().int().positive(), - cursor: NonEmptyStringSchema.optional(), - decodedCursor: CursorDataSchema -}) - -export type CursorPaginationNormalized = z.infer - -/** - * Normalized pagination (union) - */ -export const PaginationNormalizedSchema = z.discriminatedUnion('type', [ - OffsetPaginationNormalizedSchema, - CursorPaginationNormalizedSchema -]) - -export type PaginationNormalized = z.infer - -// ============================================================================ -// QUERY SPEC SCHEMA -// ============================================================================ - -/** - * Pagination configuration - * - * All options for pagination in one place - */ -export const PaginationConfigSchema = z.object({ - limits: z.object({ - minLimit: z.number().int().positive().optional().default(1), - maxLimit: z.number().int().positive().optional().default(100), - defaultLimit: z.number().int().positive().optional().default(20), - maxOffset: z.number().int().positive().optional().default(1_000_000), - cursorTTL: z.number().int().positive().optional().default(86400), // 24 hours - }).optional().default({ - minLimit: 1, - maxLimit: 100, - defaultLimit: 20, - maxOffset: 1_000_000, - cursorTTL: 86400 - }), - cursorSecret: z.string().optional(), -}) - -export type PaginationConfig = z.infer - -/** - * Filters configuration - * - * All options for filtering in one place - */ -export const FiltersConfigSchema = z.object({ - registry: FilterRegistrySchema.optional(), - defaults: FiltersNormalizedSchema.optional(), - mergeDefaults: z.boolean().optional().default(true), - disabled: z.boolean().optional().default(false), - limits: z.object({ - maxFilters: z.number().int().positive().optional().default(20), - }).optional().default({ maxFilters: 20 }), -}) - -export type FiltersConfig = z.input - -/** - * Sorts configuration - * - * All options for sorting in one place - */ -export const SortsConfigSchema = z.object({ - tiebreaker: z.string().default('id'), - allowedFields: z.array(z.string()).optional(), - mergeDefaults: z.boolean().optional().default(true), - defaults: SortsNormalizedSchema.optional(), - disabled: z.boolean().optional().default(false), - limits: z.object({ - maxSorts: z.number().int().positive().optional().default(5), - }).optional().default({ maxSorts: 5 }), -}) - -export type SortsConfig = z.input - -/** - * Fields configuration - * - * All options for field selection in one place - */ -export const FieldsConfigSchema = z.object({ - allowedFields: z.array(z.string()).optional(), - defaults: z.array(z.string()).optional(), - disabled: z.boolean().default(false), - resourceType: z.string().optional(), // For JSON:API format -}).default({ disabled: false }) - -export type FieldsConfig = z.input - -/** - * Complete query specification (output from composite schema) - * - * This is the normalized, validated output that contains all query parameters - * ready to be applied to a database query. - */ -export const QuerySpecSchema = z.object({ - pagination: PaginationNormalizedSchema, - filters: FiltersNormalizedSchema.nullable(), - sorts: SortsNormalizedSchema.nullable(), - fields: FieldSelectionNormalizedSchema.nullable(), -}) - -export type QuerySpec = z.infer - -// ============================================================================ -// ENDPOINT CONFIGURATION (UPDATED) -// ============================================================================ - -/** - * Endpoint query configuration - * - * FIXED: All component configs are optional and have sensible defaults - * TypeScript types are clean (no | undefined noise) - * - * @example - * ```typescript - * // Minimal config - * const config = { - * tiebreaker: 'id' - * } - * - * // Full config - * const config = { - * tiebreaker: 'id', - * pagination: { - * limits: { defaultLimit: 50, maxLimit: 100 }, - * cursorSecret: CURSOR_SECRET - * }, - * filters: { - * registry: { ... }, - * defaults: [ ... ], - * mergeDefaults: true, - * disabled: false, - * limits: { maxFilters: 10 } - * }, - * sorts: { - * allowedFields: ['created_at', 'id'], - * defaults: [ ... ], - * disabled: false, - * limits: { maxSorts: 3 } - * }, - * fields: { - * allowedFields: ['id', 'title'], - * defaults: ['id'], - * disabled: false - * } - * } - * ``` - */ -export const EndpointQueryConfigSchema = z.object({ - pagination: PaginationConfigSchema, - filters: FiltersConfigSchema, - sorts: SortsConfigSchema, - fields: FieldsConfigSchema, -}) - -export type EndpointQueryConfig = z.input \ No newline at end of file diff --git a/utils/query/sorting.ts b/utils/query/sorting.ts deleted file mode 100644 index 9aa7b63..0000000 --- a/utils/query/sorting.ts +++ /dev/null @@ -1,275 +0,0 @@ -// utils/query/sorting.ts -/** - * Query sorting with multi-source support and validation - * - * Features: - * - Explicit direction: sort=field:direction,field:direction - * - Field allowlists via registry - * - Max sort count (DoS protection) - * - Always adds tiebreaker for deterministic ordering - * - Query/JSON/FormData source adapters - */ - -import { z } from 'zod' - -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '../endpoint/schemas.ts' -import { - SortsNormalizedSchema, - type SortNormalized, - type SortsNormalized, - type SortDirection, - type SortsConfig, -} from './schemas.ts' - -// ============================================================================ -// WIRE SCHEMAS (raw incoming data) -// ============================================================================ - -/** - * Query parameter wire schema (raw incoming) - * Supports: ?sort=created_at:desc,id:asc - */ -export const SortsQueryWire = BaseQuerySchema.extend({ - sort: ZStringOrStringArray.optional() -}) - -/** - * JSON body wire schema (raw incoming) - * Expects: { sorts: [{ field: 'created_at', direction: 'desc' }] } - */ -export const SortsJsonWire = BaseJsonSchema.pipe( - z.object({ sorts: SortsNormalizedSchema.default([]) }) -) - -/** - * FormData wire schema (raw incoming) - */ -export const SortsFormWire = BaseFormSchema - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/** - * Parse colon syntax from string - * Syntax: created_at:desc,id:asc - */ -function parseColonSyntax(data: string | undefined): SortsNormalized { - if (typeof data !== 'string' || !data?.trim()) return [] - - const sorts: SortNormalized[] = [] - const segments = data.split(',').map(s => s.trim()).filter(Boolean) - - for (const segment of segments) { - const [fieldRaw, dirRaw] = segment.split(':') - const field = (fieldRaw ?? '').trim() - const direction = (dirRaw ?? 'asc').trim().toLowerCase() - - if (!field) continue; - if (direction !== 'asc' && direction !== 'desc') continue; - - sorts.push({ - field: field, - direction: direction as SortDirection, - tiebreaker: false, - }) - } - - return sorts -} - -// ============================================================================ -// SOURCE ADAPTERS -// ============================================================================ - - -/** - * Query parameter adapter (uses z.codec) - * Supports: ?sort=created_at:desc,id:asc - * - * @example - * const adapter = createSortsQueryAdapter() - * const normalized = adapter.decode({ sort: 'created_at:desc,id:asc' }) - * // => [{ field: 'created_at', direction: 'desc' }, { field: 'id', direction: 'asc' }] - */ -export function createSortsQueryAdapter() { - return z.codec( - SortsQueryWire, // Input (wire) - SortsNormalizedSchema, // Output (normalized) - { - decode: (query) => { - // Extract sort value (handle ZStringOrStringArray) - const raw = Array.isArray(query.sort) ? query.sort[0] : query.sort - return parseColonSyntax(raw) - }, - encode: (normalized) => { - // Reverse transform: SortsNormalized -> query string - if (normalized.length === 0) return {} as z.infer - const sortString = normalized - .map(s => `${s.field}:${s.direction}`) - .join(',') - return { sort: sortString } as z.infer - } - } - ) -} - -/** - * JSON body adapter (uses z.codec) - * Expects: { sorts: [{ field: 'created_at', direction: 'desc' }] } - * - * @example - * const adapter = createSortsJsonAdapter() - * const normalized = adapter.decode({ - * sorts: [{ field: 'created_at', direction: 'desc' }] - * }) - */ -export function createSortsJsonAdapter() { - return z.codec( - SortsJsonWire, // Input (wire) - SortsNormalizedSchema, // Output (normalized) - { - decode: (raw) => { - return raw.sorts as SortsNormalized - }, - encode: (normalized) => { - return { sorts: normalized.map(s => Object.assign(s, { tiebreaker: false })) } - } - } - ) -} - -/** - * FormData adapter (uses z.codec) - * Supports: sort=created_at:desc,id:asc - * - * @example - * const adapter = createSortsFormAdapter() - * const formData = new FormData() - * formData.append('sort', 'created_at:desc,id:asc') - * const normalized = adapter.decode(formData) - */ -export function createSortsFormAdapter() { - return z.codec( - SortsFormWire, // Input (wire) - SortsNormalizedSchema, // Output (normalized) - { - decode: (raw): SortsNormalized => { - const sortValue = raw.sort - if (!sortValue) return [] - - const sortStr = Array.isArray(sortValue) - ? String(sortValue[0]) - : String(sortValue) - - return parseColonSyntax(sortStr) - }, - encode: (normalized) => { - if (normalized.length === 0) return {} - const sortString = normalized - .map(s => `${s.field}:${s.direction}`) - .join(',') - return { sort: sortString } as z.infer - } - } - ) -} - -// ============================================================================ -// SCHEMA COMPOSITION WITH VALIDATION -// ============================================================================ - -/** - * Create endpoint-specific sorts schema with validation - * All validation happens in .superRefine() so middleware handles errors - * - * @param config Configuration for sorts validation - * @param config.source Input source type ('query' | 'json' | 'form') - * @param config.allowedFields Array of sortable field names (allowlist) - * @param config.limits Optional limits configuration (maxSorts) - * @param config.tiebreaker Field to use as tiebreaker (default: 'id') - * @param config.defaultSort Default sort when none provided - * - * @example - * const schema = createSortsSchema({ - * source: 'query', - * allowedFields: ['created_at', 'title', 'id'], - * limits: { maxSorts: 3 }, - * tiebreaker: 'id', - * defaultSort: [{ field: 'created_at', direction: 'desc' }] - * }) - * - * // Parse and validate - * const sorts = schema.parse({ sort: 'created_at:desc' }) - * // => [{ field: 'created_at', direction: 'desc' }, { field: 'id', direction: 'asc' }] - */ -export function createSortsSchema(config: { - source: 'query' | 'json' | 'form' -} & SortsConfig) { - // When disabled, always return null - if (config.disabled) { - return z.null() - } - - const adapter = - config.source === 'query' ? createSortsQueryAdapter() : - config.source === 'json' ? createSortsJsonAdapter() : - createSortsFormAdapter() - - const allowSet = new Set(config.allowedFields) - const maxSorts = config.limits?.maxSorts ?? 5 - const tiebreaker = config.tiebreaker ?? 'id' - - return adapter - .transform(sorts => { - // Use default if no sorts provided - if (sorts.length === 0 && config.defaults) { - return config.defaults - } - - // Merge defaults if enabled - if (config.mergeDefaults && config.defaults) { - const defaultSorts = config.defaults.filter(def => !sorts.some(s => s.field === def.field)) - return [...defaultSorts, ...sorts] - } - - return sorts - }) - .superRefine((sorts, ctx) => { - // Check sort count before adding tiebreaker (DoS protection) - if (sorts.length > maxSorts) { - ctx.addIssue({ - code: "too_big", - maximum: maxSorts, - origin: 'array', - path: [], - message: `Too many sorts: maximum ${maxSorts} allowed, got ${sorts.length}`, - }) - return // Don't continue - } - - // Validate allowed fields - if (allowSet.size > 0) { - sorts.forEach((sort, idx) => { - if (!allowSet!.has(sort.field)) { - ctx.addIssue({ - code: "custom", - path: [idx, 'field'], - message: `Field '${sort.field}' is not sortable. Allowed fields: ${config.allowedFields!.join(', ')}`, - }) - } - }) - } - }) - .transform((sorts) => { - // Always add tiebreaker if not already present - const _sorts = sorts.map(s => Object.assign(s, { tiebreaker: s.field === tiebreaker })); - const hasTiebreaker = _sorts.some(s => s.tiebreaker) - if (!hasTiebreaker) { - _sorts.push({ field: tiebreaker, direction: 'asc' as SortDirection, tiebreaker: true }) - return _sorts - } - - return _sorts - }) -} \ No newline at end of file diff --git a/utils/response/errors.ts b/utils/response/errors.ts deleted file mode 100644 index 943255c..0000000 --- a/utils/response/errors.ts +++ /dev/null @@ -1,636 +0,0 @@ -import type { ContentfulStatusCode } from 'hono/utils/http-status' -import type { ErrorResponse, ErrorResult, ErrorsResult, ProblemDetails, ProblemDetailsWithErrors, ResponseResult, ValidationErrorDetail } from './schemas.ts' -import { HTTPException } from 'hono/http-exception' - -export const BASE_ERROR_URL = 'https://backend.okikio.dev/error' -export const BASE_DOCS_URL = 'https://docs.okikio.dev/errors' - -/** Canonical problem type URIs. */ -export const ERROR_TYPES = { - BAD_REQUEST: `${BASE_ERROR_URL}/bad-request`, - UNAUTHORIZED: `${BASE_ERROR_URL}/unauthorized`, - FORBIDDEN: `${BASE_ERROR_URL}/forbidden`, - NOT_FOUND: `${BASE_ERROR_URL}/not-found`, - METHOD_NOT_ALLOWED: `${BASE_ERROR_URL}/method-not-allowed`, - NOT_ACCEPTABLE: `${BASE_ERROR_URL}/not-acceptable`, - REQUEST_TIMEOUT: `${BASE_ERROR_URL}/request-timeout`, - CONFLICT: `${BASE_ERROR_URL}/conflict`, - GONE: `${BASE_ERROR_URL}/gone`, - PRECONDITION_FAILED: `${BASE_ERROR_URL}/precondition-failed`, - PAYLOAD_TOO_LARGE: `${BASE_ERROR_URL}/payload-too-large`, - URI_TOO_LONG: `${BASE_ERROR_URL}/uri-too-long`, - UNSUPPORTED_MEDIA_TYPE: `${BASE_ERROR_URL}/unsupported-media-type`, - RANGE_NOT_SATISFIABLE: `${BASE_ERROR_URL}/range-not-satisfiable`, - UNPROCESSABLE_ENTITY: `${BASE_ERROR_URL}/unprocessable-entity`, - PRECONDITION_REQUIRED: `${BASE_ERROR_URL}/precondition-required`, - REQUEST_HEADER_FIELDS_TOO_LARGE: `${BASE_ERROR_URL}/request-header-fields-too-large`, - UNAVAILABLE_FOR_LEGAL_REASONS: `${BASE_ERROR_URL}/unavailable-for-legal-reasons`, - VALIDATION_ERROR: `${BASE_ERROR_URL}/validation-error`, - RATE_LIMIT_EXCEEDED: `${BASE_ERROR_URL}/rate-limit-exceeded`, - INTERNAL_SERVER_ERROR: `${BASE_ERROR_URL}/internal-server-error`, - NOT_IMPLEMENTED: `${BASE_ERROR_URL}/not-implemented`, - BAD_GATEWAY: `${BASE_ERROR_URL}/bad-gateway`, - SERVICE_UNAVAILABLE: `${BASE_ERROR_URL}/service-unavailable`, - GATEWAY_TIMEOUT: `${BASE_ERROR_URL}/gateway-timeout`, -} as const - -/** Canonical docs links for each problem type. */ -export const ERROR_DOCS = { - BAD_REQUEST: `${BASE_DOCS_URL}/bad-request`, - UNAUTHORIZED: `${BASE_DOCS_URL}/unauthorized`, - FORBIDDEN: `${BASE_DOCS_URL}/forbidden`, - NOT_FOUND: `${BASE_DOCS_URL}/not-found`, - METHOD_NOT_ALLOWED: `${BASE_DOCS_URL}/method-not-allowed`, - NOT_ACCEPTABLE: `${BASE_DOCS_URL}/not-acceptable`, - REQUEST_TIMEOUT: `${BASE_DOCS_URL}/request-timeout`, - CONFLICT: `${BASE_DOCS_URL}/conflict`, - GONE: `${BASE_DOCS_URL}/gone`, - PRECONDITION_FAILED: `${BASE_DOCS_URL}/precondition-failed`, - PAYLOAD_TOO_LARGE: `${BASE_DOCS_URL}/payload-too-large`, - URI_TOO_LONG: `${BASE_DOCS_URL}/uri-too-long`, - UNSUPPORTED_MEDIA_TYPE: `${BASE_DOCS_URL}/unsupported-media-type`, - RANGE_NOT_SATISFIABLE: `${BASE_DOCS_URL}/range-not-satisfiable`, - UNPROCESSABLE_ENTITY: `${BASE_DOCS_URL}/unprocessable-entity`, - PRECONDITION_REQUIRED: `${BASE_DOCS_URL}/precondition-required`, - REQUEST_HEADER_FIELDS_TOO_LARGE: `${BASE_DOCS_URL}/request-header-fields-too-large`, - UNAVAILABLE_FOR_LEGAL_REASONS: `${BASE_DOCS_URL}/unavailable-for-legal-reasons`, - VALIDATION_ERROR: `${BASE_DOCS_URL}/validation-error`, - RATE_LIMIT_EXCEEDED: `${BASE_DOCS_URL}/rate-limit-exceeded`, - INTERNAL_SERVER_ERROR: `${BASE_DOCS_URL}/internal-server-error`, - NOT_IMPLEMENTED: `${BASE_DOCS_URL}/not-implemented`, - BAD_GATEWAY: `${BASE_DOCS_URL}/bad-gateway`, - SERVICE_UNAVAILABLE: `${BASE_DOCS_URL}/service-unavailable`, - GATEWAY_TIMEOUT: `${BASE_DOCS_URL}/gateway-timeout`, -} as const - -/** Canonical human-readable titles by status. */ -export const STATUS_TITLES: Readonly> = { - 200: 'OK', - 201: 'Created', - 202: 'Accepted', - 204: 'No Content', - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 405: 'Method Not Allowed', - 406: 'Not Acceptable', - 408: 'Request Timeout', - 409: 'Conflict', - 410: 'Gone', - 412: 'Precondition Failed', - 413: 'Payload Too Large', - 414: 'URI Too Long', - 415: 'Unsupported Media Type', - 416: 'Range Not Satisfiable', - 422: 'Unprocessable Entity', - 428: 'Precondition Required', - 429: 'Too Many Requests', - 431: 'Request Header Fields Too Large', - 451: 'Unavailable For Legal Reasons', - 500: 'Internal Server Error', - 501: 'Not Implemented', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - 504: 'Gateway Timeout', -} - -export function titleFor(status: number): string { - return STATUS_TITLES[status] ?? 'Error' -} - -// ============================================================================ -// Status → convenience factory name map (symbolic) -// ============================================================================ - -export const STATUS_TO_FUNCTION_MAP = { - 400: 'badRequest', - 401: 'unauthorized', - 403: 'forbidden', - 404: 'notFound', - 405: 'methodNotAllowed', - 406: 'notAcceptable', - 408: 'requestTimeout', - 409: 'conflict', - 410: 'gone', - 412: 'preconditionFailed', - 413: 'payloadTooLarge', - 414: 'uriTooLong', - 415: 'unsupportedMediaType', - 416: 'rangeNotSatisfiable', - 422: 'unprocessableEntity', - 428: 'preconditionRequired', - 429: 'rateLimitExceeded', - 431: 'requestHeaderFieldsTooLarge', - 451: 'unavailableForLegalReasons', - 500: 'internalServerError', - 501: 'notImplemented', - 502: 'badGateway', - 503: 'serviceUnavailable', - 504: 'gatewayTimeout', -} as const - -export type KnownErrorStatus = keyof typeof STATUS_TO_FUNCTION_MAP - -// ============================================================================ -// Extensions (intent-forward extra fields) -// ============================================================================ - -export interface MethodNotAllowedExtension { allowed: string[] } -export interface UnsupportedMediaTypeExtension { supported?: string[] } -export interface PayloadTooLargeExtension { limitBytes?: number } -export interface RateLimitExtension { retryAfter: number } -export interface ServiceUnavailableExtension { service: string } - -// ============================================================================ -// Private base builder for problems (always uses STATUS_TITLES) -// ============================================================================ - -export function baseProblem( - status: ContentfulStatusCode, - type: string, - instance: string, - detail: string, - docs?: string, - extensions?: Record -): ErrorResult { - return [ - Object.assign( - { - type, - title: titleFor(status), - status, - detail, - instance, - timestamp: new Date(), - }, - docs ? { docs } : {}, - extensions ? extensions : {}, - ) as ProblemDetails, - status, - { 'Content-Type': 'application/problem+json' }, - ] as const -} - -// ============================================================================ -// Multi-error responses -// ============================================================================ - -/** - * Build RFC7807 with an `errors` array for multi-violations. - * - * @example - * const errors = [{ field: 'email', message: 'Invalid email' }] - * return c.json(...errs(ERROR_TYPES.UNPROCESSABLE_ENTITY, titleFor(422), 422, c.req.path, errors)) - */ -export function errs( - type: string, - title: string, - status: ContentfulStatusCode, - instance: string, - errors: ValidationErrorDetail[], - detail?: string, - docs?: string -): ErrorsResult { - const errorCount = errors.length - const defaultDetail = errorCount === 1 ? '1 error occurred' : `${errorCount} errors occurred` - return [ - Object.assign( - { - type, - title, - status, - detail: detail || defaultDetail, - instance, - timestamp: new Date(), - errors, - }, - docs ? { docs } : {}, - ) as ProblemDetailsWithErrors, - status, - { 'Content-Type': 'application/problem+json' }, - ] as const -} - -/** - * Canonical 422 validation failure shape with field-level details. - * - * @example - * return c.json(...validationFailed(c.req.path, toErrs(zodError.issues))) - */ -export function validationFailed( - instance: string, - errors: ValidationErrorDetail[], - detail?: string -): ErrorsResult { - const status = 422 as const - const errorCount = errors.length - const defaultDetail = - errorCount === 1 - ? 'Request validation failed on 1 field' - : `Request validation failed on ${errorCount} fields` - - return errs( - ERROR_TYPES.VALIDATION_ERROR, - titleFor(status), - status, - instance, - errors, - detail || defaultDetail, - ERROR_DOCS.VALIDATION_ERROR - ) -} - - -// ============================================================================ -// Convenience error factories (titles always from STATUS_TITLES) -// ============================================================================ - -export function badRequest( - instance: string, - detail: string, - extensions?: Record -): ErrorResult { - return baseProblem(400, ERROR_TYPES.BAD_REQUEST, instance, detail, ERROR_DOCS.BAD_REQUEST, extensions) -} - -export function unauthorized( - instance: string, - detail: string = 'Authentication required', - extensions?: Record -): ErrorResult { - return baseProblem(401, ERROR_TYPES.UNAUTHORIZED, instance, detail, ERROR_DOCS.UNAUTHORIZED, extensions) -} - -export function forbidden( - instance: string, - detail: string = 'Insufficient permissions', - extensions?: Record -): ErrorResult { - return baseProblem(403, ERROR_TYPES.FORBIDDEN, instance, detail, ERROR_DOCS.FORBIDDEN, extensions) -} - -export function notFound( - instance: string, - detail: string = 'Resource not found', - extensions?: Record -): ErrorResult { - return baseProblem(404, ERROR_TYPES.NOT_FOUND, instance, detail, ERROR_DOCS.NOT_FOUND, extensions) -} - -export function methodNotAllowed( - instance: string, - allowed: MethodNotAllowedExtension['allowed'], - detail: string = 'Method not allowed', - extensions?: Record -): ErrorResult { - return baseProblem( - 405, - ERROR_TYPES.METHOD_NOT_ALLOWED, - instance, - detail, - ERROR_DOCS.METHOD_NOT_ALLOWED, - Object.assign({ allowed }, extensions ? extensions : {}), - ) -} - -export function notAcceptable( - instance: string, - detail: string = 'Not acceptable', - extensions?: Record -): ErrorResult { - return baseProblem(406, ERROR_TYPES.NOT_ACCEPTABLE, instance, detail, ERROR_DOCS.NOT_ACCEPTABLE, extensions) -} - -export function requestTimeout( - instance: string, - detail: string = 'Request timed out', - extensions?: Record -): ErrorResult { - return baseProblem(408, ERROR_TYPES.REQUEST_TIMEOUT, instance, detail, ERROR_DOCS.REQUEST_TIMEOUT, extensions) -} - -export function conflict( - instance: string, - detail: string = 'Resource already exists', - extensions?: Record -): ErrorResult { - return baseProblem(409, ERROR_TYPES.CONFLICT, instance, detail, ERROR_DOCS.CONFLICT, extensions) -} - -export function gone( - instance: string, - detail: string = 'Resource is gone', - extensions?: Record -): ErrorResult { - return baseProblem(410, ERROR_TYPES.GONE, instance, detail, ERROR_DOCS.GONE, extensions) -} - -export function preconditionFailed( - instance: string, - detail: string = 'Precondition failed', - extensions?: Record -): ErrorResult { - return baseProblem(412, ERROR_TYPES.PRECONDITION_FAILED, instance, detail, ERROR_DOCS.PRECONDITION_FAILED, extensions) -} - -export function payloadTooLarge( - instance: string, - limitBytes?: PayloadTooLargeExtension['limitBytes'], - detail: string = 'Payload too large', - extensions?: Record -): ErrorResult { - return baseProblem( - 413, - ERROR_TYPES.PAYLOAD_TOO_LARGE, - instance, - detail, - ERROR_DOCS.PAYLOAD_TOO_LARGE, - Object.assign(limitBytes ? { limitBytes } : {}, extensions ? extensions : {}), - ) -} - -export function uriTooLong( - instance: string, - detail: string = 'URI too long', - extensions?: Record -): ErrorResult { - return baseProblem(414, ERROR_TYPES.URI_TOO_LONG, instance, detail, ERROR_DOCS.URI_TOO_LONG, extensions) -} - -export function unsupportedMediaType( - instance: string, - supported?: UnsupportedMediaTypeExtension['supported'], - detail: string = 'Unsupported media type', - extensions?: Record -): ErrorResult { - return baseProblem( - 415, - ERROR_TYPES.UNSUPPORTED_MEDIA_TYPE, - instance, - detail, - ERROR_DOCS.UNSUPPORTED_MEDIA_TYPE, - Object.assign(supported ? { supported } : {}, extensions ? extensions : {}), - ) -} - -export function rangeNotSatisfiable( - instance: string, - detail: string = 'Range not satisfiable', - extensions?: Record -): ErrorResult { - return baseProblem(416, ERROR_TYPES.RANGE_NOT_SATISFIABLE, instance, detail, ERROR_DOCS.RANGE_NOT_SATISFIABLE, extensions) -} - -export function unprocessableEntity( - instance: string, - detail: string = 'Request contains semantic errors', - extensions?: Record -): ErrorResult { - return baseProblem(422, ERROR_TYPES.UNPROCESSABLE_ENTITY, instance, detail, ERROR_DOCS.UNPROCESSABLE_ENTITY, extensions) -} - -export function preconditionRequired( - instance: string, - detail: string = 'Precondition required', - extensions?: Record -): ErrorResult { - return baseProblem(428, ERROR_TYPES.PRECONDITION_REQUIRED, instance, detail, ERROR_DOCS.PRECONDITION_REQUIRED, extensions) -} - -export function rateLimitExceeded( - instance: string, - retryAfter: RateLimitExtension['retryAfter'], - extensions?: Record -): ErrorResult { - return baseProblem( - 429, - ERROR_TYPES.RATE_LIMIT_EXCEEDED, - instance, - `Rate limit exceeded. Retry after ${retryAfter} seconds.`, - ERROR_DOCS.RATE_LIMIT_EXCEEDED, - Object.assign({ retryAfter }, extensions ? extensions : {}), - ) -} - -export function requestHeaderFieldsTooLarge( - instance: string, - detail: string = 'Request header fields too large', - extensions?: Record -): ErrorResult { - return baseProblem( - 431, - ERROR_TYPES.REQUEST_HEADER_FIELDS_TOO_LARGE, - instance, - detail, - ERROR_DOCS.REQUEST_HEADER_FIELDS_TOO_LARGE, - extensions - ) -} - -export function unavailableForLegalReasons( - instance: string, - detail: string = 'Unavailable for legal reasons', - extensions?: Record -): ErrorResult { - return baseProblem( - 451, - ERROR_TYPES.UNAVAILABLE_FOR_LEGAL_REASONS, - instance, - detail, - ERROR_DOCS.UNAVAILABLE_FOR_LEGAL_REASONS, - extensions - ) -} - -export function internalServerError( - instance: string, - detail: string = 'Internal server error', - extensions?: Record -): ErrorResult { - return baseProblem(500, ERROR_TYPES.INTERNAL_SERVER_ERROR, instance, detail, ERROR_DOCS.INTERNAL_SERVER_ERROR, extensions) -} - -export function notImplemented( - instance: string, - detail: string = 'Not implemented', - extensions?: Record -): ErrorResult { - return baseProblem(501, ERROR_TYPES.NOT_IMPLEMENTED, instance, detail, ERROR_DOCS.NOT_IMPLEMENTED, extensions) -} - -export function badGateway( - instance: string, - detail: string = 'Bad gateway', - extensions?: Record -): ErrorResult { - return baseProblem(502, ERROR_TYPES.BAD_GATEWAY, instance, detail, ERROR_DOCS.BAD_GATEWAY, extensions) -} - -export function serviceUnavailable( - instance: string, - service: ServiceUnavailableExtension['service'], - detail?: string, - extensions?: Record -): ErrorResult { - return baseProblem( - 503, - ERROR_TYPES.SERVICE_UNAVAILABLE, - instance, - detail || `Upstream service '${service}' unavailable`, - ERROR_DOCS.SERVICE_UNAVAILABLE, - Object.assign({ service }, extensions ? extensions : {}), - ) -} - -export function gatewayTimeout( - instance: string, - detail: string = 'Gateway timeout', - extensions?: Record -): ErrorResult { - return baseProblem(504, ERROR_TYPES.GATEWAY_TIMEOUT, instance, detail, ERROR_DOCS.GATEWAY_TIMEOUT, extensions) -} - -// ============================================================================ -// err(): status-agnostic entrypoint with smart delegation (no `any` casting) -// ============================================================================ - -/** - * Build a Problem Details error for any status. - * - Delegates to the matching convenience factory (with required extras). - * - Falls back to a generic RFC7807 if status is not mapped. - */ -export function err( - status: ContentfulStatusCode, - instance: string, - detail: string, - extensions?: Record -): ErrorResult { - switch (status as number) { - case 400: return badRequest(instance, detail, extensions) - case 401: return unauthorized(instance, detail, extensions) - case 403: return forbidden(instance, detail, extensions) - case 404: return notFound(instance, detail, extensions) - case 405: { - // If caller has `allowed`, they should call methodNotAllowed directly. - return methodNotAllowed(instance, [], detail, extensions) - } - case 406: return notAcceptable(instance, detail, extensions) - case 408: return requestTimeout(instance, detail, extensions) - case 409: return conflict(instance, detail, extensions) - case 410: return gone(instance, detail, extensions) - case 412: return preconditionFailed(instance, detail, extensions) - case 413: return payloadTooLarge(instance, undefined, detail, extensions) - case 414: return uriTooLong(instance, detail, extensions) - case 415: return unsupportedMediaType(instance, undefined, detail, extensions) - case 416: return rangeNotSatisfiable(instance, detail, extensions) - case 422: return unprocessableEntity(instance, detail, extensions) - case 428: return preconditionRequired(instance, detail, extensions) - case 429: { - const retryAfter = - extensions && typeof (extensions as { retryAfter?: unknown }).retryAfter === 'number' - ? (extensions as { retryAfter: number }).retryAfter - : 60 - return rateLimitExceeded(instance, retryAfter, extensions) - } - case 431: return requestHeaderFieldsTooLarge(instance, detail, extensions) - case 451: return unavailableForLegalReasons(instance, detail, extensions) - case 500: return internalServerError(instance, detail, extensions) - case 501: return notImplemented(instance, detail, extensions) - case 502: return badGateway(instance, detail, extensions) - case 503: { - const service = - extensions && typeof (extensions as { service?: unknown }).service === 'string' - ? (extensions as { service: string }).service - : 'unknown' - return serviceUnavailable(instance, service, detail, extensions) - } - case 504: return gatewayTimeout(instance, detail, extensions) - default: - // Generic fallback uses canonical title & inferred type. - return baseProblem( - status, - `${BASE_ERROR_URL}/${String(status)}`, - instance, - detail, - undefined, - extensions - ) - } -} - -// ============================================================================ -// Header helpers (typed) -// ============================================================================ - -/** - * Compute extra HTTP headers that should accompany a Problem response. - * - 429/503: set `Retry-After` from `extensions.retryAfter` (seconds) when present. - */ -export function extraProblemHeaders( - status: ContentfulStatusCode, - extensions?: Record -): Record { - const out: Record = {} - const retryAfter = - extensions && typeof (extensions as { retryAfter?: unknown }).retryAfter === 'number' - ? (extensions as { retryAfter: number }).retryAfter - : undefined - - if ((status === 429 || status === 503) && typeof retryAfter === 'number') { - out['Retry-After'] = String(retryAfter) - } - return out -} - -/** - * Throws an RFC 7807–compliant HTTP problem response. - * - * This utility wraps an `ErrorResult` tuple into a Hono `HTTPException`, - * ensuring that validation errors or other structured error payloads - * are returned in a standardized JSON format. - * - * The response body follows the [RFC 7807 Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc7807) - * specification, allowing clients to consume consistent error information. - * - * @param err - A tuple containing: - * - `error`: The problem details object (e.g. validation errors). - * - `status`: The HTTP status code to return. - * - `headers`: Optional headers to include in the response. - * - * @throws {HTTPException} Always throws a Hono `HTTPException` with the given - * problem details, status code, and headers. - * - * @example - * ```ts - * // Example usage inside a Hono route: - * if (!isValid(input)) { - * exception([{ title: "Invalid input", detail: "Field X is required" }, 400, { "Content-Type": "application/problem+json" }]); - * } - * ``` - */ -export function exception(err: ErrorResponse) { - const [error, status, headers] = err; - - // Return RFC 7807 validation error array - return new HTTPException(status, { - res: new Response(JSON.stringify(error), { status, headers: headers }), - }); -} - -/** - * Check if response is an error by inspecting Content-Type - * Don't destructure before checking - this maintains type narrowing - * - * @example - * const result = await someApiCall() - * if (isErrorResponse(result)) { - * const [error, status, headers] = result // ✅ result is now ErrorResponse - * return c.json(error, status, headers) - * } - * // ✅ result is now SuccessResponse - * const [data, status, headers] = result - */ -export function isErrorResponse(response: ResponseResult): response is ErrorResponse { - return response[2]['Content-Type'] === 'application/problem+json' -} \ No newline at end of file diff --git a/utils/response/index.ts b/utils/response/index.ts deleted file mode 100644 index 91a7480..0000000 --- a/utils/response/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * RFC 7807 + Success Envelope Utilities for Hono - * ------------------------------------------------ - * - Canonical status titles & problem type/docs registries - * - Strict tuples for success & error responses (stable 3-tuple shape) - * - Convenience error factories for all common HTTP errors - * - Success helpers: ok, created (201 + Location), accepted (202), noContent (204), paginate - * - errs() + validationFailed() for multi-error payloads - * - Header utilities: extraProblemHeaders() and withHeaders() with precise typing - * - * Notes - * - Uses Object.assign for meta/header building to match your style preferences. - * - Avoids `any` in public types; no unsafe casts in user-facing APIs. - * - ESM-friendly, Deno v2 / TS strict, tree-shakeable. - */ - -export type * from "./schemas.ts"; -export * from "./errors.ts"; -export * from "./success.ts"; -export * from "./status-codes.ts" - -// ============================================================================ -// Quick usage examples (copy/paste into handlers) -// ============================================================================ -// -// // OK -// return c.json(...ok({ id: 'like-1' })) -// -// // Created + Location -// const res = created({ id }, `/api/things/${id}`) -// return c.json(...withHeaders(res, { Location: `/api/things/${id}` })) -// -// // Rate limited with Retry-After header -// const err429 = rateLimitExceeded(c.req.path, 120) -// return c.json(...withHeaders(err429, extraProblemHeaders(429, { retryAfter: 120 }))) -// -// // Validation -// return c.json(...validationFailed(c.req.path, [{ field: 'email', message: 'Invalid' }])) -// -// // Status-agnostic -// return c.json(...err(404, c.req.path, 'Thing not found')) diff --git a/utils/response/schemas.ts b/utils/response/schemas.ts deleted file mode 100644 index 3fa6552..0000000 --- a/utils/response/schemas.ts +++ /dev/null @@ -1,487 +0,0 @@ -// utils/response/schemas.ts -/** - * Response types and schemas following RFC 7807 (Problem Details) and success envelopes - * - * Architecture: - * - Everything is a Zod schema first - * - Types are inferred from schemas via z.infer - * - Factory functions create generic schemas (like in fields.ts, filtering.ts) - * - Follows same pattern as query processing utilities - * - Aligns with Hono's ContentfulStatusCode and ContentlessStatusCode types - */ - -import type { ContentfulStatusCode, ContentlessStatusCode } from 'hono/utils/http-status' -import type { RequestHeader } from 'hono/utils/headers' - -import { ContentfulStatusCodeSchema } from './status-codes.ts' -import { QuerySpecSchema } from '../query/schemas.ts' -import { z } from 'zod' - -// ============================================================================ -// BASE COMPONENT SCHEMAS -// ============================================================================ - -/** - * Validation error detail schema - * Individual field-level or rule-level validation error - */ -export const ValidationErrorDetailSchema = z.object({ - field: z.string(), - message: z.string() -}) - -export type ValidationErrorDetail = z.infer - -/** - * RFC 7807 Problem Details base schema - */ -export const ProblemDetailsSchema = z.object({ - type: z.string(), - title: z.string(), - status: ContentfulStatusCodeSchema, - detail: z.string(), - instance: z.string(), - timestamp: z.coerce.date(), - docs: z.url().optional() -}) - -export type ProblemDetails = z.infer - -/** - * RFC 7807 Problem Details with validation errors array - */ -export const ProblemDetailsWithErrorsSchema = ProblemDetailsSchema.extend({ - errors: z.array(ValidationErrorDetailSchema) -}) - -export type ProblemDetailsWithErrors = z.infer - -/** - * Metadata describing the pagination state of a result set. - * - * This interface is designed to support both cursor-based and offset-based - * pagination strategies. It captures the current slice of data and provides - * hints for navigating forward/backward. - * - * Supports cursor-based and offset-based pagination, plus - * optional approximate totals and expiry timestamps. - */ -export const PaginationSchema = z.object({ - hasMore: z.boolean(), - limit: z.number().int().positive(), - count: z.number().int().min(0), - nextCursor: z.string().optional(), - prevCursor: z.string().optional(), - offset: z.number().int().min(0).optional(), - total: z.number().int().min(0).optional(), - approxTotal: z.number().int().min(0).optional(), - expiresAt: z.coerce.date().optional() -}) - -export type Pagination = z.infer - -/** - * Base metadata schema - accepts arbitrary additional fields - */ -export const DataMetadataSchema = z.object().catchall(z.unknown()) - -export type DataMetadata = z.infer - -/** - * LinkMap - * - * Normalized representation of RFC 8288-style links for a single response. - * - * - Keys are **link relation types** (`rel`): "self", "next", "prev", "first", "last", etc. - * - Values are URI references (often relative paths like `/articles?page=2`). - * - `self` is **required** and must be non-empty. - * - Standard pagination rels (`first`, `last`, `next`, `prev`) are optional. - * - Any additional rel (e.g. "describedby", "up", custom rels) is allowed via catchall. - * - * @example - * const links = LinkMapSchema.parse({ - * self: "/articles?offset=40&limit=20", - * first: "/articles?offset=0&limit=20", - * prev: "/articles?offset=20&limit=20", - * next: "/articles?offset=60&limit=20", - * last: "/articles?offset=120&limit=20", - * }) - * - * @example - * // With custom rels - * const links = LinkMapSchema.parse({ - * self: "/articles?offset=0&limit=20", - * describedby: "https://api.example.com/schemas/articles", - * profile: "https://api.example.com/profiles/pagination", - * }) - */ -export const LinkMapSchema = z - .object({ - self: z.string().min(1, "self link must be a non-empty URI reference"), - - // Common pagination link relations (all optional) - first: z.string().min(1).optional(), - last: z.string().min(1).optional(), - next: z.string().min(1).optional(), - prev: z.string().min(1).optional(), - }) - // Allow additional rels like "describedby", "up", "profile", etc. - .catchall(z.string().min(1)); - -export type LinkMap = z.infer; - -// ============================================================================ -// HEADER SCHEMAS -// ============================================================================ - -/** Standard Headers - properties are optional but must be strings when present */ -export const StandardHeadersSchema = z.object().catchall(z.string()); - -/** - * Standard JSON response headers - */ -export const JsonHeadersSchema = StandardHeadersSchema.extend({ - 'Content-Type': z.union([z.literal('application/json'), z.string()]) -}) - -export type JsonHeaders = z.infer - -/** - * Pagination response headers (includes RFC 8288 Link header) - */ -export const JsonHeadersWithLinksSchema = JsonHeadersSchema.extend({ - 'Link': z.string(), - 'X-Total-Count': z.string().optional(), - 'X-Per-Page': z.string().optional(), - 'X-Page': z.string().optional(), - 'X-Total-Pages': z.string().optional(), - 'Preference-Applied': z.string().optional(), - 'Range-Unit': z.string().optional(), - 'Content-Range': z.string().optional() -}) - -export type JsonHeadersWithLinks = z.infer - -/** - * RFC 7807 problem+json headers - */ -export const ProblemHeadersSchema = StandardHeadersSchema.extend({ - 'Content-Type': z.literal('application/problem+json') -}) - -export type ProblemHeaders = z.infer - -/** - * Generic standard headers type (for helper function signatures) - */ -export type StandardHeaders = { - [K in RequestHeader]?: string -} & z.infer - -// ============================================================================ -// ENVELOPE SCHEMA FACTORIES (for generic data types) -// ============================================================================ - -/** - * Create success envelope schema - * - * Structure: { data: T, meta: { timestamp: string, ...custom } } - * - * @example - * const UserSchema = z.object({ id: z.string(), name: z.string() }) - * const EnvelopeSchema = makeSuccessEnvelopeSchema(UserSchema) - * type Envelope = z.infer - * // => { data: User, meta: { timestamp: string } } - */ -export function makeSuccessEnvelopeSchema< - TData extends z.ZodType, - TMeta extends z.ZodObject = z.ZodObject<{}> ->( - dataSchema: TData, - metaSchema?: TMeta -) { - const baseMeta = z.object({ - timestamp: z.coerce.date(), - }) - - const meta = metaSchema - ? z.intersection(baseMeta, metaSchema) - : baseMeta - - return z.object({ - data: dataSchema, - meta: meta - }) -} - -/** - * Generic success envelope type (for helpers like ok(), created()) - * For actual validation, use makeSuccessEnvelopeSchema() - */ -export interface SuccessEnvelope { - data: T - meta: M & { timestamp: string } -} - - -/** - * Create pagination envelope schema - * - * Structure: { data: T[], meta: { timestamp: string, pagination: Pagination } } - * - * @example - * const UserSchema = z.object({ id: z.string(), name: z.string() }) - * const EnvelopeSchema = makePaginationEnvelopeSchema(z.array(UserSchema)) - * type Envelope = z.infer - */ -export function makePaginationEnvelopeSchema( - dataSchema: TData -) { - return makeSuccessEnvelopeSchema(dataSchema, PaginationMetadataSchema) -} - -/** - * Generic pagination envelope type (for helpers like paginate()) - * For actual validation, use makePaginationEnvelopeSchema() - */ -export type PaginationEnvelope = SuccessEnvelope - -/** - * Pagination metadata schema (pre-built) - */ -export const PaginationMetadataSchema = DataMetadataSchema.extend({ - pagination: PaginationSchema, - query: QuerySpecSchema.optional(), -}) - -export type PaginationMetadata = z.infer - -/** - * Create contentless result tuple schema - * For responses with no body (204, 101, 205, 304) - * - * @example - * const ResultSchema = makeContentlessResultSchema(204) - * type Result = z.infer - * // => readonly [undefined, 204, JsonHeaders] - */ -export const ContentlessResultSchema = z.tuple([ - z.undefined(), - ContentfulStatusCodeSchema, - JsonHeadersSchema -]) - -/** - * Generic contentless result tuple type - * For actual validation, use makeContentlessResultSchema() - */ -export type ContentlessResult = readonly [ - undefined, - ContentlessStatusCode, - JsonHeaders -] - -// ============================================================================ -// RESULT TUPLE SCHEMA FACTORIES -// ============================================================================ - -/** - * Create success result tuple schema - * For responses with data (200, 201, etc.) - * - * @example - * const UserSchema = z.object({ id: z.string() }) - * const ResultSchema = makeSuccessResultSchema(UserSchema, 200) - * type Result = z.infer - * // => readonly [{ data: User, meta: { timestamp: string } }, 200, JsonHeaders] - */ -export function makeSuccessResultSchema< - TData extends z.ZodType ->( - dataSchema: TData, - metaSchema?: z.ZodObject -) { - return z.tuple([ - makeSuccessEnvelopeSchema(dataSchema, metaSchema), - ContentfulStatusCodeSchema, - JsonHeadersSchema - ]) -} - -/** - * Generic success result tuple type - * For actual validation, use makeSuccessResultSchema() - */ -export type GenericSuccessResult = readonly [ - E, - ContentfulStatusCode, - H -] - -/** - * Success Result - */ -export type SuccessResult = GenericSuccessResult> - -/** - * Create pagination result tuple schema - * Always returns 200 with pagination metadata - * - * @example - * const UserSchema = z.object({ id: z.string() }) - * const ResultSchema = makePaginationResultSchema(z.array(UserSchema)) - * type Result = z.infer - * // => readonly [{ data: User[], meta: { timestamp: string, pagination: Pagination } }, 200, JsonHeadersWithLinks] - */ -export function makePaginationResultSchema( - dataSchema: TData -) { - return z.tuple([ - makePaginationEnvelopeSchema(dataSchema), - ContentfulStatusCodeSchema, - JsonHeadersWithLinksSchema - ]) -} - -/** - * Generic pagination result tuple type - * For actual validation, use makePaginationResultSchema() - */ -export type PaginationResult = GenericSuccessResult, JsonHeadersWithLinks> - -/** - * Create error result tuple schema - * For RFC 7807 error responses - * - * @example - * const ResultSchema = ErrorResultSchema - * type Result = z.infer - * // => readonly [ProblemDetails, 404, ProblemHeaders] - */ -export const ErrorResultSchema = z.tuple([ - ProblemDetailsSchema, - ContentfulStatusCodeSchema, - ProblemHeadersSchema -]) - -/** - * Generic error result tuple type - * For actual validation, use makeErrorResultSchema() - */ -export type ErrorResult = readonly [ - ProblemDetails, - ContentfulStatusCode, - ProblemHeaders -] - -/** - * Create validation errors result tuple schema - * For 422 responses with multiple field errors - * - * @example - * const ResultSchema = makeErrorsResultSchema() - * type Result = z.infer - * // => readonly [ProblemDetailsWithErrors, 422, ProblemHeaders] - */ -export const ErrorsResultSchema = z.tuple([ - ProblemDetailsWithErrorsSchema, - ContentfulStatusCodeSchema, - ProblemHeadersSchema -]) - -/** - * Generic errors result tuple type (with validation errors array) - * For actual validation, use makeErrorsResultSchema() - */ -export type ErrorsResult = readonly [ - ProblemDetailsWithErrors, - ContentfulStatusCode, - ProblemHeaders -] - -// ============================================================================ -// RESPONSE UNION SCHEMAS (for comprehensive validation) -// ============================================================================ - -/** - * Create success response union schema - * Includes contentless (204), success, and pagination results - * - * @example - * const UserSchema = z.object({ id: z.string() }) - * const ResponseSchema = makeSuccessResponseSchema(UserSchema) - * - * // Can validate any of: - * // - [{ data: User, meta }, 200, JsonHeaders] - * // - [{ data: User[], meta: { pagination } }, 200, JsonHeadersWithLinks] - * // - [undefined, 204, JsonHeaders] - */ -export function makeSuccessResponseSchema( - dataSchema: TData -) { - return z.union([ - makeSuccessResultSchema(dataSchema), - makePaginationResultSchema(dataSchema), - ContentlessResultSchema - ]) -} - -/** - * Union of success response types - */ -export type SuccessResponse = - | SuccessResult - | PaginationResult - | ContentlessResult - -/** - * Create error response union schema - * Includes common error status codes - * - * @example - * const ResponseSchema = ErrorResponseSchema - * - * // Can validate any of: - * // - [ProblemDetails, 400, ProblemHeaders] - * // - [ProblemDetails, 404, ProblemHeaders] - * // - [ProblemDetailsWithErrors, 422, ProblemHeaders] - * // - [ProblemDetails, 500, ProblemHeaders] - */ -export const ErrorResponseSchema = z.union([ - ErrorResultSchema, - ErrorsResultSchema -]) - -/** - * Union of error response types - */ -export type ErrorResponse = - | ErrorResult - | ErrorsResult - - -/** - * Create complete response union schema - * Includes all success and error variants - * - * @example - * const UserSchema = z.object({ id: z.string() }) - * const ResponseSchema = makeResponseResultSchema(UserSchema) - * - * // Can validate any response tuple from your API - */ -export function makeResponseResultSchema( - dataSchema: TData -) { - return z.union([ - makeSuccessResponseSchema(dataSchema), - ErrorResponseSchema, - ]) -} - -/** - * Union of all response tuple types - */ -export type ResponseResult = - | SuccessResponse - | ErrorResponse \ No newline at end of file diff --git a/utils/response/status-codes.ts b/utils/response/status-codes.ts deleted file mode 100644 index 25aafd6..0000000 --- a/utils/response/status-codes.ts +++ /dev/null @@ -1,210 +0,0 @@ -// utils/response/status-codes.ts -/** - * HTTP status-code schemas and types. - * - * Architecture & Conventions 🗺️ - * ──────────────────────────── - * – Every status-code *category* is expressed first as a **Zod schema** - * (literal-union of numeric codes). - * – The **TypeScript type** is inferred via `z.infer<…>` immediately - * after the schema. - * – Section headers mirror the layout used in **schemas.ts** to keep the - * codebase consistent and easy to scan. :contentReference[oaicite:0]{index=0} - */ - -import { z } from 'zod' - -/* ============================================================================ - * 1×× INFORMATIONAL STATUS CODES - * ============================================================================ - */ - -/** - * Informational (1××) status codes. - */ -export const InfoStatusCodeSchema = z.union([ - z.literal(100), - z.literal(101), - z.literal(102), - z.literal(103), -]) -export type InfoStatusCode = z.infer - -/* ============================================================================ - * 2×× SUCCESS STATUS CODES - * ============================================================================ - */ - -/** - * Successful (2××) status codes. - */ -export const SuccessStatusCodeSchema = z.union([ - z.literal(200), - z.literal(201), - z.literal(202), - z.literal(203), - z.literal(204), - z.literal(205), - z.literal(206), - z.literal(207), - z.literal(208), - z.literal(226), -]) -export type SuccessStatusCode = z.infer - -/* ============================================================================ - * 3×× REDIRECTION STATUS CODES - * (includes the historical 305 / 306 for completeness) - * ============================================================================ - */ - -/** - * Deprecated redirection status codes (305 & 306). - */ -export const DeprecatedStatusCodeSchema = z.union([ - z.literal(305), - z.literal(306), -]) -export type DeprecatedStatusCode = z.infer - -/** - * Redirection (3××) status codes. - */ -export const RedirectStatusCodeSchema = z.union([ - z.literal(300), - z.literal(301), - z.literal(302), - z.literal(303), - z.literal(304), - ...DeprecatedStatusCodeSchema.options, - z.literal(307), - z.literal(308), -]) -export type RedirectStatusCode = z.infer - -/* ============================================================================ - * 4×× CLIENT-ERROR STATUS CODES - * ============================================================================ - */ - -/** - * Client-error (4××) status codes. - */ -export const ClientErrorStatusCodeSchema = z.union([ - z.literal(400), - z.literal(401), - z.literal(402), - z.literal(403), - z.literal(404), - z.literal(405), - z.literal(406), - z.literal(407), - z.literal(408), - z.literal(409), - z.literal(410), - z.literal(411), - z.literal(412), - z.literal(413), - z.literal(414), - z.literal(415), - z.literal(416), - z.literal(417), - z.literal(418), - z.literal(421), - z.literal(422), - z.literal(423), - z.literal(424), - z.literal(425), - z.literal(426), - z.literal(428), - z.literal(429), - z.literal(431), - z.literal(451), -]) -export type ClientErrorStatusCode = z.infer - -/* ============================================================================ - * 5×× SERVER-ERROR STATUS CODES - * ============================================================================ - */ - -/** - * Server-error (5××) status codes. - */ -export const ServerErrorStatusCodeSchema = z.union([ - z.literal(500), - z.literal(501), - z.literal(502), - z.literal(503), - z.literal(504), - z.literal(505), - z.literal(506), - z.literal(507), - z.literal(508), - z.literal(510), - z.literal(511), -]) -export type ServerErrorStatusCode = z.infer - -/* ============================================================================ - * SPECIAL / UNOFFICIAL CODES - * ============================================================================ - */ - -/** - * Unofficial / “unknown” status code (-1). - * - * @example - * ```ts - * return c.text("Unknown Error", -1 as UnofficialStatusCode) - * ``` - */ -export const UnofficialStatusCodeSchema = z.literal(-1) -export type UnofficialStatusCode = z.infer - -/** - * @deprecated — Use `UnofficialStatusCode` instead. - */ -export type UnOfficalStatusCode = UnofficialStatusCode - -/* ============================================================================ - * AGGREGATE STATUS-CODE SCHEMAS - * ============================================================================ - */ - -/** - * Any legal HTTP status code (including unofficial). - */ -export const StatusCodeSchema = z.union([ - InfoStatusCodeSchema, - SuccessStatusCodeSchema, - RedirectStatusCodeSchema, - ClientErrorStatusCodeSchema, - ServerErrorStatusCodeSchema, - UnofficialStatusCodeSchema, -]) -export type StatusCode = z.infer - -/** - * Status codes that MUST NOT include a body in the response. - * (101 Switching Protocols · 204 No Content · 205 Reset Content · 304 Not Modified) - */ -export const ContentlessStatusCodeSchema = z.union([ - z.literal(101), - z.literal(204), - z.literal(205), - z.literal(304), -]) -export type ContentlessStatusCode = z.infer - -/** - * Status codes that MAY include a body in the response. - * Defined as *all* legal codes minus the four content-less ones. - */ -export const ContentfulStatusCodeSchema = StatusCodeSchema.refine( - (code): code is Exclude => - !ContentlessStatusCodeSchema.safeParse(code).success, - { error: 'Contentful status code expected' }, - ) -export type ContentfulStatusCode = z.infer - diff --git a/utils/response/success.ts b/utils/response/success.ts deleted file mode 100644 index b33fe97..0000000 --- a/utils/response/success.ts +++ /dev/null @@ -1,404 +0,0 @@ -import type { ContentlessStatusCode, ContentfulStatusCode, StatusCode } from 'hono/utils/http-status' -import type { ContentlessResult, Pagination, SuccessResult, PaginationResult, DataMetadata, JsonHeadersWithLinks, StandardHeaders, ResponseResult, SuccessResponse, SuccessEnvelope, GenericSuccessResult, PaginationMetadata, LinkMap } from './schemas.ts' - -/** - * 200/201/202/204 OK-style success envelope. - * - * @example - * return c.json(...ok({ id: 'like-1' })) // 200 - * return c.json(...ok(resource, 201)) // 201 Created (use `created` if you have a Location) - * return c.json(...ok(null, 204)) // 204 No Content - */ -export function ok( - data: T, - statusCode: ContentlessStatusCode, -): ContentlessResult; - -export function ok( - data: T, - statusCode: ContentfulStatusCode, - meta?: M -): SuccessResult; - -export function ok( - data: T, - statusCode: StatusCode = 200, - meta = {} as M -): SuccessResult | ContentlessResult { - switch (statusCode) { - case 101: - case 204: - case 205: - case 304: - return [undefined, statusCode as ContentlessStatusCode, { 'Content-Type': 'application/json' }] as const - } - - return [ - { - data, - meta: Object.assign({ timestamp: new Date().toISOString() }, (meta ?? {})), - }, - statusCode as ContentfulStatusCode, - { 'Content-Type': 'application/json' }, - ] as const -} - -/** - * 201 Created — includes optional Location header. - * - * @example - * const res = created({ id }, `/api/orders/${id}`) - * return c.json(...withHeaders(res, { Location: `/api/orders/${id}` })) - */ -export function created( - data: T, - location?: string, - meta?: M -): SuccessResult { - const result = ok(data, 201, meta) - return location ? withHeaders(result, { Location: location }) : result -} - -/** - * 202 Accepted — server accepted the request for processing. - * - * Provide tracking metadata to help clients poll or subscribe. - * - * @example - * return c.json(...accepted({ taskId }, { tracking: { taskId, status: 'queued' } })) - */ -export function accepted( - data: T, - meta?: M -): SuccessResult { - return ok(data, 202, meta) -} - -/** - * 204 No Content — success with no payload. `data` is always null. - * - * @example - * return c.json(...noContent()) - */ -export function noContent(): ContentlessResult { - return ok(null, 204) -} - -/** - * Paginated success (cursor or offset) with standard + de-facto headers. - * - * Headers emitted: - * - Always: - * - Link: rel="next"/"prev" for cursor; rel="first"/"next"/"prev"/"last" for offset - * - Offset mode (when available): - * - X-Total-Count: - * - X-Per-Page: - * - X-Page: <1-based page index> - * - X-Total-Pages: // only when exact total known - * - Range-Unit: items // standards-aligned - * - Content-Range: start-end/total // only when exact total known - * - Preference-Applied: count=exact|estimated - * - * Notes: - * - We never guess "first/last" for cursor paging (that’s typically unstable). For offset we include both. - * - When only approxTotal is available, we still emit X-Total-Count (as best-effort) and mark - * Preference-Applied: count=estimated, but we avoid Content-Range and X-Total-Pages (those imply exactness). - * - * @example - * return c.json(...paginate(c.req.url, items, { hasMore: true, nextCursor: 'abc', limit: 20 })) - */ -export function paginate( - url = "/", - data: T, - pagination: Pagination -): PaginationResult { - // --- headers & links ------------------------------------------------------- - const headers: StandardHeaders = {} - const linkMap: LinkMap = { self: url }; - const linkHeaderParts: string[] = [] - - // Self link (current page) - if (pagination.offset !== undefined && pagination.limit) { - // Canonical offset-based URL for this page - const selfUrl = buildOffsetUrl(url, pagination.offset, pagination.limit); - linkHeaderParts.push(`<${selfUrl}>; rel="self"`) - linkMap.self = selfUrl - } else { - // Fallback: treat `path` as already representing the current URL (path + query) - linkHeaderParts.push(`<${url}>; rel="self"`) - linkMap.self = url - } - - // Cursor links - if (pagination.nextCursor) { - const nextUrl = buildCursorUrl(url, pagination.nextCursor, pagination.limit) - linkHeaderParts.push(`<${nextUrl}>; rel="next"`) - linkMap.next = nextUrl - } - if (pagination.prevCursor) { - const prevUrl = buildCursorUrl(url, pagination.prevCursor, pagination.limit) - linkHeaderParts.push(`<${prevUrl}>; rel="prev"`) - linkMap.next = prevUrl - } - - // Offset links + extra headers - if (pagination.offset !== undefined && pagination.limit) { - const firstOffset = 0 - const nextOffset = pagination.offset + pagination.limit - const prevOffset = Math.max(pagination.offset - pagination.limit, 0) - - const firstUrl = buildOffsetUrl(url, firstOffset, pagination.limit); - const nextUrl = buildOffsetUrl(url, nextOffset, pagination.limit); - const prevUrl = buildOffsetUrl(url, prevOffset, pagination.limit); - - linkMap.first = firstUrl; - linkMap.next ??= nextUrl; - linkMap.prev ??= prevUrl; - - linkHeaderParts.push(`<${firstUrl}>; rel="first"`); - linkHeaderParts.push(`<${nextUrl}>; rel="next"`); - linkHeaderParts.push(`<${prevUrl}>; rel="prev"`); - - const total = pagination.total - const approx = pagination.approxTotal - - // De-facto admin-friendly counters - // Prefer exact `total`; fall back to `approxTotal` if present. - if (typeof total === "number" || typeof approx === "number") { - const totalCount = typeof total === "number" ? total : (approx as number) - headers["X-Total-Count"] = String(totalCount) - headers["X-Per-Page"] = String(pagination.limit) - headers["X-Page"] = String(Math.floor((pagination.offset ?? 0) / pagination.limit) + 1) - // Only compute total pages when exact total is known - if (typeof total === "number" && pagination.limit > 0) { - headers["X-Total-Pages"] = String(Math.max(Math.ceil(total / pagination.limit), 1)) - } - // Signal whether the server used exact or estimated counts - headers["Preference-Applied"] = typeof total === "number" ? "count=exact" : "count=estimated" - } - - // Standards-aligned range headers (only when exact total is known) - // - Range-Unit: items - // - Content-Range: start-end/total (end is inclusive) - if (typeof total === "number") { - const start = Math.max(pagination.offset, 0) - const end = Math.max(Math.min(start + pagination.limit - 1, Math.max(total - 1, 0)), 0) - headers["Range-Unit"] = "items" - headers["Content-Range"] = `${start}-${end}/${total}` - } - - // Last link only when exact total is known (so it's meaningful) - if (typeof total === "number" && total >= 0) { - const lastOffset = Math.max(total - pagination.limit, 0) - const lastUrl = buildOffsetUrl(url, lastOffset, pagination.limit); - linkHeaderParts.push(`<${lastUrl}>; rel="last"`); - linkMap.last = lastUrl; - } - } - - if (linkHeaderParts.length > 0) { - headers["Link"] = Array.from(new Set(linkHeaderParts)).join(", ").trim() - } - - // --- meta envelope --------------------------------------------------------- - const paginationMeta = Object.assign( - { - hasMore: pagination.hasMore, - limit: pagination.limit, - count: pagination.count, - }, - pagination.nextCursor !== undefined ? { nextCursor: pagination.nextCursor } : {}, - pagination.prevCursor !== undefined ? { prevCursor: pagination.prevCursor } : {}, - pagination.offset !== undefined ? { offset: pagination.offset } : {}, - pagination.total !== undefined ? { total: pagination.total } : {}, - (pagination.approxTotal !== undefined || pagination.total !== undefined) - ? { approxTotal: pagination.total ?? pagination.approxTotal } - : {}, - pagination.expiresAt !== undefined ? { expiresAt: pagination.expiresAt } : {}, - ) as Pagination; - - - const meta: PaginationMetadata = { pagination: paginationMeta, links: linkMap }; - - // Return typed pagination tuple with headers - return withHeaders(ok(data, 200, meta), headers as JsonHeadersWithLinks) satisfies PaginationResult -} - -/** - * Allowed values for query parameters. - * `null` and `undefined` are treated as "do not include". - */ -type QueryValue = string | number | boolean | null | undefined; - -/** - * Safely add or replace query params on a path or full URL. - * - * - Preserves existing query params - * - Works with relative paths ("/search?q=x") and full URLs ("https://example.com/search?q=x") - * - Preserves `#hash` fragments - * - * @example - * buildUrlWithParams("/search?q=superman", { cursor: "abc", limit: 20 }); - * // "/search?q=superman&cursor=abc&limit=20" - * - * @example - * buildUrlWithParams("https://example.com/list?offset=10", { offset: 30 }); - * // "https://example.com/list?offset=30" - */ -export function buildUrlWithParams( - path: string, - paramsToSet: Record, -): string { - // Split off hash fragment (if any) so we can re-attach it later - const [beforeHash, hash = ""] = path.split("#", 2); - // Split path and existing query string (if any) - const [base, existingQuery = ""] = beforeHash.split("?", 2); - - const searchParams = new URLSearchParams(existingQuery); - - // Apply new/updated params - for (const [key, value] of Object.entries(paramsToSet)) { - if (value === null || value === undefined) { - // Skip nullish values (could also choose to delete instead) - continue; - } - - let stringValue: string; - - if (typeof value === "boolean") { - stringValue = value ? "true" : "false"; - } else { - stringValue = String(value); - } - - searchParams.set(key, stringValue); - } - - const queryString = searchParams.toString(); - const hashPart = hash ? `#${hash}` : ""; - - if (queryString.length === 0) { - return `${base}${hashPart}`; - } - - return `${base}?${queryString}${hashPart}`; -} - -/** - * Helper to build cursor-based pagination URLs. - * - * - Safely adds/replaces `cursor` and `limit` - * - Preserves any existing query params - * - * @example - * buildCursorUrl("/search?q=superman", "abc123", 20); - * // "/search?q=superman&cursor=abc123&limit=20" - */ -export function buildCursorUrl( - path = "/", - cursor: string, - limit: number, -): string { - return buildUrlWithParams(path, { - cursor, - limit, - }); -} - -/** - * Helper to build offset-based pagination URLs. - * - * - Safely adds/replaces `offset` and `limit` - * - Preserves any existing query params - * - * @example - * buildOffsetUrl("/list?sort=desc", 40, 20); - * // "/list?sort=desc&offset=40&limit=20" - */ -export function buildOffsetUrl( - path: string, - offset: number, - limit: number, -): string { - return buildUrlWithParams(path, { - offset, - limit, - }); -} - -/** - * Merge extra headers into any 3-tuple response. - * - * Preserves exact body and status types, merges headers via intersection. - * Return type remains structurally compatible with ResponseResult members. - */ -export function withHeaders< - const T extends readonly [any, StatusCode, StandardHeaders], - const E extends StandardHeaders ->( - result: T, - extra: E -): readonly [T[0], T[1], T[2] & E] { - const [body, status, headers] = result - return [ - body, - status, - { ...headers, ...extra } as T[2] & E, - ] as const -} - -/** - * Merge extra metadata into the response envelope’s `meta` field. - * - * - Preserves `data` and status as-is - * - Deep-merges `meta` at the top level (shallow per key) - * - Keeps the `timestamp` from `ok()` intact - * - * @example - * const res = paginate(url, items, pagination) - * const enriched = withMeta(res, { - * query: { - * durationMs, - * filters, - * sorts, - * fields, - * source: { backend: 'supabase', adapter: 'query' }, - * }, - * }) - * return c.json(...enriched) - */ -export function withMeta< - const T extends GenericSuccessResult, - const M extends DataMetadata ->( - result: T, - extra: M -): readonly [ - SuccessEnvelope, - T[1], - T[2] -] { - const [body, status, headers] = result - - const mergedMeta = Object.assign( - {}, - body.meta ?? {}, - extra ?? {}, - ) - - return [ - Object.assign(body, { - data: body.data, - meta: mergedMeta as T[0]['meta'] & M, - }), - status, - headers, - ] as const -} - -/** - * Check if response is success - */ -export function isSuccessResponse(response: ResponseResult): response is SuccessResponse { - return response[2]['Content-Type'] !== 'application/problem+json' -} -- 2.51.2