@shopify/admin-api-client
Advanced tools
Comparing version 0.2.4 to 0.2.5
@@ -5,3 +5,3 @@ 'use strict'; | ||
// This is value is replaced with package.json version during rollup build process | ||
const DEFAULT_CLIENT_VERSION = "0.2.4"; | ||
const DEFAULT_CLIENT_VERSION = "0.2.5"; | ||
const ACCESS_TOKEN_HEADER = "X-Shopify-Access-Token"; | ||
@@ -8,0 +8,0 @@ const CLIENT = "Admin API Client"; |
@@ -9,3 +9,3 @@ 'use strict'; | ||
const trimmedDomain = storeDomain.trim(); | ||
const protocolUrl = trimmedDomain.startsWith("http:") || trimmedDomain.startsWith("https:") | ||
const protocolUrl = trimmedDomain.match(/^https?:/) | ||
? trimmedDomain | ||
@@ -12,0 +12,0 @@ : `https://${trimmedDomain}`; |
@@ -15,8 +15,16 @@ 'use strict'; | ||
const RETRIABLE_STATUS_CODES = [429, 503]; | ||
const DEFER_OPERATION_REGEX = /@(defer)\b/i; | ||
const NEWLINE_SEPARATOR = "\r\n"; | ||
const BOUNDARY_HEADER_REGEX = /boundary="?([^=";]+)"?/i; | ||
const HEADER_SEPARATOR = NEWLINE_SEPARATOR + NEWLINE_SEPARATOR; | ||
exports.BOUNDARY_HEADER_REGEX = BOUNDARY_HEADER_REGEX; | ||
exports.CLIENT = CLIENT; | ||
exports.CONTENT_TYPES = CONTENT_TYPES; | ||
exports.DEFER_OPERATION_REGEX = DEFER_OPERATION_REGEX; | ||
exports.GQL_API_ERROR = GQL_API_ERROR; | ||
exports.HEADER_SEPARATOR = HEADER_SEPARATOR; | ||
exports.MAX_RETRIES = MAX_RETRIES; | ||
exports.MIN_RETRIES = MIN_RETRIES; | ||
exports.NEWLINE_SEPARATOR = NEWLINE_SEPARATOR; | ||
exports.NO_DATA_OR_ERRORS_ERROR = NO_DATA_OR_ERRORS_ERROR; | ||
@@ -23,0 +31,0 @@ exports.RETRIABLE_STATUS_CODES = RETRIABLE_STATUS_CODES; |
@@ -22,2 +22,3 @@ 'use strict'; | ||
const request = generateRequest(fetch); | ||
const requestStream = generateRequestStream(fetch); | ||
return { | ||
@@ -27,2 +28,3 @@ config, | ||
request, | ||
requestStream, | ||
}; | ||
@@ -62,6 +64,9 @@ } | ||
utilities.validateRetries({ client: constants.CLIENT, retries: overrideRetries }); | ||
const flatHeaders = Object.fromEntries(Object.entries({ ...headers, ...overrideHeaders }).map(([key, value]) => [ | ||
key, | ||
Array.isArray(value) ? value.join(", ") : value.toString(), | ||
])); | ||
const flatHeaders = Object.entries({ | ||
...headers, | ||
...overrideHeaders, | ||
}).reduce((headers, [key, value]) => { | ||
headers[key] = Array.isArray(value) ? value.join(", ") : value.toString(); | ||
return headers; | ||
}, {}); | ||
const fetchParams = [ | ||
@@ -80,2 +85,5 @@ overrideUrl ?? url, | ||
return async (...props) => { | ||
if (constants.DEFER_OPERATION_REGEX.test(props[0])) { | ||
throw new Error(utilities.formatErrorMessage("This operation will result in a streamable response - use requestStream() instead.")); | ||
} | ||
try { | ||
@@ -114,2 +122,213 @@ const response = await fetch(...props); | ||
} | ||
async function* getStreamBodyIterator(response) { | ||
// Support node-fetch format | ||
if (response.body[Symbol.asyncIterator]) { | ||
for await (const chunk of response.body) { | ||
yield chunk.toString(); | ||
} | ||
} | ||
else { | ||
const reader = response.body.getReader(); | ||
const decoder = new TextDecoder(); | ||
let readResult; | ||
try { | ||
while (!(readResult = await reader.read()).done) { | ||
yield decoder.decode(readResult.value); | ||
} | ||
} | ||
finally { | ||
reader.cancel(); | ||
} | ||
} | ||
} | ||
function readStreamChunk(streamBodyIterator, boundary) { | ||
return { | ||
async *[Symbol.asyncIterator]() { | ||
try { | ||
let buffer = ""; | ||
for await (const textChunk of streamBodyIterator) { | ||
buffer += textChunk; | ||
if (buffer.indexOf(boundary) > -1) { | ||
const lastBoundaryIndex = buffer.lastIndexOf(boundary); | ||
const fullResponses = buffer.slice(0, lastBoundaryIndex); | ||
const chunkBodies = fullResponses | ||
.split(boundary) | ||
.filter((chunk) => chunk.trim().length > 0) | ||
.map((chunk) => { | ||
const body = chunk | ||
.slice(chunk.indexOf(constants.HEADER_SEPARATOR) + constants.HEADER_SEPARATOR.length) | ||
.trim(); | ||
return body; | ||
}); | ||
if (chunkBodies.length > 0) { | ||
yield chunkBodies; | ||
} | ||
buffer = buffer.slice(lastBoundaryIndex + boundary.length); | ||
if (buffer.trim() === `--`) { | ||
buffer = ""; | ||
} | ||
} | ||
} | ||
} | ||
catch (error) { | ||
throw new Error(`Error occured while processing stream payload - ${utilities.getErrorMessage(error)}`); | ||
} | ||
}, | ||
}; | ||
} | ||
function createJsonResponseAsyncIterator(response) { | ||
return { | ||
async *[Symbol.asyncIterator]() { | ||
const processedResponse = await processJSONResponse(response); | ||
yield { | ||
...processedResponse, | ||
hasNext: false, | ||
}; | ||
}, | ||
}; | ||
} | ||
function getResponseDataFromChunkBodies(chunkBodies) { | ||
return chunkBodies | ||
.map((value) => { | ||
try { | ||
return JSON.parse(value); | ||
} | ||
catch (error) { | ||
throw new Error(`Error in parsing multipart response - ${utilities.getErrorMessage(error)}`); | ||
} | ||
}) | ||
.map((payload) => { | ||
const { data, incremental, hasNext, extensions, errors } = payload; | ||
// initial data chunk | ||
if (!incremental) { | ||
return { | ||
data: data || {}, | ||
...utilities.getKeyValueIfValid("errors", errors), | ||
...utilities.getKeyValueIfValid("extensions", extensions), | ||
hasNext, | ||
}; | ||
} | ||
// subsequent data chunks | ||
const incrementalArray = incremental.map(({ data, path, errors }) => { | ||
return { | ||
data: data && path ? utilities.buildDataObjectByPath(path, data) : {}, | ||
...utilities.getKeyValueIfValid("errors", errors), | ||
}; | ||
}); | ||
return { | ||
data: incrementalArray.length === 1 | ||
? incrementalArray[0].data | ||
: utilities.buildCombinedDataObject([ | ||
...incrementalArray.map(({ data }) => data), | ||
]), | ||
...utilities.getKeyValueIfValid("errors", utilities.combineErrors(incrementalArray)), | ||
hasNext, | ||
}; | ||
}); | ||
} | ||
function validateResponseData(responseErrors, combinedData) { | ||
if (responseErrors.length > 0) { | ||
throw new Error(constants.GQL_API_ERROR, { | ||
cause: { | ||
graphQLErrors: responseErrors, | ||
}, | ||
}); | ||
} | ||
if (Object.keys(combinedData).length === 0) { | ||
throw new Error(constants.NO_DATA_OR_ERRORS_ERROR); | ||
} | ||
} | ||
function createMultipartResponseAsyncInterator(response, responseContentType) { | ||
const boundaryHeader = (responseContentType ?? "").match(constants.BOUNDARY_HEADER_REGEX); | ||
const boundary = `--${boundaryHeader ? boundaryHeader[1] : "-"}`; | ||
if (!response.body?.getReader && | ||
!response.body[Symbol.asyncIterator]) { | ||
throw new Error("API multipart response did not return an iterable body", { | ||
cause: response, | ||
}); | ||
} | ||
const streamBodyIterator = getStreamBodyIterator(response); | ||
let combinedData = {}; | ||
let responseExtensions; | ||
return { | ||
async *[Symbol.asyncIterator]() { | ||
try { | ||
let streamHasNext = true; | ||
for await (const chunkBodies of readStreamChunk(streamBodyIterator, boundary)) { | ||
const responseData = getResponseDataFromChunkBodies(chunkBodies); | ||
responseExtensions = | ||
responseData.find((datum) => datum.extensions)?.extensions ?? | ||
responseExtensions; | ||
const responseErrors = utilities.combineErrors(responseData); | ||
combinedData = utilities.buildCombinedDataObject([ | ||
combinedData, | ||
...responseData.map(({ data }) => data), | ||
]); | ||
streamHasNext = responseData.slice(-1)[0].hasNext; | ||
validateResponseData(responseErrors, combinedData); | ||
yield { | ||
...utilities.getKeyValueIfValid("data", combinedData), | ||
...utilities.getKeyValueIfValid("extensions", responseExtensions), | ||
hasNext: streamHasNext, | ||
}; | ||
} | ||
if (streamHasNext) { | ||
throw new Error(`Response stream terminated unexpectedly`); | ||
} | ||
} | ||
catch (error) { | ||
const cause = utilities.getErrorCause(error); | ||
yield { | ||
...utilities.getKeyValueIfValid("data", combinedData), | ||
...utilities.getKeyValueIfValid("extensions", responseExtensions), | ||
errors: { | ||
message: utilities.formatErrorMessage(utilities.getErrorMessage(error)), | ||
networkStatusCode: response.status, | ||
...utilities.getKeyValueIfValid("graphQLErrors", cause?.graphQLErrors), | ||
response, | ||
}, | ||
hasNext: false, | ||
}; | ||
} | ||
}, | ||
}; | ||
} | ||
function generateRequestStream(fetch) { | ||
return async (...props) => { | ||
if (!constants.DEFER_OPERATION_REGEX.test(props[0])) { | ||
throw new Error(utilities.formatErrorMessage("This operation does not result in a streamable response - use request() instead.")); | ||
} | ||
try { | ||
const response = await fetch(...props); | ||
const { statusText } = response; | ||
if (!response.ok) { | ||
throw new Error(statusText, { cause: response }); | ||
} | ||
const responseContentType = response.headers.get("content-type") || ""; | ||
switch (true) { | ||
case responseContentType.includes(constants.CONTENT_TYPES.json): | ||
return createJsonResponseAsyncIterator(response); | ||
case responseContentType.includes(constants.CONTENT_TYPES.multipart): | ||
return createMultipartResponseAsyncInterator(response, responseContentType); | ||
default: | ||
throw new Error(`${constants.UNEXPECTED_CONTENT_TYPE_ERROR} ${responseContentType}`, { cause: response }); | ||
} | ||
} | ||
catch (error) { | ||
return { | ||
async *[Symbol.asyncIterator]() { | ||
const response = utilities.getErrorCause(error); | ||
yield { | ||
errors: { | ||
message: utilities.formatErrorMessage(utilities.getErrorMessage(error)), | ||
...utilities.getKeyValueIfValid("networkStatusCode", response?.status), | ||
...utilities.getKeyValueIfValid("response", response), | ||
}, | ||
hasNext: false, | ||
}; | ||
}, | ||
}; | ||
} | ||
}; | ||
} | ||
@@ -116,0 +335,0 @@ exports.createGraphQLClient = createGraphQLClient; |
@@ -11,2 +11,10 @@ 'use strict'; | ||
} | ||
function getErrorCause(error) { | ||
return error instanceof Error && error.cause ? error.cause : undefined; | ||
} | ||
function combineErrors(dataArray) { | ||
return dataArray.flatMap(({ errors }) => { | ||
return errors ?? []; | ||
}); | ||
} | ||
function validateRetries({ client, retries, }) { | ||
@@ -28,4 +36,35 @@ if (retries !== undefined && | ||
} | ||
function buildDataObjectByPath(path, data) { | ||
if (path.length === 0) { | ||
return data; | ||
} | ||
const key = path.pop(); | ||
const newData = { | ||
[key]: data, | ||
}; | ||
if (path.length === 0) { | ||
return newData; | ||
} | ||
return buildDataObjectByPath(path, newData); | ||
} | ||
function combineObjects(baseObject, newObject) { | ||
return Object.keys(newObject || {}).reduce((acc, key) => { | ||
if ((typeof newObject[key] === "object" || Array.isArray(newObject[key])) && | ||
baseObject[key]) { | ||
acc[key] = combineObjects(baseObject[key], newObject[key]); | ||
return acc; | ||
} | ||
acc[key] = newObject[key]; | ||
return acc; | ||
}, Array.isArray(baseObject) ? [...baseObject] : { ...baseObject }); | ||
} | ||
function buildCombinedDataObject([initialDatum, ...remainingData]) { | ||
return remainingData.reduce(combineObjects, { ...initialDatum }); | ||
} | ||
exports.buildCombinedDataObject = buildCombinedDataObject; | ||
exports.buildDataObjectByPath = buildDataObjectByPath; | ||
exports.combineErrors = combineErrors; | ||
exports.formatErrorMessage = formatErrorMessage; | ||
exports.getErrorCause = getErrorCause; | ||
exports.getErrorMessage = getErrorMessage; | ||
@@ -32,0 +71,0 @@ exports.getKeyValueIfValid = getKeyValueIfValid; |
{ | ||
"name": "@shopify/admin-api-client", | ||
"version": "0.2.4", | ||
"version": "0.2.5", | ||
"description": "Shopify Admin API Client - A lightweight JS client to interact with Shopify's Admin API", | ||
@@ -63,3 +63,3 @@ "repository": { | ||
"dependencies": { | ||
"@shopify/graphql-client": "^0.9.4" | ||
"@shopify/graphql-client": "^0.10.0" | ||
}, | ||
@@ -66,0 +66,0 @@ "devDependencies": { |
@@ -68,3 +68,3 @@ # Admin API Client | ||
| config | [`AdminApiClientConfig`](#adminapiclientconfig-properties) | Configuration for the client | | ||
| getHeaders | `(headers?: {[key: string]: string}) => {[key: string]: string` | Returns Admin API specific headers needed to interact with the API. If additional `headers` are provided, the custom headers will be included in the returned headers object. | | ||
| getHeaders | `(headers?: Record<string, string \| string[]>) => Record<string, string \| string[]>` | Returns Admin API specific headers needed to interact with the API. If additional `headers` are provided, the custom headers will be included in the returned headers object. | | ||
| getApiUrl | `(apiVersion?: string) => string` | Returns the shop specific API url. If an API version is provided, the returned URL will include the provided version, else the URL will include the API version set at client initialization. | | ||
@@ -81,3 +81,3 @@ | fetch | `(operation: string, options?:`[`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties)`) => Promise<Response>` | Fetches data from Admin API using the provided GQL `operation` string and [`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties) object and returns the network response. | | ||
| accessToken | `string` | The provided public access token. If `privateAccessToken` was provided, `publicAccessToken` will not be available. | | ||
| headers | `{[key: string]: string}` | The headers generated by the client during initialization | | ||
| headers | `Record<string, string \| string[]>` | The headers generated by the client during initialization | | ||
| apiUrl | `string` | The API URL generated from the provided store domain and api version | | ||
@@ -90,5 +90,5 @@ | retries? | `number` | The number of retries the client will attempt when the API responds with a `Too Many Requests (429)` or `Service Unavailable (503)` response | | ||
| -------------- | ------------------------ | ---------------------------------------------------- | | ||
| variables? | `{[key: string]: any}` | Variable values needed in the graphQL operation | | ||
| variables? | `Record<string, any>` | Variable values needed in the graphQL operation | | ||
| apiVersion? | `string` | The Admin API version to use in the API request | | ||
| headers? | `{[key: string]: string}`| Customized headers to be included in the API request | | ||
| headers? | `Record<string, string \| string[]>`| Customized headers to be included in the API request | | ||
| retries? | `number` | Alternative number of retries for the request. Retries only occur for requests that were abandoned or if the server responds with a `Too Many Request (429)` or `Service Unavailable (503)` response. Minimum value is `0` and maximum value is `3`. | | ||
@@ -102,3 +102,3 @@ | ||
| errors? | [`ResponseErrors`](#responseerrors) | Error object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. | | ||
| extensions? | `{[key: string]: any}` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the localization context information used to generate the returned API response. | | ||
| extensions? | `Record<string, any>` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the localization context information used to generate the returned API response. | | ||
@@ -105,0 +105,0 @@ ### `ResponseErrors` |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
252432
1778
16
+ Added@shopify/graphql-client@0.10.4(transitive)
- Removed@shopify/graphql-client@0.9.4(transitive)