summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPuyodead1 <puyodead@proton.me>2023-01-13 08:52:24 -0500
committerPuyodead1 <puyodead@proton.me>2023-03-18 19:28:46 -0400
commitd8ecc4269f2049f64ee60f518076ccd162857a36 (patch)
treecd14b6082bac049b26a8a493898b43ef3216c10b /src
parentadd Xbox connection (diff)
downloadserver-d8ecc4269f2049f64ee60f518076ccd162857a36.tar.xz
replace node-fetch with wretch
Diffstat (limited to 'src')
-rw-r--r--src/connections/BattleNet/index.ts73
-rw-r--r--src/connections/Discord/index.ts62
-rw-r--r--src/connections/EpicGames/index.ts52
-rw-r--r--src/connections/Facebook/index.ts55
-rw-r--r--src/connections/GitHub/index.ts42
-rw-r--r--src/connections/Reddit/index.ts58
-rw-r--r--src/connections/Spotify/index.ts123
-rw-r--r--src/connections/Twitch/index.ts107
-rw-r--r--src/connections/Twitter/index.ts120
-rw-r--r--src/connections/Xbox/index.ts134
10 files changed, 277 insertions, 549 deletions
diff --git a/src/connections/BattleNet/index.ts b/src/connections/BattleNet/index.ts
index 8e8eeeed..96c3993c 100644
--- a/src/connections/BattleNet/index.ts
+++ b/src/connections/BattleNet/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import Connection from "../../util/connections/Connection";
 import { BattleNetSettings } from "./BattleNetSettings";
 
@@ -67,68 +66,40 @@ export default class BattleNetConnection extends Connection {
 
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
-			},
-			body: new URLSearchParams({
-				grant_type: "authorization_code",
-				code: code,
-				client_id: this.settings.clientId!,
-				client_secret: this.settings.clientSecret!,
-				redirect_uri: `${
-					Config.get().cdn.endpointPrivate || "http://localhost:3001"
-				}/connections/${this.id}/callback`,
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to exchange code", 0, 400);
-				}
-
-				return res.json();
 			})
-			.then(
-				(
-					res: ConnectedAccountCommonOAuthTokenResponse &
-						BattleNetErrorResponse,
-				) => {
-					if (res.error) throw new Error(res.error_description);
-					return res;
-				},
+			.body(
+				new URLSearchParams({
+					grant_type: "authorization_code",
+					code: code,
+					client_id: this.settings.clientId!,
+					client_secret: this.settings.clientSecret!,
+					redirect_uri: `${
+						Config.get().cdn.endpointPrivate ||
+						"http://localhost:3001"
+					}/connections/${this.id}/callback`,
+				}),
 			)
+			.post()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging token for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
 	async getUser(token: string): Promise<BattleNetConnectionUser> {
 		const url = new URL(this.userInfoUrl);
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Authorization: `Bearer ${token}`,
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
-			})
-			.then((res: BattleNetConnectionUser & BattleNetErrorResponse) => {
-				if (res.error) throw new Error(res.error_description);
-				return res;
 			})
+			.get()
+			.json<BattleNetConnectionUser>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
diff --git a/src/connections/Discord/index.ts b/src/connections/Discord/index.ts
index 23f5d978..52fc9ffd 100644
--- a/src/connections/Discord/index.ts
+++ b/src/connections/Discord/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import Connection from "../../util/connections/Connection";
 import { DiscordSettings } from "./DiscordSettings";
 
@@ -66,56 +65,41 @@ export default class DiscordConnection extends Connection {
 		this.validateState(state);
 		const url = this.getTokenUrl();
 
-		return fetch(url, {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				"Content-Type": "application/x-www-form-urlencoded",
-			},
-			body: new URLSearchParams({
-				client_id: this.settings.clientId!,
-				client_secret: this.settings.clientSecret!,
-				grant_type: "authorization_code",
-				code: code,
-				redirect_uri: `${
-					Config.get().cdn.endpointPrivate || "http://localhost:3001"
-				}/connections/${this.id}/callback`,
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to exchange token", 0, 400);
-				}
-
-				return res.json();
 			})
+			.body(
+				new URLSearchParams({
+					client_id: this.settings.clientId!,
+					client_secret: this.settings.clientSecret!,
+					grant_type: "authorization_code",
+					code: code,
+					redirect_uri: `${
+						Config.get().cdn.endpointPrivate ||
+						"http://localhost:3001"
+					}/connections/${this.id}/callback`,
+				}),
+			)
+			.post()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging token for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
 	async getUser(token: string): Promise<UserResponse> {
 		const url = new URL(this.userInfoUrl);
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Authorization: `Bearer ${token}`,
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
 			})
