refactor(server): http query utils for pagination

This commit is contained in:
RainbowBird
2026-03-28 02:25:44 +08:00
committed by RainbowBird
parent ab5a00b77f
commit 2714a44c5b
4 changed files with 213 additions and 25 deletions
+3 -25
View File
@@ -3,32 +3,10 @@ import type { FluxAuditService } from '../services/flux-audit'
import type { HonoEnv } from '../types/hono'
import { Hono } from 'hono'
import { fallback, integer, nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot'
import { parse } from 'valibot'
import { authGuard } from '../middlewares/auth'
const FluxHistoryQuerySchema = object({
limit: fallback(
pipe(
optional(string(), '20'),
nonEmpty(),
transform(input => Number.parseInt(input, 10)),
integer(),
transform(value => Math.min(Math.max(value, 1), 100)),
),
20,
),
offset: fallback(
pipe(
optional(string(), '0'),
nonEmpty(),
transform(input => Number.parseInt(input, 10)),
integer(),
transform(value => Math.max(value, 0)),
),
0,
),
})
import { LimitOffsetPaginationQuerySchema } from '../utils/http-query'
export function createFluxRoutes(fluxService: FluxService, fluxAuditService: FluxAuditService) {
return new Hono<HonoEnv>()
@@ -40,7 +18,7 @@ export function createFluxRoutes(fluxService: FluxService, fluxAuditService: Flu
})
.get('/history', async (c) => {
const user = c.get('user')!
const { limit, offset } = parse(FluxHistoryQuerySchema, {
const { limit, offset } = parse(LimitOffsetPaginationQuerySchema, {
limit: c.req.query('limit'),
offset: c.req.query('offset'),
})
+108
View File
@@ -0,0 +1,108 @@
import type { FluxService } from '../../services/flux'
import type { FluxAuditService } from '../../services/flux-audit'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { ApiError } from '../../utils/error'
import { createFluxRoutes } from '../flux'
function createMockFluxService(): FluxService {
return {
getFlux: vi.fn(async (userId: string) => ({ userId, flux: 42 })),
updateStripeCustomerId: vi.fn(),
} as any
}
function createMockFluxAuditService(): FluxAuditService {
return {
createEntry: vi.fn(),
createEntries: vi.fn(),
getHistory: vi.fn(async (_userId: string, limit: number, offset: number) => ({
records: [
{
id: 'ledger-1',
type: 'credit',
amount: 5,
description: 'Top up',
metadata: { source: 'test' },
createdAt: new Date('2026-03-27T10:00:00.000Z'),
},
],
hasMore: limit === 100 && offset === 0,
})),
} as any
}
function createTestApp(fluxService: FluxService, fluxAuditService: FluxAuditService) {
const routes = createFluxRoutes(fluxService, fluxAuditService)
const app = new Hono<HonoEnv>()
app.onError((err, c) => {
if (err instanceof ApiError) {
return c.json({
error: err.errorCode,
message: err.message,
details: err.details,
}, err.statusCode)
}
return c.json({ error: 'Internal Server Error', message: err.message }, 500)
})
app.use('*', async (c, next) => {
const user = (c.env as any)?.user
if (user) {
c.set('user', user)
}
await next()
})
app.route('/api/users/me/flux', routes)
return app
}
const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' }
describe('fluxRoutes', () => {
it('get /api/users/me/flux should return the current user balance', async () => {
const fluxService = createMockFluxService()
const app = createTestApp(fluxService, createMockFluxAuditService())
const res = await app.fetch(
new Request('http://localhost/api/users/me/flux'),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ userId: 'user-1', flux: 42 })
expect(fluxService.getFlux).toHaveBeenCalledWith('user-1')
})
it('get /api/users/me/flux/history should clamp pagination query values', async () => {
const fluxAuditService = createMockFluxAuditService()
const app = createTestApp(createMockFluxService(), fluxAuditService)
const res = await app.fetch(
new Request('http://localhost/api/users/me/flux/history?limit=999&offset=-12'),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
expect(fluxAuditService.getHistory).toHaveBeenCalledWith('user-1', 100, 0)
expect(await res.json()).toEqual({
records: [
{
id: 'ledger-1',
type: 'credit',
amount: 5,
description: 'Top up',
metadata: { source: 'test' },
createdAt: '2026-03-27T10:00:00.000Z',
},
],
hasMore: true,
})
})
})
+46
View File
@@ -0,0 +1,46 @@
import { fallback, integer, object, optional, pipe, string, transform } from 'valibot'
interface QueryIntegerSchemaOptions {
defaultValue: number
minimum?: number
maximum?: number
}
function clampQueryInteger(value: number, minimum?: number, maximum?: number): number {
if (minimum != null && value < minimum)
return minimum
if (maximum != null && value > maximum)
return maximum
return value
}
/**
* Parse a query-string integer with an explicit default and optional bounds.
* Invalid, missing, or empty inputs fall back to the declared default.
*/
export function createQueryIntegerSchema(options: QueryIntegerSchemaOptions) {
return fallback(
pipe(
optional(string(), String(options.defaultValue)),
transform(input => input.trim()),
transform(input => Number.parseInt(input, 10)),
integer(),
transform(value => clampQueryInteger(value, options.minimum, options.maximum)),
),
options.defaultValue,
)
}
export const LimitOffsetPaginationQuerySchema = object({
limit: createQueryIntegerSchema({
defaultValue: 20,
minimum: 1,
maximum: 100,
}),
offset: createQueryIntegerSchema({
defaultValue: 0,
minimum: 0,
}),
})
@@ -0,0 +1,56 @@
import { parse } from 'valibot'
import { describe, expect, it } from 'vitest'
import { createQueryIntegerSchema, LimitOffsetPaginationQuerySchema } from '../http-query'
describe('http query utils', () => {
it('uses the declared default when the query value is missing', () => {
const schema = createQueryIntegerSchema({
defaultValue: 20,
minimum: 1,
maximum: 100,
})
expect(parse(schema, undefined)).toBe(20)
})
it('falls back to the declared default when the query value is invalid', () => {
const schema = createQueryIntegerSchema({
defaultValue: 20,
minimum: 1,
maximum: 100,
})
expect(parse(schema, 'NaN')).toBe(20)
expect(parse(schema, '')).toBe(20)
})
it('clamps values to the declared bounds', () => {
const schema = createQueryIntegerSchema({
defaultValue: 20,
minimum: 1,
maximum: 100,
})
expect(parse(schema, '-12')).toBe(1)
expect(parse(schema, '999')).toBe(100)
})
it('parses limit/offset pagination queries with defaults and clamping', () => {
expect(parse(LimitOffsetPaginationQuerySchema, {
limit: undefined,
offset: undefined,
})).toEqual({
limit: 20,
offset: 0,
})
expect(parse(LimitOffsetPaginationQuerySchema, {
limit: '999',
offset: '-5',
})).toEqual({
limit: 100,
offset: 0,
})
})
})