Skip to content

Commit cd0e94e

Browse files
committed
refactor: endpoints using common code
1 parent 79e2c16 commit cd0e94e

File tree

15 files changed

+128
-81
lines changed

15 files changed

+128
-81
lines changed

src/app/api/movies/[movieId]/route.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('GET /api/movies/[movieId]', () => {
7373
const json = await res.json();
7474

7575
expect(res.status).toBe(500);
76-
expect(json).toEqual({ error: 'unable to get movies' });
76+
expect(json).toEqual({ error: 'unable to get movie' });
7777
});
7878

7979
it('returns 500 if configuration fetch fails', async () => {
@@ -91,6 +91,6 @@ describe('GET /api/movies/[movieId]', () => {
9191
const json = await res.json();
9292

9393
expect(res.status).toBe(500);
94-
expect(json).toEqual({ error: 'unable to get movies' });
94+
expect(json).toEqual({ error: 'unable to get movie' });
9595
});
9696
});
Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { z } from 'zod';
33
import { fetchTMDB } from '@/lib/tmdb';
4-
import type { TMDBMovieDetail, TMDBConfigurationResponse } from '@/types/tmdb';
4+
import type { TMDBMovieDetail } from '@/types/tmdb';
55
import type { APIResponse, MovieDetailResponse } from '@/types/api';
6+
import { ApiError } from '@/lib/apiError';
7+
import { enrichWithPosterUrl, getTMDBImageConfig } from '@/lib/tmdb-utls';
68

