Skip to content

Commit 411fb38

Browse files
authored
Merge pull request #165 from Ebuka321/fix/remove-unsafe-any-types-api
fix: remove unsafe any types from API details
2 parents f02bf6c + 2dabeed commit 411fb38

3 files changed

Lines changed: 307 additions & 57 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"react-native-safe-area-context": "5.4.0",
6363
"react-native-screens": "~4.11.1",
6464
"react-native-svg": "15.11.2",
65+
"zod": "^3.23.8",
6566
"zustand": "^4.5.2"
6667
},
6768
"devDependencies": {

src/types/api.ts

Lines changed: 233 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,233 @@
1-
export interface ApiResponse<T> {
2-
success: boolean;
3-
data?: T;
4-
error?: string;
5-
message?: string;
6-
}
7-
8-
export interface PaginatedResponse<T> {
9-
data: T[];
10-
total: number;
11-
page: number;
12-
limit: number;
13-
hasNext: boolean;
14-
hasPrev: boolean;
15-
}
16-
17-
export interface NotificationPreferences {
18-
pushEnabled: boolean;
19-
emailEnabled: boolean;
20-
billingReminders: boolean;
21-
cryptoUpdates: boolean;
22-
spendingAlerts: boolean;
23-
}
24-
25-
export interface UserProfile {
26-
id: string;
27-
email: string;
28-
name: string;
29-
avatar?: string;
30-
preferences: NotificationPreferences;
31-
createdAt: Date;
32-
updatedAt: Date;
33-
}
34-
35-
export interface AppSettings {
36-
theme: 'light' | 'dark' | 'system';
37-
currency: string;
38-
language: string;
39-
notifications: NotificationPreferences;
40-
privacy: {
41-
dataSharing: boolean;
42-
analytics: boolean;
43-
};
44-
}
45-
46-
export interface ErrorState {
47-
message: string;
48-
code?: string;
49-
details?: Record<string, unknown>;
50-
timestamp: Date;
51-
}
52-
53-
export interface LoadingState {
54-
isLoading: boolean;
55-
message?: string;
56-
progress?: number;
57-
}
1+
import { z } from 'zod';
2+
3+
export const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
4+
z.object({
5+
success: z.boolean(),
6+
data: dataSchema.optional(),
7+
error: z.string().optional(),
8+
message: z.string().optional(),
9+
});
10+
11+
export const PaginatedResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
12+
z.object({
13+
data: z.array(dataSchema),
14+
total: z.number(),
15+
page: z.number(),
16+
limit: z.number(),
17+
hasNext: z.boolean(),
18+
hasPrev: z.boolean(),
19+
});
20+
21+
export const NotificationPreferencesSchema = z.object({
22+
pushEnabled: z.boolean(),
23+
emailEnabled: z.boolean(),
24+
billingReminders: z.boolean(),
25+
cryptoUpdates: z.boolean(),
26+
spendingAlerts: z.boolean(),
27+
});
28+
29+
export const UserProfileSchema = z.object({
30+
id: z.string(),
31+
email: z.string().email(),
32+
name: z.string(),
33+
avatar: z.string().optional(),
34+
preferences: NotificationPreferencesSchema,
35+
createdAt: z.union([z.string(), z.date()]),
36+
updatedAt: z.union([z.string(), z.date()]),
37+
});
38+
39+
export const AppSettingsSchema = z.object({
40+
theme: z.enum(['light', 'dark', 'system']),
41+
currency: z.string(),
42+
language: z.string(),
43+
notifications: NotificationPreferencesSchema,
44+
privacy: z.object({
45+
dataSharing: z.boolean(),
46+
analytics: z.boolean(),
47+
}),
48+
});
49+
50+
export const ErrorStateSchema = z.object({
51+
message: z.string(),
52+
code: z.string().optional(),
53+
details: z.record(z.unknown()).optional(),
54+
timestamp: z.union([z.string(), z.date()]),
55+
});
56+
57+
export const LoadingStateSchema = z.object({
58+
isLoading: z.boolean(),
59+
message: z.string().optional(),
60+
progress: z.number().optional(),
61+
});
62+
63+
export const SubscriptionCategorySchema = z.enum([
64+
'streaming',
65+
'software',
66+
'gaming',
67+
'productivity',
68+
'fitness',
69+
'education',
70+
'finance',
71+
'other',
72+
]);
73+
74+
export const BillingCycleSchema = z.enum(['monthly', 'yearly', 'weekly', 'custom']);
75+
76+
export const SubscriptionSchema = z.object({
77+
id: z.string(),
78+
name: z.string(),
79+
description: z.string().optional(),
80+
category: SubscriptionCategorySchema,
81+
price: z.number(),
82+
currency: z.string(),
83+
billingCycle: BillingCycleSchema,
84+
nextBillingDate: z.union([z.string(), z.date()]),
85+
isActive: z.boolean(),
86+
notificationsEnabled: z.boolean().optional(),
87+
isCryptoEnabled: z.boolean(),
88+
cryptoStreamId: z.string().optional(),
89+
cryptoToken: z.string().optional(),
90+
cryptoAmount: z.number().optional(),
91+
gasBudget: z.number().optional(),
92+
totalGasSpent: z.number().optional(),
93+
chargeCount: z.number().optional(),
94+
lastGasCost: z.number().optional(),
95+
createdAt: z.union([z.string(), z.date()]),
96+
updatedAt: z.union([z.string(), z.date()]),
97+
});
98+
99+
export const SubscriptionStatsSchema = z.object({
100+
totalActive: z.number(),
101+
totalMonthlySpend: z.number(),
102+
totalYearlySpend: z.number(),
103+
categoryBreakdown: z.record(SubscriptionCategorySchema, z.number()),
104+
totalGasSpent: z.number().optional(),
105+
});
106+
107+
export const SubscriptionFormDataSchema = SubscriptionSchema.omit({
108+
id: true,
109+
isActive: true,
110+
createdAt: true,
111+
updatedAt: true,
112+
totalGasSpent: true,
113+
chargeCount: true,
114+
lastGasCost: true,
115+
cryptoStreamId: true,
116+
}).partial({
117+
notificationsEnabled: true,
118+
description: true,
119+
cryptoToken: true,
120+
cryptoAmount: true,
121+
gasBudget: true,
122+
});
123+
124+
export const TokenBalanceSchema = z.object({
125+
symbol: z.string(),
126+
name: z.string(),
127+
address: z.string(),
128+
balance: z.string(),
129+
decimals: z.number(),
130+
logoURI: z.string().optional(),
131+
});
132+
133+
export const WalletConnectionSchema = z.object({
134+
address: z.string(),
135+
chainId: z.number(),
136+
isConnected: z.boolean(),
137+
provider: z.unknown().optional(),
138+
eip1193Provider: z.unknown().optional(),
139+
});
140+
141+
export const CryptoStreamSchema = z.object({
142+
id: z.string(),
143+
subscriptionId: z.string(),
144+
token: z.string(),
145+
amount: z.number(),
146+
flowRate: z.string(),
147+
startDate: z.union([z.string(), z.date()]),
148+
endDate: z.union([z.string(), z.date()]).optional(),
149+
isActive: z.boolean(),
150+
protocol: z.enum(['superfluid', 'sablier']),
151+
streamId: z.string().optional(),
152+
});
153+
154+
export const StreamSetupSchema = z.object({
155+
token: z.string(),
156+
amount: z.number(),
157+
flowRate: z.string(),
158+
startDate: z.union([z.string(), z.date()]),
159+
endDate: z.union([z.string(), z.date()]).optional(),
160+
protocol: z.enum(['superfluid', 'sablier']),
161+
});
162+
163+
export const GasEstimateSchema = z.object({
164+
gasLimit: z.string(),
165+
gasPrice: z.string(),
166+
estimatedCost: z.string(),
167+
});
168+
169+
export const TransactionSchema = z.object({
170+
hash: z.string(),
171+
from: z.string(),
172+
to: z.string(),
173+
value: z.string(),
174+
gasUsed: z.string(),
175+
gasPrice: z.string(),
176+
status: z.enum(['pending', 'confirmed', 'failed']),
177+
timestamp: z.union([z.string(), z.date()]),
178+
});
179+
180+
export const QueuedTransactionPayloadSchema = z.object({
181+
protocol: z.enum(['superfluid', 'sablier']),
182+
token: z.string(),
183+
amount: z.string(),
184+
recipientAddress: z.string(),
185+
chainId: z.number(),
186+
startTime: z.number().optional(),
187+
stopTime: z.number().optional(),
188+
});
189+
190+
export const QueuedTransactionSchema = z.object({
191+
id: z.string(),
192+
createdAt: z.number(),
193+
updatedAt: z.number(),
194+
attempts: z.number(),
195+
lastAttemptAt: z.number().optional(),
196+
conflictKey: z.string(),
197+
status: z.enum(['pending', 'processing']),
198+
payload: QueuedTransactionPayloadSchema,
199+
lastError: z.string().optional(),
200+
});
201+
202+
export const SuperfluidStreamResultSchema = z.object({
203+
txHash: z.string(),
204+
streamId: z.string(),
205+
});
206+
207+
export const ExecuteOrQueueResultSchema = z.object({
208+
queued: z.boolean(),
209+
transactionId: z.string(),
210+
streamId: z.string().optional(),
211+
txHash: z.string().optional(),
212+
});
213+
214+
export type ApiResponse<T> = z.infer<ReturnType<typeof ApiResponseSchema>>;
215+
export type PaginatedResponse<T> = z.infer<ReturnType<typeof PaginatedResponseSchema>>;
216+
export type NotificationPreferences = z.infer<typeof NotificationPreferencesSchema>;
217+
export type UserProfile = z.infer<typeof UserProfileSchema>;
218+
export type AppSettings = z.infer<typeof AppSettingsSchema>;
219+
export type ErrorState = z.infer<typeof ErrorStateSchema>;
220+
export type LoadingState = z.infer<typeof LoadingStateSchema>;
221+
export type Subscription = z.infer<typeof SubscriptionSchema>;
222+
export type SubscriptionStats = z.infer<typeof SubscriptionStatsSchema>;
223+
export type SubscriptionFormData = z.infer<typeof SubscriptionFormDataSchema>;
224+
export type TokenBalance = z.infer<typeof TokenBalanceSchema>;
225+
export type WalletConnection = z.infer<typeof WalletConnectionSchema>;
226+
export type CryptoStream = z.infer<typeof CryptoStreamSchema>;
227+
export type StreamSetup = z.infer<typeof StreamSetupSchema>;
228+
export type GasEstimate = z.infer<typeof GasEstimateSchema>;
229+
export type Transaction = z.infer<typeof TransactionSchema>;
230+
export type QueuedTransactionPayload = z.infer<typeof QueuedTransactionPayloadSchema>;
231+
export type QueuedTransaction = z.infer<typeof QueuedTransactionSchema>;
232+
export type SuperfluidStreamResult = z.infer<typeof SuperfluidStreamResultSchema>;
233+
export type ExecuteOrQueueResult = z.infer<typeof ExecuteOrQueueResultSchema>;

src/utils/validation.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { z } from 'zod';
2+
3+
export class ValidationError extends Error {
4+
constructor(
5+
message: string,
6+
public readonly issues: z.ZodIssue[]
7+
) {
8+
super(message);
9+
this.name = 'ValidationError';
10+
}
11+
}
12+
13+
export function parseResponse<T>(
14+
schema: z.ZodSchema<T>,
15+
data: unknown,
16+
context = 'API response'
17+
): T {
18+
const result = schema.safeParse(data);
19+
20+
if (!result.success) {
21+
const issues = result.error.issues;
22+
const summary = issues
23+
.slice(0, 3)
24+
.map((i) => `${i.path.join('.')}: ${i.message}`)
25+
.join('; ');
26+
throw new ValidationError(`${context} validation failed: ${summary}`, issues);
27+
}
28+
29+
return result.data;
30+
}
31+
32+
export function tryParseResponse<T>(
33+
schema: z.ZodSchema<T>,
34+
data: unknown,
35+
context = 'API response'
36+
): { success: true; data: T } | { success: false; error: ValidationError } {
37+
const result = schema.safeParse(data);
38+
39+
if (!result.success) {
40+
const issues = result.error.issues;
41+
const summary = issues
42+
.slice(0, 3)
43+
.map((i) => `${i.path.join('.')}: ${i.message}`)
44+
.join('; ');
45+
return {
46+
success: false,
47+
error: new ValidationError(`${context} validation failed: ${summary}`, issues),
48+
};
49+
}
50+
51+
return { success: true, data: result.data };
52+
}
53+
54+
export function parseDate(value: unknown): Date | null {
55+
if (value instanceof Date) {
56+
return Number.isNaN(value.getTime()) ? null : value;
57+
}
58+
if (typeof value === 'string' || typeof value === 'number') {
59+
const date = new Date(value);
60+
return Number.isNaN(date.getTime()) ? null : date;
61+
}
62+
return null;
63+
}
64+
65+
export function coerceDate(schema: z.ZodType<Date>): z.ZodType<Date> {
66+
return z.union([schema, z.string(), z.number()]).transform((val) => {
67+
const date = parseDate(val);
68+
if (!date) {
69+
throw new Error('Invalid date');
70+
}
71+
return date;
72+
}) as z.ZodType<Date>;
73+
}

0 commit comments

Comments
 (0)