Skip to content

Commit

Permalink
[API] use wretch library for easier fetch calls (#429)
Browse files Browse the repository at this point in the history
Closes DG-138

## What changed? Why?
Moves to using a helper fetch library for the REST & frontend situations
that previously used fetch directly. Similar but nicer API & such.
  • Loading branch information
dgattey authored Dec 30, 2023
1 parent 716717f commit c1b3305
Show file tree
Hide file tree
Showing 13 changed files with 86 additions and 93 deletions.
5 changes: 3 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"clean": "rm -rf .next"
},
"dependencies": {
"api-clients": "workspace:*",
"@contentful/rich-text-react-renderer": "15.19.0",
"@contentful/rich-text-types": "16.3.0",
"@emotion/cache": "11.11.0",
Expand All @@ -25,6 +24,7 @@
"@mui/material": "5.15.2",
"@next/bundle-analyzer": "14.0.4",
"animate-css-grid": "1.5.1",
"api-clients": "workspace:*",
"db": "workspace:*",
"dotenv-mono": "1.3.13",
"graphql": "16.8.1",
Expand All @@ -42,7 +42,8 @@
"sharp": "0.33.1",
"swr": "2.2.4",
"ui": "workspace:*",
"webpack": "5.89.0"
"webpack": "5.89.0",
"wretch": "2.8.0"
},
"devDependencies": {
"@next/eslint-plugin-next": "14.0.4",
Expand Down
34 changes: 21 additions & 13 deletions apps/web/src/api/server/spotify/fetchRecentlyPlayed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,33 @@ const RECENTLY_PLAYED_RESOURCE = 'me/player/recently-played?limit=1';
* as `null`, or returns full JSON.
*/
export async function fetchRecentlyPlayed(): Promise<null | Track> {
const currentlyPlaying = await spotifyClient.fetch<CurrentlyPlaying>(CURRENTLY_PLAYING_RESOURCE);

switch (currentlyPlaying.status) {
const { response, status } = await spotifyClient.get(CURRENTLY_PLAYING_RESOURCE);
switch (status) {
case 200: {
const data = await currentlyPlaying.json();
return data?.item ?? null;
const { item } = await response.json<CurrentlyPlaying>();
return item;
}
case 204: {
// Fetch the last played song instead
const recentlyPlayed = await spotifyClient.fetch<RecentlyPlayed>(RECENTLY_PLAYED_RESOURCE);
if (recentlyPlayed.status !== 200) {
return null;
}
const data = await recentlyPlayed.json();
const item = data?.items[0];
return item ? { ...item.track, played_at: item.played_at } : null;
// We aren't currently playing a song but we could have recently played one
return fetchLastPlayed();
}
default:
// This could be rate limiting, or auth problems, etc.
return null;
}
}

/**
* Fetches the song that last played from spotify using a valid access token.
* May have no content, which signifies nothing is playing, which is returned
* as `null`, or returns full JSON.
*/
async function fetchLastPlayed(): Promise<null | Track> {
const { response, status } = await spotifyClient.get(RECENTLY_PLAYED_RESOURCE);
if (status !== 200) {
return null;
}
const data = await response.json<RecentlyPlayed>();
const item = data.items[0];
return item ? { ...item.track, played_at: item.played_at } : null;
}
4 changes: 2 additions & 2 deletions apps/web/src/api/server/spotify/spotifyClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ function createExpirationDate(expiryDistanceInSeconds: number) {
* A REST client set up to make authed calls to Spotify
*/
export const spotifyClient = createClient({
endpoint: 'https://api.spotify.com/v1',
endpoint: 'https://api.spotify.com/v1/',
accessKey: 'spotify',
refreshTokenConfig: {
endpoint: 'https://accounts.spotify.com/api/token',
endpoint: 'https://accounts.spotify.com/api/token/',
headers: {
Authorization: `Basic ${SPOTIFY_CLIENT_AUTH}`,
},
Expand Down
8 changes: 3 additions & 5 deletions apps/web/src/api/server/strava/fetchStravaActivityFromApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ import { paredStravaActivity } from './paredStravaActivity';
* budget. When in doubt, fall back to DB.
*/
export const fetchStravaActivityFromApi = async (id: number) => {
const activity = await stravaClient.fetch<StravaDetailedActivity & Record<string, unknown>>(
`activities/${id}`,
);
if (activity.status !== 200) {
const { response, status } = await stravaClient.get(`activities/${id}`);
if (status !== 200) {
return null;
}
const allData = await activity.json();
const allData = await response.json<StravaDetailedActivity & Record<string, unknown>>();
return paredStravaActivity(allData);
};
4 changes: 2 additions & 2 deletions apps/web/src/api/server/strava/stravaClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ invariant(STRAVA_CLIENT_SECRET, 'Missing Strava client secret');
* A REST client set up to make authed calls to Strava
*/
export const stravaClient = createClient({
endpoint: 'https://www.strava.com/api/v3',
endpoint: 'https://www.strava.com/api/v3/',
accessKey: STRAVA_TOKEN_NAME,
refreshTokenConfig: {
endpoint: 'https://www.strava.com/api/v3/oauth/token',
endpoint: 'https://www.strava.com/api/v3/oauth/token/',
data: {
client_id: STRAVA_CLIENT_ID,
client_secret: STRAVA_CLIENT_SECRET,
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/api/useData.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import useSWR from 'swr';
import wretch from 'wretch';
import type { AwaitedType, EndpointKey, EndpointType } from './endpoints';

const api = wretch('/api/');

/**
* Uses a well-typed `fetch` to call an API endpoint using the api
* key given, then grabs the JSON data from it.
*/
const fetchData = async <Key extends EndpointKey>(key: Key): Promise<EndpointType<Key>> => {
const result = await fetch<EndpointType<Key>>(`/api/${key}`);
return result.json();
const result = api.get(key);
return result.json<EndpointType<Key>>();
};

/**
Expand Down
File renamed without changes.
9 changes: 0 additions & 9 deletions apps/web/src/types/fetch.d.ts

This file was deleted.

14 changes: 0 additions & 14 deletions apps/web/src/types/json.d.ts

This file was deleted.

71 changes: 37 additions & 34 deletions packages/api-clients/authenticatedRestClient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Wretch } from 'wretch';
import wretch from 'wretch';
import type { RefreshTokenConfig } from './RefreshTokenConfig';
import { refreshedAccessToken } from './refreshedAccessToken';

Expand All @@ -19,43 +21,44 @@ export type ClientProps = {
};

/**
* Returns true if we're authed via status code
* Creates a REST client that can be used to fetch resources from a base API, with auto-refreshing
* access tokens built into the fetch function.
*/
const isAuthedStatus = <Type>({ status }: FetchResult<Type>) => !(status >= 400 && status < 500);
export function createClient({ endpoint, accessKey, refreshTokenConfig }: ClientProps) {
const api = wretch(endpoint).content('application/json');

/**
* Fetches a resource from a base API, after grabbing a refreshed access
* token if needed first to use for authentication.
*/
const fetchWithAuth =
({ endpoint, accessKey, refreshTokenConfig }: ClientProps) =>
async <Type>(resource: string): Promise<FetchResult<Type | undefined>> => {
// Actually fetches, forcing a refreshed key if necessary. Passes Bearer auth and requests JSON
const runFetch = async (forceRefresh: boolean) => {
const accessToken = await refreshedAccessToken(accessKey, refreshTokenConfig, forceRefresh);
return fetch<Type>(`${endpoint}/${resource}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
/**
* Generates an auth header with a refreshed access token
*/
async function addAuth<Self, Chain, Resolver>(
request: Self & Wretch<Self, Chain, Resolver>,
forceRefresh: boolean,
) {
const accessToken = await refreshedAccessToken(accessKey, refreshTokenConfig, forceRefresh);
return request.auth(`Bearer ${accessToken}`);
}

/**
* Fetches a resource from the API, using a refreshed access token if necessary. Returns a
* Wretch chain that can be used to further process the response based on status code.
*/
async function getWithAuth(resource: string) {
const authedApi = await addAuth(api, false);
const response = authedApi.get(resource).unauthorized(async (_error, req) => {
// Renew credentials once and try to fetch again but fail if we hit another unauthorized
const authedReq = await addAuth(req, false);
return authedReq.get(resource).unauthorized((err) => {
throw err;
});
});
const { status } = await response.res().catch((err: { status: number }) => err);
return {
response,
status,
};
}

// Fetch data normally, refreshing the access token if necessary
const data = await runFetch(false);
if (isAuthedStatus(data)) {
return data;
}

// We weren't authed but in the process of fetching, we refreshed the token,
// so try again with that new tokem, and return whatever we have
return runFetch(true);
return {
get: getWithAuth,
};

/**
* Creates a REST client that can be used to fetch resources from a base API, with auto-refreshing
* access tokens built into the fetch function.
*/
export const createClient = (props: ClientProps) => ({
fetch: fetchWithAuth(props),
});
}
3 changes: 2 additions & 1 deletion packages/api-clients/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"dependencies": {
"db": "workspace:*",
"graphql-request": "6.1.0",
"shared-core": "workspace:*"
"shared-core": "workspace:*",
"wretch": "2.8.0"
},
"devDependencies": {
"dotenv-mono": "1.3.13",
Expand Down
9 changes: 0 additions & 9 deletions packages/api-clients/types/fetch.d.ts

This file was deleted.

11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 comment on commit c1b3305

@vercel
Copy link

@vercel vercel bot commented on c1b3305 Dec 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

dg – ./

dg-dgattey.vercel.app
dg-git-main-dgattey.vercel.app
dg.vercel.app
dylangattey.com

Please sign in to comment.