feat(server): api-helpers fuer parsePositiveIntParam + validateBody
- src/lib/server/api-helpers.ts mit parsePositiveIntParam(), validateBody<T>() und ErrorResponse type - 13 unit tests fuer die beiden helper (HttpError-Shape verifiziert) - Konsolidiert spaeter 9x parseId und 11x safeParse-Bloecke aus den +server.ts handlern Findings aus REVIEW-2026-04-18.md (Refactor A) und redundancy.md
This commit is contained in:
39
src/lib/server/api-helpers.ts
Normal file
39
src/lib/server/api-helpers.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { ZodSchema } from 'zod';
|
||||||
|
|
||||||
|
// Shared error body shape for SvelteKit `error()` calls. `issues` is set
|
||||||
|
// when validateBody fails so the client can show a precise validation
|
||||||
|
// hint; everywhere else only `message` is used.
|
||||||
|
export type ErrorResponse = {
|
||||||
|
message: string;
|
||||||
|
issues?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a route param (or query param) as a positive integer (>=1).
|
||||||
|
* Throws SvelteKit `error(400)` with `Missing <field>` when null/undefined,
|
||||||
|
* or `Invalid <field>` when the value is not an integer >= 1.
|
||||||
|
*/
|
||||||
|
export function parsePositiveIntParam(
|
||||||
|
raw: string | undefined | null,
|
||||||
|
field: string
|
||||||
|
): number {
|
||||||
|
if (raw == null) error(400, { message: `Missing ${field}` });
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` });
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an unknown body against a Zod schema. Throws SvelteKit
|
||||||
|
* `error(400, { message: 'Invalid body', issues })` on mismatch and returns
|
||||||
|
* the typed parse result on success. Accepts `null` (the typical result of
|
||||||
|
* `await request.json().catch(() => null)`).
|
||||||
|
*/
|
||||||
|
export function validateBody<T>(body: unknown, schema: ZodSchema<T>): T {
|
||||||
|
const parsed = schema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
error(400, { message: 'Invalid body', issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
95
tests/unit/api-helpers.test.ts
Normal file
95
tests/unit/api-helpers.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parsePositiveIntParam, validateBody } from '../../src/lib/server/api-helpers';
|
||||||
|
|
||||||
|
// SvelteKit's `error()` throws an HttpError shape with { status, body }.
|
||||||
|
// We verify both — wrapping these everywhere costs nothing and keeps the
|
||||||
|
// API contract stable.
|
||||||
|
|
||||||
|
function expectHttpError(fn: () => unknown, status: number, message?: string) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { status?: number; body?: { message?: string } };
|
||||||
|
expect(e.status, `status should be ${status}`).toBe(status);
|
||||||
|
if (message !== undefined) {
|
||||||
|
expect(e.body?.message).toBe(message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('expected fn to throw, but it returned normally');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parsePositiveIntParam', () => {
|
||||||
|
it('parses a valid positive integer', () => {
|
||||||
|
expect(parsePositiveIntParam('42', 'id')).toBe(42);
|
||||||
|
expect(parsePositiveIntParam('1', 'id')).toBe(1);
|
||||||
|
expect(parsePositiveIntParam('999999', 'id')).toBe(999999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for zero', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('0', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for negative numbers', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('-1', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for non-integer', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('1.5', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for non-numeric strings', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('abc', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for empty string', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for null', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam(null, 'id'), 400, 'Missing id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for undefined', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam(undefined, 'id'), 400, 'Missing id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the provided field name in error messages', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('foo', 'recipe_id'), 400, 'Invalid recipe_id');
|
||||||
|
expectHttpError(() => parsePositiveIntParam(null, 'recipe_id'), 400, 'Missing recipe_id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateBody', () => {
|
||||||
|
const Schema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
age: z.number().int().nonnegative()
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns parsed data when valid', () => {
|
||||||
|
const result = validateBody({ name: 'foo', age: 42 }, Schema);
|
||||||
|
expect(result).toEqual({ name: 'foo', age: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 with message and issues on schema mismatch', () => {
|
||||||
|
try {
|
||||||
|
validateBody({ name: '', age: -1 }, Schema);
|
||||||
|
throw new Error('expected throw');
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { status?: number; body?: { message?: string; issues?: unknown[] } };
|
||||||
|
expect(e.status).toBe(400);
|
||||||
|
expect(e.body?.message).toBe('Invalid body');
|
||||||
|
expect(Array.isArray(e.body?.issues)).toBe(true);
|
||||||
|
expect(e.body?.issues?.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for null body (request.json failure case)', () => {
|
||||||
|
expectHttpError(() => validateBody(null, Schema), 400, 'Invalid body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for primitive non-object body', () => {
|
||||||
|
expectHttpError(() => validateBody('a string', Schema), 400, 'Invalid body');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user