+			.get()
+			.json<UserResponse>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
diff --git a/src/connections/EpicGames/index.ts b/src/connections/EpicGames/index.ts
index c720dc5d..247d2435 100644
--- a/src/connections/EpicGames/index.ts
+++ b/src/connections/EpicGames/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import Connection from "../../util/connections/Connection";
 import { EpicGamesSettings } from "./EpicGamesSettings";
 
@@ -73,31 +72,24 @@ export default class EpicGamesConnection extends Connection {
 
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				Authorization: `Basic ${Buffer.from(
 					`${this.settings.clientId}:${this.settings.clientSecret}`,
 				).toString("base64")}`,
 				"Content-Type": "application/x-www-form-urlencoded",
-			},
-			body: new URLSearchParams({
-				grant_type: "authorization_code",
-				code,
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to exchange code", 0, 400);
-				}
-
-				return res.json();
 			})
+			.body(
+				new URLSearchParams({
+					grant_type: "authorization_code",
+					code,
+				}),
+			)
+			.post()
+			.json<EpicTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging token for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
@@ -108,23 +100,15 @@ export default class EpicGamesConnection extends Connection {
 		);
 		const url = new URL(this.userInfoUrl);
 		url.searchParams.append("accountId", sub);
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+
+		return wretch(url.toString())
+			.headers({
 				Authorization: `Bearer ${token}`,
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
 			})
+			.get()
+			.json<UserResponse[]>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
diff --git a/src/connections/Facebook/index.ts b/src/connections/Facebook/index.ts
index 67f8da79..5413f867 100644
--- a/src/connections/Facebook/index.ts
+++ b/src/connections/Facebook/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import Connection from "../../util/connections/Connection";
 import { FacebookSettings } from "./FacebookSettings";
 
@@ -83,59 +82,29 @@ export default class FacebookConnection extends Connection {
 
 		const url = this.getTokenUrl(code);
 
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to exchange code", 0, 400);
-				}
-
-				return res.json();
 			})
-			.then(
-				(
-					res: ConnectedAccountCommonOAuthTokenResponse &
-						FacebookErrorResponse,
-				) => {
-					if (res.error) throw new Error(res.error.message);
-					return res;
-				},
-			)
+			.get()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging token for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
 	async getUser(token: string): Promise<UserResponse> {
 		const url = new URL(this.userInfoUrl);
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+
+		return wretch(url.toString())
+			.headers({
 				Authorization: `Bearer ${token}`,
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
-			})
-			.then((res: UserResponse & FacebookErrorResponse) => {
-				if (res.error) throw new Error(res.error.message);
-				return res;
 			})
+			.get()
+			.json<UserResponse>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
diff --git a/src/connections/GitHub/index.ts b/src/connections/GitHub/index.ts
index aa686b03..8380e765 100644
--- a/src/connections/GitHub/index.ts
+++ b/src/connections/GitHub/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import Connection from "../../util/connections/Connection";
 import { GitHubSettings } from "./GitHubSettings";
 
@@ -65,46 +64,29 @@ export default class GitHubConnection extends Connection {
 
 		const url = this.getTokenUrl(code);
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to exchange code", 0, 400);
-				}
-
-				return res.json();
 			})
+
+			.post()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging code for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
 	async getUser(token: string): Promise<UserResponse> {
 		const url = new URL(this.userInfoUrl);
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Authorization: `Bearer ${token}`,
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
 			})
+			.get()
+			.json<UserResponse>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
diff --git a/src/connections/Reddit/index.ts b/src/connections/Reddit/index.ts
index 06fbcbe5..70b4a8af 100644
--- a/src/connections/Reddit/index.ts
+++ b/src/connections/Reddit/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import Connection from "../../util/connections/Connection";
 import { RedditSettings } from "./RedditSettings";
 
