diff --git a/frontend/src/components/nodes/NodeCreateForm.vue b/frontend/src/components/nodes/NodeCreateForm.vue index d2f1667..96d3825 100644 --- a/frontend/src/components/nodes/NodeCreateForm.vue +++ b/frontend/src/components/nodes/NodeCreateForm.vue @@ -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"; diff --git a/frontend/src/components/nodes/NodeDeleteConfirmationForm.vue b/frontend/src/components/nodes/NodeDeleteConfirmationForm.vue index 2262853..3a2307c 100644 --- a/frontend/src/components/nodes/NodeDeleteConfirmationForm.vue +++ b/frontend/src/components/nodes/NodeDeleteConfirmationForm.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"; diff --git a/frontend/src/components/nodes/NodeDeleteForm.vue b/frontend/src/components/nodes/NodeDeleteForm.vue index 974c8db..4e7521b 100644 --- a/frontend/src/components/nodes/NodeDeleteForm.vue +++ b/frontend/src/components/nodes/NodeDeleteForm.vue @@ -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); diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index dc21947..19e3c52 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -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 { - this.config = await api.get("config", isClientConfig); + this.config = await api.get( + "config" as Path, + isClientConfig + ); }, }, }); diff --git a/frontend/src/stores/node.ts b/frontend/src/stores/node.ts index a41b030..ad00678 100644 --- a/frontend/src/stores/node.ts +++ b/frontend/src/stores/node.ts @@ -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 { - 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 { - return await api.get(`node/${token}`, isStoredNode); + return await api.get(`node/${token}` as Path, isStoredNode); }, async deleteByToken(token: Token): Promise { - await api.delete(`node/${token}`); + await api.delete(`node/${token}` as Path); }, }, }); diff --git a/frontend/src/stores/nodes.ts b/frontend/src/stores/nodes.ts index 7143ed2..ae635b9 100644 --- a/frontend/src/stores/nodes.ts +++ b/frontend/src/stores/nodes.ts @@ -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, diff --git a/frontend/src/stores/statistics.ts b/frontend/src/stores/statistics.ts index 4d92cea..2a20b14 100644 --- a/frontend/src/stores/statistics.ts +++ b/frontend/src/stores/statistics.ts @@ -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 { this.statistics = await internalApi.get( - "statistics", + "statistics" as Path, isStatistics ); }, diff --git a/frontend/src/stores/version.ts b/frontend/src/stores/version.ts index 5e64664..7031c43 100644 --- a/frontend/src/stores/version.ts +++ b/frontend/src/stores/version.ts @@ -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 { const response = await api.get( - "version", + "version" as Path, isVersionResponse ); this.version = response.version; diff --git a/frontend/src/utils/Api.ts b/frontend/src/utils/Api.ts deleted file mode 100644 index 90bb20c..0000000 --- a/frontend/src/utils/Api.ts +++ /dev/null @@ -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 { - 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 { - 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 { - entries: T[]; - total: number; -} - -interface ApiResponse { - 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( - method: Method, - path: string, - isT?: TypeGuard, - bodyData?: object, - queryParams?: object - ): Promise> { - 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( - path: string, - isT: TypeGuard, - postData: object, - queryParams?: object - ): Promise { - const response = await this.sendRequest( - "POST", - path, - isT, - postData, - queryParams - ); - return response.result; - } - - async put( - path: string, - isT: TypeGuard, - putData: object, - queryParams?: object - ): Promise { - const response = await this.sendRequest( - "PUT", - path, - isT, - putData, - queryParams - ); - return response.result; - } - - private async doGet( - path: string, - isT: TypeGuard, - queryParams?: object - ): Promise> { - return await this.sendRequest( - "GET", - path, - isT, - undefined, - queryParams - ); - } - - async get( - path: string, - isT: TypeGuard, - queryParams?: object - ): Promise { - const response = await this.doGet(path, isT, queryParams); - return response.result; - } - - async getPagedList( - path: string, - isElement: TypeGuard, - page: number, - itemsPerPage: number, - sortDirection?: SortDirection, - sortField?: SortField, - filter?: object - ): Promise> { - 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 { - await this.sendRequest("DELETE", path); - } -} - -export const api = new Api(); - -class InternalApi extends Api { - constructor() { - super("internal/api/"); - } -} - -export const internalApi = new InternalApi(); diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts new file mode 100644 index 0000000..d00bfb0 --- /dev/null +++ b/frontend/src/utils/api.ts @@ -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 { + 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 { + 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 { + 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 { + /** + * 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 { + /** + * 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 { + 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 ``. + * + * @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 ``. + * @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( + method: HttpMethod, + path: Path, + isT?: TypeGuard, + bodyData?: object, + queryParams?: object + ): Promise> { + 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 ``. + * + * @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 ``. + * @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( + path: Path, + isT: TypeGuard, + postData: object, + queryParams?: object + ): Promise { + const response = await this.sendRequest( + 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 ``. + * + * @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 ``. + * @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( + path: Path, + isT: TypeGuard, + putData: object, + queryParams?: object + ): Promise { + const response = await this.sendRequest( + 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 ``. + * + * @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 ``. + * @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( + path: Path, + isT: TypeGuard, + queryParams?: object + ): Promise> { + return await this.sendRequest( + HttpMethod.GET, + path, + isT, + undefined, + queryParams + ); + } + + /** + * Perform a typesafe HTTP GET request against the servers REST-API. Makes sure the response body type + * matches ``. + * + * @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 ``. + * @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( + path: Path, + isT: TypeGuard, + queryParams?: object + ): Promise { + 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 ``. + * @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( + path: Path, + isElement: TypeGuard, + page: number, + itemsPerPage: number, + sortDirection?: SortDirection, + sortField?: SortField, + filter?: object + ): Promise> { + 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 { + 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(); diff --git a/server/app.ts b/server/app.ts index 2785bc6..36a2d6e 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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 { @@ -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 => { if (jsTemplateFiles.indexOf(req.path) >= 0) { - await serveTemplate("application/javascript", req, res); + await serveTemplate(MimeType.APPLICATION_JSON, req, res); } }); diff --git a/server/resources/mailResource.ts b/server/resources/mailResource.ts index 6c0cea1..29568e9 100644 --- a/server/resources/mailResource.ts +++ b/server/resources/mailResource.ts @@ -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)); diff --git a/server/resources/monitoringResource.ts b/server/resources/monitoringResource.ts index a1be4bb..6dfca82 100644 --- a/server/resources/monitoringResource.ts +++ b/server/resources/monitoringResource.ts @@ -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)); diff --git a/server/resources/nodeResource.ts b/server/resources/nodeResource.ts index 10b6d62..5f87796 100644 --- a/server/resources/nodeResource.ts +++ b/server/resources/nodeResource.ts @@ -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) diff --git a/server/resources/taskResource.ts b/server/resources/taskResource.ts index feb03f6..01585a1 100644 --- a/server/resources/taskResource.ts +++ b/server/resources/taskResource.ts @@ -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)); diff --git a/server/shared/types/newtypes.ts b/server/shared/types/newtypes.ts index 144c0c0..057b961 100644 --- a/server/shared/types/newtypes.ts +++ b/server/shared/types/newtypes.ts @@ -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. */ diff --git a/server/shared/utils/http.ts b/server/shared/utils/http.ts new file mode 100644 index 0000000..d0642ba --- /dev/null +++ b/server/shared/utils/http.ts @@ -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, +} diff --git a/server/utils/errorTypes.ts b/server/utils/errorTypes.ts index 63de38a..b3d74a6 100644 --- a/server/utils/errorTypes.ts +++ b/server/utils/errorTypes.ts @@ -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 }, }; diff --git a/server/utils/resources.ts b/server/utils/resources.ts index 1600acc..cf2c9c9 100644 --- a/server/utils/resources.ts +++ b/server/utils/resources.ts @@ -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( } 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(