UNPKG

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
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 };