/** * 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();