@@ -74,57 +73,42 @@ export default class RedditConnection extends Connection {
 
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				Authorization: `Basic ${Buffer.from(
 					`${this.settings.clientId}:${this.settings.clientSecret}`,
 				).toString("base64")}`,
 				"Content-Type": "application/x-www-form-urlencoded",
-			},
-			body: new URLSearchParams({
-				grant_type: "authorization_code",
-				code: code,
-				redirect_uri: `${
-					Config.get().cdn.endpointPrivate || "http://localhost:3001"
-				}/connections/${this.id}/callback`,
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to code", 0, 400);
-				}
-
-				return res.json();
 			})
+			.body(
+				new URLSearchParams({
+					grant_type: "authorization_code",
+					code: code,
+					redirect_uri: `${
+						Config.get().cdn.endpointPrivate ||
+						"http://localhost:3001"
+					}/connections/${this.id}/callback`,
+				}),
+			)
+			.post()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging code for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
 	async getUser(token: string): Promise<UserResponse> {
 		const url = new URL(this.userInfoUrl);
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Authorization: `Bearer ${token}`,
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
 			})
+			.get()
+			.json<UserResponse>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
diff --git a/src/connections/Spotify/index.ts b/src/connections/Spotify/index.ts
index 44a4bc28..54ec2696 100644
--- a/src/connections/Spotify/index.ts
+++ b/src/connections/Spotify/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import RefreshableConnection from "../../util/connections/RefreshableConnection";
 import { SpotifySettings } from "./SpotifySettings";
 
@@ -83,122 +82,78 @@ export default class SpotifyConnection extends RefreshableConnection {
 
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				"Content-Type": "application/x-www-form-urlencoded",
 				Authorization: `Basic ${Buffer.from(
 					`${this.settings.clientId!}:${this.settings.clientSecret!}`,
 				).toString("base64")}`,
-			},
-			body: new URLSearchParams({
-				grant_type: "authorization_code",
-				code: code,
-				redirect_uri: `${
-					Config.get().cdn.endpointPrivate || "http://localhost:3001"
-				}/connections/${this.id}/callback`,
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to refresh token", 0, 400);
-				}
-
-				return res.json();
 			})
-			.then(
-				(
-					res: ConnectedAccountCommonOAuthTokenResponse &
-						TokenErrorResponse,
-				) => {
-					if (res.error)
-						throw new ApiError(res.error_description, 0, 400);
-					return res;
-				},
+			.body(
+				new URLSearchParams({
+					grant_type: "authorization_code",
+					code: code,
+					redirect_uri: `${
+						Config.get().cdn.endpointPrivate ||
+						"http://localhost:3001"
+					}/connections/${this.id}/callback`,
+				}),
 			)
+			.post()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging code for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
-	async refreshToken(connectedAccount: ConnectedAccount) {
+	async refreshToken(
+		connectedAccount: ConnectedAccount,
+	): Promise<ConnectedAccountCommonOAuthTokenResponse> {
 		if (!connectedAccount.token_data?.refresh_token)
 			throw new Error("No refresh token available.");
 		const refresh_token = connectedAccount.token_data.refresh_token;
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				"Content-Type": "application/x-www-form-urlencoded",
 				Authorization: `Basic ${Buffer.from(
 					`${this.settings.clientId!}:${this.settings.clientSecret!}`,
 				).toString("base64")}`,
-			},
-			body: new URLSearchParams({
-				grant_type: "refresh_token",
-				refresh_token,
-			}),
-		})
-			.then(async (res) => {
-				if ([400, 401].includes(res.status)) {
-					// assume the token was revoked
-					await connectedAccount.revoke();
-					return DiscordApiErrors.CONNECTION_REVOKED;
-				}
-				// otherwise throw a general error
-				if (!res.ok) {
-					throw new ApiError("Failed to refresh token", 0, 400);
-				}
-
-				return await res.json();
 			})
