Refactor api utils and replace some magic values by enums.
This commit is contained in:
parent
518d986c20
commit
8f8194467c
|
@ -26,7 +26,7 @@ import ValidationForm from "@/components/form/ValidationForm.vue";
|
|||
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
||||
import { route, RouteName } from "@/router";
|
||||
import RouteButton from "@/components/form/RouteButton.vue";
|
||||
import { ApiError } from "@/utils/Api";
|
||||
import { ApiError } from "@/utils/api";
|
||||
import NodeCoordinatesInput from "@/components/nodes/NodeCoordinatesInput.vue";
|
||||
import OutsideOfCommunityConfirmationForm from "@/components/nodes/OutsideOfCommunityConfirmationForm.vue";
|
||||
import CheckboxInput from "@/components/form/CheckboxInput.vue";
|
||||
|
|
|
@ -8,7 +8,7 @@ import type { Hostname, StoredNode } from "@/types";
|
|||
import { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
|
||||
import router, { route, RouteName } from "@/router";
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { ApiError } from "@/utils/Api";
|
||||
import { ApiError } from "@/utils/api";
|
||||
import ErrorCard from "@/components/ErrorCard.vue";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import ValidationForm from "@/components/form/ValidationForm.vue";
|
|||
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
||||
import { route, RouteName } from "@/router";
|
||||
import RouteButton from "@/components/form/RouteButton.vue";
|
||||
import { ApiError } from "@/utils/Api";
|
||||
import { ApiError } from "@/utils/api";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const email = computed(() => configStore.getConfig.community.contactEmail);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { type ClientConfig, isClientConfig } from "@/types";
|
||||
import { api } from "@/utils/Api";
|
||||
import { type ClientConfig, isClientConfig, type Path } from "@/types";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface ConfigStoreState {
|
||||
config: ClientConfig;
|
||||
|
@ -21,7 +21,10 @@ export const useConfigStore = defineStore({
|
|||
},
|
||||
actions: {
|
||||
async refresh(): Promise<void> {
|
||||
this.config = await api.get<ClientConfig>("config", isClientConfig);
|
||||
this.config = await api.get<ClientConfig>(
|
||||
"config" as Path,
|
||||
isClientConfig
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -3,10 +3,11 @@ import {
|
|||
type CreateOrUpdateNode,
|
||||
isNodeTokenResponse,
|
||||
isStoredNode,
|
||||
type Path,
|
||||
type StoredNode,
|
||||
type Token,
|
||||
} from "@/types";
|
||||
import { api } from "@/utils/Api";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface NodeStoreState {}
|
||||
|
@ -19,16 +20,20 @@ export const useNodeStore = defineStore({
|
|||
getters: {},
|
||||
actions: {
|
||||
async create(node: CreateOrUpdateNode): Promise<StoredNode> {
|
||||
const response = await api.post("node", isNodeTokenResponse, node);
|
||||
const response = await api.post(
|
||||
"node" as Path,
|
||||
isNodeTokenResponse,
|
||||
node
|
||||
);
|
||||
return response.node;
|
||||
},
|
||||
|
||||
async fetchByToken(token: Token): Promise<StoredNode> {
|
||||
return await api.get(`node/${token}`, isStoredNode);
|
||||
return await api.get(`node/${token}` as Path, isStoredNode);
|
||||
},
|
||||
|
||||
async deleteByToken(token: Token): Promise<void> {
|
||||
await api.delete(`node/${token}`);
|
||||
await api.delete(`node/${token}` as Path);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -4,9 +4,10 @@ import {
|
|||
isDomainSpecificNodeResponse,
|
||||
type NodesFilter,
|
||||
NodeSortFieldEnum,
|
||||
type Path,
|
||||
SortDirection,
|
||||
} from "@/types";
|
||||
import { internalApi } from "@/utils/Api";
|
||||
import { internalApi } from "@/utils/api";
|
||||
|
||||
interface NodesStoreState {
|
||||
nodes: DomainSpecificNodeResponse[];
|
||||
|
@ -65,7 +66,7 @@ export const useNodesStore = defineStore({
|
|||
DomainSpecificNodeResponse,
|
||||
NodeSortFieldEnum
|
||||
>(
|
||||
"nodes",
|
||||
"nodes" as Path,
|
||||
isDomainSpecificNodeResponse,
|
||||
page,
|
||||
nodesPerPage,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { isStatistics, type Statistics } from "@/types";
|
||||
import { internalApi } from "@/utils/Api";
|
||||
import { isStatistics, type Path, type Statistics } from "@/types";
|
||||
import { internalApi } from "@/utils/api";
|
||||
|
||||
interface StatisticsStoreState {
|
||||
statistics: Statistics | null;
|
||||
|
@ -21,7 +21,7 @@ export const useStatisticsStore = defineStore({
|
|||
actions: {
|
||||
async refresh(): Promise<void> {
|
||||
this.statistics = await internalApi.get<Statistics>(
|
||||
"statistics",
|
||||
"statistics" as Path,
|
||||
isStatistics
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { isObject, isVersion, type Version } from "@/types";
|
||||
import { api } from "@/utils/Api";
|
||||
import { isObject, isVersion, type Path, type Version } from "@/types";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface VersionResponse {
|
||||
version: Version;
|
||||
|
@ -30,7 +30,7 @@ export const useVersionStore = defineStore({
|
|||
actions: {
|
||||
async refresh(): Promise<void> {
|
||||
const response = await api.get<VersionResponse>(
|
||||
"version",
|
||||
"version" as Path,
|
||||
isVersionResponse
|
||||
);
|
||||
this.version = response.version;
|
||||
|
|
|
@ -1,285 +0,0 @@
|
|||
import {
|
||||
hasOwnProperty,
|
||||
isPlainObject,
|
||||
isString,
|
||||
type JSONValue,
|
||||
SortDirection,
|
||||
toIsArray,
|
||||
type TypeGuard,
|
||||
} from "@/types";
|
||||
import type { Headers } from "request";
|
||||
import { parseToInteger } from "@/utils/Numbers";
|
||||
|
||||
type Method = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
enum Header {
|
||||
CONTENT_TYPE = "Content-Type",
|
||||
"X_TOTAL_COUNT" = "x-total-count",
|
||||
}
|
||||
|
||||
enum MimeType {
|
||||
APPLICATION_JSON = "application/json",
|
||||
}
|
||||
|
||||
enum ApiErrorType {
|
||||
REQUEST_FAILED = "request_failed",
|
||||
UNEXPECTED_RESULT_TYPE = "unexpected_result_type",
|
||||
}
|
||||
|
||||
enum HttpStatusCode {
|
||||
NOT_FOUND = 404,
|
||||
CONFLICT = 409,
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
private constructor(
|
||||
message: string,
|
||||
private status: number,
|
||||
private errorType: ApiErrorType,
|
||||
private body: JSONValue
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static async requestFailed(
|
||||
method: Method,
|
||||
path: string,
|
||||
response: Response
|
||||
): Promise<ApiError> {
|
||||
const body: JSONValue =
|
||||
response.headers.get(Header.CONTENT_TYPE) ===
|
||||
MimeType.APPLICATION_JSON
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
|
||||
return new ApiError(
|
||||
`API ${method} request failed: ${path} => ${
|
||||
response.status
|
||||
} - ${JSON.stringify(body)}`,
|
||||
response.status,
|
||||
ApiErrorType.REQUEST_FAILED,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
static async unexpectedResultType(
|
||||
method: Method,
|
||||
path: string,
|
||||
response: Response,
|
||||
json: JSONValue
|
||||
): Promise<ApiError> {
|
||||
return new ApiError(
|
||||
`API ${method} request result has unexpected type. ${path} => ${JSON.stringify(
|
||||
json
|
||||
)}`,
|
||||
response.status,
|
||||
ApiErrorType.UNEXPECTED_RESULT_TYPE,
|
||||
json
|
||||
);
|
||||
}
|
||||
|
||||
isNotFoundError(): boolean {
|
||||
return (
|
||||
this.errorType === ApiErrorType.REQUEST_FAILED &&
|
||||
this.status === HttpStatusCode.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
isConflict(): boolean {
|
||||
return (
|
||||
this.errorType === ApiErrorType.REQUEST_FAILED &&
|
||||
this.status === HttpStatusCode.CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
getConflictField(): string | undefined {
|
||||
if (
|
||||
!this.isConflict() ||
|
||||
!isPlainObject(this.body) ||
|
||||
!hasOwnProperty(this.body, "field") ||
|
||||
!isString(this.body.field)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.body.field;
|
||||
}
|
||||
}
|
||||
|
||||
interface PagedListResult<T> {
|
||||
entries: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
result: T;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
class Api {
|
||||
private baseURL: string = import.meta.env.BASE_URL;
|
||||
private apiPrefix = "api/";
|
||||
|
||||
constructor(apiPrefix?: string) {
|
||||
if (apiPrefix) {
|
||||
this.apiPrefix = apiPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
private toURL(path: string, queryParams?: object): string {
|
||||
let queryString = "";
|
||||
if (queryParams) {
|
||||
const queryStrings: string[] = [];
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
queryStrings.push(
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
|
||||
);
|
||||
}
|
||||
if (queryStrings.length > 0) {
|
||||
queryString = `?${queryStrings.join("&")}`;
|
||||
}
|
||||
}
|
||||
return this.baseURL + this.apiPrefix + path + queryString;
|
||||
}
|
||||
|
||||
private async sendRequest<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
isT?: TypeGuard<T>,
|
||||
bodyData?: object,
|
||||
queryParams?: object
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = this.toURL(path, queryParams);
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
};
|
||||
if (bodyData) {
|
||||
options.body = JSON.stringify(bodyData);
|
||||
options.headers = {
|
||||
[Header.CONTENT_TYPE]: MimeType.APPLICATION_JSON,
|
||||
};
|
||||
}
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw await ApiError.requestFailed(method, path, response);
|
||||
}
|
||||
|
||||
if (isT) {
|
||||
const json: JSONValue = await response.json();
|
||||
if (!isT(json)) {
|
||||
console.log(json);
|
||||
throw await ApiError.unexpectedResultType(
|
||||
method,
|
||||
path,
|
||||
response,
|
||||
json
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
result: json,
|
||||
headers: response.headers,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
result: undefined as unknown as T,
|
||||
headers: response.headers,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async post<T>(
|
||||
path: string,
|
||||
isT: TypeGuard<T>,
|
||||
postData: object,
|
||||
queryParams?: object
|
||||
): Promise<T> {
|
||||
const response = await this.sendRequest<T>(
|
||||
"POST",
|
||||
path,
|
||||
isT,
|
||||
postData,
|
||||
queryParams
|
||||
);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async put<T>(
|
||||
path: string,
|
||||
isT: TypeGuard<T>,
|
||||
putData: object,
|
||||
queryParams?: object
|
||||
): Promise<T> {
|
||||
const response = await this.sendRequest<T>(
|
||||
"PUT",
|
||||
path,
|
||||
isT,
|
||||
putData,
|
||||
queryParams
|
||||
);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
private async doGet<T>(
|
||||
path: string,
|
||||
isT: TypeGuard<T>,
|
||||
queryParams?: object
|
||||
): Promise<ApiResponse<T>> {
|
||||
return await this.sendRequest<T>(
|
||||
"GET",
|
||||
path,
|
||||
isT,
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
}
|
||||
|
||||
async get<T>(
|
||||
path: string,
|
||||
isT: TypeGuard<T>,
|
||||
queryParams?: object
|
||||
): Promise<T> {
|
||||
const response = await this.doGet(path, isT, queryParams);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async getPagedList<Element, SortField>(
|
||||
path: string,
|
||||
isElement: TypeGuard<Element>,
|
||||
page: number,
|
||||
itemsPerPage: number,
|
||||
sortDirection?: SortDirection,
|
||||
sortField?: SortField,
|
||||
filter?: object
|
||||
): Promise<PagedListResult<Element>> {
|
||||
const response = await this.doGet(path, toIsArray(isElement), {
|
||||
_page: page,
|
||||
_perPage: itemsPerPage,
|
||||
_sortDir: sortDirection,
|
||||
_sortField: sortField,
|
||||
...filter,
|
||||
});
|
||||
const totalStr = response.headers.get(Header.X_TOTAL_COUNT);
|
||||
const total = parseToInteger(totalStr, 10);
|
||||
|
||||
return {
|
||||
entries: response.result,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
await this.sendRequest("DELETE", path);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new Api();
|
||||
|
||||
class InternalApi extends Api {
|
||||
constructor() {
|
||||
super("internal/api/");
|
||||
}
|
||||
}
|
||||
|
||||
export const internalApi = new InternalApi();
|
518
frontend/src/utils/api.ts
Normal file
518
frontend/src/utils/api.ts
Normal file
|
@ -0,0 +1,518 @@
|
|||
/**
|
||||
* Utility classes for interacting with the servers REST-API.
|
||||
*/
|
||||
import {
|
||||
hasOwnProperty,
|
||||
isPlainObject,
|
||||
isString,
|
||||
type JSONValue,
|
||||
type Path,
|
||||
SortDirection,
|
||||
toIsArray,
|
||||
type TypeGuard,
|
||||
type Url,
|
||||
} from "@/types";
|
||||
import type { Headers } from "request";
|
||||
import { parseToInteger } from "@/shared/utils/numbers";
|
||||
import {
|
||||
HttpHeader,
|
||||
HttpMethod,
|
||||
HttpStatusCode,
|
||||
MimeType,
|
||||
} from "@/shared/utils/http";
|
||||
|
||||
/**
|
||||
* Enum to distinguish different API errors.
|
||||
*/
|
||||
enum ApiErrorType {
|
||||
/**
|
||||
* The HTTP request failed.
|
||||
*/
|
||||
REQUEST_FAILED = "request_failed",
|
||||
|
||||
/**
|
||||
* The HTTP response body was no valid JSON.
|
||||
*/
|
||||
MALFORMED_JSON = "malformed_json",
|
||||
|
||||
/**
|
||||
* The HTTP request resulted in a response with an unexpected body payload.
|
||||
*/
|
||||
UNEXPECTED_RESULT_TYPE = "unexpected_result_type",
|
||||
}
|
||||
|
||||
/**
|
||||
* An error thrown when interacting with the REST-API fails in some way.
|
||||
*
|
||||
* You can use the different `is*`-Methods to distinguish between different error types.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
private constructor(
|
||||
message: string,
|
||||
private status: HttpStatusCode,
|
||||
private errorType: ApiErrorType,
|
||||
private body: JSONValue
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an {@link ApiError} in case the HTTP request fails.
|
||||
*
|
||||
* @param method - The HTTP method used in the request.
|
||||
* @param url - The URL used in the HTTP request.
|
||||
* @param response - The response object as provided by the `fetch`-API.
|
||||
*/
|
||||
static async requestFailed(
|
||||
method: HttpMethod,
|
||||
url: Url,
|
||||
response: Response
|
||||
): Promise<ApiError> {
|
||||
const body: JSONValue =
|
||||
response.headers.get(HttpHeader.CONTENT_TYPE) ===
|
||||
MimeType.APPLICATION_JSON
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
|
||||
return new ApiError(
|
||||
`API ${method} request failed: ${url} => ${
|
||||
response.status
|
||||
} - ${JSON.stringify(body)}`,
|
||||
response.status,
|
||||
ApiErrorType.REQUEST_FAILED,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
static async malformedJSON(
|
||||
method: HttpMethod,
|
||||
url: Url,
|
||||
response: Response,
|
||||
error: unknown
|
||||
): Promise<ApiError> {
|
||||
const errorMsg = hasOwnProperty(error, "message")
|
||||
? `${error.message}`
|
||||
: `${error}`;
|
||||
const body = await response.text();
|
||||
return new ApiError(
|
||||
`API ${method} request returned malformed JSON: ${url} => ${response.status} - ${errorMsg} - ${body}`,
|
||||
response.status,
|
||||
ApiErrorType.MALFORMED_JSON,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an {@link ApiError} in case the HTTP request is successful but the responses body payload has an
|
||||
* unexpected type.
|
||||
*
|
||||
* @param method - The HTTP method used in the request.
|
||||
* @param url - The URL used in the HTTP request.
|
||||
* @param response - The response object as provided by the `fetch`-API.
|
||||
* @param json - The JSON body from the response.
|
||||
*/
|
||||
static async unexpectedResultType(
|
||||
method: HttpMethod,
|
||||
url: Url,
|
||||
response: Response,
|
||||
json: JSONValue
|
||||
): Promise<ApiError> {
|
||||
return new ApiError(
|
||||
`API ${method} request result has unexpected type. ${url} => ${JSON.stringify(
|
||||
json
|
||||
)}`,
|
||||
response.status,
|
||||
ApiErrorType.UNEXPECTED_RESULT_TYPE,
|
||||
json
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` if the request failed with {@link HttpStatusCode.NOT_FOUND}.
|
||||
*/
|
||||
isNotFoundError(): boolean {
|
||||
return (
|
||||
this.errorType === ApiErrorType.REQUEST_FAILED &&
|
||||
this.status === HttpStatusCode.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` if the request failed with {@link HttpStatusCode.CONFLICT}.
|
||||
*/
|
||||
isConflict(): boolean {
|
||||
return (
|
||||
this.errorType === ApiErrorType.REQUEST_FAILED &&
|
||||
this.status === HttpStatusCode.CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In case of a failed request {@link HttpStatusCode.CONFLICT} this method returns the conflicting field if any.
|
||||
*/
|
||||
getConflictField(): string | undefined {
|
||||
if (
|
||||
!this.isConflict() ||
|
||||
!isPlainObject(this.body) ||
|
||||
!hasOwnProperty(this.body, "field") ||
|
||||
!isString(this.body.field)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.body.field;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of paged GET request against the REST-API.
|
||||
*
|
||||
* See {@link Api.getPagedList}.
|
||||
*/
|
||||
export interface ApiPagedListResult<T> {
|
||||
/**
|
||||
* Array of entries for the requested page.
|
||||
*/
|
||||
entries: T[];
|
||||
|
||||
/**
|
||||
* Total number of entities known by the server for the specified filters (ignoring paging).
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response of an REST-API request including the responses HTTP headers.
|
||||
*/
|
||||
interface ApiResponse<T> {
|
||||
/**
|
||||
* Parsed response body.
|
||||
*/
|
||||
result: T;
|
||||
|
||||
/**
|
||||
* Response header of the HTTP request.
|
||||
*/
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class to make type-safe HTTP requests against the servers REST-API.
|
||||
*/
|
||||
class Api {
|
||||
/**
|
||||
* Base URL of the server to make REST-API calls agains.
|
||||
* @private
|
||||
*/
|
||||
private baseURL: Url = import.meta.env.BASE_URL as Url;
|
||||
|
||||
/**
|
||||
* Prefix to prepend to the path for each request.
|
||||
* @private
|
||||
*/
|
||||
private apiPrefix: Path;
|
||||
|
||||
/**
|
||||
* Creates an {@link Api} object for REST-API requests beneath the given path prefix.
|
||||
*
|
||||
* @param apiPrefix - Prefix to prepend to the path for each request.
|
||||
*/
|
||||
constructor(apiPrefix?: Path) {
|
||||
this.apiPrefix = apiPrefix || ("api/" as Path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a {@link Url} form the specified path and query parameters. The path is considered to be beneath
|
||||
* {@link Api.apiPrefix}.
|
||||
*
|
||||
* @param path - Path of the REST-API resource.
|
||||
* @param queryParams - Optional query parameters to include in the URL.
|
||||
* @returns The complete URL.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private toURL(path: Path, queryParams?: object): Url {
|
||||
let queryString = "";
|
||||
if (queryParams) {
|
||||
const queryStrings: string[] = [];
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
queryStrings.push(
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
|
||||
);
|
||||
}
|
||||
if (queryStrings.length > 0) {
|
||||
queryString = `?${queryStrings.join("&")}`;
|
||||
}
|
||||
}
|
||||
return (this.baseURL + this.apiPrefix + path + queryString) as Url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the JSON payload from the response's body.
|
||||
*
|
||||
* @param method - The HTTP method used in the request.
|
||||
* @param url - The URL used in the HTTP request.
|
||||
* @param response - The response object as provided by the `fetch`-API.
|
||||
* @returns The JSON payload.
|
||||
* @throws {@link ApiError} in case the body cannot be parsed to be valid JSON.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private async getJSONFromResponse(
|
||||
method: HttpMethod,
|
||||
url: Url,
|
||||
response: Response
|
||||
): Promise<JSONValue> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw await ApiError.malformedJSON(method, url, response, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a typesafe HTTP request against the servers REST-API. Makes sure the response body type matches `<T>`.
|
||||
*
|
||||
* @param method - The HTTP method to be used in the request.
|
||||
* @param path - The path of the REST-API resource to make request agains.
|
||||
* @param isT - Optional {@link TypeGuard} to check the payload in the response body has the expected type `<T>`.
|
||||
* @param bodyData - JSON data to be sent in the request body.
|
||||
* @param queryParams - Optional query parameters for the URL used in the request.
|
||||
* @returns The typesafe payload in the response body together with the responses HTTP headers.
|
||||
*
|
||||
* @throws {@link ApiError} in case the request fails or the response body is malformed or has an unexpected type.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private async sendRequest<T>(
|
||||
method: HttpMethod,
|
||||
path: Path,
|
||||
isT?: TypeGuard<T>,
|
||||
bodyData?: object,
|
||||
queryParams?: object
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = this.toURL(path, queryParams);
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
};
|
||||
if (bodyData) {
|
||||
options.body = JSON.stringify(bodyData);
|
||||
options.headers = {
|
||||
[HttpHeader.CONTENT_TYPE]: MimeType.APPLICATION_JSON,
|
||||
};
|
||||
}
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw await ApiError.requestFailed(method, url, response);
|
||||
}
|
||||
|
||||
if (isT) {
|
||||
const json = await this.getJSONFromResponse(method, url, response);
|
||||
if (!isT(json)) {
|
||||
console.log(json);
|
||||
throw await ApiError.unexpectedResultType(
|
||||
method,
|
||||
url,
|
||||
response,
|
||||
json
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
result: json,
|
||||
headers: response.headers,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
result: undefined as unknown as T,
|
||||
headers: response.headers,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a typesafe HTTP POST request against the servers REST-API. Makes sure the response body type
|
||||
* matches `<T>`.
|
||||
*
|
||||
* @param path - The path of the REST-API resource to make request agains.
|
||||
* @param isT - {@link TypeGuard} to check the payload in the response body has the expected type `<T>`.
|
||||
* @param postData - JSON data to be posted in the request body.
|
||||
* @param queryParams - Optional query parameters for the URL used in the request.
|
||||
* @returns The typesafe payload in the response body.
|
||||
*
|
||||
* @throws {@link ApiError} in case the request fails or the response body is malformed or has an unexpected type.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async post<T>(
|
||||
path: Path,
|
||||
isT: TypeGuard<T>,
|
||||
postData: object,
|
||||
queryParams?: object
|
||||
): Promise<T> {
|
||||
const response = await this.sendRequest<T>(
|
||||
HttpMethod.POST,
|
||||
path,
|
||||
isT,
|
||||
postData,
|
||||
queryParams
|
||||
);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a typesafe HTTP PUT request against the servers REST-API. Makes sure the response body type
|
||||
* matches `<T>`.
|
||||
*
|
||||
* @param path - The path of the REST-API resource to make request agains.
|
||||
* @param isT - {@link TypeGuard} to check the payload in the response body has the expected type `<T>`.
|
||||
* @param putData - JSON data to be put in the request body.
|
||||
* @param queryParams - Optional query parameters for the URL used in the request.
|
||||
* @returns The typesafe payload in the response body.
|
||||
*
|
||||
* @throws {@link ApiError} in case the request fails or the response body is malformed or has an unexpected type.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async put<T>(
|
||||
path: Path,
|
||||
isT: TypeGuard<T>,
|
||||
putData: object,
|
||||
queryParams?: object
|
||||
): Promise<T> {
|
||||
const response = await this.sendRequest<T>(
|
||||
HttpMethod.PUT,
|
||||
path,
|
||||
isT,
|
||||
putData,
|
||||
queryParams
|
||||
);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a typesafe HTTP GET request against the servers REST-API. Makes sure the response body type
|
||||
* matches `<T>`.
|
||||
*
|
||||
* @param path - The path of the REST-API resource to make request agains.
|
||||
* @param isT - {@link TypeGuard} to check the payload in the response body has the expected type `<T>`.
|
||||
* @param queryParams - Optional query parameters for the URL used in the request.
|
||||
* @returns The typesafe payload in the response body together with the responses HTTP headers.
|
||||
*
|
||||
* @throws {@link ApiError} in case the request fails or the response body is malformed or has an unexpected type.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private async doGet<T>(
|
||||
path: Path,
|
||||
isT: TypeGuard<T>,
|
||||
queryParams?: object
|
||||
): Promise<ApiResponse<T>> {
|
||||
return await this.sendRequest<T>(
|
||||
HttpMethod.GET,
|
||||
path,
|
||||
isT,
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a typesafe HTTP GET request against the servers REST-API. Makes sure the response body type
|
||||
* matches `<T>`.
|
||||
*
|
||||
* @param path - The path of the REST-API resource to make request agains.
|
||||
* @param isT - {@link TypeGuard} to check the payload in the response body has the expected type `<T>`.
|
||||
* @param queryParams - Optional query parameters for the URL used in the request.
|
||||
* @returns The typesafe payload in the response body.
|
||||
*
|
||||
* @throws {@link ApiError} in case the request fails or the response body is malformed or has an unexpected type.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async get<T>(
|
||||
path: Path,
|
||||
isT: TypeGuard<T>,
|
||||
queryParams?: object
|
||||
): Promise<T> {
|
||||
const response = await this.doGet(path, isT, queryParams);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a typesafe HTTP GET request against the servers REST-API to get a paged list of items.
|
||||
*
|
||||
* @param path - The path of the REST-API resource to make request agains.
|
||||
* @param isElement - {@link TypeGuard} to check the payload in the response body has the expected type `<T>`.
|
||||
* @param page - Page number to query for. The first page has the number 1.
|
||||
* @param itemsPerPage - The number of items to get for each page.
|
||||
* @param sortDirection - Optional direction to sort the items in.
|
||||
* @param sortField - Optional field to sort the items by.
|
||||
* @param filter - Optional filters to filter items with.
|
||||
* @returns The typesafe array of entries in the response body along with the number of total items matching
|
||||
* the given filters.
|
||||
*
|
||||
* @throws {@link ApiError} in case the request fails or the response body is malformed or has an unexpected type.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async getPagedList<Element, SortField>(
|
||||
path: Path,
|
||||
isElement: TypeGuard<Element>,
|
||||
page: number,
|
||||
itemsPerPage: number,
|
||||
sortDirection?: SortDirection,
|
||||
sortField?: SortField,
|
||||
filter?: object
|
||||
): Promise<ApiPagedListResult<Element>> {
|
||||
const response = await this.doGet(path, toIsArray(isElement), {
|
||||
_page: page,
|
||||
_perPage: itemsPerPage,
|
||||
_sortDir: sortDirection,
|
||||
_sortField: sortField,
|
||||
...filter,
|
||||
});
|
||||
const totalStr = response.headers.get(HttpHeader.X_TOTAL_COUNT);
|
||||
const total = parseToInteger(totalStr);
|
||||
|
||||
return {
|
||||
entries: response.result,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a typesafe HTTP DELETE request against the servers REST-API.
|
||||
*
|
||||
* @param path - The path of the REST-API resource to make request agains.
|
||||
*
|
||||
* @throws {@link ApiError} in case the request fails or the response body is malformed or has an unexpected type.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async delete(path: Path): Promise<void> {
|
||||
await this.sendRequest(HttpMethod.DELETE, path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link Api} object to make REST-API calls against the servers public API.
|
||||
*/
|
||||
export const api = new Api();
|
||||
|
||||
/**
|
||||
* Helper class to make REST-API calls against the servers internal API.
|
||||
*
|
||||
* See {@link Api}.
|
||||
*/
|
||||
class InternalApi extends Api {
|
||||
constructor() {
|
||||
super("internal/api/" as Path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link Api} object to make REST-API calls against the servers internal API.
|
||||
*/
|
||||
export const internalApi = new InternalApi();
|
|
@ -11,6 +11,7 @@ import { config } from "./config";
|
|||
import type { CleartextPassword, PasswordHash, Username } from "./types";
|
||||
import { isString } from "./types";
|
||||
import Logger from "./logger";
|
||||
import { HttpHeader, HttpStatusCode, MimeType } from "./shared/utils/http";
|
||||
|
||||
export const app: Express = express();
|
||||
|
||||
|
@ -111,7 +112,7 @@ export function init(): void {
|
|||
router.use(compress());
|
||||
|
||||
async function serveTemplate(
|
||||
mimeType: string,
|
||||
mimeType: MimeType,
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
|
@ -120,13 +121,15 @@ export function init(): void {
|
|||
"utf8"
|
||||
);
|
||||
|
||||
res.writeHead(200, { "Content-Type": mimeType });
|
||||
res.writeHead(HttpStatusCode.OK, {
|
||||
[HttpHeader.CONTENT_TYPE]: mimeType,
|
||||
});
|
||||
res.end(_.template(body)({ config: config.client }));
|
||||
}
|
||||
|
||||
usePromise(async (req: Request, res: Response): Promise<void> => {
|
||||
if (jsTemplateFiles.indexOf(req.path) >= 0) {
|
||||
await serveTemplate("application/javascript", req, res);
|
||||
await serveTemplate(MimeType.APPLICATION_JSON, req, res);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import { normalizeString, parseInteger } from "../shared/utils/strings";
|
|||
import { forConstraint } from "../shared/validation/validator";
|
||||
import type { Request, Response } from "express";
|
||||
import { isString, Mail, MailId } from "../types";
|
||||
import { HttpHeader } from "../shared/utils/http";
|
||||
|
||||
const isValidId = forConstraint(CONSTRAINTS.id, false);
|
||||
|
||||
|
@ -39,7 +40,7 @@ async function doGetAll(
|
|||
export function getAll(req: Request, res: Response): void {
|
||||
doGetAll(req)
|
||||
.then(({ total, mails }) => {
|
||||
res.set("X-Total-Count", total.toString(10));
|
||||
res.set(HttpHeader.X_TOTAL_COUNT, total.toString(10));
|
||||
return Resources.success(res, mails);
|
||||
})
|
||||
.catch((err) => Resources.error(res, err));
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
NodeMonitoringStateResponse,
|
||||
toMonitoringResponse,
|
||||
} from "../types";
|
||||
import { HttpHeader } from "../shared/utils/http";
|
||||
|
||||
const isValidToken = forConstraint(CONSTRAINTS.token, false);
|
||||
|
||||
|
@ -33,7 +34,7 @@ async function doGetAll(
|
|||
export function getAll(req: Request, res: Response): void {
|
||||
doGetAll(req)
|
||||
.then(({ total, result }) => {
|
||||
res.set("X-Total-Count", total.toString(10));
|
||||
res.set(HttpHeader.X_TOTAL_COUNT, total.toString(10));
|
||||
Resources.success(res, result);
|
||||
})
|
||||
.catch((err) => Resources.error(res, err));
|
||||
|
|
|
@ -27,6 +27,7 @@ import {
|
|||
toNodeTokenResponse,
|
||||
} from "../types";
|
||||
import { filterUndefinedFromJSON } from "../shared/utils/json";
|
||||
import { HttpHeader } from "../shared/utils/http";
|
||||
|
||||
const nodeFields = [
|
||||
"hostname",
|
||||
|
@ -165,7 +166,7 @@ export function getAll(req: Request, res: Response): void {
|
|||
total: number;
|
||||
pageNodes: DomainSpecificNodeResponse[];
|
||||
}) => {
|
||||
res.set("X-Total-Count", result.total.toString(10));
|
||||
res.set(HttpHeader.X_TOTAL_COUNT, result.total.toString(10));
|
||||
return Resources.success(
|
||||
res,
|
||||
result.pageNodes.map(filterUndefinedFromJSON)
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
TaskState,
|
||||
UnixTimestampSeconds,
|
||||
} from "../types";
|
||||
import { HttpHeader } from "../shared/utils/http";
|
||||
|
||||
const isValidId = forConstraint(CONSTRAINTS.id, false);
|
||||
|
||||
|
@ -109,7 +110,7 @@ async function doGetAll(
|
|||
export function getAll(req: Request, res: Response): void {
|
||||
doGetAll(req)
|
||||
.then(({ total, pageTasks }) => {
|
||||
res.set("X-Total-Count", total.toString(10));
|
||||
res.set(HttpHeader.X_TOTAL_COUNT, total.toString(10));
|
||||
Resources.success(res, pageTasks.map(toTaskResponse));
|
||||
})
|
||||
.catch((err) => Resources.error(res, err));
|
||||
|
|
|
@ -68,6 +68,18 @@ export type Url = string & { readonly __tag: unique symbol };
|
|||
*/
|
||||
export const isUrl = toIsNewtype(isString, "" as Url);
|
||||
|
||||
/**
|
||||
* Typesafe string representation of paths.
|
||||
*/
|
||||
export type Path = string & { readonly __tag: unique symbol };
|
||||
|
||||
/**
|
||||
* Type guard for {@link Path}.
|
||||
*
|
||||
* @param arg - Value to check.
|
||||
*/
|
||||
export const isPath = toIsNewtype(isString, "" as Url);
|
||||
|
||||
/**
|
||||
* Fastd VPN key of a Freifunk node. This is the key used by the node to open a VPN tunnel to Freifunk gateways.
|
||||
*/
|
||||
|
|
84
server/shared/utils/http.ts
Normal file
84
server/shared/utils/http.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Enum representing supported HTTP methods.
|
||||
*/
|
||||
export enum HttpMethod {
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET}.
|
||||
*/
|
||||
GET = "GET",
|
||||
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST}.
|
||||
*/
|
||||
POST = "POST",
|
||||
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT}.
|
||||
*/
|
||||
PUT = "PUT",
|
||||
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE}.
|
||||
*/
|
||||
DELETE = "DELETE",
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing supported HTTP headers.
|
||||
*/
|
||||
export enum HttpHeader {
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type}.
|
||||
*/
|
||||
CONTENT_TYPE = "Content-Type",
|
||||
|
||||
/**
|
||||
* Holds the total number of entities known by the server matching the request (ignoring paging parameters).
|
||||
*/
|
||||
X_TOTAL_COUNT = "X-Total-Count",
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing supported mime-types.
|
||||
*/
|
||||
export enum MimeType {
|
||||
/**
|
||||
* The content is JSON.
|
||||
*/
|
||||
APPLICATION_JSON = "application/json",
|
||||
|
||||
/**
|
||||
* The content is (X)HTML.
|
||||
*/
|
||||
TEXT_HTML = "text/html",
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing supported HTTP response status codes.
|
||||
*/
|
||||
export enum HttpStatusCode {
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/200}.
|
||||
*/
|
||||
OK = 200,
|
||||
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400}.
|
||||
*/
|
||||
BAD_REQUEST = 400,
|
||||
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404}.
|
||||
*/
|
||||
NOT_FOUND = 404,
|
||||
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409}.
|
||||
*/
|
||||
CONFLICT = 409,
|
||||
|
||||
/**
|
||||
* See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500}.
|
||||
*/
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
import { HttpStatusCode } from "../shared/utils/http";
|
||||
|
||||
// TODO: Replace this by throwing typed errors.
|
||||
export default {
|
||||
badRequest: { code: 400 },
|
||||
notFound: { code: 404 },
|
||||
conflict: { code: 409 },
|
||||
internalError: { code: 500 },
|
||||
badRequest: { code: HttpStatusCode.BAD_REQUEST },
|
||||
notFound: { code: HttpStatusCode.NOT_FOUND },
|
||||
conflict: { code: HttpStatusCode.CONFLICT },
|
||||
internalError: { code: HttpStatusCode.INTERNAL_SERVER_ERROR },
|
||||
};
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
TypeGuard,
|
||||
} from "../types";
|
||||
import { getFieldIfExists } from "../shared/utils/objects";
|
||||
import { HttpHeader, HttpStatusCode, MimeType } from "../shared/utils/http";
|
||||
|
||||
export type RequestData = JSONObject;
|
||||
export type RequestHandler = (request: Request, response: Response) => void;
|
||||
|
@ -46,30 +47,34 @@ export type FilterClause = { query: string; params: unknown[] };
|
|||
|
||||
function respond(
|
||||
res: Response,
|
||||
httpCode: number,
|
||||
httpCode: HttpStatusCode,
|
||||
data: string,
|
||||
type: "html"
|
||||
type: MimeType.TEXT_HTML
|
||||
): void;
|
||||
function respond(
|
||||
res: Response,
|
||||
httpCode: number,
|
||||
httpCode: HttpStatusCode,
|
||||
data: JSONValue,
|
||||
type: "json"
|
||||
type: MimeType.APPLICATION_JSON
|
||||
): void;
|
||||
function respond(
|
||||
res: Response,
|
||||
httpCode: number,
|
||||
httpCode: HttpStatusCode,
|
||||
data: JSONValue,
|
||||
type: "html" | "json"
|
||||
type: MimeType.APPLICATION_JSON | MimeType.TEXT_HTML
|
||||
): void {
|
||||
switch (type) {
|
||||
case "html":
|
||||
res.writeHead(httpCode, { "Content-Type": "text/html" });
|
||||
case MimeType.TEXT_HTML:
|
||||
res.writeHead(httpCode, {
|
||||
[HttpHeader.CONTENT_TYPE]: MimeType.TEXT_HTML,
|
||||
});
|
||||
res.end(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
res.writeHead(httpCode, { "Content-Type": "application/json" });
|
||||
res.writeHead(httpCode, {
|
||||
[HttpHeader.CONTENT_TYPE]: MimeType.APPLICATION_JSON,
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
break;
|
||||
}
|
||||
|
@ -365,18 +370,18 @@ export function filterClause<SortField>(
|
|||
}
|
||||
|
||||
export function success(res: Response, data: JSONValue) {
|
||||
respond(res, 200, data, "json");
|
||||
respond(res, HttpStatusCode.OK, data, MimeType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
export function successHtml(res: Response, html: string) {
|
||||
respond(res, 200, html, "html");
|
||||
respond(res, HttpStatusCode.OK, html, MimeType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
export function error(
|
||||
res: Response,
|
||||
err: { data: JSONValue; type: { code: number } }
|
||||
err: { data: JSONValue; type: { code: HttpStatusCode } }
|
||||
) {
|
||||
respond(res, err.type.code, err.data, "json");
|
||||
respond(res, err.type.code, err.data, MimeType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
export function handleJSON<Response>(
|
||||
|
|
Loading…
Reference in a new issue