mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 19:30:38 +08:00
We have a new apiVersion value in the config json that needs to be changed for each breaking change on httpd so we can simulate a new resource to the cache and get it cleared. Also improves the ResponseParseError giving the user more information on, if the node or the web client is outdated
182 lines
5.0 KiB
TypeScript
182 lines
5.0 KiB
TypeScript
// This module provides low-level capabilities to interact with a typed
|
|
// JSON HTTP API.
|
|
|
|
import type { ZodIssue, ZodType, TypeOf } from "zod";
|
|
|
|
import { config } from "@app/lib/config";
|
|
import { compare } from "compare-versions";
|
|
|
|
export interface BaseUrl {
|
|
hostname: string;
|
|
port: number;
|
|
scheme: string;
|
|
}
|
|
|
|
// Error that is thrown by `Fetcher` methods.
|
|
export class ResponseError extends Error {
|
|
public method: string;
|
|
public url: string;
|
|
public status: number;
|
|
public body: unknown;
|
|
|
|
public constructor(method: string, response: Response, body_: unknown) {
|
|
const body: any = body_;
|
|
if (
|
|
typeof body === "object" &&
|
|
body !== null &&
|
|
typeof body.message === "string"
|
|
) {
|
|
super(body.message);
|
|
} else {
|
|
super("Response error");
|
|
}
|
|
|
|
this.method = method;
|
|
this.body = body_;
|
|
this.status = response.status;
|
|
this.url = response.url;
|
|
}
|
|
}
|
|
|
|
// Error that is thrown by `Fetcher` methods when parsing the response
|
|
// body fails.
|
|
export class ResponseParseError extends Error {
|
|
public method: string;
|
|
public body: unknown;
|
|
public description: string;
|
|
public apiVersion: string | undefined;
|
|
public zodIssues: ZodIssue[];
|
|
public path?: string;
|
|
|
|
public constructor(
|
|
method: string,
|
|
body: unknown,
|
|
apiVersion: string | undefined,
|
|
zodIssues: ZodIssue[],
|
|
path?: string,
|
|
) {
|
|
super("Failed to parse response body");
|
|
|
|
let description: string;
|
|
if (
|
|
apiVersion === undefined ||
|
|
compare(apiVersion, config.nodes.apiVersion, "<")
|
|
) {
|
|
description = `The node you are fetching from seems to be outdated, make sure the httpd API version is at least ${config.nodes.apiVersion} currently ${apiVersion ?? "unknown"}.`;
|
|
} else if (
|
|
config.nodes.apiVersion === undefined ||
|
|
compare(apiVersion, config.nodes.apiVersion, ">")
|
|
) {
|
|
description = `The web client you are using is outdated, make sure it supports at least ${apiVersion} to interact with this node currently ${config.nodes.apiVersion ?? "unknown"}.`;
|
|
} else {
|
|
description =
|
|
"This is usually due to a version mismatch between the seed and the web interface.";
|
|
}
|
|
this.apiVersion = apiVersion;
|
|
this.description =
|
|
"The response received from the seed does not match the expected schema.<br/>".concat(
|
|
description,
|
|
);
|
|
|
|
this.method = method;
|
|
this.path = path;
|
|
this.body = body;
|
|
this.zodIssues = zodIssues;
|
|
}
|
|
}
|
|
|
|
export interface RequestOptions {
|
|
abort?: AbortSignal;
|
|
}
|
|
|
|
export interface FetchParams {
|
|
method: Method;
|
|
// Path to append to the `Fetcher`s base URL to get the final URL.
|
|
path?: string;
|
|
// Object that is serialized into JSON and sent as the data.
|
|
body?: unknown;
|
|
// Query parameters to be serialized with URLSearchParams.
|
|
query?: Record<string, string | number | boolean>;
|
|
options?: RequestOptions;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
type Method = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
|
|
|
|
export class Fetcher {
|
|
#baseUrl: BaseUrl;
|
|
|
|
public constructor(baseUrl: BaseUrl) {
|
|
this.#baseUrl = baseUrl;
|
|
}
|
|
|
|
// Execute a fetch and parse the result with the provided schema.
|
|
// Return the parsed payload.
|
|
//
|
|
// Throws `ResponseError` if the response status code is not `200`.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
public async fetchOk<T extends ZodType<any>>(
|
|
params: FetchParams,
|
|
schema: T,
|
|
): Promise<TypeOf<T>> {
|
|
const response = await this.fetch({
|
|
...params,
|
|
query: { ...params.query, v: config.nodes.apiVersion },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let responseBody = await response.text();
|
|
try {
|
|
responseBody = JSON.parse(responseBody);
|
|
} catch (_e: unknown) {
|
|
// We keep the original text response body.
|
|
}
|
|
throw new ResponseError(params.method, response, responseBody);
|
|
}
|
|
|
|
const responseBody = await response.json();
|
|
const result = schema.safeParse(responseBody);
|
|
if (result.success) {
|
|
return result.data;
|
|
} else {
|
|
const response = await this.fetch({ method: "GET" });
|
|
const info = await response.json();
|
|
throw new ResponseParseError(
|
|
params.method,
|
|
responseBody,
|
|
info.apiVersion,
|
|
result.error.errors,
|
|
params.path,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async fetch({
|
|
method,
|
|
path,
|
|
body,
|
|
options = {},
|
|
query,
|
|
headers = {},
|
|
}: FetchParams): Promise<Response> {
|
|
if (body !== undefined && headers["content-type"] === undefined) {
|
|
headers["content-type"] = "application/json";
|
|
}
|
|
|
|
const pathSegment = path === undefined ? "" : `/${path}`;
|
|
|
|
let url = `${this.#baseUrl.scheme}://${this.#baseUrl.hostname}:${this.#baseUrl.port}/api/v1${pathSegment}`;
|
|
|
|
if (query) {
|
|
const searchparams = new URLSearchParams(query as Record<string, string>);
|
|
url = `${url}?${searchparams.toString()}`;
|
|
}
|
|
return globalThis.fetch(url, {
|
|
method,
|
|
headers,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
signal: options.abort,
|
|
});
|
|
}
|
|
}
|