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 ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
||||||
import { route, RouteName } from "@/router";
|
import { route, RouteName } from "@/router";
|
||||||
import RouteButton from "@/components/form/RouteButton.vue";
|
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 NodeCoordinatesInput from "@/components/nodes/NodeCoordinatesInput.vue";
|
||||||
import OutsideOfCommunityConfirmationForm from "@/components/nodes/OutsideOfCommunityConfirmationForm.vue";
|
import OutsideOfCommunityConfirmationForm from "@/components/nodes/OutsideOfCommunityConfirmationForm.vue";
|
||||||
import CheckboxInput from "@/components/form/CheckboxInput.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 { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
|
||||||
import router, { route, RouteName } from "@/router";
|
import router, { route, RouteName } from "@/router";
|
||||||
import { computed, nextTick, ref } from "vue";
|
import { computed, nextTick, ref } from "vue";
|
||||||
import { ApiError } from "@/utils/Api";
|
import { ApiError } from "@/utils/api";
|
||||||
import ErrorCard from "@/components/ErrorCard.vue";
|
import ErrorCard from "@/components/ErrorCard.vue";
|
||||||
import { useConfigStore } from "@/stores/config";
|
import { useConfigStore } from "@/stores/config";
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ import ValidationForm from "@/components/form/ValidationForm.vue";
|
||||||
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
||||||
import { route, RouteName } from "@/router";
|
import { route, RouteName } from "@/router";
|
||||||
import RouteButton from "@/components/form/RouteButton.vue";
|
import RouteButton from "@/components/form/RouteButton.vue";
|
||||||
import { ApiError } from "@/utils/Api";
|
import { ApiError } from "@/utils/api";
|
||||||
|
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
const email = computed(() => configStore.getConfig.community.contactEmail);
|
const email = computed(() => configStore.getConfig.community.contactEmail);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { type ClientConfig, isClientConfig } from "@/types";
|
import { type ClientConfig, isClientConfig, type Path } from "@/types";
|
||||||
import { api } from "@/utils/Api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface ConfigStoreState {
|
interface ConfigStoreState {
|
||||||
config: ClientConfig;
|
config: ClientConfig;
|
||||||
|
@ -21,7 +21,10 @@ export const useConfigStore = defineStore({
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async refresh(): Promise<void> {
|
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,
|
type CreateOrUpdateNode,
|
||||||
isNodeTokenResponse,
|
isNodeTokenResponse,
|
||||||
isStoredNode,
|
isStoredNode,
|
||||||
|
type Path,
|
||||||
type StoredNode,
|
type StoredNode,
|
||||||
type Token,
|
type Token,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { api } from "@/utils/Api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
interface NodeStoreState {}
|
interface NodeStoreState {}
|
||||||
|
@ -19,16 +20,20 @@ export const useNodeStore = defineStore({
|
||||||
getters: {},
|
getters: {},
|
||||||
actions: {
|
actions: {
|
||||||
async create(node: CreateOrUpdateNode): Promise<StoredNode> {
|
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;
|
return response.node;
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchByToken(token: Token): Promise<StoredNode> {
|
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> {
|
async deleteByToken(token: Token): Promise<void> {
|
||||||
await api.delete(`node/${token}`);
|
await api.delete(`node/${token}` as Path);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,9 +4,10 @@ import {
|
||||||
isDomainSpecificNodeResponse,
|
isDomainSpecificNodeResponse,
|
||||||
type NodesFilter,
|
type NodesFilter,
|
||||||
NodeSortFieldEnum,
|
NodeSortFieldEnum,
|
||||||
|
type Path,
|
||||||
SortDirection,
|
SortDirection,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { internalApi } from "@/utils/Api";
|
import { internalApi } from "@/utils/api";
|
||||||
|
|
||||||
interface NodesStoreState {
|
interface NodesStoreState {
|
||||||
nodes: DomainSpecificNodeResponse[];
|
nodes: DomainSpecificNodeResponse[];
|
||||||
|
@ -65,7 +66,7 @@ export const useNodesStore = defineStore({
|
||||||
DomainSpecificNodeResponse,
|
DomainSpecificNodeResponse,
|
||||||
NodeSortFieldEnum
|
NodeSortFieldEnum
|
||||||
>(
|
>(
|
||||||
"nodes",
|
"nodes" as Path,
|
||||||
isDomainSpecificNodeResponse,
|
isDomainSpecificNodeResponse,
|
||||||
page,
|
page,
|
||||||
nodesPerPage,
|
nodesPerPage,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { isStatistics, type Statistics } from "@/types";
|
import { isStatistics, type Path, type Statistics } from "@/types";
|
||||||
import { internalApi } from "@/utils/Api";
|
import { internalApi } from "@/utils/api";
|
||||||
|
|
||||||
interface StatisticsStoreState {
|
interface StatisticsStoreState {
|
||||||
statistics: Statistics | null;
|
statistics: Statistics | null;
|
||||||
|
@ -21,7 +21,7 @@ export const useStatisticsStore = defineStore({
|
||||||
actions: {
|
actions: {
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
this.statistics = await internalApi.get<Statistics>(
|
this.statistics = await internalApi.get<Statistics>(
|
||||||
"statistics",
|
"statistics" as Path,
|
||||||
isStatistics
|
isStatistics
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { isObject, isVersion, type Version } from "@/types";
|
import { isObject, isVersion, type Path, type Version } from "@/types";
|
||||||
import { api } from "@/utils/Api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface VersionResponse {
|
interface VersionResponse {
|
||||||
version: Version;
|
version: Version;
|
||||||
|
@ -30,7 +30,7 @@ export const useVersionStore = defineStore({
|
||||||
actions: {
|
actions: {
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
const response = await api.get<VersionResponse>(
|
const response = await api.get<VersionResponse>(
|
||||||
"version",
|
"version" as Path,
|
||||||
isVersionResponse
|
isVersionResponse
|
||||||
);
|
);
|
||||||
this.version = response.version;
|
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 type { CleartextPassword, PasswordHash, Username } from "./types";
|
||||||
import { isString } from "./types";
|
import { isString } from "./types";
|
||||||
import Logger from "./logger";
|
import Logger from "./logger";
|
||||||
|
import { HttpHeader, HttpStatusCode, MimeType } from "./shared/utils/http";
|
||||||
|
|
||||||
export const app: Express = express();
|
export const app: Express = express();
|
||||||
|
|
||||||
|
@ -111,7 +112,7 @@ export function init(): void {
|
||||||
router.use(compress());
|
router.use(compress());
|
||||||
|
|
||||||
async function serveTemplate(
|
async function serveTemplate(
|
||||||
mimeType: string,
|
mimeType: MimeType,
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
@ -120,13 +121,15 @@ export function init(): void {
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": mimeType });
|
res.writeHead(HttpStatusCode.OK, {
|
||||||
|
[HttpHeader.CONTENT_TYPE]: mimeType,
|
||||||
|
});
|
||||||
res.end(_.template(body)({ config: config.client }));
|
res.end(_.template(body)({ config: config.client }));
|
||||||
}
|
}
|
||||||
|
|
||||||
usePromise(async (req: Request, res: Response): Promise<void> => {
|
usePromise(async (req: Request, res: Response): Promise<void> => {
|
||||||
if (jsTemplateFiles.indexOf(req.path) >= 0) {
|
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 { forConstraint } from "../shared/validation/validator";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { isString, Mail, MailId } from "../types";
|
import { isString, Mail, MailId } from "../types";
|
||||||
|
import { HttpHeader } from "../shared/utils/http";
|
||||||
|
|
||||||
const isValidId = forConstraint(CONSTRAINTS.id, false);
|
const isValidId = forConstraint(CONSTRAINTS.id, false);
|
||||||
|
|
||||||
|
@ -39,7 +40,7 @@ async function doGetAll(
|
||||||
export function getAll(req: Request, res: Response): void {
|
export function getAll(req: Request, res: Response): void {
|
||||||
doGetAll(req)
|
doGetAll(req)
|
||||||
.then(({ total, mails }) => {
|
.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);
|
return Resources.success(res, mails);
|
||||||
})
|
})
|
||||||
.catch((err) => Resources.error(res, err));
|
.catch((err) => Resources.error(res, err));
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
NodeMonitoringStateResponse,
|
NodeMonitoringStateResponse,
|
||||||
toMonitoringResponse,
|
toMonitoringResponse,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { HttpHeader } from "../shared/utils/http";
|
||||||
|
|
||||||
const isValidToken = forConstraint(CONSTRAINTS.token, false);
|
const isValidToken = forConstraint(CONSTRAINTS.token, false);
|
||||||
|
|
||||||
|
@ -33,7 +34,7 @@ async function doGetAll(
|
||||||
export function getAll(req: Request, res: Response): void {
|
export function getAll(req: Request, res: Response): void {
|
||||||
doGetAll(req)
|
doGetAll(req)
|
||||||
.then(({ total, result }) => {
|
.then(({ total, result }) => {
|
||||||
res.set("X-Total-Count", total.toString(10));
|
res.set(HttpHeader.X_TOTAL_COUNT, total.toString(10));
|
||||||
Resources.success(res, result);
|
Resources.success(res, result);
|
||||||
})
|
})
|
||||||
.catch((err) => Resources.error(res, err));
|
.catch((err) => Resources.error(res, err));
|
||||||
|
|
|
@ -27,6 +27,7 @@ import {
|
||||||
toNodeTokenResponse,
|
toNodeTokenResponse,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { filterUndefinedFromJSON } from "../shared/utils/json";
|
import { filterUndefinedFromJSON } from "../shared/utils/json";
|
||||||
|
import { HttpHeader } from "../shared/utils/http";
|
||||||
|
|
||||||
const nodeFields = [
|
const nodeFields = [
|
||||||
"hostname",
|
"hostname",
|
||||||
|
@ -165,7 +166,7 @@ export function getAll(req: Request, res: Response): void {
|
||||||
total: number;
|
total: number;
|
||||||
pageNodes: DomainSpecificNodeResponse[];
|
pageNodes: DomainSpecificNodeResponse[];
|
||||||
}) => {
|
}) => {
|
||||||
res.set("X-Total-Count", result.total.toString(10));
|
res.set(HttpHeader.X_TOTAL_COUNT, result.total.toString(10));
|
||||||
return Resources.success(
|
return Resources.success(
|
||||||
res,
|
res,
|
||||||
result.pageNodes.map(filterUndefinedFromJSON)
|
result.pageNodes.map(filterUndefinedFromJSON)
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
TaskState,
|
TaskState,
|
||||||
UnixTimestampSeconds,
|
UnixTimestampSeconds,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { HttpHeader } from "../shared/utils/http";
|
||||||
|
|
||||||
const isValidId = forConstraint(CONSTRAINTS.id, false);
|
const isValidId = forConstraint(CONSTRAINTS.id, false);
|
||||||
|
|
||||||
|
@ -109,7 +110,7 @@ async function doGetAll(
|
||||||
export function getAll(req: Request, res: Response): void {
|
export function getAll(req: Request, res: Response): void {
|
||||||
doGetAll(req)
|
doGetAll(req)
|
||||||
.then(({ total, pageTasks }) => {
|
.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));
|
Resources.success(res, pageTasks.map(toTaskResponse));
|
||||||
})
|
})
|
||||||
.catch((err) => Resources.error(res, err));
|
.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);
|
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.
|
* 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 {
|
export default {
|
||||||
badRequest: { code: 400 },
|
badRequest: { code: HttpStatusCode.BAD_REQUEST },
|
||||||
notFound: { code: 404 },
|
notFound: { code: HttpStatusCode.NOT_FOUND },
|
||||||
conflict: { code: 409 },
|
conflict: { code: HttpStatusCode.CONFLICT },
|
||||||
internalError: { code: 500 },
|
internalError: { code: HttpStatusCode.INTERNAL_SERVER_ERROR },
|
||||||
};
|
};
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
TypeGuard,
|
TypeGuard,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { getFieldIfExists } from "../shared/utils/objects";
|
import { getFieldIfExists } from "../shared/utils/objects";
|
||||||
|
import { HttpHeader, HttpStatusCode, MimeType } from "../shared/utils/http";
|
||||||
|
|
||||||
export type RequestData = JSONObject;
|
export type RequestData = JSONObject;
|
||||||
export type RequestHandler = (request: Request, response: Response) => void;
|
export type RequestHandler = (request: Request, response: Response) => void;
|
||||||
|
@ -46,30 +47,34 @@ export type FilterClause = { query: string; params: unknown[] };
|
||||||
|
|
||||||
function respond(
|
function respond(
|
||||||
res: Response,
|
res: Response,
|
||||||
httpCode: number,
|
httpCode: HttpStatusCode,
|
||||||
data: string,
|
data: string,
|
||||||
type: "html"
|
type: MimeType.TEXT_HTML
|
||||||
): void;
|
): void;
|
||||||
function respond(
|
function respond(
|
||||||
res: Response,
|
res: Response,
|
||||||
httpCode: number,
|
httpCode: HttpStatusCode,
|
||||||
data: JSONValue,
|
data: JSONValue,
|
||||||
type: "json"
|
type: MimeType.APPLICATION_JSON
|
||||||
): void;
|
): void;
|
||||||
function respond(
|
function respond(
|
||||||
res: Response,
|
res: Response,
|
||||||
httpCode: number,
|
httpCode: HttpStatusCode,
|
||||||
data: JSONValue,
|
data: JSONValue,
|
||||||
type: "html" | "json"
|
type: MimeType.APPLICATION_JSON | MimeType.TEXT_HTML
|
||||||
): void {
|
): void {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "html":
|
case MimeType.TEXT_HTML:
|
||||||
res.writeHead(httpCode, { "Content-Type": "text/html" });
|
res.writeHead(httpCode, {
|
||||||
|
[HttpHeader.CONTENT_TYPE]: MimeType.TEXT_HTML,
|
||||||
|
});
|
||||||
res.end(data);
|
res.end(data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
res.writeHead(httpCode, { "Content-Type": "application/json" });
|
res.writeHead(httpCode, {
|
||||||
|
[HttpHeader.CONTENT_TYPE]: MimeType.APPLICATION_JSON,
|
||||||
|
});
|
||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -365,18 +370,18 @@ export function filterClause<SortField>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function success(res: Response, data: JSONValue) {
|
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) {
|
export function successHtml(res: Response, html: string) {
|
||||||
respond(res, 200, html, "html");
|
respond(res, HttpStatusCode.OK, html, MimeType.APPLICATION_JSON);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function error(
|
export function error(
|
||||||
res: Response,
|
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>(
|
export function handleJSON<Response>(
|
||||||
|
|
Loading…
Reference in a new issue