-			.then(
-				(
-					res: ConnectedAccountCommonOAuthTokenResponse &
-						TokenErrorResponse,
-				) => {
-					if (res.error)
-						throw new ApiError(res.error_description, 0, 400);
-					return res;
-				},
+			.body(
+				new URLSearchParams({
+					grant_type: "refresh_token",
+					refresh_token,
+				}),
 			)
+			.post()
+			.unauthorized(async () => {
+				// assume the token was revoked
+				await connectedAccount.revoke();
+				return DiscordApiErrors.CONNECTION_REVOKED;
+			})
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error refreshing token for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
 	async getUser(token: string): Promise<UserResponse> {
 		const url = new URL(this.userInfoUrl);
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+
+		return wretch(url.toString())
+			.headers({
 				Authorization: `Bearer ${token}`,
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
-			})
-			.then((res: UserResponse & ErrorResponse) => {
-				if (res.error) throw new Error(res.error.message);
-				return res;
 			})
+			.get()
+			.json<UserResponse>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
diff --git a/src/connections/Twitch/index.ts b/src/connections/Twitch/index.ts
index ce04f098..264db3cc 100644
--- a/src/connections/Twitch/index.ts
+++ b/src/connections/Twitch/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import RefreshableConnection from "../../util/connections/RefreshableConnection";
 import { TwitchSettings } from "./TwitchSettings";
 
@@ -75,33 +74,27 @@ export default class TwitchConnection extends RefreshableConnection {
 
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				"Content-Type": "application/x-www-form-urlencoded",
-			},
-			body: new URLSearchParams({
-				grant_type: "authorization_code",
-				code: code,
-				client_id: this.settings.clientId!,
-				client_secret: this.settings.clientSecret!,
-				redirect_uri: `${
-					Config.get().cdn.endpointPrivate || "http://localhost:3001"
-				}/connections/${this.id}/callback`,
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to exchange code", 0, 400);
-				}
-
-				return res.json();
 			})
+			.body(
+				new URLSearchParams({
+					grant_type: "authorization_code",
+					code: code,
+					client_id: this.settings.clientId!,
+					client_secret: this.settings.clientSecret!,
+					redirect_uri: `${
+						Config.get().cdn.endpointPrivate ||
+						"http://localhost:3001"
+					}/connections/${this.id}/callback`,
+				}),
+			)
+			.post()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging code for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
@@ -115,60 +108,44 @@ export default class TwitchConnection extends RefreshableConnection {
 
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				"Content-Type": "application/x-www-form-urlencoded",
-			},
-			body: new URLSearchParams({
-				grant_type: "refresh_token",
-				client_id: this.settings.clientId!,
-				client_secret: this.settings.clientSecret!,
-				refresh_token: refresh_token,
-			}),
-		})
-			.then(async (res) => {
-				if ([400, 401].includes(res.status)) {
-					// assume the token was revoked
-					await connectedAccount.revoke();
-					return DiscordApiErrors.CONNECTION_REVOKED;
-				}
-				// otherwise throw a general error
-				if (!res.ok) {
-					throw new ApiError("Failed to refresh token", 0, 400);
-				}
-
-				return await res.json();
 			})
+			.body(
+				new URLSearchParams({
+					grant_type: "refresh_token",
+					client_id: this.settings.clientId!,
+					client_secret: this.settings.clientSecret!,
+					refresh_token: refresh_token,
+				}),
+			)
+			.post()
+			.unauthorized(async () => {
+				// assume the token was revoked
+				await connectedAccount.revoke();
+				return DiscordApiErrors.CONNECTION_REVOKED;
+			})
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error refreshing token for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
 	async getUser(token: string): Promise<TwitchConnectionUserResponse> {
 		const url = new URL(this.userInfoUrl);
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+
+		return wretch(url.toString())
+			.headers({
 				Authorization: `Bearer ${token}`,
 				"Client-Id": this.settings.clientId!,
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
 			})
+			.get()
+			.json<TwitchConnectionUserResponse>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
diff --git a/src/connections/Twitter/index.ts b/src/connections/Twitter/index.ts
index d8a765ac..ad9d55d4 100644
--- a/src/connections/Twitter/index.ts
+++ b/src/connections/Twitter/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import RefreshableConnection from "../../util/connections/RefreshableConnection";
 import { TwitterSettings } from "./TwitterSettings";
 
@@ -77,45 +76,30 @@ export default class TwitterConnection extends RefreshableConnection {
 
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				"Content-Type": "application/x-www-form-urlencoded",
 				Authorization: `Basic ${Buffer.from(
 					`${this.settings.clientId!}:${this.settings.clientSecret!}`,
 				).toString("base64")}`,
-			},
-			body: new URLSearchParams({
-				grant_type: "authorization_code",
-				code: code,
-				client_id: this.settings.clientId!,
-				redirect_uri: `${
-					Config.get().cdn.endpointPrivate || "http://localhost:3001"
-				}/connections/${this.id}/callback`,
-				code_verifier: "challenge", // TODO: properly use PKCE challenge
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to exchange code", 0, 400);
-				}
-
-				return res.json();
 			})
