@stellar/stellar-sdk
Version:
A library for working with the Stellar network, including communication with the Horizon and Soroban RPC servers.
357 lines (353 loc) • 11.7 kB
JavaScript
'use strict';
var eventsource = require('eventsource');
var network = require('../errors/network.js');
var not_found = require('../errors/not_found.js');
var bad_request = require('../errors/bad_request.js');
var horizon_axios_client = require('./horizon_axios_client.js');
var url = require('../utils/url.js');
const JOINABLE = ["transaction"];
class CallBuilder {
url;
filter;
originalSegments;
neighborRoot;
httpClient;
constructor(serverUrl, httpClient, neighborRoot = "") {
this.url = new URL(serverUrl);
this.filter = [];
this.originalSegments = this.url.pathname.split("/").filter((s) => s.length > 0);
this.neighborRoot = neighborRoot;
this.httpClient = httpClient;
}
setPath(...segments) {
const endpointSegments = segments.flatMap(
(segment) => segment.split("/").filter((s) => s.length > 0)
);
this.url.pathname = this.originalSegments.concat(endpointSegments).join("/");
}
/**
* Triggers a HTTP request using this builder's current configuration.
* @returns a Promise that resolves to the server's response.
*/
call() {
this.checkFilter();
return this._sendNormalRequest(this.url).then(
(r) => this._parseResponse(r)
);
}
//// TODO: Migrate to async, BUT that's a change in behavior and tests "rejects two filters" will fail.
//// It's because async will check within promise, which makes more sense when using awaits instead of Promises.
// public async call(): Promise<T> {
// this.checkFilter();
// const r = await this._sendNormalRequest(this.url);
// return this._parseResponse(r);
// }
//// /* actually equals */
//// public call(): Promise<T> {
//// return Promise.resolve().then(() => {
//// this.checkFilter();
//// return this._sendNormalRequest(this.url)
//// }).then((r) => {
//// this._parseResponse(r)
//// });
//// }
/**
* Creates an EventSource that listens for incoming messages from the server. To stop listening for new
* events call the function returned by this method.
* @see [Horizon Response Format](https://developers.stellar.org/api/introduction/response-format/)
* @see [MDN EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
* @param options - (optional) EventSource options.
* - `onmessage` (optional): Callback function to handle incoming messages.
* - `onerror` (optional): Callback function to handle errors.
* - `reconnectTimeout` (optional): Custom stream connection timeout in ms, default is 15 seconds.
* @returns Close function. Run to close the connection and stop listening for new events.
*/
stream(options = {}) {
this.checkFilter();
const streamUrl = new URL(this.url);
streamUrl.searchParams.set("X-Client-Name", "js-stellar-sdk");
streamUrl.searchParams.set("X-Client-Version", horizon_axios_client.version);
const { headers } = this.httpClient.defaults;
if (headers) {
const headerNames = ["X-App-Name", "X-App-Version"];
headerNames.forEach((name) => {
let value;
if (headers instanceof Headers) {
value = headers.get(name) ?? void 0;
} else if (Array.isArray(headers)) {
const entry = headers.find(([key]) => key === name);
value = entry?.[1];
} else {
value = headers[name];
}
if (value) {
streamUrl.searchParams.set(name, value);
}
});
}
let es;
let timeout;
const createTimeout = () => {
timeout = setTimeout(
() => {
es?.close();
es = createEventSource();
},
options.reconnectTimeout || 15 * 1e3
);
};
const createEventSource = () => {
try {
es = new eventsource.EventSource(streamUrl.toString());
} catch (err) {
if (options.onerror) {
options.onerror(err);
}
}
createTimeout();
if (!es) {
return es;
}
let closed = false;
const onClose = () => {
if (closed) {
return;
}
clearTimeout(timeout);
es.close();
createEventSource();
closed = true;
};
const onMessage = (message) => {
if (message.type === "close") {
onClose();
return;
}
const result = message.data ? this._parseRecord(JSON.parse(message.data)) : message;
if (result.paging_token) {
streamUrl.searchParams.set("cursor", result.paging_token);
}
clearTimeout(timeout);
createTimeout();
if (typeof options.onmessage !== "undefined") {
options.onmessage(result);
}
};
const onError = (error) => {
if (options.onerror) {
options.onerror(error);
}
};
if (es.addEventListener) {
es.addEventListener("message", onMessage.bind(this));
es.addEventListener("error", onError.bind(this));
es.addEventListener("close", onClose.bind(this));
} else {
es.onmessage = onMessage.bind(this);
es.onerror = onError.bind(this);
}
return es;
};
createEventSource();
return () => {
clearTimeout(timeout);
es?.close();
};
}
/**
* Sets `cursor` parameter for the current call. Returns the CallBuilder object on which this method has been called.
* @see [Paging](https://developers.stellar.org/api/introduction/pagination/)
* @param cursor - A cursor is a value that points to a specific location in a collection of resources.
* @returns current CallBuilder instance
*/
cursor(cursor) {
this.url.searchParams.set("cursor", cursor);
return this;
}
/**
* Sets `limit` parameter for the current call. Returns the CallBuilder object on which this method has been called.
* @see [Paging](https://developers.stellar.org/api/introduction/pagination/)
* @param recordsNumber - Number of records the server should return.
* @returns current CallBuilder instance
*/
limit(recordsNumber) {
this.url.searchParams.set("limit", recordsNumber.toString());
return this;
}
/**
* Sets `order` parameter for the current call. Returns the CallBuilder object on which this method has been called.
* @param direction - Sort direction
* @returns current CallBuilder instance
*/
order(direction) {
this.url.searchParams.set("order", direction);
return this;
}
/**
* Sets `join` parameter for the current call. The `join` parameter
* includes the requested resource in the response. Currently, the
* only valid value for the parameter is `transactions` and is only
* supported on the operations and payments endpoints. The response
* will include a `transaction` field for each operation in the
* response.
*
* @param include - join Records to be included in the response.
* @returns current CallBuilder instance.
*/
join(include) {
this.url.searchParams.set("join", include);
return this;
}
/**
* A helper method to craft queries to "neighbor" endpoints.
*
* For example, we have an `/effects` suffix endpoint on many different
* "root" endpoints, such as `/transactions/:id` and `/accounts/:id`. So,
* it's helpful to be able to conveniently create queries to the
* `/accounts/:id/effects` endpoint:
*
* `this.forEndpoint("accounts", accountId)`.
*
* @param endpoint - neighbor endpoint in question, like /operations
* @param param - filter parameter, like an operation ID
*
* @returns this CallBuilder instance
*/
forEndpoint(endpoint, param) {
if (this.neighborRoot === "") {
throw new Error("Invalid usage: neighborRoot not set in constructor");
}
this.filter.push([endpoint, param, this.neighborRoot]);
return this;
}
/**
* @hidden
* @returns */
checkFilter() {
if (this.filter.length >= 2) {
throw new bad_request.BadRequestError("Too many filters specified", this.filter);
}
if (this.filter.length === 1) {
const newSegment = this.originalSegments.concat(this.filter[0]);
this.url.pathname = newSegment.join("/");
}
}
/**
* Convert a link object to a function that fetches that link.
* @hidden
* @param link - A link object
* - `href`: the URI of the link
* - `templated` (optional): Whether the link is templated
* @returns A function that requests the link
*/
_requestFnForLink(link) {
return async (opts = {}) => {
let uri;
if (link.templated) {
uri = new URL(url.expandUriTemplate(link.href, opts), this.url);
} else {
uri = new URL(link.href, this.url);
}
const r = await this._sendNormalRequest(uri);
return this._parseResponse(r);
};
}
/**
* Given the json response, find and convert each link into a function that
* calls that link.
* @hidden
* @param json - JSON response
* @returns JSON response with string links replaced with functions
*/
_parseRecord(json) {
if (!json._links) {
return json;
}
Object.keys(json._links).forEach((key) => {
const n = json._links[key];
let included = false;
if (typeof json[key] !== "undefined") {
json[`${key}_attr`] = json[key];
included = true;
}
if (included && JOINABLE.indexOf(key) >= 0) {
const record = this._parseRecord(json[key]);
json[key] = async () => record;
} else {
json[key] = this._requestFnForLink(n);
}
});
return json;
}
async _sendNormalRequest(initialUrl) {
const url = new URL(initialUrl);
url.protocol = this.url.protocol;
url.host = this.url.host;
return this.httpClient.get(url.toString()).then((response) => response.data).catch(this._handleNetworkError);
}
/**
* @hidden
* @param json - Response object
* @returns Extended response
*/
_parseResponse(json) {
if (json._embedded && json._embedded.records) {
return this._toCollectionPage(json);
}
return this._parseRecord(json);
}
/**
* @hidden
* @param json - Response object
* @returns Extended response object
*/
_toCollectionPage(json) {
for (let i = 0; i < json._embedded.records.length; i += 1) {
json._embedded.records[i] = this._parseRecord(json._embedded.records[i]);
}
return {
records: json._embedded.records,
next: async () => {
const r = await this._sendNormalRequest(
new URL(json._links.next.href, this.url)
);
return this._toCollectionPage(r);
},
prev: async () => {
const r = await this._sendNormalRequest(
new URL(json._links.prev.href, this.url)
);
return this._toCollectionPage(r);
}
};
}
/**
* @hidden
* @param error - Network error object
* @returns Promise that rejects with a human-readable error
*/
async _handleNetworkError(error) {
if (error.response && error.response.status) {
switch (error.response.status) {
case 404:
return Promise.reject(
new not_found.NotFoundError(
error.response.statusText ?? "Not Found",
error.response.data
)
);
default:
return Promise.reject(
new network.NetworkError(
error.response.statusText ?? "Unknown",
error.response.data
)
);
}
} else {
return Promise.reject(new Error(error.message));
}
}
}
exports.CallBuilder = CallBuilder;
//# sourceMappingURL=call_builder.js.map