518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
/**
|
|
* 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();
|