-			.then(
-				(
-					res: ConnectedAccountCommonOAuthTokenResponse &
-						TwitterErrorResponse,
-				) => {
-					if (res.error) throw new Error(res.error_description);
-					return res;
-				},
+			.body(
+				new URLSearchParams({
+					grant_type: "authorization_code",
+					code: code,
+					client_id: this.settings.clientId!,
+					redirect_uri: `${
+						Config.get().cdn.endpointPrivate ||
+						"http://localhost:3001"
+					}/connections/${this.id}/callback`,
+					code_verifier: "challenge", // TODO: properly use PKCE challenge
+				}),
 			)
+			.post()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging code for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
@@ -129,72 +113,44 @@ export default class TwitterConnection extends RefreshableConnection {
 
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				"Content-Type": "application/x-www-form-urlencoded",
 				Authorization: `Basic ${Buffer.from(
 					`${this.settings.clientId!}:${this.settings.clientSecret!}`,
 				).toString("base64")}`,
-			},
-			body: new URLSearchParams({
-				grant_type: "refresh_token",
-				refresh_token,
-				client_id: this.settings.clientId!,
-				redirect_uri: `${
-					Config.get().cdn.endpointPrivate || "http://localhost:3001"
-				}/connections/${this.id}/callback`,
-				code_verifier: "challenge", // TODO: properly use PKCE challenge
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to exchange code", 0, 400);
-				}
-
-				return res.json();
 			})
