UNPKG

reso.js

Version:

A robust, Typescript-first Node.js client designed for interacting with RESO Web API services, fully aligned with the current RESO Web API specification.

361 lines (344 loc) 9.96 kB
'use strict'; const ofetch = require('ofetch'); const PQueue = require('p-queue'); const defu = require('defu'); const ufo = require('ufo'); function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } const PQueue__default = /*#__PURE__*/_interopDefaultCompat(PQueue); 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 ofetch.$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__default({ concurrency: 1, interval: opts.duration ?? 60 * 1e3, intervalCap: opts.points ?? 100, carryoverIntervalCount: 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.defu(defaults, overrides); } let providerOverrides = { limiter: { duration: 60 * 1e3 // 1m } }; if (provider === "spark") { providerOverrides = defu.defu(providerOverrides, { limiter: { points: 300 } }); } else if (provider === "bridge") { providerOverrides = defu.defu(providerOverrides, { limiter: { points: 334 } }); } else if (provider === "mlsgrid") { providerOverrides = defu.defu(providerOverrides, { limiter: { points: 120 } }); } return defu.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; constructor(opts) { this.http = opts.http; this.hooks = opts.hooks ?? {}; } /** * Fetch the OData $metadata. * * @param [query] The optional query to apply for the metadata request (rarely used). * @returns The raw metadata XML document */ $metadata(query) { return this.request("/$metadata", { query }); } /** * Fetch a single resource entity by its unique ID. * @param resource The name of the resource collection. * @param id The unique identifier for the specific entity. * @param [query] The optional query to apply. * @returns The entity */ async readById(resource, id, query) { const resourceId = `(${typeof id === "string" ? `'${id}'` : id})`; return this.request(`/${resource + resourceId}`, { query }); } /** * Fetch a resource collection. * * The generator will yield a response for each page of results * and automatically follow the `@odata.nextLink` until all entities are retrieved. * * @param resource The name of the resource collection. * @param [query] The optional query to apply. * @returns An async generator that yields paginated collection responses. */ async *readByQuery(resource, query) { let hasNext = false; let nextPath = null; do { const response = await this.request(nextPath ?? `/${resource}`, { query }); if (response.nextLink && response.nextLink?.length > 0) { const { search, pathname } = ufo.parseURL(response.nextLink); query = search.slice(1); nextPath = pathname; hasNext = query.length > 0; } else { hasNext = false; } yield response; } while (hasNext); } async request(path, options) { let fetchOptions = {}; if (options?.query) { fetchOptions.query = ufo.parseQuery(options.query); } const rawResponse = await ofetch.ofetch.raw(new URL(path, this.http.baseURL).toString(), { method: "GET", ...fetchOptions }); const rawData = rawResponse._data; if (!rawData) { throw new Error("No response received"); } if (typeof rawData === "string") { return rawData; } const { "@odata.count": count, "@odata.nextLink": nextLink, "@odata.context": context, ...remaining } = rawData; const response = { data: "value" in remaining ? remaining.value : remaining }; if ("@odata.context" in rawData) { response.context = context; } if ("value" in rawData && Array.isArray(rawData.value)) { if ("@odata.count" in rawData) { response.count = Number(count); } if ("@odata.nextLink" in rawData) { response.nextLink = String(nextLink); } return response; } return response; } } 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)] } }); } exports.FeedError = FeedError; exports.TransportErrorCode = TransportErrorCode; exports.createFeed = createFeed;