Skip to content

Commit dd69074

Browse files
authored
Merge pull request #52 from okekefrancis112/creator
feat: add safe integer parser for query params
2 parents 853027f + 0428420 commit dd69074

5 files changed

Lines changed: 153 additions & 49 deletions

File tree

src/modules/creator/creator.controller.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,43 @@
11
// src/modules/creator/creator.controller.ts
22
import { Request, Response } from 'express';
3+
import { ZodError } from 'zod';
4+
import { z } from 'zod';
35
import {
46
sendPaginatedSuccess,
57
sendError,
8+
sendValidationError,
69
ErrorCode,
710
} from '../../utils/api-response.utils';
811
import { getPaginatedCreators } from './creator.service';
912
import { parseCreatorSortOptions } from './creator.utils';
13+
import { safeIntParam } from '../../utils/query.utils';
1014
import {
11-
validatePageSize,
12-
PageSizeExceededError,
13-
} from '../../utils/pagination-guard.utils';
15+
DEFAULT_PAGE,
16+
DEFAULT_PAGE_SIZE,
17+
MIN_PAGE_SIZE,
18+
MAX_PAGE_SIZE,
19+
} from '../../constants/pagination.constants';
20+
21+
const LegacyCreatorQuerySchema = z.object({
22+
page: safeIntParam({
23+
defaultValue: DEFAULT_PAGE,
24+
min: MIN_PAGE_SIZE,
25+
max: Number.MAX_SAFE_INTEGER,
26+
label: 'Page',
27+
}),
28+
limit: safeIntParam({
29+
defaultValue: DEFAULT_PAGE_SIZE,
30+
min: MIN_PAGE_SIZE,
31+
max: MAX_PAGE_SIZE,
32+
label: 'Limit',
33+
}),
34+
sortBy: z.string().optional(),
35+
sortOrder: z.string().optional(),
36+
});
1437

