refactor: Rework API request handling with an Either<>
type
Signed-off-by: Christoph Heiss <contact@christoph-heiss.at>
This commit is contained in:
parent
48589fa023
commit
54ffc131e8
47
src/lib/either.ts
Normal file
47
src/lib/either.ts
Normal 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<>');
|
||||
}
|
|
@ -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) : {});
|
||||
}
|
||||
|
||||
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 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({});
|
||||
});
|
||||
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 {
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
Reference in a new issue