UNPKG

@kontent-ai/core-sdk

Version:

Core package with shared / common functionality for Kontent.ai SDKs

314 lines (274 loc) 9.5 kB
import type { CommonHeaderNames, Header, KontentErrorResponseData, RetryStrategyOptions } from "../models/core.models.js"; import type { CoreSdkError } from "../models/error.models.js"; import type { JsonValue } from "../models/json.models.js"; import { sdkInfo } from "../sdk-info.js"; import { isNotUndefined } from "../utils/core.utils.js"; import { getErrorMessage } from "../utils/error.utils.js"; import { getSdkIdHeader } from "../utils/header.utils.js"; import { runWithRetryAsync, toRequiredRetryStrategyOptions } from "../utils/retry.utils.js"; import { type Result, tryCatch, tryCatchAsync } from "../utils/try.utils.js"; import { getDefaultHttpAdapter } from "./http.adapter.js"; import type { AdapterResponse, DefaultHttpServiceConfig, DownloadFileRequestOptions, ExecuteRequestOptions, HttpResponse, HttpService, UploadFileRequestOptions, } from "./http.models.js"; export function getDefaultHttpService(config?: DefaultHttpServiceConfig): HttpService { const withUnknownErrorHandlingAsync = async <TResponseData extends JsonValue | Blob, TBodyData extends JsonValue | Blob>({ url, funcAsync, }: { readonly url: string; readonly funcAsync: () => Promise<HttpResponse<TResponseData, TBodyData>> }): Promise< HttpResponse<TResponseData, TBodyData> > => { const { success, data, error } = await tryCatchAsync(funcAsync); if (success) { return data; } return { success: false, error: { reason: "unknown", message: "Unknown error. See the error object for more details.", url: url, originalError: error, }, }; }; const resolveRequestAsync = async <TResponseData extends JsonValue | Blob, TBodyData extends JsonValue | Blob>({ options, resolveDataAsync, }: { readonly options: ExecuteRequestOptions<TBodyData>; readonly resolveDataAsync: (response: AdapterResponse) => Promise<TResponseData>; }): Promise<HttpResponse<TResponseData, TBodyData>> => { return await withUnknownErrorHandlingAsync({ url: options.url, funcAsync: async () => { const adapter = config?.adapter ?? getDefaultHttpAdapter(); const getCombinedRequestHeaders = (): readonly Header[] => { return getRequestHeaders([...(config?.requestHeaders ?? []), ...(options.requestHeaders ?? [])], options.body); }; const getRequestBody = (): Result<string | Blob | null, CoreSdkError> => { if (options.body === null) { return { success: true, data: null, }; } if (options.body instanceof Blob) { return { success: true, data: options.body, }; } const { success, data: parsedBody, error } = tryCatch(() => JSON.stringify(options.body)); if (!success) { return { success: false, error: { message: "Failed to stringify body of request.", url: options.url, reason: "invalidBody", originalError: error, }, }; } return { success: true, data: parsedBody, }; }; const getUrl = (): Result<URL, CoreSdkError> => { const { success, data: parsedUrl, error } = tryCatch(() => new URL(options.url)); if (!success) { return { success: false, error: { message: `Failed to parse url '${options.url}'.`, url: options.url, reason: "invalidUrl", originalError: error, }, }; } return { success: true, data: parsedUrl, }; }; const requestHeaders = getCombinedRequestHeaders(); const retryStrategyOptions: Required<RetryStrategyOptions> = toRequiredRetryStrategyOptions(config?.retryStrategy); const withRetryAsync = async ( funcAsync: () => Promise<HttpResponse<TResponseData, TBodyData>>, ): Promise<HttpResponse<TResponseData, TBodyData>> => { return await runWithRetryAsync({ url: options.url, retryStrategyOptions, retryAttempt: 0, requestHeaders, method: options.method, funcAsync: async () => { return await funcAsync(); }, }); }; const { success: urlParsedSuccess, data: parsedUrl, error: urlError } = getUrl(); if (!urlParsedSuccess) { return { success: false, error: urlError, }; } const { success: requestBodyParsedSuccess, data: requestBody, error: requestBodyError } = getRequestBody(); if (!requestBodyParsedSuccess) { return { success: false, error: requestBodyError, }; } const getResponseAsync = async (): Promise<AdapterResponse> => { return await adapter.requestAsync({ url: parsedUrl.toString(), method: options.method, requestHeaders, body: requestBody, }); }; const getErrorForInvalidResponseAsync = async (response: AdapterResponse): Promise<CoreSdkError> => { const sharedErrorData: Pick<CoreSdkError, "message" | "url"> = { message: getErrorMessage({ url: options.url, adapterResponse: response, method: options.method, }), url: options.url, }; if (response.status === 404) { const error: CoreSdkError<"notFound"> = { ...sharedErrorData, reason: "notFound", isValidResponse: response.isValidResponse, responseHeaders: response.responseHeaders, status: 404, statusText: response.statusText, kontentErrorResponse: await getKontentErrorDataAsync(response), }; return error; } const error: CoreSdkError<"invalidResponse"> = { ...sharedErrorData, reason: "invalidResponse", isValidResponse: response.isValidResponse, responseHeaders: response.responseHeaders, status: response.status, statusText: response.statusText, kontentErrorResponse: await getKontentErrorDataAsync(response), }; return error; }; const resolveResponseAsync = async (response: AdapterResponse): Promise<HttpResponse<TResponseData, TBodyData>> => { if (!response.isValidResponse) { return { success: false, error: await getErrorForInvalidResponseAsync(response), }; } return { success: true, response: { data: await resolveDataAsync(response), body: options.body, method: options.method, adapterResponse: { isValidResponse: response.isValidResponse, responseHeaders: response.responseHeaders, status: response.status, statusText: response.statusText, }, requestHeaders: requestHeaders, }, }; }; return await withRetryAsync(async () => await resolveResponseAsync(await getResponseAsync())); }, }); }; return { requestAsync: async <TResponseData extends JsonValue, TBodyData extends JsonValue>(options: ExecuteRequestOptions<TBodyData>) => { return await resolveRequestAsync<TResponseData, TBodyData>({ options, resolveDataAsync: async (response) => { return (await response.toJsonAsync()) as TResponseData; }, }); }, downloadFileAsync: async (options: DownloadFileRequestOptions): Promise<HttpResponse<Blob, null>> => { return await resolveRequestAsync<Blob, null>({ options: { ...options, method: "GET", body: null, }, resolveDataAsync: async (response) => { return await response.toBlobAsync(); }, }); }, uploadFileAsync: async <TResponseData extends JsonValue>(options: UploadFileRequestOptions): Promise<HttpResponse<TResponseData, Blob>> => { return await resolveRequestAsync<TResponseData, Blob>({ options, resolveDataAsync: async (response) => { return (await response.toJsonAsync()) as TResponseData; }, }); }, }; } async function getKontentErrorDataAsync(response: AdapterResponse): Promise<KontentErrorResponseData | undefined> { if ( response.responseHeaders .find((header) => header.name.toLowerCase() === ("Content-Type" satisfies CommonHeaderNames).toLowerCase()) ?.value.toLowerCase() .includes("application/json") ) { const json = (await response.toJsonAsync()) as Partial<KontentErrorResponseData>; // We check the existence of 'message' property which should always be set when the error is a Kontent API error if (!json.message) { return undefined; } return { ...json, message: json.message, }; } return undefined; } function getRequestHeaders(headers: readonly Header[] | undefined, body: Blob | JsonValue): readonly Header[] { const existingContentTypeHeader = headers?.find((header) => header.name.toLowerCase() === ("Content-Type" satisfies CommonHeaderNames).toLowerCase()); const existingSdkVersionHeader = headers?.find((header) => header.name.toLowerCase() === ("X-KC-SDKID" satisfies CommonHeaderNames).toLowerCase()); const contentTypeHeader: Header | undefined = existingContentTypeHeader ? undefined : { name: "Content-Type" satisfies CommonHeaderNames, value: body instanceof Blob ? body.type : "application/json", }; const sdkVersionHeader: Header | undefined = existingSdkVersionHeader ? undefined : getSdkIdHeader({ host: sdkInfo.host, name: sdkInfo.name, version: sdkInfo.version, }); const contentLengthHeader: Header | undefined = body instanceof Blob ? { name: "Content-Length" satisfies CommonHeaderNames, value: body.size.toString(), } : undefined; return [...(headers ?? []), contentTypeHeader, contentLengthHeader, sdkVersionHeader].filter(isNotUndefined); }