Refactor api utils and replace some magic values by enums.

This commit is contained in:
baldo 2022-09-20 19:09:49 +02:00
parent 518d986c20
commit 8f8194467c
19 changed files with 680 additions and 327 deletions

View file

@ -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";

View file

@ -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";

View file

@ -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);

View file

@ -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
);
}, },
}, },
}); });

View file

@ -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);
}, },
}, },
}); });

View file

@ -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,

View file

@ -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
); );
}, },

View file

@ -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;

View file

@ -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
View 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();

View file

@ -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);
} }
}); });

View file

@ -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));

View file

@ -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));

View file

@ -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)

View file

@ -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));

View file

@ -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.
*/ */

View 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,
}

View file

@ -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 },
}; };

View file

@ -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>(