@graphql-mesh/transport-rest
Version:
400 lines (399 loc) • 18.6 kB
JavaScript
import { dset } from 'dset';
import { getNamedType, isListType, isNonNullType, isScalarType, isUnionType, } from 'graphql';
import { parse as qsParse, stringify as qsStringify } from 'qs';
import urlJoin from 'url-join';
import { process } from '@graphql-mesh/cross-helpers';
import { stringInterpolator } from '@graphql-mesh/string-interpolation';
import { DefaultLogger, getHeadersObj } from '@graphql-mesh/utils';
import { createGraphQLError, memoize1 } from '@graphql-tools/utils';
import { Blob, File, FormData } from '@whatwg-node/fetch';
import { isFileUpload } from './isFileUpload.js';
import { getJsonApiFieldsQuery } from './jsonApiFields.js';
import { resolveDataByUnionInputType } from './resolveDataByUnionInputType.js';
const isListTypeOrNonNullListType = memoize1(function isListTypeOrNonNullListType(type) {
if (isNonNullType(type)) {
return isListType(type.ofType);
}
return isListType(type);
});
const defaultQsOptions = {
indices: false,
};
export function addHTTPRootFieldResolver(schema, field, globalLogger = new DefaultLogger('HTTP'), globalFetch, { path, operationSpecificHeaders, httpMethod, isBinary, requestBaseBody, queryParamArgMap, queryStringOptionsByParam, jsonApiFields, }, { sourceName, endpoint, timeout, operationHeaders: globalOperationHeaders, queryStringOptions: globalQueryStringOptions = {}, queryParams: globalQueryParams, }) {
globalQueryStringOptions = {
...defaultQsOptions,
...globalQueryStringOptions,
};
const returnNamedGraphQLType = getNamedType(field.type);
field.resolve = async (root, args, context, info) => {
if (jsonApiFields) {
args.fields = undefined;
}
const logger = context?.logger || globalLogger;
const operationLogger = logger.child(`${info.parentType.name}.${info.fieldName}`);
operationLogger.debug(`=> Resolving`);
const interpolationData = { root, args, context, env: process.env };
const interpolatedBaseUrl = stringInterpolator.parse(endpoint, interpolationData);
const interpolatedPath = stringInterpolator.parse(path, interpolationData);
let fullPath = urlJoin(interpolatedBaseUrl, interpolatedPath);
const headers = {};
for (const headerName in globalOperationHeaders) {
const nonInterpolatedValue = globalOperationHeaders[headerName];
const interpolatedValue = stringInterpolator.parse(nonInterpolatedValue, interpolationData);
if (interpolatedValue) {
headers[headerName.toLowerCase()] = interpolatedValue;
}
}
if (operationSpecificHeaders) {
for (const headerName in operationSpecificHeaders) {
const nonInterpolatedValue = operationSpecificHeaders[headerName];
const interpolatedValue = stringInterpolator.parse(nonInterpolatedValue, interpolationData);
if (interpolatedValue) {
headers[headerName.toLowerCase()] = interpolatedValue;
}
}
}
const requestInit = {
method: httpMethod,
headers,
};
if (timeout) {
requestInit.signal = AbortSignal.timeout(timeout);
}
// Handle binary data
if (isBinary) {
const binaryUpload = await args.input;
if (isFileUpload(binaryUpload)) {
const readable = binaryUpload.createReadStream();
const chunks = [];
for await (const chunk of readable) {
for (const byte of chunk) {
chunks.push(byte);
}
}
requestInit.body = new Uint8Array(chunks);
const [, contentType] = Object.entries(headers).find(([key]) => key.toLowerCase() === 'content-type') || [];
if (!contentType) {
headers['content-type'] = binaryUpload.mimetype;
}
}
requestInit.body = binaryUpload;
}
else {
if (requestBaseBody != null) {
args.input = args.input || {};
for (const key in requestBaseBody) {
const configValue = requestBaseBody[key];
if (typeof configValue === 'string') {
const value = stringInterpolator.parse(configValue, interpolationData);
dset(args.input, key, value);
}
else {
args.input[key] = configValue;
}
}
}
// Resolve union input
const input = (args.input = resolveDataByUnionInputType(args.input, field.args?.find(arg => arg.name === 'input')?.type, schema));
if (input != null) {
const [, contentType] = Object.entries(headers).find(([key]) => key.toLowerCase() === 'content-type') || [];
if (contentType?.startsWith('application/x-www-form-urlencoded')) {
requestInit.body = qsStringify(input, globalQueryStringOptions);
}
else if (contentType?.startsWith('multipart/form-data')) {
delete headers['content-type'];
delete headers['Content-Type'];
const formData = new FormData();
for (const key in input) {
const inputValue = input[key];
if (inputValue != null) {
let formDataValue;
if (typeof inputValue === 'object') {
if (inputValue instanceof File) {
formDataValue = inputValue;
}
else if (inputValue.name && inputValue instanceof Blob) {
formDataValue = new File([inputValue], inputValue.name, {
type: inputValue.type,
});
}
else if (inputValue.arrayBuffer) {
const arrayBuffer = await inputValue.arrayBuffer();
if (inputValue.name) {
formDataValue = new File([arrayBuffer], inputValue.name, {
type: inputValue.type,
});
}
else {
formDataValue = new Blob([arrayBuffer], { type: inputValue.type });
}
}
else {
formDataValue = JSON.stringify(inputValue);
}
}
else {
formDataValue = inputValue.toString();
}
formData.append(key, formDataValue);
}
}
requestInit.body = formData;
}
else if (contentType?.startsWith('text/plain')) {
requestInit.body = input;
}
else {
requestInit.body = JSON.stringify(input);
}
}
}
if (globalQueryParams) {
for (const queryParamName in globalQueryParams) {
if (queryParamArgMap != null &&
queryParamName in queryParamArgMap &&
queryParamArgMap[queryParamName] in args) {
continue;
}
const interpolatedQueryParam = stringInterpolator.parse(globalQueryParams[queryParamName].toString(), interpolationData);
const queryParamsString = qsStringify({
[queryParamName]: interpolatedQueryParam,
}, {
...globalQueryStringOptions,
...queryStringOptionsByParam?.[queryParamName],
});
fullPath += fullPath.includes('?') ? '&' : '?';
fullPath += queryParamsString;
}
}
if (queryParamArgMap) {
for (const queryParamName in queryParamArgMap) {
const argName = queryParamArgMap[queryParamName];
let argValue = args[argName];
if (argValue != null) {
// Somehow it doesn't serialize URLs so we need to do it manually.
if (argValue instanceof URL) {
argValue = argValue.toString();
}
const opts = {
...globalQueryStringOptions,
...queryStringOptionsByParam?.[queryParamName],
};
let queryParamObj = argValue;
if (Array.isArray(argValue) || !(typeof argValue === 'object' && opts.destructObject)) {
queryParamObj = {
[queryParamName]: argValue,
};
}
else {
queryParamObj = resolveDataByUnionInputType(queryParamObj, field.args?.find(arg => arg.name === argName)?.type, schema);
}
const queryParamsString = qsStringify(queryParamObj, opts);
fullPath += fullPath.includes('?') ? '&' : '?';
fullPath += queryParamsString;
}
}
}
if (jsonApiFields) {
fullPath += fullPath.includes('?') ? '&' : '?';
fullPath += `fields=${getJsonApiFieldsQuery(info)}`;
}
operationLogger.debug(`=> Fetching `, fullPath, `=>`, requestInit);
const fetch = context?.fetch || globalFetch;
if (!fetch) {
return createGraphQLError(`You should have fetch defined in either the config or the context!`, {
extensions: {
request: {
url: fullPath,
method: httpMethod,
},
},
});
}
// Trick to pass `sourceName` to the `fetch` function for tracing
const response = await fetch(fullPath, requestInit, context, {
...info,
sourceName,
});
// If return type is a file
if (returnNamedGraphQLType.name === 'File') {
return response.blob();
}
const responseText = await response.text();
operationLogger.debug(`=> Received`, {
headers: response.headers,
text: responseText,
});
let responseJson;
try {
responseJson = JSON.parse(responseText);
}
catch (error) {
// The result might be defined as scalar
if (isScalarType(returnNamedGraphQLType)) {
operationLogger.debug(` => Return type is not a JSON so returning ${responseText}`);
return responseText;
}
else if (response.status === 204 || (response.status === 200 && responseText === '')) {
responseJson = {};
}
else if (response.status.toString().startsWith('2')) {
logger.debug(`Unexpected response in ${field.name};\n\t${responseText}`);
return createGraphQLError(`Unexpected response in ${field.name}`, {
extensions: {
http: {
status: response.status,
statusText: response.statusText,
headers: getHeadersObj(response.headers),
},
request: {
url: fullPath,
method: httpMethod,
},
responseText,
originalError: {
message: error.message,
stack: error.stack,
},
},
});
}
else {
return createGraphQLError(`HTTP Error: ${response.status}, Could not invoke operation ${httpMethod} ${path}`, {
extensions: {
request: {
url: fullPath,
method: httpMethod,
},
responseText,
responseStatus: response.status,
responseStatusText: response.statusText,
responseHeaders: getHeadersObj(response.headers),
},
});
}
}
if (!response.status.toString().startsWith('2')) {
if (!isUnionType(returnNamedGraphQLType)) {
return createGraphQLError(`HTTP Error: ${response.status}, Could not invoke operation ${httpMethod} ${path}`, {
extensions: {
http: {
status: response.status,
statusText: response.statusText,
headers: getHeadersObj(response.headers),
},
request: {
url: fullPath,
method: httpMethod,
},
responseJson,
},
});
}
}
operationLogger.debug(`Returning `, responseJson);
// Sometimes API returns an array but the return type is not an array
const isListReturnType = isListTypeOrNonNullListType(field.type);
const isArrayResponse = Array.isArray(responseJson);
if (isListReturnType && !isArrayResponse) {
operationLogger.debug(`Response is not array but return type is list. Normalizing the response`);
responseJson = [responseJson];
}
if (!isListReturnType && isArrayResponse) {
operationLogger.debug(`Response is array but return type is not list. Normalizing the response`);
responseJson = responseJson[0];
}
const addResponseMetadata = (obj) => {
if (typeof obj !== 'object') {
return obj;
}
Object.defineProperties(obj, {
$field: {
get() {
return field.name;
},
},
$url: {
get() {
return fullPath.split('?')[0];
},
},
$method: {
get() {
return httpMethod;
},
},
$statusCode: {
get() {
return response.status;
},
},
$statusText: {
get() {
return response.statusText;
},
},
$headers: {
get() {
return requestInit.headers;
},
},
$request: {
get() {
return new Proxy({}, {
get(_, requestProp) {
switch (requestProp) {
case 'query':
return qsParse(fullPath.split('?')[1]);
case 'path':
return new Proxy(args, {
get(_, prop) {
return args[prop] || args.input?.[prop] || obj?.[prop];
},
has(_, prop) {
return prop in args || (args.input && prop in args.input) || obj?.[prop];
},
});
case 'header':
return requestInit.headers;
case 'body':
return requestInit.body;
}
},
});
},
},
$response: {
get() {
return new Proxy({}, {
get(_, responseProp) {
switch (responseProp) {
case 'header':
return getHeadersObj(response.headers);
case 'body':
return obj;
case 'query':
return qsParse(fullPath.split('?')[1]);
case 'path':
return new Proxy(args, {
get(_, prop) {
return args[prop] || args.input?.[prop] || obj?.[prop];
},
has(_, prop) {
return prop in args || (args.input && prop in args.input) || obj?.[prop];
},
});
}
},
});
},
},
});
return obj;
};
operationLogger.debug(`Adding response metadata to the response object`);
return Array.isArray(responseJson)
? responseJson.map(obj => addResponseMetadata(obj))
: addResponseMetadata(responseJson);
};
}