import { AdminRestApiClient, createAdminRestApiClient, } from '@shopify/admin-api-client'; import * as LosslessJSON from 'lossless-json'; import { clientLoggerFactory, getUserAgent, throwFailedRequest, } from '../../common'; import { HashFormat, NormalizedRequest, abstractFetch, canonicalizeHeaders, createSHA256HMAC, getHeader, } from '../../../../runtime'; import {ConfigInterface} from '../../../base-types'; import * as ShopifyErrors from '../../../error'; import {logger} from '../../../logger'; import { RestRequestReturn, PageInfo, RestClientParams, PageInfoParams, } from '../types'; import type { RequestParams, GetRequestParams, PutRequestParams, PostRequestParams, DeleteRequestParams, } from '../../types'; import {ApiVersion, Method} from '../../../types'; import {Session} from '../../../session/session'; export interface RestClientClassParams { config: ConfigInterface; formatPaths?: boolean; } interface DeprecationInterface { message: string | null; path: string; body?: string; } export class RestClient { public static config: ConfigInterface; public static formatPaths: boolean; static LINK_HEADER_REGEXP = /<([^<]+)>; rel="([^"]+)"/; static DEFAULT_LIMIT = '50'; static RETRY_WAIT_TIME = 1000; static readonly DEPRECATION_ALERT_DELAY = 300000; loggedDeprecations: Record = {}; readonly client: AdminRestApiClient; readonly session: Session; readonly apiVersion: ApiVersion; public constructor({session, apiVersion}: RestClientParams) { const config = this.restClass().config; if (!config.isCustomStoreApp && !session.accessToken) { throw new ShopifyErrors.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. */ public async get(params: GetRequestParams) { return this.request({method: Method.Get, ...params}); } /** * Performs a POST request on the given path. */ public async post(params: PostRequestParams) { return this.request({method: Method.Post, ...params}); } /** * Performs a PUT request on the given path. */ public async put(params: PutRequestParams) { return this.request({method: Method.Put, ...params}); } /** * Performs a DELETE request on the given path. */ public async delete(params: DeleteRequestParams) { return this.request({method: Method.Delete, ...params}); } protected async request( params: RequestParams, ): Promise> { const requestParams = { headers: { ...params.extraHeaders, ...(params.type ? {'Content-Type': params.type.toString()} : {}), }, retries: params.tries ? params.tries - 1 : undefined, searchParams: params.query, }; let response: 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 ShopifyErrors.InvalidRequestError( `Unsupported request method '${params.method}'`, ); } const bodyString: string = await response.text(); // Some DELETE requests return an empty body but are still valid responses, we want those to go through const body: any = 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: RestRequestReturn = { 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: 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; } private restClass() { return this.constructor as typeof RestClient; } /** * Parse JSON with lossless-json to preserve numeric precision. * Converts all ID fields (ending with _id, _ids, or named 'id') to strings. */ private parseJsonWithLosslessNumbers(jsonString: string): any { // 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: any, key?: string): any => { 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: any = {}; 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); } private buildRequestParams(newPageUrl: string): PageInfoParams { 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()), }; } private async logDeprecations( request: NormalizedRequest, response: RestRequestReturn, ) { const config = this.restClass().config; const deprecationReason = getHeader( response.headers, 'X-Shopify-API-Deprecated-Reason', ); if (deprecationReason) { const deprecation: DeprecationInterface = { 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 as string).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); } } } } export function restClientClass( params: RestClientClassParams, ): typeof RestClient { const {config, formatPaths} = params; class NewRestClient extends RestClient { public static config = config; public static formatPaths = formatPaths === undefined ? true : formatPaths; } Reflect.defineProperty(NewRestClient, 'name', { value: 'RestClient', }); return NewRestClient as typeof RestClient; }