@translated/lara
Version:
Official Lara SDK for JavaScript and Node.js
290 lines (289 loc) • 13.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.LaraClient = void 0;
const credentials_1 = require("../../credentials");
const crypto_1 = __importDefault(require("../../crypto"));
const errors_1 = require("../../errors");
const parse_content_1 = require("../../utils/parse-content");
const sdk_version_1 = require("../../utils/sdk-version");
/** @internal */
class LaraClient {
constructor(auth) {
this.crypto = (0, crypto_1.default)();
this.extraHeaders = {};
if (auth instanceof credentials_1.AccessKey) {
this.accessKey = auth;
}
else if (auth instanceof credentials_1.AuthToken) {
this.authToken = auth;
}
else {
throw new Error("Invalid authentication method provided");
}
}
setExtraHeader(name, value) {
this.extraHeaders[name] = value;
}
// ─────────────────────────────────────────────────────────────────────────────
// Public HTTP Methods
// ─────────────────────────────────────────────────────────────────────────────
get(path, queryParams, headers) {
return this.request("GET", this.buildPathWithQuery(path, queryParams), undefined, undefined, headers);
}
delete(path, queryParams, body, headers) {
return this.request("DELETE", this.buildPathWithQuery(path, queryParams), body, undefined, headers);
}
post(path, body, files, headers, streamResponse) {
return this.request("POST", path, body, files, headers, undefined, streamResponse);
}
async *postAndGetStream(path, body, files, headers) {
for await (const chunk of this.requestStream("POST", path, body, files, headers)) {
yield chunk;
}
}
put(path, body, files, headers) {
return this.request("PUT", path, body, files, headers);
}
// ─────────────────────────────────────────────────────────────────────────────
// Request Handling
// ─────────────────────────────────────────────────────────────────────────────
async request(method, path, body, files, headers, retryCount = 0, streamResponse) {
await this.ensureAuthenticated();
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const requestHeaders = await this.buildRequestHeaders(body, files, headers);
const requestBody = this.buildRequestBody(body, files);
requestHeaders.Authorization = `Bearer ${this.token}`;
const response = await this.send(method, normalizedPath, requestHeaders, requestBody, streamResponse);
if (this.isSuccessResponse(response)) {
return streamResponse ? response.body : (0, parse_content_1.parseContent)(response.body);
}
// Handle 401 - token expired, refresh and retry once
if (response.statusCode === 401 && retryCount < 1) {
this.token = undefined;
await this.refreshOrReauthenticate();
return this.request(method, path, body, files, headers, retryCount + 1, streamResponse);
}
throw this.createApiError(response);
}
async *requestStream(method, path, body, files, headers, retryCount = 0) {
await this.ensureAuthenticated();
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const requestHeaders = await this.buildRequestHeaders(body, files, headers);
const requestBody = this.buildRequestBody(body, files);
requestHeaders.Authorization = `Bearer ${this.token}`;
for await (const chunk of this.sendAndGetStream(method, normalizedPath, requestHeaders, requestBody)) {
// Handle 401 - token expired, refresh and retry once
if (chunk.statusCode === 401 && retryCount < 1) {
this.token = undefined;
await this.refreshOrReauthenticate();
yield* this.requestStream(method, path, body, files, headers, retryCount + 1);
return;
}
// Handle other errors
if (!this.isSuccessResponse(chunk)) {
throw this.createApiError(chunk);
}
yield (0, parse_content_1.parseContent)(chunk.body);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Authentication
// ─────────────────────────────────────────────────────────────────────────────
isTokenExpired(bufferMs = 5000) {
if (!this.token)
return true;
try {
const parts = this.token.split(".");
if (parts.length !== 3)
return true;
let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4)
b64 += "=";
const decoded = typeof Buffer !== "undefined"
? // Node solution
Buffer.from(b64, "base64").toString("utf-8")
: // Browser solution
atob(b64);
const { exp } = JSON.parse(decoded);
return typeof exp === "number" && exp * 1000 <= Date.now() + bufferMs;
}
catch {
return true;
}
}
async ensureAuthenticated() {
if (this.token && !this.isTokenExpired())
return;
this.token = undefined;
// Use existing promise if authentication is already in progress (mutex pattern)
if (this.authenticationPromise) {
return this.authenticationPromise;
}
// Start new authentication with single retry on failure
this.authenticationPromise = this.performAuthentication();
try {
await this.authenticationPromise;
this.authenticationPromise = undefined;
}
catch (error) {
// Clear promise and retry once
this.authenticationPromise = undefined;
// Only retry if the error is not a 401 or 403
if (error instanceof errors_1.LaraApiError && (error.statusCode === 401 || error.statusCode === 403)) {
throw error;
}
try {
this.authenticationPromise = this.performAuthentication();
await this.authenticationPromise;
this.authenticationPromise = undefined;
}
catch (retryError) {
this.authenticationPromise = undefined;
throw retryError instanceof errors_1.LaraApiError
? retryError
: new errors_1.LaraApiError(500, "AuthenticationError", retryError.message);
}
}
}
async performAuthentication() {
// If we have a pre-existing auth token, use it directly (one-shot, consumed before any network call)
if (this.authToken) {
this.token = this.authToken.token;
this.refreshToken = this.authToken.refreshToken;
this.authToken = undefined;
return;
}
return this.doRefreshOrReauthenticate();
}
refreshOrReauthenticate() {
if (this.refreshPromise)
return this.refreshPromise;
this.refreshPromise = this.doRefreshOrReauthenticate().finally(() => {
this.refreshPromise = undefined;
});
return this.refreshPromise;
}
async doRefreshOrReauthenticate() {
if (this.refreshToken) {
try {
await this.refreshTokens();
return;
}
catch (error) {
this.refreshToken = undefined;
if (!this.accessKey)
throw error;
}
}
if (this.accessKey) {
await this.authenticateWithAccessKey();
return;
}
throw new Error("No authentication method available for token renewal");
}
async refreshTokens() {
const headers = {
Authorization: `Bearer ${this.refreshToken}`,
"X-Lara-Date": new Date().toUTCString(),
...this.extraHeaders
};
const response = await this.send("POST", "/v2/auth/refresh", headers);
this.handleAuthResponse(response);
}
async authenticateWithAccessKey() {
if (!this.accessKey) {
throw new Error("No access key provided");
}
const body = { id: this.accessKey.id };
const contentMD5 = await this.crypto.digestBase64(JSON.stringify(body));
const headers = {
"Content-Type": "application/json",
"X-Lara-Date": new Date().toUTCString(),
"Content-MD5": contentMD5,
...this.extraHeaders
};
headers.Authorization = `Lara:${await this.sign("POST", "/v2/auth", headers)}`;
const response = await this.send("POST", "/v2/auth", headers, body);
this.handleAuthResponse(response);
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper Methods
// ─────────────────────────────────────────────────────────────────────────────
buildPathWithQuery(path, queryParams) {
const filteredParams = this.filterNullish(queryParams);
if (!filteredParams)
return path;
return `${path}?${new URLSearchParams(filteredParams).toString()}`;
}
async buildRequestHeaders(body, files, customHeaders) {
const headers = {
"X-Lara-Date": new Date().toUTCString(),
"X-Lara-SDK-Name": "lara-node",
"X-Lara-SDK-Version": sdk_version_1.version,
...this.filterNullish(this.extraHeaders),
...this.filterNullish(customHeaders)
};
const filteredBody = this.filterNullish(body);
if (files) {
headers["Content-Type"] = "multipart/form-data";
}
else if (filteredBody) {
headers["Content-Type"] = "application/json";
const jsonBody = JSON.stringify(filteredBody, undefined, 0);
// The content length header is needed to send delete requests with a body in some environments
headers["Content-Length"] = new TextEncoder().encode(jsonBody).length.toString();
}
return headers;
}
buildRequestBody(body, files) {
const filteredBody = this.filterNullish(body);
if (files) {
// Validate and wrap files
const wrappedFiles = {};
for (const [key, file] of Object.entries(files)) {
wrappedFiles[key] = this.wrapMultiPartFile(file);
}
return { ...wrappedFiles, ...filteredBody };
}
return filteredBody;
}
filterNullish(obj) {
if (!obj)
return undefined;
const filtered = Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined && v !== null));
return Object.keys(filtered).length > 0 ? filtered : undefined;
}
isSuccessResponse(response) {
return response.statusCode >= 200 && response.statusCode < 300;
}
handleAuthResponse(response) {
if (this.isSuccessResponse(response)) {
this.token = response.body.token;
const newRefreshToken = response.headers["x-lara-refresh-token"];
if (newRefreshToken) {
this.refreshToken = newRefreshToken;
}
return;
}
throw this.createApiError(response);
}
createApiError(response) {
const error = response.body || {};
return new errors_1.LaraApiError(response.statusCode, error.type || "UnknownError", error.message || "An unknown error occurred");
}
async sign(method, path, headers) {
if (!this.accessKey) {
throw new Error("Access key not provided for signing");
}
const date = headers["X-Lara-Date"].trim();
const contentMD5 = (headers["Content-MD5"] || "").trim();
const contentType = (headers["Content-Type"] || "").trim();
const httpMethod = (headers["X-HTTP-Method-Override"] || method).trim().toUpperCase();
const challenge = `${httpMethod}\n${path}\n${contentMD5}\n${contentType}\n${date}`;
return this.crypto.hmac(this.accessKey.secret, challenge);
}
}
exports.LaraClient = LaraClient;