reso.js
Version:
reso.js is a Node.js client for interacting with RESO Web API services, fully aligned with the current RESO Web API specification.
335 lines (321 loc) • 8.89 kB
JavaScript
import { $fetch } from 'ofetch';
import PQueue from 'p-queue';
import { defu } from 'defu';
class Auth {
token;
tokenType;
constructor(opts) {
this.token = opts?.token ?? null;
this.tokenType = opts?.tokenType ?? "Bearer";
}
}
class AuthBearer extends Auth {
constructor(opts) {
super({
token: opts.credentials.token,
tokenType: opts.credentials.tokenType
});
}
isExpired() {
return false;
}
async refresh() {
return;
}
async getToken() {
return { token: this.token, tokenType: this.tokenType };
}
}
class AuthClientCredentials extends Auth {
expiresAt;
credentials;
refreshBuffer;
constructor(opts) {
super();
this.credentials = opts.credentials;
this.refreshBuffer = opts.refreshBuffer ?? 30 * 1e3;
this.expiresAt = 0;
}
isExpired() {
return this.expiresAt < Date.now();
}
async refresh() {
const { clientId, clientSecret, grantType, scope, tokenURL } = this.credentials;
const clientCredentialQuery = {
client_id: clientId,
client_secret: clientSecret
};
if (grantType) {
clientCredentialQuery["grant_type"] = grantType;
}
if (scope) {
clientCredentialQuery["scope"] = scope;
}
const authResponse = await $fetch(tokenURL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
query: clientCredentialQuery
});
const { access_token, expires_in, token_type } = authResponse;
this.token = access_token;
this.expiresAt = Date.now() + Number(expires_in) - this.refreshBuffer;
this.tokenType = token_type ?? "Bearer";
}
async getToken() {
if (this.isExpired()) {
await this.refresh();
}
return { token: this.token, tokenType: this.tokenType };
}
}
function createAuth(opts) {
if (opts.type === "bearer") {
return new AuthBearer(opts);
}
return new AuthClientCredentials(opts);
}
function createLimiter(opts) {
return new PQueue({
concurrency: 1,
interval: opts.duration ?? 60 * 1e3,
intervalCap: opts.points ?? 100,
carryoverConcurrencyCount: true
});
}
function getProvider(url) {
if (url.includes("api.mlsgrid.com")) {
return "mlsgrid";
}
if (url.includes("api.bridgedataoutput.com")) {
return "bridge";
}
if (url.includes("sparkapi.com")) {
return "spark";
}
if (url.includes("api-trestle.corelogic.com")) {
return "trestle";
}
return null;
}
function getDefaults(overrides) {
const provider = getProvider(overrides.http.baseURL);
const defaults = {};
if (provider === null) {
return defu(defaults, overrides);
}
let providerOverrides = {
limiter: {
duration: 60 * 1e3
// 1m
}
};
if (provider === "spark") {
providerOverrides = defu(providerOverrides, {
limiter: {
points: 300
}
});
} else if (provider === "bridge") {
providerOverrides = defu(providerOverrides, {
limiter: {
points: 334
}
});
} else if (provider === "mlsgrid") {
providerOverrides = defu(providerOverrides, {
limiter: {
points: 120
}
});
}
return defu(defaults, overrides, providerOverrides);
}
function toArray(val) {
if (val === void 0 || val === null) {
return [];
}
if (typeof val === "string") {
return val.split(",");
}
return Array.isArray(val) ? val : [val];
}
var TransportErrorCode = /* @__PURE__ */ ((TransportErrorCode2) => {
TransportErrorCode2["ServiceUnavailable"] = "SERVICE_UNAVAILABLE";
TransportErrorCode2["Forbidden"] = "FORBIDDEN";
TransportErrorCode2["BadRequest"] = "BAD_REQUEST";
TransportErrorCode2["NotFound"] = "NOT_FOUND";
TransportErrorCode2["RequestEntityTooLarge"] = "REQUEST_ENTITY_TO_LARGE";
TransportErrorCode2["UnsupportedMedia"] = "UNSUPPORTED_MEDIA";
TransportErrorCode2["TooManyRequests"] = "TOO_MANY_REQUESTS";
TransportErrorCode2["InternalServerError"] = "INTERNAL_SERVER_ERROR";
TransportErrorCode2["NotImplemented"] = "NOT_IMPLEMENTED";
TransportErrorCode2["Unknown"] = "UNKNOWN";
return TransportErrorCode2;
})(TransportErrorCode || {});
class FeedError extends Error {
name;
code;
target;
details;
constructor(opts) {
super(opts.message);
this.code = opts.code ?? 500;
this.target = opts.target ?? null;
this.details = opts.details ?? [];
switch (this.code) {
case 400:
this.name = TransportErrorCode.BadRequest;
break;
case 403:
this.name = TransportErrorCode.Forbidden;
break;
case 404:
this.name = TransportErrorCode.NotFound;
break;
case 413:
this.name = TransportErrorCode.RequestEntityTooLarge;
break;
case 415:
this.name = TransportErrorCode.UnsupportedMedia;
break;
case 429:
this.name = TransportErrorCode.TooManyRequests;
break;
case 500:
this.name = TransportErrorCode.InternalServerError;
break;
case 501:
this.name = TransportErrorCode.NotImplemented;
break;
case 503:
this.name = TransportErrorCode.ServiceUnavailable;
break;
default:
this.name = TransportErrorCode.Unknown;
break;
}
}
toString() {
return `${this.name} [${this.code}]: ${this.message}`;
}
}
class Feed {
http;
hooks;
client;
constructor(opts) {
this.http = opts.http;
this.hooks = opts.hooks ?? {};
this.client = $fetch.create({
...this.http,
onRequest: this.hooks.onRequest ?? [],
onRequestError: this.hooks.onRequestError ?? [],
onResponse: this.hooks.onResponse ?? [],
onResponseError: this.hooks.onResponseError ?? []
});
}
$metadata(query) {
return this.request(this.buildURL("/$metadata", query));
}
buildURL(url, query) {
if (!query) {
return url;
}
return url + "?" + query;
}
async *readByQuery(resource, query) {
let url = this.buildURL(resource, query);
do {
const readResponse = await this.request(url || resource);
url = null;
if (readResponse && "nextLink" in readResponse) {
url = readResponse.nextLink;
}
yield readResponse;
} while (url);
}
async readById(resource, id, query) {
return this.request(
this.buildURL(resource + `(${typeof id === "string" ? `'${id}'` : id})`, query)
);
}
async request(path, opts) {
const response = await this.client.raw(path, opts ?? {}).catch((error) => {
throw new FeedError({
message: error.data?.error.message ?? error.message,
code: error.statusCode ?? error.data?.error.code ?? 0,
...error.data?.error
});
});
const data = response._data;
if (!data) {
return null;
}
if (typeof data === "string") {
return data;
}
const { "@odata.count": count, "@odata.nextLink": nextLink, "@odata.context": context, ...remaining } = data;
const providerResponse = "value" in remaining ? {
values: remaining.value
} : { value: remaining };
if (typeof count === "string" || typeof count === "number") {
providerResponse.count = Number(count);
}
if (nextLink) {
providerResponse.nextLink = nextLink;
}
if (context) {
providerResponse.context = context;
}
return providerResponse;
}
}
function createFeed(opts) {
const { hooks, ...overrides } = opts ?? {};
if (!opts?.http?.baseURL) {
throw new Error("A baseURL is required");
}
const options = getDefaults(overrides);
const globalHooks = {
onRequest: [],
onRequestError: [
({ error }) => {
throw new FeedError({
message: error.message,
code: 503
});
}
],
onResponse: [],
onResponseError: []
};
let limiter;
if (options.limiter) {
limiter = createLimiter(options.limiter);
globalHooks.onRequest?.push(() => new Promise((r) => limiter?.add(() => r())));
}
if (options.auth) {
const auth = createAuth(options.auth);
globalHooks.onRequest?.push(async ({ options: options2 }) => {
if (auth.isExpired()) {
limiter?.pause();
await auth.refresh();
limiter?.start();
}
const { token, tokenType } = await auth.getToken();
options2.headers["Authorization"] = `${tokenType} ${token}`;
});
}
return new Feed({
http: options.http,
hooks: {
onRequest: [...toArray(globalHooks.onRequest), ...toArray(hooks?.onRequest)],
onRequestError: [...toArray(globalHooks.onRequestError), ...toArray(hooks?.onRequestError)],
onResponse: [...toArray(globalHooks.onResponse), ...toArray(hooks?.onResponse)],
onResponseError: [...toArray(globalHooks.onResponseError), ...toArray(hooks?.onResponseError)]
}
});
}
export { FeedError, TransportErrorCode, createFeed };