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
|
// 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 {
|
||||||
|
|
|
@ -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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
Reference in a new issue