Skip to content

Commit

Permalink
refactor(api/spotify): extra succinct functions from GET
Browse files Browse the repository at this point in the history
  • Loading branch information
kieran-osgood committed Oct 24, 2024
1 parent cfd6d00 commit 6757952
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 44 deletions.
4 changes: 2 additions & 2 deletions src/components/about/spotify.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { z } from "astro/zod";
import type { TrackSchema } from "src/pages/api/spotify";
import type { Track } from "src/pages/api/spotify";

type SpotifyProps = {
track: z.infer<typeof TrackSchema>;
track: z.infer<typeof Track>;
};

export default function Spotify(props: SpotifyProps) {
Expand Down
6 changes: 3 additions & 3 deletions src/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { z } from "astro/zod";
import { http, HttpResponse } from "msw";
import type { TokenSchema, RecentlyPlayedSchema } from "src/pages/api/spotify";
import type { Tokens, RecentlyPlayed } from "src/pages/api/spotify";
import type { StackOverflow } from "src/pages/api/stackoverflow";

export const handlers = [
Expand Down Expand Up @@ -34,14 +34,14 @@ export const handlers = [
}),

http.post("https://accounts.spotify.com/api/token", () => {
const result: z.infer<typeof TokenSchema> = {
const result: z.infer<typeof Tokens> = {
access_token: "token",
};
return HttpResponse.json(result);
}),

http.get("https://api.spotify.com/v1/me/player/recently-played", () => {
const result: z.infer<typeof RecentlyPlayedSchema> = {
const result: z.infer<typeof RecentlyPlayed> = {
items: [
{
track: {
Expand Down
118 changes: 80 additions & 38 deletions src/pages/api/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,76 +5,118 @@ const RECENTLY_PLAYED_ENDPOINT =
"https://api.spotify.com/v1/me/player/recently-played";
const REFRESH_ENDPOINT = "https://accounts.spotify.com/api/token";

const authOptions = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: import.meta.env.SPOTIFY_WEB_API_REFRESH_TOKEN,
});

// NOTE: these schemas are intentionally sparse and missing properties
export const TrackSchema = z.object({
export const Track = z.object({
artists: z.array(z.object({ name: z.string() })),
external_urls: z.object({ spotify: z.string() }),
name: z.string(),
});

export type Track = z.infer<typeof TrackSchema>;
export type Track = z.infer<typeof Track>;

export const TokenSchema = z.object({
export const Tokens = z.object({
access_token: z.z.string(),
});
type Tokens = z.infer<typeof Tokens>;

export const RecentlyPlayedSchema = z.object({
items: z.array(z.object({ track: TrackSchema })),
export const RecentlyPlayed = z.object({
items: z.array(z.object({ track: Track })),
});
type RecentlyPlayed = z.infer<typeof RecentlyPlayed>;

const authOptions = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: import.meta.env.SPOTIFY_WEB_API_REFRESH_TOKEN,
});
type SuccessOrError<T> = [null, error: Response] | [result: T, null];

/**
Access tokens only last for 1hour so no use storing them in env
Instead we just request new access_token when we need the spotify data
as we don't want to store the tokens client side (allows users to view my data)
*/
const GET: APIRoute & {
Schema: typeof TrackSchema;
} = async (): Promise<Response> => {
// TODO: cache this to avoid hitting rate limits
async function fetchToken(): Promise<SuccessOrError<Tokens>> {
const tokenResponse = await fetch(REFRESH_ENDPOINT, {
method: "POST",
body: authOptions.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
Authorization: import.meta.env.SPOTIFY_AUTHORIZATION,
},
})
.then((response) => response.json())
.then((response) => TokenSchema.safeParse(response));
});

if (!tokenResponse.success) {
console.error("Retrieve token failed: ", tokenResponse.error.format());
return new Response(null, { status: 401 });
if (tokenResponse.ok === false) {
console.error(`[${fetchToken.name}][FetchFail] !ok: `, tokenResponse);
return [null, new Response(null, { status: tokenResponse.status })];
}

const result = Tokens.safeParse(await tokenResponse.json());

if (result.success === false) {
console.error(
`[${fetchToken.name}][SchemaFail] !success: `,
result.error.format(),
);
return [null, new Response(null, { status: 401 })];
}

return [result.data, null];
}

async function fetchRecentlyPlayed(
tokens: Tokens,
): Promise<SuccessOrError<RecentlyPlayed>> {
const url = new URL(RECENTLY_PLAYED_ENDPOINT);
url.searchParams.set("limit", "1");

// TODO: cache this to avoid hitting rate limits
const recentlyPlayed = await fetch(url, {
const recentlyPlayedResponse = await fetch(url, {
method: "get",
headers: {
Authorization: `Bearer ${tokenResponse.data.access_token}`,
},
})
.then((response) => response.json())
.then((response) => RecentlyPlayedSchema.safeParse(response));
headers: { Authorization: `Bearer ${tokens.access_token}` },
});

if (recentlyPlayedResponse.ok === false) {
console.error(
`[${fetchRecentlyPlayed.name}][FetchFail] !ok: `,
recentlyPlayedResponse,
);
return [
null,
new Response(null, { status: recentlyPlayedResponse.status }),
];
}

if (!recentlyPlayed.success) {
const recentlyPlayedResult = RecentlyPlayed.safeParse(
await recentlyPlayedResponse.json(),
);

if (recentlyPlayedResult.success === false) {
console.error(
"Recently played has changed shape: ",
recentlyPlayed.error.format(),
`[${fetchRecentlyPlayed.name}][SchemaFail] !success: `,
recentlyPlayedResult.error.format(),
);
return new Response(null, { status: 403 });
return [null, new Response(null, { status: 403 })];
}

return [recentlyPlayedResult.data, null];
}

type GET = APIRoute & { Schema: typeof Track };
/**
Access tokens only last for 1hour so no use storing them in env
Instead we just request new access_token when we need the spotify data
as we don't want to store the tokens client side (allows users to view my data)
*/
const GET: GET = async () => {
const [tokens, tokenError] = await fetchToken();
if (tokenError) {
return tokenError;
}

const [recentlyPlayed, recentlyPlayedError] =
await fetchRecentlyPlayed(tokens);
if (recentlyPlayedError) {
return recentlyPlayedError;
}

return new Response(JSON.stringify(recentlyPlayed.data.items?.[0]?.track));
return new Response(JSON.stringify(recentlyPlayed.items?.[0]?.track));
};

GET.Schema = TrackSchema;
GET.Schema = Track;

export { GET };
1 change: 0 additions & 1 deletion src/pages/api/stackoverflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export type StackOverflow = z.infer<typeof StackOverflow>;
const GET: APIRoute & {
Schema: typeof StackOverflow;
} = async (): Promise<Response> => {
// TODO: cache this to avoid hitting rate limits
const stackOverflowResponse = await fetch(ROUTE, { cache: "force-cache" });

if (!stackOverflowResponse.ok) {
Expand Down

0 comments on commit 6757952

Please sign in to comment.