@graphql-tools/executor-http
Advanced tools
Comparing version 1.1.14 to 1.2.0-alpha-753096403f92c5dbcdd17828acdd17a1aa53a51e
# @graphql-tools/executor-http | ||
## 1.2.0-alpha-753096403f92c5dbcdd17828acdd17a1aa53a51e | ||
### Minor Changes | ||
- [#313](https://github.com/graphql-hive/gateway/pull/313) [`7530964`](https://github.com/graphql-hive/gateway/commit/753096403f92c5dbcdd17828acdd17a1aa53a51e) Thanks [@ardatan](https://github.com/ardatan)! - Automatic Persisted Queries support for upstream requests | ||
For HTTP Executor; | ||
```ts | ||
buildHTTPExecutor({ | ||
// ... | ||
apq: true, | ||
}); | ||
``` | ||
For Gateway Configuration; | ||
```ts | ||
export const gatewayConfig = defineConfig({ | ||
transportEntries: { | ||
'*': { | ||
options: { | ||
apq: true, | ||
}, | ||
}, | ||
}, | ||
}); | ||
``` | ||
## 1.1.14 | ||
@@ -4,0 +33,0 @@ |
@@ -58,2 +58,3 @@ import { ExecutionRequest, DisposableSyncExecutor, DisposableAsyncExecutor } from '@graphql-tools/utils'; | ||
print?: (doc: DocumentNode) => string; | ||
apq?: boolean; | ||
/** | ||
@@ -65,2 +66,8 @@ * Enable [Explicit Resource Management](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management) | ||
} | ||
type SerializedRequest = { | ||
query?: string; | ||
variables?: Record<string, any>; | ||
operationName?: string; | ||
extensions?: any; | ||
}; | ||
type HeadersConfig = Record<string, string>; | ||
@@ -78,2 +85,2 @@ declare function buildHTTPExecutor(options?: Omit<HTTPExecutorOptions, 'fetch'> & { | ||
export { type AsyncFetchFn, type AsyncImportFn, type FetchFn, type HTTPExecutorOptions, type HeadersConfig, type RegularFetchFn, type SyncFetchFn, type SyncImportFn, type SyncResponse, buildHTTPExecutor, isLiveQueryOperationDefinitionNode }; | ||
export { type AsyncFetchFn, type AsyncImportFn, type FetchFn, type HTTPExecutorOptions, type HeadersConfig, type RegularFetchFn, type SerializedRequest, type SyncFetchFn, type SyncImportFn, type SyncResponse, buildHTTPExecutor, isLiveQueryOperationDefinitionNode }; |
import { isAsyncIterable, isPromise, mapMaybePromise, memoize1, createGraphQLError, inspect, mapAsyncIterator, mergeIncrementalResult, getOperationASTFromRequest } from '@graphql-tools/utils'; | ||
import { DisposableSymbols } from '@whatwg-node/disposablestack'; | ||
import { File, FormData, TextDecoder, fetch } from '@whatwg-node/fetch'; | ||
import { File, FormData, TextEncoder, crypto, TextDecoder, fetch } from '@whatwg-node/fetch'; | ||
import { ValueOrPromise } from 'value-or-promise'; | ||
@@ -31,12 +31,10 @@ import { extractFiles, isExtractableFile } from 'extract-files'; | ||
} | ||
function createFormDataFromVariables({ | ||
query, | ||
variables, | ||
operationName, | ||
extensions | ||
}, { | ||
function createFormDataFromVariables(body, { | ||
File: FileCtor = File, | ||
FormData: FormDataCtor = FormData | ||
}) { | ||
const vars = Object.assign({}, variables); | ||
if (!body.variables) { | ||
return JSON.stringify(body); | ||
} | ||
const vars = Object.assign({}, body.variables); | ||
const { clone, files } = extractFiles( | ||
@@ -48,12 +46,3 @@ vars, | ||
if (files.size === 0) { | ||
return JSON.stringify( | ||
{ | ||
query, | ||
variables, | ||
operationName, | ||
extensions | ||
}, | ||
null, | ||
2 | ||
); | ||
return JSON.stringify(body); | ||
} | ||
@@ -72,6 +61,4 @@ const map = {}; | ||
JSON.stringify({ | ||
query, | ||
variables: clone, | ||
operationName, | ||
extensions | ||
...body, | ||
variables: clone | ||
}) | ||
@@ -154,2 +141,16 @@ ); | ||
} | ||
function hashSHA256(str) { | ||
const textEncoder = new TextEncoder(); | ||
const utf8 = textEncoder.encode(str); | ||
return mapMaybePromise( | ||
crypto.subtle.digest("SHA-256", utf8), | ||
(hashBuffer) => { | ||
let hashHex = ""; | ||
for (const bytes of new Uint8Array(hashBuffer)) { | ||
hashHex += bytes.toString(16).padStart(2, "0"); | ||
} | ||
return hashHex; | ||
} | ||
); | ||
} | ||
@@ -257,6 +258,3 @@ const DELIM = "\n\n"; | ||
baseUrl = "", | ||
query, | ||
variables, | ||
operationName, | ||
extensions | ||
body | ||
}) { | ||
@@ -266,12 +264,14 @@ const dummyHostname = "https://dummyhostname.com"; | ||
const urlObj = new URL(validUrl); | ||
urlObj.searchParams.set("query", stripIgnoredCharacters(query)); | ||
if (variables && Object.keys(variables).length > 0) { | ||
urlObj.searchParams.set("variables", JSON.stringify(variables)); | ||
if (body.query) { | ||
urlObj.searchParams.set("query", body.query); | ||
} | ||
if (operationName) { | ||
urlObj.searchParams.set("operationName", operationName); | ||
if (body.variables && Object.keys(body.variables).length > 0) { | ||
urlObj.searchParams.set("variables", JSON.stringify(body.variables)); | ||
} | ||
if (extensions) { | ||
urlObj.searchParams.set("extensions", JSON.stringify(extensions)); | ||
if (body.operationName) { | ||
urlObj.searchParams.set("operationName", body.operationName); | ||
} | ||
if (body.extensions) { | ||
urlObj.searchParams.set("extensions", JSON.stringify(body.extensions)); | ||
} | ||
const finalUrl = urlObj.toString().replace(dummyHostname, ""); | ||
@@ -301,3 +301,3 @@ return finalUrl; | ||
const sharedSignal = createSignalWrapper(disposeCtrl.signal); | ||
const baseExecutor = (request) => { | ||
const baseExecutor = (request, excludeQuery) => { | ||
if (sharedSignal.aborted) { | ||
@@ -333,3 +333,2 @@ return createResultForAbort(sharedSignal.reason); | ||
} | ||
const query = printFn(request.document); | ||
let signal = sharedSignal; | ||
@@ -348,94 +347,149 @@ if (options?.timeout) { | ||
}; | ||
return new ValueOrPromise(() => { | ||
switch (method) { | ||
case "GET": { | ||
const finalUrl = prepareGETUrl({ | ||
baseUrl: endpoint, | ||
query, | ||
variables: request.variables, | ||
operationName: request.operationName, | ||
extensions: request.extensions | ||
}); | ||
const fetchOptions = { | ||
method: "GET", | ||
headers, | ||
signal | ||
const query = printFn(request.document); | ||
let serializeFn = function serialize() { | ||
return { | ||
query: excludeQuery ? void 0 : printFn(request.document), | ||
variables: (request.variables && Object.keys(request.variables).length) > 0 ? request.variables : void 0, | ||
operationName: request.operationName ? request.operationName : void 0, | ||
extensions: request.extensions && Object.keys(request.extensions).length > 0 ? request.extensions : void 0 | ||
}; | ||
}; | ||
if (options?.apq) { | ||
serializeFn = function serializeWithAPQ() { | ||
return mapMaybePromise(hashSHA256(query), (sha256Hash) => { | ||
const extensions = request.extensions || {}; | ||
extensions["persistedQuery"] = { | ||
version: 1, | ||
sha256Hash | ||
}; | ||
if (options?.credentials != null) { | ||
fetchOptions.credentials = options.credentials; | ||
return { | ||
query: excludeQuery ? void 0 : query, | ||
variables: (request.variables && Object.keys(request.variables).length) > 0 ? request.variables : void 0, | ||
operationName: request.operationName ? request.operationName : void 0, | ||
extensions | ||
}; | ||
}); | ||
}; | ||
} | ||
return mapMaybePromise( | ||
serializeFn(), | ||
(body) => new ValueOrPromise(() => { | ||
switch (method) { | ||
case "GET": { | ||
const finalUrl = prepareGETUrl({ | ||
baseUrl: endpoint, | ||
body | ||
}); | ||
const fetchOptions = { | ||
method: "GET", | ||
headers, | ||
signal | ||
}; | ||
if (options?.credentials != null) { | ||
fetchOptions.credentials = options.credentials; | ||
} | ||
upstreamErrorExtensions.request.url = finalUrl; | ||
return fetchFn( | ||
finalUrl, | ||
fetchOptions, | ||
request.context, | ||
request.info | ||
); | ||
} | ||
upstreamErrorExtensions.request.url = finalUrl; | ||
return fetchFn(finalUrl, fetchOptions, request.context, request.info); | ||
case "POST": { | ||
upstreamErrorExtensions.request.body = body; | ||
return mapMaybePromise( | ||
createFormDataFromVariables(body, { | ||
File: options?.File, | ||
FormData: options?.FormData | ||
}), | ||
(body2) => { | ||
if (typeof body2 === "string" && !headers["content-type"]) { | ||
upstreamErrorExtensions.request.body = body2; | ||
headers["content-type"] = "application/json"; | ||
} | ||
const fetchOptions = { | ||
method: "POST", | ||
body: body2, | ||
headers, | ||
signal | ||
}; | ||
if (options?.credentials != null) { | ||
fetchOptions.credentials = options.credentials; | ||
} | ||
return fetchFn( | ||
endpoint, | ||
fetchOptions, | ||
request.context, | ||
request.info | ||
); | ||
} | ||
); | ||
} | ||
} | ||
case "POST": { | ||
const body = { | ||
query, | ||
variables: request.variables, | ||
operationName: request.operationName, | ||
extensions: request.extensions | ||
}; | ||
upstreamErrorExtensions.request.body = body; | ||
return mapMaybePromise( | ||
createFormDataFromVariables(body, { | ||
File: options?.File, | ||
FormData: options?.FormData | ||
}), | ||
(body2) => { | ||
if (typeof body2 === "string" && !headers["content-type"]) { | ||
upstreamErrorExtensions.request.body = body2; | ||
headers["content-type"] = "application/json"; | ||
} | ||
const fetchOptions = { | ||
method: "POST", | ||
body: body2, | ||
headers, | ||
signal | ||
}; | ||
if (options?.credentials != null) { | ||
fetchOptions.credentials = options.credentials; | ||
} | ||
return fetchFn( | ||
endpoint, | ||
fetchOptions, | ||
request.context, | ||
request.info | ||
); | ||
} | ||
}).then((fetchResult) => { | ||
upstreamErrorExtensions.response.status = fetchResult.status; | ||
upstreamErrorExtensions.response.statusText = fetchResult.statusText; | ||
Object.defineProperty(upstreamErrorExtensions.response, "headers", { | ||
get() { | ||
return Object.fromEntries(fetchResult.headers.entries()); | ||
} | ||
}); | ||
if (options?.retry != null && !fetchResult.status.toString().startsWith("2")) { | ||
throw new Error( | ||
fetchResult.statusText || `Upstream HTTP Error: ${fetchResult.status}` | ||
); | ||
} | ||
} | ||
}).then((fetchResult) => { | ||
upstreamErrorExtensions.response.status = fetchResult.status; | ||
upstreamErrorExtensions.response.statusText = fetchResult.statusText; | ||
Object.defineProperty(upstreamErrorExtensions.response, "headers", { | ||
get() { | ||
return Object.fromEntries(fetchResult.headers.entries()); | ||
const contentType = fetchResult.headers.get("content-type"); | ||
if (contentType?.includes("text/event-stream")) { | ||
return handleEventStreamResponse(signal, fetchResult); | ||
} else if (contentType?.includes("multipart/mixed")) { | ||
return handleMultipartMixedResponse(fetchResult); | ||
} | ||
}); | ||
if (options?.retry != null && !fetchResult.status.toString().startsWith("2")) { | ||
throw new Error( | ||
fetchResult.statusText || `Upstream HTTP Error: ${fetchResult.status}` | ||
); | ||
} | ||
const contentType = fetchResult.headers.get("content-type"); | ||
if (contentType?.includes("text/event-stream")) { | ||
return handleEventStreamResponse(signal, fetchResult); | ||
} else if (contentType?.includes("multipart/mixed")) { | ||
return handleMultipartMixedResponse(fetchResult); | ||
} | ||
return fetchResult.text(); | ||
}).then((result) => { | ||
if (typeof result === "string") { | ||
upstreamErrorExtensions.response.body = result; | ||
if (result) { | ||
try { | ||
const parsedResult = JSON.parse(result); | ||
upstreamErrorExtensions.response.body = parsedResult; | ||
if (parsedResult.data == null && (parsedResult.errors == null || parsedResult.errors.length === 0)) { | ||
return fetchResult.text(); | ||
}).then((result) => { | ||
if (typeof result === "string") { | ||
upstreamErrorExtensions.response.body = result; | ||
if (result) { | ||
try { | ||
const parsedResult = JSON.parse(result); | ||
upstreamErrorExtensions.response.body = parsedResult; | ||
if (parsedResult.data == null && (parsedResult.errors == null || parsedResult.errors.length === 0)) { | ||
return { | ||
errors: [ | ||
createGraphQLError( | ||
'Unexpected empty "data" and "errors" fields in result: ' + result, | ||
{ | ||
extensions: upstreamErrorExtensions | ||
} | ||
) | ||
] | ||
}; | ||
} | ||
if (Array.isArray(parsedResult.errors)) { | ||
return { | ||
...parsedResult, | ||
errors: parsedResult.errors.map( | ||
({ | ||
message, | ||
...options2 | ||
}) => createGraphQLError(message, { | ||
...options2, | ||
extensions: { | ||
code: "DOWNSTREAM_SERVICE_ERROR", | ||
...options2.extensions || {} | ||
} | ||
}) | ||
) | ||
}; | ||
} | ||
return parsedResult; | ||
} catch (e) { | ||
return { | ||
errors: [ | ||
createGraphQLError( | ||
'Unexpected empty "data" and "errors" fields in result: ' + result, | ||
`Unexpected response: ${JSON.stringify(result)}`, | ||
{ | ||
extensions: upstreamErrorExtensions | ||
extensions: upstreamErrorExtensions, | ||
originalError: e | ||
} | ||
@@ -446,42 +500,21 @@ ) | ||
} | ||
if (Array.isArray(parsedResult.errors)) { | ||
return { | ||
...parsedResult, | ||
errors: parsedResult.errors.map( | ||
({ | ||
message, | ||
...options2 | ||
}) => createGraphQLError(message, { | ||
...options2, | ||
extensions: { | ||
code: "DOWNSTREAM_SERVICE_ERROR", | ||
...options2.extensions || {} | ||
} | ||
}) | ||
) | ||
}; | ||
} | ||
return parsedResult; | ||
} catch (e) { | ||
return { | ||
errors: [ | ||
createGraphQLError( | ||
`Unexpected response: ${JSON.stringify(result)}`, | ||
{ | ||
extensions: upstreamErrorExtensions, | ||
originalError: e | ||
} | ||
) | ||
] | ||
}; | ||
} | ||
} else { | ||
return result; | ||
} | ||
} else { | ||
return result; | ||
} | ||
}).catch((e) => { | ||
if (e.name === "AggregateError") { | ||
}).catch((e) => { | ||
if (e.name === "AggregateError") { | ||
return { | ||
errors: e.errors.map( | ||
(e2) => coerceFetchError(e2, { | ||
signal, | ||
endpoint, | ||
upstreamErrorExtensions | ||
}) | ||
) | ||
}; | ||
} | ||
return { | ||
errors: e.errors.map( | ||
(e2) => coerceFetchError(e2, { | ||
errors: [ | ||
coerceFetchError(e, { | ||
signal, | ||
@@ -491,18 +524,25 @@ endpoint, | ||
}) | ||
) | ||
] | ||
}; | ||
} | ||
return { | ||
errors: [ | ||
coerceFetchError(e, { | ||
signal, | ||
endpoint, | ||
upstreamErrorExtensions | ||
}) | ||
] | ||
}; | ||
}).resolve(); | ||
}).resolve() | ||
); | ||
}; | ||
let executor = baseExecutor; | ||
if (options?.apq != null) { | ||
executor = function apqExecutor(request) { | ||
return mapMaybePromise( | ||
baseExecutor(request, true), | ||
(res) => { | ||
if (res.errors?.some( | ||
(error) => error.extensions["code"] === "PERSISTED_QUERY_NOT_FOUND" || error.message === "PersistedQueryNotFound" | ||
)) { | ||
return baseExecutor(request, false); | ||
} | ||
return res; | ||
} | ||
); | ||
}; | ||
} | ||
if (options?.retry != null) { | ||
const prevExecutor = executor; | ||
executor = function retryExecutor(request) { | ||
@@ -524,3 +564,3 @@ let result; | ||
} | ||
return mapMaybePromise(baseExecutor(request), (res) => { | ||
return mapMaybePromise(prevExecutor(request), (res) => { | ||
result = res; | ||
@@ -527,0 +567,0 @@ if (result?.errors?.length) { |
{ | ||
"name": "@graphql-tools/executor-http", | ||
"version": "1.1.14", | ||
"version": "1.2.0-alpha-753096403f92c5dbcdd17828acdd17a1aa53a51e", | ||
"type": "module", | ||
@@ -52,2 +52,3 @@ "description": "A set of utils for faster development of GraphQL tools", | ||
"devDependencies": { | ||
"@apollo/server": "^4.11.2", | ||
"@types/extract-files": "8.1.3", | ||
@@ -54,0 +55,0 @@ "@whatwg-node/disposablestack": "^0.0.5", |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
68924
1292
6
2
7