1538
export async function listCreators(req: Request, res: Response) {
1639
try {
17-
const page = parseInt(req.query.page as string) || 1;
18-
const limitInput = parseInt(req.query.limit as string) || 10;
19-
const sortBy = req.query.sortBy as string;
20-
const sortOrder = req.query.sortOrder as string;
21-
22-
if (page < 1) {
23-
return sendError(
24-
res,
25-
400,
26-
ErrorCode.VALIDATION_ERROR,
27-
'Invalid pagination parameters'
28-
);
29-
}
30-
31-
// Validate page size using the reusable guard
32-
const limit = validatePageSize(limitInput);
40+
const { page, limit, sortBy, sortOrder } = LegacyCreatorQuerySchema.parse(req.query);
3341

3442
const sort = parseCreatorSortOptions(sortBy, sortOrder);
3543

@@ -47,8 +55,12 @@ export async function listCreators(req: Request, res: Response) {
4755
'Creators retrieved successfully'
4856
);
4957
} catch (error) {
50-
if (error instanceof PageSizeExceededError) {
51-
return sendError(res, 400, ErrorCode.VALIDATION_ERROR, error.message);
58+
if (error instanceof ZodError) {
59+
const details = error.errors.map(err => ({
60+
field: err.path.join('.'),
61+
message: err.message,
62+
}));
63+
return sendValidationError(res, 'Invalid query parameters', details);
5264
}
5365
console.error('Error listing creators:', error);
5466
return sendError(

src/modules/creators/creators.controllers.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
serializeCreatorList,
66
CreatorListResponse,
77
} from './creators.serializers';
8+
import { mapPublicCreatorStats } from './creators.stats';
89
import {
910
sendSuccess,
1011
sendValidationError,
@@ -48,3 +49,38 @@ export const httpListCreators: AsyncController = async (req, res, next) => {
4849
next(error);
4950
}
5051
};
52+
53+
/**
54+
* Controller for GET /api/v1/creators/:id/stats
55+
*
56+
* Returns public stats for a specific creator.
57+
* Validates creator ID and applies caching via middleware.
58+
*/
59+
export const httpGetCreatorStats: AsyncController = async (req, res, next) => {
60+
try {
61+
const { id } = req.params;
62+
63+
// Validate creator ID format (basic validation)
64+
if (!id || typeof id !== 'string') {
65+
return sendValidationError(res, 'Invalid creator ID', [
66+
{ field: 'id', message: 'Creator ID must be a valid string' },
67+
]);
68+
}
69+
70+
// TODO: Fetch actual creator metrics from database/service
71+
// For now, return placeholder data
72+
const placeholderMetrics = {
73+
holderCount: 0,
74+
totalSupply: 0,
75+
totalVolume: 0,
76+
lastActivityAt: undefined,
77+
};
78+
79+
// Serialize using the public stats mapper
80+
const stats = mapPublicCreatorStats(placeholderMetrics);
81+
82+
sendSuccess(res, stats);
83+
} catch (error) {
84+
next(error);
85+
}
86+
};

src/modules/creators/creators.schemas.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import {
33
CREATOR_LIST_SORT_OPTIONS,
44
CREATOR_LIST_SORT_ORDERS,
55
} from './creators.sort';
6+
import { safeIntParam } from '../../utils/query.utils';
67
import {
7-
MAX_PAGE_SIZE,
8+
DEFAULT_PAGE_SIZE,
9+
DEFAULT_OFFSET,
810
MIN_PAGE_SIZE,
11+
MAX_PAGE_SIZE,
912
} from '../../constants/pagination.constants';
1013

1114
/**
@@ -19,22 +22,18 @@ import {
1922
*/
2023
export const CreatorListQuerySchema = z.object({
2124
// Pagination
22-
limit: z
23-
.string()
24-
.optional()
25-
.default('20')
26-
.transform(val => parseInt(val, 10))
27-
.refine(val => val >= MIN_PAGE_SIZE && val <= MAX_PAGE_SIZE, {
28-
message: `Limit must be between ${MIN_PAGE_SIZE} and ${MAX_PAGE_SIZE}`,
29-
}),
30-
offset: z
31-
.string()
32-
.optional()
33-
.default('0')
34-
.transform(val => parseInt(val, 10))
35-
.refine(val => val >= 0, {
36-
message: 'Offset must be non-negative',
37-
}),
25+
limit: safeIntParam({
26+
defaultValue: DEFAULT_PAGE_SIZE,
27+
min: MIN_PAGE_SIZE,
28+
max: MAX_PAGE_SIZE,
29+
label: 'Limit',
30+
}),
31+
offset: safeIntParam({
32+
defaultValue: DEFAULT_OFFSET,
33+
min: 0,
34+
max: Number.MAX_SAFE_INTEGER,
35+
label: 'Offset',
36+
}),
3837

3938
// Sorting
4039
sort: z.enum(CREATOR_LIST_SORT_OPTIONS).optional().default('createdAt'),
Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
// src/modules/creators/creators.stats.ts
2-
// Helper for formatting public creator stats in API responses.
2+
// Mapper for shaping public creator stats in API responses.
33

44
import { CreatorMetrics } from '../../types/profile.types';
55

6+
/**
7+
* Public field names exposed by the stats mapper.
8+
* Single source of truth for what gets included in public stats responses.
9+
*/
10+
export const CREATOR_STATS_FIELDS = [
11+
'holderCount',
12+
'totalSupply',
13+
'totalVolume',
14+
'lastActivityAt',
15+
] as const;
16+
17+
export type CreatorStatsField = (typeof CREATOR_STATS_FIELDS)[number];
18+
619
/**
720
* Public-facing creator stats shape.
821
*
@@ -17,27 +30,45 @@ export interface PublicCreatorStats {
1730
}
1831

1932
/**
20-
* Format a CreatorMetrics object into a public stats response.
33+
* Maps each public stats field to its corresponding internal CreatorMetrics key.
34+
* Currently 1:1, but the indirection lets internal field names change
35+
* without breaking the public API contract.
36+
*
37+
* Uses `as const satisfies` to retain literal types for type-safe indexing
38+
* while enforcing that all public fields map to valid CreatorMetrics keys.
39+
*/
40+
const CREATOR_STATS_FIELD_MAP = {
41+
holderCount: 'holderCount',
42+
totalSupply: 'totalSupply',
43+
totalVolume: 'totalVolume',
44+
lastActivityAt: 'lastActivityAt',
45+
} as const satisfies Record<CreatorStatsField, keyof CreatorMetrics>;
46+
47+
/**
48+
* Map a CreatorMetrics object into a public stats response.
2149
*
22-
* Centralizes the public stats shape so all creator endpoints
23-
* return a consistent structure.
50+
* Uses CREATOR_STATS_FIELD_MAP to build the output, ensuring only
51+
* mapped fields are included. Optional fields are omitted when undefined.
2452
*
2553
* @param metrics - Internal creator metrics
2654
* @returns Public stats object safe for API responses
2755
*
2856
* @example
29-
* serializePublicCreatorStats({ holderCount: 10, totalSupply: 100, totalVolume: 500 })
57+
* mapPublicCreatorStats({ holderCount: 10, totalSupply: 100, totalVolume: 500 })
3058
* // => { holderCount: 10, totalSupply: 100, totalVolume: 500 }
3159
*/
32-
export function serializePublicCreatorStats(
60+
export function mapPublicCreatorStats(
3361
metrics: CreatorMetrics
3462
): PublicCreatorStats {
3563
return {
36-
holderCount: metrics.holderCount,
37-
totalSupply: metrics.totalSupply,
38-
totalVolume: metrics.totalVolume,
39-
...(metrics.lastActivityAt !== undefined
40-
? { lastActivityAt: metrics.lastActivityAt }
64+
holderCount: metrics[CREATOR_STATS_FIELD_MAP.holderCount],
65+
totalSupply: metrics[CREATOR_STATS_FIELD_MAP.totalSupply],
66+
totalVolume: metrics[CREATOR_STATS_FIELD_MAP.totalVolume],
67+
...(metrics[CREATOR_STATS_FIELD_MAP.lastActivityAt] !== undefined
68+
? {
69+
lastActivityAt:
70+
metrics[CREATOR_STATS_FIELD_MAP.lastActivityAt],
71+
}
4172
: {}),
4273
};
4374
}

src/utils/query.utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { z } from 'zod';
2+
3+
/**
4+
* Creates a Zod schema for safely parsing an integer query parameter.
5+
*
6+
* Accepts a string (as Express delivers query params), applies a default,
7+
* converts to integer, and validates within [min, max].
8+
* Non-numeric strings resolve to NaN, which fails the bounds refine.
9+
*/
10+
export function safeIntParam(options: {
11+
defaultValue: number;
12+
min: number;
13+
max: number;
14+
label: string;
15+
}) {
16+
const { defaultValue, min, max, label } = options;
17+
18+
return z
19+
.string()
20+
.optional()
21+
.default(String(defaultValue))
22+
.transform(val => parseInt(val, 10))
23+
.refine(val => !Number.isNaN(val) && val >= min && val <= max, {
24+
message: `${label} must be an integer between ${min} and ${max}`,
25+
});
26+
}

0 commit comments

Comments
 (0)