Skip to content

Commit 4a548d0

Browse files
committed
fix: validate ActivityFeed API response schema
1 parent 6c6bc3e commit 4a548d0

1 file changed

Lines changed: 118 additions & 4 deletions

File tree

src/components/ActivityFeed.tsx

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,58 @@ interface EventType {
99
};
1010
}
1111

12+
interface FetchError {
13+
message: string;
14+
isRateLimited: boolean;
15+
statusCode?: number;
16+
}
17+
18+
// Custom error class for ActivityFeed errors
19+
class ActivityFeedError extends Error {
20+
constructor(
21+
message: string,
22+
public isRateLimited = false,
23+
public statusCode?: number
24+
) {
25+
super(message);
26+
this.name = "ActivityFeedError";
27+
}
28+
}
29+
30+
// Type guard to validate if data is EventType[]
31+
const isEventTypeArray = (data: unknown): data is EventType[] => {
32+
if (!Array.isArray(data)) return false;
33+
return data.every((item) => {
34+
if (typeof item !== "object" || item === null) return false;
35+
36+
// Validate required fields
37+
if (
38+
typeof item.id !== "string" ||
39+
typeof item.type !== "string" ||
40+
typeof item.created_at !== "string"
41+
) {
42+
return false;
43+
}
44+
45+
// Validate optional repo field
46+
if (item.repo !== undefined) {
47+
if (
48+
typeof item.repo !== "object" ||
49+
item.repo === null ||
50+
typeof item.repo.name !== "string"
51+
) {
52+
return false;
53+
}
54+
}
55+
56+
return true;
57+
});
58+
};
59+
1260
export default function ActivityFeed({ username }: { username: string }) {
1361
const [events, setEvents] = useState<EventType[]>([]);
1462
const [loading, setLoading] = useState(true);
63+
const [error, setError] = useState<FetchError | null>(null);
1564

1665
// 🕒 time ago function
1766
const getTimeAgo = (dateString: string) => {
@@ -29,16 +78,65 @@ export default function ActivityFeed({ username }: { username: string }) {
2978
const fetchEvents = async () => {
3079
try {
3180
setLoading(true);
81+
setError(null);
3282

3383
const res = await fetch(
3484
`https://api.github.com/users/${username}/events`
3585
);
86+
87+
// ✅ Check HTTP response success
88+
if (!res.ok) {
89+
const isRateLimited = res.status === 403;
90+
const errorData = await res.json().catch(() => ({}));
91+
const errorMessage =
92+
typeof errorData === "object" &&
93+
errorData !== null &&
94+
"message" in errorData &&
95+
typeof errorData.message === "string"
96+
? errorData.message
97+
: `HTTP ${res.status}: Failed to fetch events`;
98+
99+
throw new ActivityFeedError(
100+
isRateLimited
101+
? "GitHub API rate limit exceeded. Try again later."
102+
: errorMessage,
103+
isRateLimited,
104+
res.status
105+
);
106+
}
107+
36108
const data = await res.json();
37109

110+
// ✅ Validate response structure matches EventType[]
111+
if (!isEventTypeArray(data)) {
112+
throw new ActivityFeedError(
113+
"Invalid API response structure. Expected array of events.",
114+
false,
115+
200
116+
);
117+
}
118+
38119
setEvents(data);
39-
setLoading(false);
40120
} catch (err) {
41-
console.error(err);
121+
const fetchError: FetchError = {
122+
message: "Failed to fetch activity. Please try again.",
123+
isRateLimited: false,
124+
};
125+
126+
// Handle ActivityFeedError instances
127+
if (err instanceof ActivityFeedError) {
128+
fetchError.message = err.message;
129+
fetchError.isRateLimited = err.isRateLimited;
130+
fetchError.statusCode = err.statusCode;
131+
} else if (err instanceof Error) {
132+
// Handle generic errors
133+
fetchError.message = err.message || fetchError.message;
134+
}
135+
136+
setError(fetchError);
137+
console.error("ActivityFeed fetch error:", fetchError);
138+
setEvents([]);
139+
} finally {
42140
setLoading(false);
43141
}
44142
};
@@ -56,9 +154,25 @@ export default function ActivityFeed({ username }: { username: string }) {
56154
</h2>
57155

58156
{loading ? (
59-
<p className="text-center">Loading...</p>
157+
<p className="text-center text-gray-500">Loading activity...</p>
158+
) : error ? (
159+
<div className="border border-red-300 rounded-lg p-4 mb-3 bg-red-50 dark:bg-red-900/20">
160+
<p className="text-sm font-semibold text-red-700 dark:text-red-300">
161+
⚠️ {error.message}
162+
</p>
163+
{error.isRateLimited && (
164+
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
165+
You've hit GitHub's API rate limit. The limit resets in 1 hour.
166+
</p>
167+
)}
168+
{error.statusCode === 404 && (
169+
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
170+
User not found. Please check the username.
171+
</p>
172+
)}
173+
</div>
60174
) : events.length === 0 ? (
61-
<p className="text-center">No activity found</p>
175+
<p className="text-center text-gray-500">No activity found</p>
62176
) : (
63177
events.slice(0, 10).map((event) => (
64178
<div

0 commit comments

Comments
 (0)