@datanest-earth/nodejs-client
Version:
Datanest API Client to easily create signed requests
428 lines (371 loc) • 14.4 kB
text/typescript
import { createHmac } from 'node:crypto';
import * as projects from './projects';
import * as workflows from './workflows';
import * as enviro from './enviro';
import * as gather from './gather';
import * as integrations from './integrations';
import * as teams from './teams';
import * as users from './users';
import * as files from './files';
import * as webhook from './webhook';
/**
* @deprecated Use `users` namespace instead
*/
const user = users;
export {
DatanestClient,
enviro,
gather,
projects,
workflows,
integrations,
teams,
user,
users,
files,
webhook,
}
export default class DatanestClient {
private apiKey: string;
private apiSecret: string;
private baseUrl: string;
private clientId: string | null = null;
private logErrors: boolean = true;
private logTrace: boolean = false;
// Static rate limiter properties (shared across all instances)
private static rateLimitMax: number = 120 / 8; // Divide by 8 to allow for shorter max delays
private static rateLimitIntervalMs: number = 60_000 / 8;
private static requestTimestamps: number[] = [];
/**
* Create a new Datanest API client
* Note: You can use environment variables instead of using
* the constructor params `apiKey` and `apiSecret`
* ENV:
* - `DATANEST_API_KEY`
* - `DATANEST_API_SECRET`
* - `DATANEST_API_BASE_URL` (optional) Default: https://app.datanest.earth/api
*/
constructor(apiKey?: string, apiSecret?: string) {
this.apiKey = apiKey || process.env.DATANEST_API_KEY || '';
this.apiSecret = apiSecret || process.env.DATANEST_API_SECRET || '';
this.baseUrl = process.env.DATANEST_API_BASE_URL || 'https://app.datanest.earth/api';
// Remove trailing slash
this.baseUrl = this.baseUrl.replace(/\/$/, '');
if (!this.baseUrl.endsWith('/api')) {
throw new Error('Invalid base URL. Must end with "/api"');
}
if (this.apiKey === "" || this.apiSecret === "") {
throw new Error('API key and secret are required.');
}
}
public static disableRateLimit() {
DatanestClient.rateLimitMax = 100_000_000;
DatanestClient.rateLimitIntervalMs = 0;
}
/** Datanest accepts up to 60 requests per minute, default limit is less for typical use */
public static setRateLimit(maxRequests: number, intervalMs: number = 60_000) {
DatanestClient.rateLimitMax = maxRequests;
DatanestClient.rateLimitIntervalMs = intervalMs;
}
private static async checkRateLimit(logWarning = false) {
while (DatanestClient.requestTimestamps.length >= DatanestClient.rateLimitMax) {
const now = Date.now();
DatanestClient.requestTimestamps = DatanestClient.requestTimestamps.filter(timestamp => now - timestamp < DatanestClient.rateLimitIntervalMs);
// Calculate wait time until the oldest timestamp expires from the window.
const waitTime = DatanestClient.rateLimitIntervalMs - (now - DatanestClient.requestTimestamps[0]);
if (logWarning) {
console.debug('Waiting for rate limit window to expire', waitTime);
}
await new Promise(resolve => setTimeout(resolve, waitTime));
}
DatanestClient.requestTimestamps.push(Date.now());
}
public setLogErrors(logErrors: boolean) {
this.logErrors = logErrors;
}
public setLogTrace(logTrace: boolean) {
this.logTrace = logTrace;
}
private signRequest(url: string, requestOptions: DatanestRequestInit) {
const hmac = createHmac('sha256', this.apiSecret);
const timestamp = Date.now() / 1000;
if (!requestOptions.headers) {
requestOptions.headers = {};
}
const headers: any = requestOptions.headers;
headers['X-Timestamp'] = timestamp.toFixed(0);
const content = `${requestOptions.method}:${url}:${requestOptions.body ? requestOptions.body + ':' : ''}${timestamp.toFixed(0)}`;
headers['X-Signature'] = hmac.update(content).digest('hex');
}
/**
* Send a request to the Datanest API
* @param method
* @param path
* @param params
* @param fetchOptions
* @throws DatanestResponseError Request HTTP server or validation error
* @returns Fetch response with readable stream.
*/
private async sendRequest(method: string, path: string, params?: Record<string, any>, fetchOptions?: DatanestRequestInit) {
if (DatanestClient.rateLimitIntervalMs > 0 && DatanestClient.rateLimitMax !== Infinity) {
await DatanestClient.checkRateLimit(this.logErrors);
}
method = method.toUpperCase();
// remove leading slash
path = path.replace(/^\//, '');
if (path.startsWith('api/')) {
throw new Error('Invalid endpoint, must not start with "api/"');
}
const headers: any = {
...(fetchOptions?.headers ?? {}),
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
};
if (this.clientId) {
headers['X-Client-ID'] = this.clientId;
}
const options: DatanestRequestInit = {
redirect: 'error',
mode: method !== 'DELETE' && method !== 'PATCH' ? 'no-cors' : undefined,
...fetchOptions,
method,
headers,
};
let url = `${this.baseUrl}/${path}`;
if (params) {
if (method === 'GET' || method === 'DELETE') {
const queryParams = new URLSearchParams(params);
if (queryParams.size) {
queryParams.sort();
// replace all spaces with %20, as URLSearchParams uses + instead of %20
url += `?${queryParams.toString().replace(/\+/g, '%20')}`;
}
} else {
options.body = JSON.stringify(params);
}
}
// Sign the request (implement the signing logic)
this.signRequest(url, options);
const response = await fetch(url, options);
if (response.status > 299) {
const error = new DatanestResponseError(`Datanest API Failed: ${path}: ${response.status}`, response.status, await response.json());
if (this.logErrors) {
console.error(error.message, error.data);
} else {
this.traceRequest(method, url, params, options, response);
}
throw error;
}
this.traceRequest(method, url, params, options, response);
return response;
}
private traceRequest(method: string, url: string, params?: Record<string, any>, options?: DatanestRequestInit, response?: Response) {
if (!this.logTrace) {
return;
}
const sanitizedOptions = options ? structuredClone(options) : undefined;
if (sanitizedOptions?.headers) {
sanitizedOptions.headers = {
...sanitizedOptions.headers,
'X-API-Key': '(REDACTED)',
'X-Signature': '(REDACTED)',
};
}
if (method === 'GET' || method === 'DELETE') {
console.log(method, url, sanitizedOptions, response?.status, response?.statusText);
return;
}
console.log(method, url, params, sanitizedOptions, response?.status, response?.statusText);
}
/**
* Send a GET request to the Datanest API
* @param path e.g. `v1/projects`
* @param params Query parameters
* @param fetchOptions
* @throws DatanestResponseError Request HTTP server or validation error
* @returns Fetch response with readable stream.
*/
public async get(path: string, params?: Record<string, any>, fetchOptions?: DatanestRequestInit) {
return await this.sendRequest('GET', path, params, fetchOptions);
}
/**
* Send a POST request to the Datanest API
* @param path e.g. `v1/projects`
* @param params Will be converted to JSON in request body
* @param fetchOptions
* @throws DatanestResponseError Request HTTP server or validation error
* @returns Fetch response with readable stream.
*/
public async post(path: string, params?: Record<string, any>, fetchOptions?: DatanestRequestInit) {
return await this.sendRequest('POST', path, params, fetchOptions);
}
/**
* Send a PATCH request to the Datanest API
* @param path e.g. `v1/projects/{uuid}`
* @param params Will be converted to JSON in request body
* @param fetchOptions
* @throws DatanestResponseError Request HTTP server or validation error
* @returns Fetch response with readable stream.
*/
public async patch(path: string, params?: Record<string, any>, fetchOptions?: DatanestRequestInit) {
return await this.sendRequest('PATCH', path, params, fetchOptions);
}
/**
* Send a PUT request to the Datanest API
* @param path e.g. `v1/projects/{uuid}`
* @param params Will be converted to JSON in request body
* @param fetchOptions
* @throws DatanestResponseError Request HTTP server or validation error
* @returns Fetch response with readable stream.
*/
public async put(path: string, params?: Record<string, any>, fetchOptions?: DatanestRequestInit) {
return await this.sendRequest('PUT', path, params, fetchOptions);
}
/**
* Send a DELETE request to the Datanest API
* @param path e.g. `v1/projects/{uuid}`
* @param params Query parameters
* @param fetchOptions
* @throws DatanestResponseError Request HTTP server or validation error
* @returns Fetch response with readable stream.
*/
public async delete(path: string, params?: Record<string, any>, fetchOptions?: DatanestRequestInit) {
return await this.sendRequest('DELETE', path, params, fetchOptions);
}
/**
* Set the base URL for the Datanest API
* @param baseUrl e.g. `https://app.datanest.earth/api`
*/
public setBaseUrl(baseUrl: string) {
this.baseUrl = baseUrl;
if (!this.baseUrl.endsWith('/api')) {
throw new Error('Invalid base URL. Must end with "/api"');
}
}
/**
* Set your client ID for the Datanest API
* This will append the `X-Client-ID` header to all requests
* @param clientId Your application identifier, this can assist Datanest for debugging assistance
*/
public setClientId(clientId: string) {
this.clientId = clientId;
}
public removeClientId() {
this.clientId = null;
}
}
/**
* From Fetch API
*/
interface RequestInit {
/** A BodyInit object or null to set request's body. */
body?: BodyInit | null;
/** A string indicating how the request will interact with the browser's cache to set request's cache. */
cache?: RequestCache;
/** A string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. Sets request's credentials. */
credentials?: RequestCredentials;
/** A Headers object, an object literal, or an array of two-item arrays to set request's headers. */
headers?: HeadersInit;
/** A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */
integrity?: string;
/** A boolean to set request's keepalive. */
keepalive?: boolean;
/** A string to set request's method. */
method?: string;
/** A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode. */
mode?: RequestMode;
/** A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */
redirect?: RequestRedirect;
/** A string whose value is a same-origin URL, "about:client", or the empty string, to set request's referrer. */
referrer?: string;
/** A referrer policy to set request's referrerPolicy. */
referrerPolicy?: ReferrerPolicy;
/** An AbortSignal to set request's signal. */
signal?: AbortSignal | null;
/** Can only be null. Used to disassociate request from any Window. */
window?: null;
}
export type DatanestRequestInit = RequestInit;
export class DatanestResponseError extends Error {
/**
* HTTP status code
*/
public status: number;
/**
* Server response data
*/
public data: any;
constructor(message: string, status: number, data: any) {
let msg = message;
if (typeof data.message === 'string') {
msg += `: ${data.message}`;
}
super(msg);
this.status = status;
this.data = data;
}
}
/**
* ISO 8601 date string
*/
export type Timestamp = string;
export type Timestamps = {
created_at: Timestamp,
updated_at: Timestamp,
};
export type SoftDelete = {
deleted_at?: Timestamp | null,
};
/**
* Supported ISO 3166-1 alpha-2 country codes
*/
export type Country2CharCode = 'NZ' | 'GB' | 'US' | 'AU' | 'CA';
export type MeasurementType = 'metre' | 'feet' | 'inch' | 'mm' | 'cm';
export type UUID = string;
export type PaginatedResponse<T> = {
data: T[],
/**
* @deprecated This may not be permanent
*/
links?: {
first: string,
last: string,
prev: null | string,
next: null | string,
},
meta: {
/**
* @deprecated This may not be permanent
*/
links?: {
url: null | string, label: string, active: boolean,
}[],
/**
* Current page number
*/
current_page: number;
/**
* Last page number
*/
last_page: number;
/**
* Number of items per page
*/
per_page: number;
/**
* Total number of items
*/
total: number;
},
};
/**
* Filters for date range queries
* Supports YYYY-MM-DD or full ISO 8601 timestamps
*/
export type DateRangeFilters = {
created_from?: Timestamp | null;
created_to?: Timestamp | null;
updated_from?: Timestamp | null;
updated_to?: Timestamp | null;
}