-			.then(
-				(
-					res: ConnectedAccountCommonOAuthTokenResponse &
-						TwitterErrorResponse,
-				) => {
-					if (res.error) throw new Error(res.error_description);
-					return res;
-				},
+			.body(
+				new URLSearchParams({
+					grant_type: "refresh_token",
+					refresh_token,
+					client_id: this.settings.clientId!,
+					redirect_uri: `${
+						Config.get().cdn.endpointPrivate ||
+						"http://localhost:3001"
+					}/connections/${this.id}/callback`,
+					code_verifier: "challenge", // TODO: properly use PKCE challenge
+				}),
 			)
+			.post()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging code for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
 	async getUser(token: string): Promise<TwitterUserResponse> {
 		const url = new URL(this.userInfoUrl);
-		return fetch(url.toString(), {
-			method: "GET",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Authorization: `Bearer ${token}`,
-			},
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
-			})
-			.then((res: TwitterUserResponse & TwitterErrorResponse) => {
-				if (res.error) throw new Error(res.error_description);
-				return res;
 			})
+			.get()
+			.json<TwitterUserResponse>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
diff --git a/src/connections/Xbox/index.ts b/src/connections/Xbox/index.ts
index eb0e2496..80a04dea 100644
--- a/src/connections/Xbox/index.ts
+++ b/src/connections/Xbox/index.ts
@@ -1,5 +1,4 @@
 import {
-	ApiError,
 	Config,
 	ConnectedAccount,
 	ConnectedAccountCommonOAuthTokenResponse,
@@ -7,7 +6,7 @@ import {
 	ConnectionLoader,
 	DiscordApiErrors,
 } from "@fosscord/util";
-import fetch from "node-fetch";
+import wretch from "wretch";
 import Connection from "../../util/connections/Connection";
 import { XboxSettings } from "./XboxSettings";
 
@@ -76,36 +75,28 @@ export default class XboxConnection extends Connection {
 	}
 
 	async getUserToken(token: string): Promise<string> {
-		return fetch(this.userAuthUrl, {
-			method: "POST",
-			headers: {
+		return wretch(this.userAuthUrl)
+			.headers({
 				"x-xbl-contract-version": "3",
 				"Content-Type": "application/json",
 				Accept: "application/json",
-			},
-			body: JSON.stringify({
-				RelyingParty: "http://auth.xboxlive.com",
-				TokenType: "JWT",
-				Properties: {
-					AuthMethod: "RPS",
-					SiteName: "user.auth.xboxlive.com",
-					RpsTicket: `d=${token}`,
-				},
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to get user token", 0, 400);
-				}
-
-				return res.json();
 			})
-			.then((res) => res.Token)
+			.body(
+				JSON.stringify({
+					RelyingParty: "http://auth.xboxlive.com",
+					TokenType: "JWT",
+					Properties: {
+						AuthMethod: "RPS",
+						SiteName: "user.auth.xboxlive.com",
+						RpsTicket: `d=${token}`,
+					},
+				}),
+			)
+			.post()
+			.json((res: XboxUserResponse) => res.Token)
 			.catch((e) => {
-				console.error(
-					`Error getting user token for ${this.id} connection: ${e}`,
-				);
-				throw DiscordApiErrors.INVALID_OAUTH_TOKEN;
+				console.error(e);
+				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
@@ -117,82 +108,57 @@ export default class XboxConnection extends Connection {
 
 		const url = this.getTokenUrl();
 
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+		return wretch(url.toString())
+			.headers({
 				Accept: "application/json",
 				"Content-Type": "application/x-www-form-urlencoded",
 				Authorization: `Basic ${Buffer.from(
 					`${this.settings.clientId!}:${this.settings.clientSecret!}`,
 				).toString("base64")}`,
-			},
-			body: new URLSearchParams({
-				grant_type: "authorization_code",
-				code: code,
-				client_id: this.settings.clientId!,
-				redirect_uri: `${
-					Config.get().cdn.endpointPrivate || "http://localhost:3001"
-				}/connections/${this.id}/callback`,
-				scope: this.scopes.join(" "),
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to exchange code", 0, 400);
-				}
-
-				return res.json();
 			})
-			.then(
-				(
-					res: ConnectedAccountCommonOAuthTokenResponse &
-						XboxErrorResponse,
-				) => {
-					if (res.error) throw new Error(res.error_description);
-					return res;
-				},
+			.body(
+				new URLSearchParams({
+					grant_type: "authorization_code",
+					code: code,
+					client_id: this.settings.clientId!,
+					redirect_uri: `${
+						Config.get().cdn.endpointPrivate ||
+						"http://localhost:3001"
+					}/connections/${this.id}/callback`,
+					scope: this.scopes.join(" "),
+				}),
 			)
+			.post()
+			.json<ConnectedAccountCommonOAuthTokenResponse>()
 			.catch((e) => {
-				console.error(
-					`Error exchanging code for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}
 
 	async getUser(token: string): Promise<XboxUserResponse> {
 		const url = new URL(this.userInfoUrl);
-		return fetch(url.toString(), {
-			method: "POST",
-			headers: {
+
+		return wretch(url.toString())
+			.headers({
 				"x-xbl-contract-version": "3",
 				"Content-Type": "application/json",
 				Accept: "application/json",
-			},
-			body: JSON.stringify({
-				RelyingParty: "http://xboxlive.com",
-				TokenType: "JWT",
-				Properties: {
-					UserTokens: [token],
-					SandboxId: "RETAIL",
-				},
-			}),
-		})
-			.then((res) => {
-				if (!res.ok) {
-					throw new ApiError("Failed to fetch user", 0, 400);
-				}
-
-				return res.json();
-			})
-			.then((res: XboxUserResponse & XboxErrorResponse) => {
-				if (res.error) throw new Error(res.error_description);
-				return res;
 			})
+			.body(
+				JSON.stringify({
+					RelyingParty: "http://xboxlive.com",
+					TokenType: "JWT",
+					Properties: {
+						UserTokens: [token],
+						SandboxId: "RETAIL",
+					},
+				}),
+			)
+			.post()
+			.json<XboxUserResponse>()
 			.catch((e) => {
-				console.error(
-					`Error fetching user for ${this.id} connection: ${e}`,
-				);
+				console.error(e);
 				throw DiscordApiErrors.GENERAL_ERROR;
 			});
 	}