79
const paramsSchema = z.object({
810
movieId: z.string().regex(/^\d+$/, 'movieId must be a number'),
@@ -12,47 +14,41 @@ export async function GET(
1214
_req: NextRequest,
1315
context: { params: Promise<{ movieId: string }> }
1416
): Promise<NextResponse<APIResponse<MovieDetailResponse>>> {
15-
const params = await context.params;
17+
try {
18+
const params = await context.params;
1619

17-
if (!params?.movieId) {
18-
return NextResponse.json({ error: 'movieId param missing' }, { status: 400 });
19-
}
20-
21-
const parsed = paramsSchema.safeParse({ movieId: params.movieId });
20+
if (!params?.movieId) {
21+
throw new ApiError('movieId param missing', 400);
22+
}
2223

23-
if (!parsed.success) {
24-
return NextResponse.json({ error: 'Invalid movieId' }, { status: 400 });
25-
}
24+
const parsed = paramsSchema.safeParse({ movieId: params.movieId });
2625

27-
const { movieId } = parsed.data;
26+
if (!parsed.success) {
27+
throw new ApiError('Invalid movieId', 400);
28+
}
2829

29-
const revalidate = Number(process.env.MOVIES_DETAIL_REVALIDATE ?? '3600');
30-
const configRevalidate = Number(process.env.MOVIES_CONFIGURATION_REVALIDATE ?? '7200');
30+
const { movieId } = parsed.data;
3131

32-
const movieRes = await fetchTMDB(`/movie/${movieId}`, revalidate);
32+
const revalidate = Number(process.env.MOVIES_DETAIL_REVALIDATE ?? '3600');
3333

34-
if (!movieRes.ok) {
35-
return NextResponse.json({ error: 'unable to get movies' }, { status: movieRes.status });
36-
}
34+
const movieRes = await fetchTMDB(`/movie/${movieId}`, revalidate);
3735

38-
const movie: TMDBMovieDetail = await movieRes.json();
36+
if (!movieRes.ok) {
37+
throw new ApiError('unable to get movie', movieRes.status);
38+
}
3939

40-
const configRes = await fetchTMDB('/configuration', configRevalidate);
40+
const movie: TMDBMovieDetail = await movieRes.json();
4141

42-
if (!configRes.ok) {
43-
return NextResponse.json({ error: 'unable to get movies' }, { status: 500 });
44-
}
42+
const { base, poster_sizes } = await getTMDBImageConfig();
4543

46-
const config: TMDBConfigurationResponse = await configRes.json();
47-
const base = config.images.secure_base_url;
48-
const size = config.images.poster_sizes.includes('w342') ? 'w342' : '';
44+
const size = poster_sizes.includes('w342') ? 'w342' : '';
4945

50-
const enrichedMovie: MovieDetailResponse = {
51-
...movie,
52-
poster_url: {
53-
default: movie.poster_path ? `${base}${size}${movie.poster_path}` : null,
54-
},
55-
};
46+
const [enrichedMovie] = enrichWithPosterUrl<TMDBMovieDetail>([movie], base, size);
5647

57-
return NextResponse.json(enrichedMovie, { status: 200 });
48+
return NextResponse.json(enrichedMovie, { status: 200 });
49+
} catch (e: unknown) {
50+
const error = e instanceof ApiError ? e.message : 'unable to get movie';
51+
const status = e instanceof ApiError ? e.status : 500;
52+
return NextResponse.json({ error }, { status });
53+
}
5854
}

src/app/api/movies/route.ts

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { z } from 'zod';
33
import { fetchTMDB } from '@/lib/tmdb';
4-
import type { TMDBSearchResponse, TMDBConfigurationResponse } from '@/types/tmdb';
4+
import type { TMDBSearchResponse, TMDBMovie } from '@/types/tmdb';
55
import type { APIResponse, MovieSearchResponse } from '@/types/api';
6+
import { enrichWithPosterUrl, getTMDBImageConfig } from '@/lib/tmdb-utls';
7+
import { ApiError } from '@/lib/apiError';
68

79
const querySchema = z.object({
810
search: z.string().min(1),
@@ -12,47 +14,41 @@ const querySchema = z.object({
1214
export async function GET(
1315
req: NextRequest
1416
): Promise<NextResponse<APIResponse<MovieSearchResponse>>> {
15-
const url = new URL(req.url);
16-
const query = Object.fromEntries(url.searchParams.entries());
17+
try {
18+
const url = new URL(req.url);
19+
const query = Object.fromEntries(url.searchParams.entries());
1720

18-
const result = querySchema.safeParse(query);
21+
const result = querySchema.safeParse(query);
1922

20-
if (!result.success) {
21-
return NextResponse.json({ error: 'Invalid query' }, { status: 400 });
22-
}
23+
if (!result.success) {
24+
throw new ApiError('Invalid query', 400);
25+
}
2326

24-
const { search, page } = result.data;
27+
const { search, page } = result.data;
2528

26-
const revalidate = Number(process.env.MOVIES_SEARCH_REVALIDATE ?? '300');
27-
const configRevalidate = Number(process.env.MOVIES_CONFIGURATION_REVALIDATE ?? '7200');
29+
const revalidate = Number(process.env.MOVIES_SEARCH_REVALIDATE ?? '300');
2830

29-
const movieRes = await fetchTMDB(
30-
`/search/movie?query=${encodeURIComponent(search)}&page=${page}`,
31-
revalidate
32-
);
31+
const moviesRes = await fetchTMDB(
32+
`/search/movie?query=${encodeURIComponent(search)}&page=${page}`,
33+
revalidate
34+
);
3335

34-
if (!movieRes.ok) {
35-
return NextResponse.json({ error: 'unable to get movies' }, { status: movieRes.status });
36-
}
36+
if (!moviesRes.ok) {
37+
throw new ApiError('unable to get movies', moviesRes.status);
38+
}
3739

38-
const data: TMDBSearchResponse = await movieRes.json();
40+
const data: TMDBSearchResponse = await moviesRes.json();
3941

40-
const configRes = await fetchTMDB('/configuration', configRevalidate);
42+
const { base, poster_sizes } = await getTMDBImageConfig();
4143

42-
if (!configRes.ok) {
43-
return NextResponse.json({ error: 'unable to get movies' }, { status: 500 });
44-
}
44+
const size = poster_sizes.includes('w154') ? 'w154' : '';
4545

46-
const config: TMDBConfigurationResponse = await configRes.json();
47-
const base = config.images.secure_base_url;
48-
const size = config.images.poster_sizes.includes('w154') ? 'w154' : '';
46+
const enrichedResults = enrichWithPosterUrl<TMDBMovie>(data.results, base, size);
4947

50-
const enrichedResults = data.results.map((movie) => ({
51-
...movie,
52-
poster_url: {
53-
default: movie.poster_path ? `${base}${size}${movie.poster_path}` : null,
54-
},
55-
}));
56-
57-
return NextResponse.json({ ...data, results: enrichedResults }, { status: 200 });
48+
return NextResponse.json({ ...data, results: enrichedResults }, { status: 200 });
49+
} catch (e: unknown) {
50+
const error = e instanceof ApiError ? e.message : 'unable to get movies';
51+
const status = e instanceof ApiError ? e.status : 500;
52+
return NextResponse.json({ error }, { status });
53+
}
5854
}

src/app/movies/[movieId]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
22
import { z } from 'zod';
33
import { getQueryClient } from '@/lib/getQueryClient';
4-
import { fetchMovieDetails } from '@/lib/fetchMovieDetails';
4+
import { fetchMovieDetails } from '@/lib/fetch/fetchMovieDetails';
55
import MovieDetailsSection from '@/features/movies/MovieDetailsSection';
66
import { movieDetailQueryKey } from '@/lib/queryKeys';
77

src/app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
2-
import { fetchMovies } from '@/lib/fetchMovies';
2+
import { fetchMovies } from '@/lib/fetch/fetchMovies';
33
import { HomeSearchSection } from '@/features/home/HomeSearchSection';
44
import { getQueryClient } from '@/lib/getQueryClient';
55
import { moviesQueryKey } from '@/lib/queryKeys';

src/components/Pagination.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { AiFillCaretLeft, AiFillCaretRight } from 'react-icons/ai';
4-
import { MouseEventHandler } from 'react';
4+
import { MouseEventHandler, useEffect, useState } from 'react';
55

66
interface PaginationProps extends React.HTMLAttributes<HTMLElement> {
77
currentPage: number;
@@ -17,13 +17,20 @@ const getPageRange = (currentPage: number, totalPages: number): number[] => {
1717
};
1818

1919
export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: PaginationProps) {
20+
const [page, setPage] = useState(currentPage);
21+
22+
useEffect(() => {
23+
setPage(currentPage);
24+
}, [currentPage]);
25+
2026
const createPageHandler: (page: number) => MouseEventHandler<HTMLButtonElement> =
2127
(page) => (e) => {
2228
e.preventDefault();
2329
onPageChange(page);
30+
setPage(page);
2431
};
2532

26-
const pageRange = getPageRange(currentPage, totalPages);
33+
const pageRange = getPageRange(page, totalPages);
2734

2835
return (
2936
<nav
@@ -32,9 +39,9 @@ export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: P
3239
{...rest}
3340
>
3441
<div className="w-4 flex justify-start">
35-
{currentPage > 1 && (
42+
{page > 1 && (
3643
<button
37-
onClick={createPageHandler(currentPage - 1)}
44+
onClick={createPageHandler(page - 1)}
3845
className="text-purple-800 hover:underline focus:underline cursor-pointer"
3946
aria-label="Previous page"
4047
>
@@ -45,9 +52,9 @@ export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: P
4552

4653
<div className="flex gap-1 items-center">
4754
{pageRange.map((p) =>
48-
p === currentPage ? (
55+
p === page ? (
4956
<button
50-
key={p}
57+
key={`${currentPage}-${p}`}
5158
aria-current="page"
5259
disabled
5360
tabIndex={-1}
@@ -57,7 +64,7 @@ export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: P
5764
</button>
5865
) : (
5966
<button
60-
key={p}
67+
key={`${currentPage}-${p}`}
6168
onClick={createPageHandler(p)}
6269
aria-label={`Page ${p}`}
6370
className="px-2 py-1 rounded transition text-purple-800 hover:underline focus:underline cursor-pointer"
@@ -82,9 +89,9 @@ export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: P
8289
</div>
8390

8491
<div className="w-4 flex justify-end">
85-
{currentPage < totalPages && (
92+
{page < totalPages && (
8693
<button
87-
onClick={createPageHandler(currentPage + 1)}
94+
onClick={createPageHandler(page + 1)}
8895
className="text-purple-800 hover:underline focus:underline cursor-pointer"
8996
aria-label="Next page"
9097
>

src/features/home/HomeSearchSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
44
import React, { useEffect, useRef, useState } from 'react';
55
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
66

7-
import { fetchMoviesClient } from '@/lib/fetchMoviesClient';
7+
import { fetchMoviesClient } from '@/lib/fetch/fetchMoviesClient';
88
import { LoadingIndicator } from '@/components/LoadingIndicator';
99
import { MovieListItem } from '@/components/MovieListItem';
1010
import { Pagination } from '@/components/Pagination';

src/features/movies/MovieDetailsSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { useQuery } from '@tanstack/react-query';
4-
import { fetchMovieDetailsClient } from '@/lib/fetchMovieDetailsClient';
4+
import { fetchMovieDetailsClient } from '@/lib/fetch/fetchMovieDetailsClient';
55
import { movieDetailQueryKey } from '@/lib/queryKeys';
66
import { BackToSearchLink } from '@/components/BackToSearchLink';
77
import { MovieDetailResponse } from '@/types/api';

src/lib/apiError.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export class ApiError extends Error {
2+
public status: number;
3+
4+
constructor(message: string, code: number) {
5+
super(message);
6+
this.status = code;
7+
8+
Object.setPrototypeOf(this, new.target.prototype);
9+
10+
this.name = this.constructor.name;
11+
}
12+
}
File renamed without changes.

0 commit comments

Comments
 (0)