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

View file

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

View file

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