refactor: Rework API request handling with an Either<> type
Some checks failed
Lint and build / lint (push) Has been cancelled
Lint and build / build (push) Has been cancelled
Lint and build / docker-image (push) Has been cancelled

Signed-off-by: Christoph Heiss <contact@christoph-heiss.at>
This commit is contained in:
Christoph Heiss 2022-09-07 23:34:04 +02:00
parent 48589fa023
commit 54ffc131e8
Signed by: c8h4
GPG key ID: 9C82009BEEDEA0FF
6 changed files with 113 additions and 53 deletions

47
src/lib/either.ts Normal file
View file

@ -0,0 +1,47 @@
export type Left<T> = {
left: T;
right?: never;
};
export type Right<U> = {
left?: never;
right: U;
};
export type Either<T, U> = NonNullable<Left<T> | Right<U>>;
export function isLeft<T, U>(e: Either<T, U>): e is Left<T> {
return e.left !== undefined;
}
export function isRight<T, U>(e: Either<T, U>): e is Right<U> {
return e.right !== undefined;
}
export function left<T>(value: T): Left<T> {
return { left: value };
}
export function right<U>(value: U): Right<U> {
return { right: value };
}
export function match<T, U>(
e: Either<T, U>,
onLeft: (left: T) => void,
onRight: (right: U) => void,
) {
if (isLeft(e) && isRight(e)) {
throw new Error('Either<> cannot have both left and right values');
}
if (isLeft(e)) {
return onLeft(e.left);
}
if (isRight(e)) {
return onRight(e.right);
}
throw new Error('match() called on empty Either<>');
}

View file

@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
import { Temporal } from '@js-temporal/polyfill';
import * as E from 'lib/either';
export function classNames(...args: any[]): string {
const classes = [];
@ -30,36 +31,40 @@ export function classNames(...args: any[]): string {
return classes.join(' ');
}
export async function apiPost(url: string, data?: any): Promise<any> {
return fetch(url, {
method: 'POST',
async function performApiRequest<D, R>(
url: string,
method: 'GET' | 'POST',
data?: D,
): Promise<E.Either<string, R>> {
try {
const response = await fetch(url, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: data ? JSON.stringify(data) : '',
})
.then((res) => res.json())
.catch((err) => {
// eslint-disable-next-line no-console
console.log(err);
return Promise.resolve({});
body: data ? JSON.stringify(data) : undefined,
});
if (response.ok) {
const body = await response.text();
return E.right(body.length > 0 ? JSON.parse(body) : {});
}
export async function apiGet(url: string): Promise<any> {
return fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
},
})
.then((res) => res.json())
.catch((err) => {
// eslint-disable-next-line no-console
console.log(err);
return Promise.resolve({});
});
const body = await response.text();
const error = body.length > 0 ? JSON.parse(body).error : 'Unknown error';
return E.left(`${response.status} ${response.statusText}: ${error}`);
} catch (err) {
return E.left(`Network error: ${err}`);
}
}
export async function apiPost<D, R>(url: string, data?: D): Promise<E.Either<string, R>> {
return performApiRequest(url, 'POST', data);
}
export async function apiGet<R>(url: string): Promise<E.Either<string, R>> {
return performApiRequest(url, 'GET');
}
export function formatTraffic(b: number): string {

View file

@ -57,6 +57,6 @@ export default withSessionRoute(
(req.session as any).user = user.id;
await req.session.save();
res.status(200).json({ success: true });
res.status(200).end();
},
);

View file

@ -16,6 +16,6 @@ export default withSessionRoute(
delete (req.session as any).user;
await req.session.save();
res.status(200).json({ success: true });
res.status(200).end();
},
);

View file

@ -6,6 +6,7 @@ import Interface from 'components/interface';
import { withSessionSsr } from 'lib/withSession';
import { apiGet } from 'lib/utils';
import type { WireguardInterface } from 'lib/wireguard';
import * as E from 'lib/either';
interface IndexProps {
user: string;
@ -13,19 +14,19 @@ interface IndexProps {
export default function Index({ user }: IndexProps) {
const [interfaces, setInterfaces] = useState<WireguardInterface[]>([]);
const [fetchFailed, setFetchFailed] = useState(false);
const [fetchFailed, setFetchFailed] = useState('');
const fetchData = async () => {
const res = await apiGet('/api/interfaces');
const res = await apiGet<WireguardInterface[]>('/api/interfaces');
if (!res || typeof res.error === 'string') {
// eslint-disable-next-line no-console
console.log(res);
setFetchFailed(true);
} else {
setFetchFailed(false);
setInterfaces(res);
}
E.match(
res,
setFetchFailed,
(ifs) => {
setFetchFailed('');
setInterfaces(ifs);
},
);
};
useEffect(() => {
@ -39,13 +40,18 @@ export default function Index({ user }: IndexProps) {
<BaseLayout userName={user}>
{fetchFailed && (
<>
<div className="card ~critical @high">
<details className="card ~critical @low">
<summary>
<strong>
Error:
</strong>
{' '}
Failed to fetch WireGuard info. Retrying in 2 seconds ...
</div>
</summary>
<span className="pl-4 text-sm">
{fetchFailed}
</span>
</details>
<hr className="sep" />
</>
)}

View file

@ -4,25 +4,27 @@ import { useState, SyntheticEvent } from 'react';
import { useRouter } from 'next/router';
import BaseLayout from 'components/base-layout';
import { apiPost, classNames } from 'lib/utils';
import * as E from 'lib/either';
export default function Login() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [failedLogin, setFailedLogin] = useState(false);
const [failedLogin, setFailedLogin] = useState('');
const submit = async (e: SyntheticEvent) => {
e.preventDefault();
setFailedLogin(false);
setFailedLogin('');
setLoading(true);
const res = await apiPost('/api/login', { username, password });
if (res.success) {
router.push('/');
} else {
setFailedLogin(true);
}
E.match(
res,
setFailedLogin,
() => router.push('/'),
);
setLoading(false);
};