UNPKG

@lokalise/node-api

Version:
328 lines (296 loc) 10.1 kB
import type { ClientData } from "../interfaces/client_data.js"; import { getVersion } from "../lokalise/pkg.js"; import { ApiError } from "../models/api_error.js"; import type { HttpMethod } from "../types/http_method.js"; export type ApiResponse = { json: Record<string, unknown>; headers: Headers; }; /** * Represents a single API request to the Lokalise API. * Handles URL construction, request initiation, response processing, and error handling. */ export class ApiRequest { /** * The default base URL for the Lokalise API. */ protected static readonly urlRoot = "https://api.lokalise.com/api2/"; /** * The resolved response from the API request. */ public response!: ApiResponse; /** * Query and path parameters used to construct the request URL. * This object is modified during URL construction, removing parameters used in path segments. */ public params: Record<string, unknown> = {}; /** * Constructs a new ApiRequest instance. * @param params - Query and/or path parameters. */ constructor(params: Record<string, unknown>) { // Copy params to avoid modifying the original object this.params = { ...params }; } /** * Static async factory method to create an ApiRequest instance with a fully resolved response. * @param uri - The endpoint URI (versioned path expected). * @param method - The HTTP method (GET, POST, PUT, DELETE, etc). * @param body - The request payload, if applicable. * @param params - Query and/or path parameters. * @param clientData - Authentication and configuration data for the request. * @returns A promise that resolves to a fully constructed ApiRequest instance with the `response` set. */ public static async create( uri: string, method: HttpMethod, body: object | object[] | null, params: Record<string, unknown>, clientData: ClientData, ): Promise<ApiRequest> { const apiRequest = new ApiRequest(params); apiRequest.response = await apiRequest.createPromise( uri, method, body, clientData, ); return apiRequest; } /** * Creates the request promise by composing the URL, building headers, and executing the fetch. * @param uri - The endpoint URI. * @param method - The HTTP method. * @param body - The request payload. * @param clientData - Client configuration and auth data. * @returns A promise resolving to an ApiResponse or rejecting with an ApiError. */ protected async createPromise( uri: string, method: HttpMethod, body: object | object[] | null, clientData: ClientData, ): Promise<ApiResponse> { const url = this.composeURI(`/${clientData.version}/${uri}`); const prefixUrl = clientData.host ?? ApiRequest.urlRoot; const headers = await this.buildHeaders(clientData, method, body); const options: RequestInit = { method: method, headers: headers, ...(method !== "GET" && body ? { body: JSON.stringify(body) } : {}), }; const target = new URL(url, prefixUrl); const stringifiedParams: Record<string, string> = Object.fromEntries( Object.entries(this.params) .filter(([, value]) => value !== undefined && value !== null) .map(([key, value]) => [key, String(value)]), ); target.search = new URLSearchParams(stringifiedParams).toString(); return this.fetchAndHandleResponse( target, options, clientData.requestTimeout, ); } /** * Executes the fetch request and handles network-level errors. * Applies a request timeout if specified. * @param target - The fully constructed request URL. * @param options - The fetch request options. * @param requestTimeout - Optional timeout in milliseconds. * @returns A promise resolving to an ApiResponse or rejecting with an ApiError. */ protected async fetchAndHandleResponse( target: URL, options: RequestInit, requestTimeout = 0, ): Promise<ApiResponse> { const signal = requestTimeout > 0 ? AbortSignal.timeout(requestTimeout) : undefined; try { const response = await fetch(target, { ...options, ...(signal ? { signal } : {}), }); return this.processResponse(response); } catch (err) { if (err instanceof Error) { if (err.name === "TimeoutError") { return Promise.reject( new ApiError(`Request timed out after ${requestTimeout}ms`, 408, { reason: "timeout", }), ); } return Promise.reject( new ApiError(err.message, 500, { reason: "network or fetch error" }), ); } return Promise.reject( new ApiError("An unknown error occurred", 500, { reason: String(err), }), ); } } /** * Processes the fetch response. * Attempts to parse JSON unless the status is 204 (No Content). * @param response - The raw fetch Response object. * @returns A promise resolving to an ApiResponse if successful, or rejecting with ApiError otherwise. */ protected async processResponse(response: Response): Promise<ApiResponse> { let responseJSON: unknown = null; try { if (response.status !== 204) { responseJSON = await response.json(); } } catch (error) { return Promise.reject( new ApiError((error as Error).message, response.status, { statusText: response.statusText, reason: "JSON parsing error", }), ); } if (response.ok) { return { json: responseJSON as Record<string, unknown>, headers: response.headers, }; } return Promise.reject(this.getErrorFromResp(responseJSON)); } /** * Derives an ApiError instance from the response JSON, which may follow various patterns. * @param respJson - The parsed JSON response from the server. * @returns An ApiError representing the server error. */ protected getErrorFromResp(respJson: unknown): ApiError { if (!respJson || typeof respJson !== "object") { return new ApiError("An unknown error occurred", 500, { reason: "unexpected response format", }); } const errorObj = respJson as Record<string, unknown>; // Top-level error format: { message: string, statusCode: number, error: string } if ( typeof errorObj.message === "string" && typeof errorObj.statusCode === "number" && typeof errorObj.error === "string" ) { return new ApiError(errorObj.message, errorObj.statusCode, { reason: errorObj.error, }); } // Nested error object: { error: { message, code, details } } if (errorObj.error && typeof errorObj.error === "object") { const { message = "Unknown error", code = 500, details, } = errorObj.error as Record<string, unknown>; const safeDetails: Record<string, string | number | boolean> = typeof details === "object" && details !== null ? (details as Record<string, string | number | boolean>) : { reason: "server error without details" }; return new ApiError( String(message), typeof code === "number" ? code : 500, safeDetails, ); } // Alternative top-level fields: { message: string, code?: number, errorCode?: number, details?: any } if ( typeof errorObj.message === "string" && (typeof errorObj.code === "number" || typeof errorObj.errorCode === "number") ) { const statusCode = typeof errorObj.code === "number" ? errorObj.code : errorObj.errorCode; const rawDetails = errorObj.details; const safeDetails: Record<string, string | number | boolean> = typeof rawDetails === "object" && rawDetails !== null ? (rawDetails as Record<string, string | number | boolean>) : { reason: "server error without details" }; return new ApiError(errorObj.message, statusCode as number, safeDetails); } // Fallback if no known error format matches return new ApiError("An unknown error occurred", 500, { reason: "unhandled error format", data: JSON.stringify(respJson), }); } /** * Builds the request headers, including authentication, compression, and JSON headers as needed. * @param clientData - Client configuration and auth data. * @param method - The HTTP method. * @param body - The request payload. * @returns A promise resolving to the constructed Headers. */ protected async buildHeaders( clientData: ClientData, method: HttpMethod, body: object | object[] | null, ): Promise<Headers> { const userAgent = clientData.userAgent?.trim() || `node-lokalise-api/${await getVersion()}`; const headers = new Headers({ Accept: "application/json", "User-Agent": userAgent, }); // Auth header can be either just the token or "<tokenType> <token>" headers.append( clientData.authHeader, clientData.tokenType.length > 0 ? `${clientData.tokenType} ${clientData.token}` : clientData.token, ); if (clientData.enableCompression) { headers.append("Accept-Encoding", "gzip,deflate"); } if (method !== "GET" && body) { headers.append("Content-Type", "application/json"); } return headers; } /** * Composes the final URI by replacing placeholders of the form `/{!:{paramName}}` * with the corresponding parameter values. * @param rawUri - The raw URI template. * @returns The final composed URI string. * @throws Error if a required parameter is missing. */ protected composeURI(rawUri: string): string { const regexp = /\{(!?):(\w+)\}/g; const uri = rawUri.replace(regexp, this.mapUriParams()); return uri.endsWith("/") ? uri.slice(0, -1) : uri; } /** * Returns a function that maps URI parameters from placeholders. * @returns A function used as a replacement callback in `composeURI`. * @throws Error if a required parameter is missing. */ protected mapUriParams(): ( substring: string, isMandatory: string, paramName: string, ) => string { return ( _substring: string, isMandatory: string, paramName: string, ): string => { if (this.params[paramName] != null) { const paramValue = String(this.params[paramName]); // Remove the parameter so it doesn't appear as a query parameter delete this.params[paramName]; return paramValue; } if (isMandatory === "!") { throw new Error(`Missing required parameter: ${paramName}`); } return ""; }; } }