Skip to content

Commit 21d34f4

Browse files
committed
fix(contributor-profile): add GitHub response validation
1 parent 53f820b commit 21d34f4

2 files changed

Lines changed: 182 additions & 10 deletions

File tree

src/pages/ContributorProfile/ContributorProfile.tsx

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,96 @@ type PR = {
1111
type Profile = {
1212
avatar_url: string;
1313
login: string;
14-
bio: string;
14+
bio: string | null;
15+
};
16+
17+
const isProfile = (data: unknown): data is Profile => {
18+
return (
19+
typeof data === "object" &&
20+
data !== null &&
21+
typeof (data as any).avatar_url === "string" &&
22+
typeof (data as any).login === "string" &&
23+
(typeof (data as any).bio === "string" || (data as any).bio === null)
24+
);
25+
};
26+
27+
const isPrArray = (data: unknown): data is PR[] => {
28+
return (
29+
Array.isArray(data) &&
30+
data.every(
31+
(item) =>
32+
typeof item === "object" &&
33+
item !== null &&
34+
typeof (item as any).title === "string" &&
35+
typeof (item as any).html_url === "string" &&
36+
typeof (item as any).repository_url === "string"
37+
)
38+
);
1539
};
1640

1741
export default function ContributorProfile() {
1842
const { username } = useParams();
1943
const [profile, setProfile] = useState<Profile | null>(null);
2044
const [prs, setPRs] = useState<PR[]>([]);
2145
const [loading, setLoading] = useState(true);
46+
const [error, setError] = useState<string | null>(null);
2247

2348
useEffect(() => {
2449
async function fetchData() {
25-
if (!username) return;
50+
if (!username) {
51+
setError("No username provided.");
52+
setLoading(false);
53+
return;
54+
}
55+
56+
setLoading(true);
57+
setError(null);
2658

2759
try {
2860
const userRes = await fetch(`https://api.github.com/users/${username}`);
61+
if (!userRes.ok) {
62+
if (userRes.status === 404) {
63+
throw new Error("User not found.");
64+
}
65+
if (userRes.status === 403) {
66+
throw new Error("GitHub API rate limit exceeded. Please try again later.");
67+
}
68+
throw new Error(`GitHub user fetch failed with status ${userRes.status}.`);
69+
}
70+
2971
const userData = await userRes.json();
72+
if (!isProfile(userData)) {
73+
throw new Error("Invalid GitHub profile response.");
74+
}
3075
setProfile(userData);
3176

3277
const prsRes = await fetch(
3378
`https://api.github.com/search/issues?q=author:${username}+type:pr`
3479
);
80+
if (!prsRes.ok) {
81+
if (prsRes.status === 403) {
82+
throw new Error("GitHub API rate limit exceeded. Please try again later.");
83+
}
84+
throw new Error(`GitHub PR fetch failed with status ${prsRes.status}.`);
85+
}
86+
3587
const prsData = await prsRes.json();
36-
setPRs(prsData.items);
37-
} catch {
38-
toast.error("Failed to fetch user data.");
88+
if (
89+
typeof prsData !== "object" ||
90+
prsData === null ||
91+
!Array.isArray((prsData as any).items) ||
92+
!isPrArray((prsData as any).items)
93+
) {
94+
throw new Error("Invalid GitHub PR response.");
95+
}
96+
97+
setPRs((prsData as any).items);
98+
} catch (err) {
99+
const message = err instanceof Error ? err.message : "Failed to fetch user data.";
100+
setError(message);
101+
toast.error(message);
102+
setProfile(null);
103+
setPRs([]);
39104
} finally {
40105
setLoading(false);
41106
}
@@ -51,6 +116,11 @@ export default function ContributorProfile() {
51116

52117
if (loading) return <div className="text-center mt-10">Loading...</div>;
53118

119+
if (error)
120+
return (
121+
<div className="text-center mt-10 text-red-600">{error}</div>
122+
);
123+
54124
if (!profile)
55125
return (
56126
<div className="text-center mt-10 text-red-600">User not found.</div>

src/pages/Contributors/Contributors.tsx

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,104 @@ interface Contributor {
2424
html_url: string;
2525
}
2626

27+
interface FetchError {
28+
message: string;
29+
isRateLimited: boolean;
30+
statusCode?: number;
31+
}
32+
33+
// Custom error class for Contributors fetch errors
34+
class ContributorsError extends Error {
35+
constructor(
36+
message: string,
37+
public isRateLimited = false,
38+
public statusCode?: number
39+
) {
40+
super(message);
41+
this.name = "ContributorsError";
42+
}
43+
}
44+
45+
// Type guard to validate if data is Contributor[]
46+
const isContributorArray = (data: unknown): data is Contributor[] => {
47+
if (!Array.isArray(data)) return false;
48+
return data.every((item) => {
49+
if (typeof item !== "object" || item === null) return false;
50+
51+
// Validate all required fields with correct types
52+
return (
53+
typeof item.id === "number" &&
54+
typeof item.login === "string" &&
55+
typeof item.avatar_url === "string" &&
56+
typeof item.contributions === "number" &&
57+
typeof item.html_url === "string"
58+
);
59+
});
60+
};
61+
2762
const ContributorsPage = () => {
2863
const [contributors, setContributors] = useState<Contributor[]>([]);
2964
const [loading, setLoading] = useState<boolean>(true);
30-
const [error, setError] = useState<string | null>(null);
65+
const [error, setError] = useState<FetchError | null>(null);
3166

3267
// Fetch contributors from GitHub API
3368
useEffect(() => {
3469
const fetchContributors = async () => {
3570
try {
71+
setLoading(true);
72+
setError(null);
73+
3674
const response = await axios.get(GITHUB_REPO_CONTRIBUTORS_URL, {
3775
withCredentials: false,
76+
timeout: 10000,
3877
});
78+
79+
// ✅ Validate response structure matches Contributor[]
80+
if (!isContributorArray(response.data)) {
81+
throw new ContributorsError(
82+
"Invalid API response structure. Expected array of contributors.",
83+
false
84+
);
85+
}
86+
3987
setContributors(response.data);
40-
} catch {
41-
setError("Failed to fetch contributors. Please try again later.");
88+
} catch (err) {
89+
const fetchError: FetchError = {
90+
message: "Failed to fetch contributors. Please try again later.",
91+
isRateLimited: false,
92+
};
93+
94+
// Handle ContributorsError instances
95+
if (err instanceof ContributorsError) {
96+
fetchError.message = err.message;
97+
fetchError.isRateLimited = err.isRateLimited;
98+
fetchError.statusCode = err.statusCode;
99+
} else if (axios.isAxiosError(err)) {
100+
// Handle Axios errors
101+
if (err.response?.status === 403) {
102+
fetchError.message =
103+
"GitHub API rate limit exceeded. Try again later.";
104+
fetchError.isRateLimited = true;
105+
fetchError.statusCode = 403;
106+
} else if (err.response?.status === 404) {
107+
fetchError.message = "Repository not found.";
108+
fetchError.statusCode = 404;
109+
} else if (err.code === "ECONNABORTED") {
110+
fetchError.message = "Request timeout. Server took too long to respond.";
111+
fetchError.statusCode = 408;
112+
} else if (err.response?.status) {
113+
fetchError.message = `HTTP ${err.response.status}: Failed to fetch contributors`;
114+
fetchError.statusCode = err.response.status;
115+
} else if (err.message) {
116+
fetchError.message = err.message;
117+
}
118+
} else if (err instanceof Error) {
119+
fetchError.message = err.message || fetchError.message;
120+
}
121+
122+
setError(fetchError);
123+
console.error("Contributors fetch error:", fetchError);
124+
setContributors([]);
42125
} finally {
43126
setLoading(false);
44127
}
@@ -57,8 +140,27 @@ const ContributorsPage = () => {
57140

58141
if (error) {
59142
return (
60-
<Box sx={{ mt: 4 }}>
61-
<Alert severity="error">{error}</Alert>
143+
<Box sx={{ mt: 4, mx: 2 }}>
144+
<Alert severity="error" sx={{ mb: 2 }}>
145+
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
146+
⚠️ {error.message}
147+
</Typography>
148+
{error.isRateLimited && (
149+
<Typography variant="caption" sx={{ display: "block", mt: 1 }}>
150+
You've hit GitHub's API rate limit. The limit resets in 1 hour.
151+
</Typography>
152+
)}
153+
{error.statusCode === 404 && (
154+
<Typography variant="caption" sx={{ display: "block", mt: 1 }}>
155+
Please verify the repository exists and is accessible.
156+
</Typography>
157+
)}
158+
{error.statusCode === 408 && (
159+
<Typography variant="caption" sx={{ display: "block", mt: 1 }}>
160+
The server took too long to respond. Please try again.
161+
</Typography>
162+
)}
163+
</Alert>
62164
</Box>
63165
);
64166
}

0 commit comments

Comments
 (0)