UNPKG

@shopify/shopify-api

Version:

Shopify API Library for Node - accelerate development with support for authentication, graphql proxy, webhooks

264 lines (261 loc) 11.1 kB
import { createAdminRestApiClient } from '@shopify/admin-api-client'; import * as LosslessJSON from 'lossless-json'; import { getUserAgent, clientLoggerFactory, throwFailedRequest } from '../../common.mjs'; import { MissingRequiredArgument, InvalidRequestError } from '../../../error.mjs'; import { logger } from '../../../logger/index.mjs'; import { Method } from '../../../types.mjs'; import { createSHA256HMAC } from '../../../../runtime/crypto/utils.mjs'; import { HashFormat } from '../../../../runtime/crypto/types.mjs'; import { abstractFetch } from '../../../../runtime/http/index.mjs'; import { canonicalizeHeaders, getHeader } from '../../../../runtime/http/headers.mjs'; class RestClient { static config; static formatPaths; static LINK_HEADER_REGEXP = /<([^<]+)>; rel="([^"]+)"/; static DEFAULT_LIMIT = '50'; static RETRY_WAIT_TIME = 1000; static DEPRECATION_ALERT_DELAY = 300000; loggedDeprecations = {}; client; session; apiVersion; constructor({ session, apiVersion }) { const config = this.restClass().config; if (!config.isCustomStoreApp && !session.accessToken) { throw new MissingRequiredArgument('Missing access token when creating REST client'); } if (apiVersion) { const message = apiVersion === config.apiVersion ? `REST client has a redundant API version override to the default ${apiVersion}` : `REST client overriding default API version ${config.apiVersion} with ${apiVersion}`; logger(config).debug(message); } const customStoreAppAccessToken = config.adminApiAccessToken ?? config.apiSecretKey; this.session = session; this.apiVersion = apiVersion ?? config.apiVersion; this.client = createAdminRestApiClient({ scheme: config.hostScheme, storeDomain: session.shop, apiVersion: apiVersion ?? config.apiVersion, accessToken: config.isCustomStoreApp ? customStoreAppAccessToken : session.accessToken, customFetchApi: abstractFetch, logger: clientLoggerFactory(config), userAgentPrefix: getUserAgent(config), defaultRetryTime: this.restClass().RETRY_WAIT_TIME, formatPaths: this.restClass().formatPaths, isTesting: config.isTesting, }); } /** * Performs a GET request on the given path. */ async get(params) { return this.request({ method: Method.Get, ...params }); } /** * Performs a POST request on the given path. */ async post(params) { return this.request({ method: Method.Post, ...params }); } /** * Performs a PUT request on the given path. */ async put(params) { return this.request({ method: Method.Put, ...params }); } /** * Performs a DELETE request on the given path. */ async delete(params) { return this.request({ method: Method.Delete, ...params }); } async request(params) { const requestParams = { headers: { ...params.extraHeaders, ...(params.type ? { 'Content-Type': params.type.toString() } : {}), }, retries: params.tries ? params.tries - 1 : undefined, searchParams: params.query, }; let response; switch (params.method) { case Method.Get: response = await this.client.get(params.path, requestParams); break; case Method.Put: response = await this.client.put(params.path, { ...requestParams, data: params.data, }); break; case Method.Post: response = await this.client.post(params.path, { ...requestParams, data: params.data, }); break; case Method.Delete: response = await this.client.delete(params.path, requestParams); break; default: throw new InvalidRequestError(`Unsupported request method '${params.method}'`); } const bodyString = await response.text(); // Some DELETE requests return an empty body but are still valid responses, we want those to go through const body = params.method === Method.Delete && bodyString === '' ? {} : this.parseJsonWithLosslessNumbers(bodyString); const responseHeaders = canonicalizeHeaders(Object.fromEntries(response.headers.entries())); if (!response.ok) { throwFailedRequest(body, (params.tries ?? 1) > 1, response); } const requestReturn = { body, headers: responseHeaders, }; await this.logDeprecations({ method: params.method, url: params.path, headers: requestParams.headers, body: params.data ? JSON.stringify(params.data) : undefined, }, requestReturn); const link = response.headers.get('Link'); if (link !== undefined) { const pageInfo = { limit: params.query?.limit ? params.query?.limit.toString() : RestClient.DEFAULT_LIMIT, }; if (link) { const links = link.split(', '); for (const link of links) { const parsedLink = link.match(RestClient.LINK_HEADER_REGEXP); if (!parsedLink) { continue; } const linkRel = parsedLink[2]; const linkUrl = new URL(parsedLink[1]); const linkFields = linkUrl.searchParams.get('fields'); const linkPageToken = linkUrl.searchParams.get('page_info'); if (!pageInfo.fields && linkFields) { pageInfo.fields = linkFields.split(','); } if (linkPageToken) { switch (linkRel) { case 'previous': pageInfo.previousPageUrl = parsedLink[1]; pageInfo.prevPage = this.buildRequestParams(parsedLink[1]); break; case 'next': pageInfo.nextPageUrl = parsedLink[1]; pageInfo.nextPage = this.buildRequestParams(parsedLink[1]); break; } } } } requestReturn.pageInfo = pageInfo; } return requestReturn; } restClass() { return this.constructor; } /** * Parse JSON with lossless-json to preserve numeric precision. * Converts all ID fields (ending with _id, _ids, or named 'id') to strings. */ parseJsonWithLosslessNumbers(jsonString) { // Parse with lossless-json first to preserve precision const parsed = LosslessJSON.parse(jsonString); // Recursively process the parsed object to convert IDs to strings const processValue = (value, key) => { if (value === null || value === undefined) { return value; } // Handle LosslessNumber instances if (value && value.isLosslessNumber === true) { const keyLower = (key || '').toLowerCase(); // Always convert ID fields to strings if (keyLower === 'id' || keyLower.endsWith('_id')) { return value.toString(); } // For non-ID fields, always convert to regular JavaScript number // The IDs have already been handled, so we can use standard conversion return Number(value.value); } // Handle arrays - special case for _ids arrays if (Array.isArray(value)) { const isIdsArray = key && key.toLowerCase().endsWith('_ids'); return value.map((item) => { // If this is an _ids array and item is a LosslessNumber, convert to string if (isIdsArray && item && item.isLosslessNumber === true) { return item.toString(); } return processValue(item); }); } // Handle objects if (typeof value === 'object') { const result = {}; for (const objKey in value) { if (Object.prototype.hasOwnProperty.call(value, objKey)) { result[objKey] = processValue(value[objKey], objKey); } } return result; } return value; }; return processValue(parsed); } buildRequestParams(newPageUrl) { const pattern = `^/admin/api/[^/]+/(.*).json$`; const url = new URL(newPageUrl); const path = url.pathname.replace(new RegExp(pattern), '$1'); return { path, query: Object.fromEntries(url.searchParams.entries()), }; } async logDeprecations(request, response) { const config = this.restClass().config; const deprecationReason = getHeader(response.headers, 'X-Shopify-API-Deprecated-Reason'); if (deprecationReason) { const deprecation = { message: deprecationReason, path: request.url, }; if (request.body) { // This can only be a string, since we're always converting the body before calling this method deprecation.body = `${request.body.substring(0, 100)}...`; } const depHash = await createSHA256HMAC(config.apiSecretKey, JSON.stringify(deprecation), HashFormat.Hex); if (!Object.keys(this.loggedDeprecations).includes(depHash) || Date.now() - this.loggedDeprecations[depHash] >= RestClient.DEPRECATION_ALERT_DELAY) { this.loggedDeprecations[depHash] = Date.now(); const stack = new Error().stack; const message = `API Deprecation Notice ${new Date().toLocaleString()} : ${JSON.stringify(deprecation)} - Stack Trace: ${stack}`; await logger(config).warning(message); } } } } function restClientClass(params) { const { config, formatPaths } = params; class NewRestClient extends RestClient { static config = config; static formatPaths = formatPaths === undefined ? true : formatPaths; } Reflect.defineProperty(NewRestClient, 'name', { value: 'RestClient', }); return NewRestClient; } export { RestClient, restClientClass }; //# sourceMappingURL=client.mjs.map