@graphql-mesh/transport-soap
Version:
461 lines (460 loc) • 21.6 kB
JavaScript
import { XMLBuilder as JSONToXMLConverter, XMLParser } from 'fast-xml-parser';
import { getNamedType, isInputObjectType, isListType, isNonNullType } from 'graphql';
import { process } from '@graphql-mesh/cross-helpers';
import { getInterpolatedHeadersFactory, stringInterpolator, } from '@graphql-mesh/string-interpolation';
import { DefaultLogger } from '@graphql-mesh/utils';
import { normalizedExecutor } from '@graphql-tools/executor';
import { createGraphQLError, getDirectiveExtensions, getRootTypes, } from '@graphql-tools/utils';
import { fetch as defaultFetchFn } from '@whatwg-node/fetch';
import { parseXmlOptions } from './parseXmlOptions.js';
function isOriginallyListType(type) {
if (isNonNullType(type)) {
return isOriginallyListType(type.ofType);
}
return isListType(type);
}
const defaultFieldResolver = function soapDefaultResolver(root, args, context, info) {
const rootField = root[info.fieldName];
if (typeof rootField === 'function') {
return rootField(args, context, info);
}
const fieldValue = rootField;
const isArray = Array.isArray(fieldValue);
const isPlural = isOriginallyListType(info.returnType);
if (isPlural && !isArray) {
return [fieldValue];
}
if (!isPlural && isArray) {
return fieldValue[0];
}
return fieldValue;
};
function normalizeArgsForConverter(args) {
if (args != null) {
if (Array.isArray(args)) {
return args.map(normalizeArgsForConverter);
}
if (typeof args === 'object') {
for (const key in args) {
args[key] = normalizeArgsForConverter(args[key]);
}
}
else {
return {
innerText: args,
};
}
}
return args;
}
function normalizeResult(result) {
if (result != null && typeof result === 'object') {
for (const key in result) {
if (key === 'innerText') {
return result.innerText;
}
result[key] = normalizeResult(result[key]);
}
if (Array.isArray(result) && result.length === 1) {
return result[0];
}
}
return result;
}
/**
* Recursively prefix every object key with `alias:`, leaving `innerText` unprefixed
* so fast-xml-parser emits it as a text node rather than an element. String values
* are passed through the string interpolator; arrays are mapped recursively.
*/
function prefixWithAlias({ alias, obj, resolverData, }) {
if (Array.isArray(obj)) {
return obj.map(item => prefixWithAlias({ alias, obj: item, resolverData }));
}
if (typeof obj === 'object' && obj !== null) {
const prefixedHeaderObj = {};
for (const key in obj) {
const aliasedKey = key === 'innerText' ? key : `${alias}:${key}`;
prefixedHeaderObj[aliasedKey] = prefixWithAlias({
alias,
obj: obj[key],
resolverData,
});
}
return prefixedHeaderObj;
}
if (typeof obj === 'string' && resolverData) {
return stringInterpolator.parse(obj, resolverData);
}
return obj;
}
// Prefixes reserved by the XML/XML Namespaces specs — must never be bound.
const RESERVED_XML_PREFIXES = new Set(['xml', 'xmlns']);
/**
* Derive a short, readable XML namespace prefix from a namespace URI.
* Traverses path segments right-to-left, skipping version tokens (e.g. "v1"),
* dotted hostnames (e.g. "www.example.com"), and reserved XML prefixes.
* e.g. "http://www.tmforum.org/mtop/fmw/xsd/hdr/v1" → "hdr"
*
* Falls back to "body" so that schemas with a single XSD namespace and no
* bodyAlias produce envelopes byte-compatible with the legacy code path.
*/
function deriveXmlPrefix(nsUri) {
const path = nsUri.replace(/^https?:\/\//, '');
const segments = path.split(/[/-]/).filter(Boolean);
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (/^v\d/.test(seg))
continue;
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(seg))
continue;
if (seg.length <= 1)
continue;
const candidate = seg.toLowerCase().substring(0, 20);
if (RESERVED_XML_PREFIXES.has(candidate))
continue;
return candidate;
}
return 'body';
}
/**
* Return (or lazily assign) a unique XML namespace prefix for nsUri.
* Collision-free: appends a numeric suffix if the derived base name is taken.
*/
function getOrAssignPrefix(nsUri, assigned, envelopeAttrs) {
const existing = assigned.get(nsUri);
if (existing) {
// Pre-assigned prefixes (e.g. body → bindingNamespace) don't get an xmlns
// declaration until first use, so unused pre-assignments don't pollute the
// envelope. Set it here on first lookup.
if (envelopeAttrs[`xmlns:${existing}`] === undefined) {
envelopeAttrs[`xmlns:${existing}`] = nsUri;
}
return existing;
}
const base = deriveXmlPrefix(nsUri);
let prefix = base;
let i = 2;
// Check both the envelope (already-declared xmlns) AND the `assigned` map
// (lazily-pre-assigned prefixes whose xmlns hasn't been emitted yet, e.g.
// body → bindingNamespace) so two namespaces can never silently land on the
// same prefix and overwrite each other's xmlns binding.
const taken = new Set(assigned.values());
while (envelopeAttrs[`xmlns:${prefix}`] !== undefined || taken.has(prefix)) {
prefix = `${base}${i++}`;
}
assigned.set(nsUri, prefix);
envelopeAttrs[`xmlns:${prefix}`] = nsUri;
return prefix;
}
/**
* Recursively build an XML-ready object from a GraphQL arg value.
* Uses the parent GraphQL type's XSD namespace (from typeNamespaceMap) to qualify
* child element names, so each field gets the prefix of the schema where it is declared.
*
* `fallbackPrefix` is used when the GraphQL type can't resolve a namespace
* (e.g. arg typed as GraphQLJSON for empty complex types or xs:any) — the
* children inherit the parent arg's prefix instead of being unprefixed.
*/
function buildValueXml(value, graphqlType, typeNamespaceMap, assigned, envelopeAttrs, resolverData, fallbackPrefix) {
if (value == null)
return value;
if (Array.isArray(value)) {
return value.map(item => buildValueXml(item, graphqlType, typeNamespaceMap, assigned, envelopeAttrs, resolverData, fallbackPrefix));
}
if (typeof value === 'object') {
const namedType = graphqlType ? getNamedType(graphqlType) : null;
const typeNsUri = namedType ? typeNamespaceMap.get(namedType.name) : null;
const resolvedPrefix = typeNsUri ? getOrAssignPrefix(typeNsUri, assigned, envelopeAttrs) : null;
const nsPrefix = resolvedPrefix ?? fallbackPrefix ?? null;
const result = {};
if (namedType && isInputObjectType(namedType)) {
const fields = namedType.getFields();
for (const key of Object.keys(value)) {
const fieldType = fields[key]?.type;
const xmlKey = nsPrefix && key !== 'innerText' ? `${nsPrefix}:${key}` : key;
result[xmlKey] = buildValueXml(value[key], fieldType, typeNamespaceMap, assigned, envelopeAttrs, resolverData, nsPrefix ?? undefined);
}
}
else {
// Scalar / GraphQLJSON / unknown type: keys aren't typed, so use the
// current namespace prefix (possibly inherited from above) for children.
for (const key of Object.keys(value)) {
const xmlKey = nsPrefix && key !== 'innerText' ? `${nsPrefix}:${key}` : key;
result[xmlKey] = buildValueXml(value[key], undefined, typeNamespaceMap, assigned, envelopeAttrs, resolverData, nsPrefix ?? undefined);
}
}
return result;
}
return {
innerText: typeof value === 'string' ? stringInterpolator.parse(value, resolverData) : String(value),
};
}
/**
* Wrap a single top-level arg in its namespace-qualified element name and build its content.
*/
function buildArgXml(argName, argValue, nsUri, argType, typeNamespaceMap, assigned, envelopeAttrs, resolverData) {
const prefix = nsUri ? getOrAssignPrefix(nsUri, assigned, envelopeAttrs) : null;
const xmlKey = prefix ? `${prefix}:${argName}` : argName;
return {
// Pass the arg's prefix as fallback so JSON-typed / unknown-typed children
// inherit the parent's namespace instead of being emitted unqualified.
[xmlKey]: buildValueXml(argValue, argType, typeNamespaceMap, assigned, envelopeAttrs, resolverData, prefix ?? undefined),
};
}
/**
* Build the async resolver for a single SOAP operation. In namespace-aware mode
* (when `argNamespaces` is present and `bodyAlias` is absent) it splits args
* across `soap:Header` / `soap:Body` using WSDL-derived metadata and qualifies
* each element with its XSD namespace prefix. Falls back to the legacy single-alias
* path when that metadata is unavailable.
*/
function createRootValueMethod({ soapAnnotations, fetchFn, jsonToXMLConverter, xmlToJSONConverter, operationHeadersFactory, logger, }) {
if (!soapAnnotations.soapNamespace) {
logger.warn(`The expected 'soapNamespace' attribute is missing in SOAP directive definition.
Update the SOAP source handler, and re-generate the schema.
Falling back to 'http://www.w3.org/2003/05/soap-envelope' as SOAP Namespace.`);
soapAnnotations.soapNamespace = 'http://www.w3.org/2003/05/soap-envelope';
}
return async function rootValueMethod(args, context, info) {
const envelopeAttributes = {
'xmlns:soap': soapAnnotations.soapNamespace,
};
const envelope = {
attributes: envelopeAttributes,
};
const resolverData = {
args,
context,
info,
env: process.env,
};
// Read typeNamespacesJson from the transport definition (which survives
// SDL roundtrip via @extraSchemaDefinitionDirective), not directly from
// schema.extensions which is dropped on serialization.
const directives = info.schema.extensions?.directives;
const transport = Array.isArray(directives?.transport)
? directives.transport[0]
: directives?.transport;
const typeNamespacesJson = typeof transport?.typeNamespacesJson === 'string'
? JSON.parse(transport.typeNamespacesJson)
: transport?.typeNamespacesJson;
const typeNamespaceMap = typeNamespacesJson
? new Map(Object.entries(typeNamespacesJson))
: undefined;
if (soapAnnotations.argNamespaces && !soapAnnotations.bodyAlias && typeNamespaceMap) {
// Namespace-aware mode: each arg/field gets the XSD namespace of its declaring schema.
// WSDL-declared soap:header parts are routed to soap:Header; the rest go to soap:Body.
const argNamespacesJson = soapAnnotations.argNamespaces;
const argNamespaces = typeof argNamespacesJson === 'string' ? JSON.parse(argNamespacesJson) : argNamespacesJson;
const headerArgSet = new Set(soapAnnotations.headerArgNames ?? []);
const fieldDef = info.parentType.getFields()[info.fieldName];
const argTypeMap = Object.fromEntries(fieldDef.args.map(a => [a.name, a.type]));
const assigned = new Map();
// Pre-assign 'body' to the binding namespace so single-namespace WSDLs
// keep their legacy 'body:' prefix even when deriveXmlPrefix would have
// produced a different name. The xmlns declaration is added lazily by
// getOrAssignPrefix on first use, so multi-namespace WSDLs that don't
// actually use the binding namespace don't end up with a stray
// xmlns:body attribute on the envelope.
if (soapAnnotations.bindingNamespace) {
assigned.set(soapAnnotations.bindingNamespace, 'body');
}
// Pre-seed the user-configured soapHeaders alias (default 'header') so
// arg namespaces whose deriveXmlPrefix would land on the same name —
// e.g. a URI ending in /header/v1 — get a numeric suffix from
// getOrAssignPrefix instead of silently clobbering this xmlns binding.
// Honored only when free; if the alias is already bound to another
// namespace (e.g. equals 'body'), we let getOrAssignPrefix fall back to
// a derived prefix below.
if (soapAnnotations.soapHeaders?.headers && soapAnnotations.soapHeaders.namespace) {
const userAlias = soapAnnotations.soapHeaders.alias ?? 'header';
const nsUri = soapAnnotations.soapHeaders.namespace;
if (!assigned.has(nsUri) &&
envelopeAttributes[`xmlns:${userAlias}`] === undefined &&
![...assigned.values()].includes(userAlias)) {
assigned.set(nsUri, userAlias);
}
}
const headerContent = {};
const bodyContent = {};
// Seed soap:Header with loader-level soapHeaders defaults first so that
// explicit GraphQL arg values take priority: the args loop below calls
// Object.assign(headerContent, chunk), which overwrites any same-key
// entry written here. This gives query args higher precedence than the
// static loader configuration on key collision.
if (soapAnnotations.soapHeaders?.headers) {
// Route through getOrAssignPrefix when a namespace is given so the alias
// is shared with any arg that uses the same namespace (pre-seeded above),
// and a numeric suffix is appended instead of clobbering another binding.
const cfgAlias = soapAnnotations.soapHeaders.namespace
? getOrAssignPrefix(soapAnnotations.soapHeaders.namespace, assigned, envelopeAttributes)
: (soapAnnotations.soapHeaders.alias ?? 'header');
Object.assign(headerContent, prefixWithAlias({
alias: cfgAlias,
obj: normalizeArgsForConverter(typeof soapAnnotations.soapHeaders.headers === 'string'
? JSON.parse(soapAnnotations.soapHeaders.headers)
: soapAnnotations.soapHeaders.headers),
resolverData,
}));
}
for (const [argName, argValue] of Object.entries(args ?? {})) {
const chunk = buildArgXml(argName, argValue, argNamespaces[argName], argTypeMap[argName], typeNamespaceMap, assigned, envelopeAttributes, resolverData);
if (headerArgSet.has(argName)) {
// Only overwrite soapHeaders defaults when the arg carries a real value.
// GraphQL may coerce an absent INPUT_OBJECT arg to null, undefined, or "".
// None of those represent an intentional caller-supplied override.
if (argValue != null &&
argValue !== '' &&
(typeof argValue !== 'object' || Object.keys(argValue).length > 0)) {
Object.assign(headerContent, chunk);
}
}
else {
Object.assign(bodyContent, chunk);
}
}
if (Object.keys(headerContent).length > 0) {
envelope['soap:Header'] = headerContent;
}
envelope['soap:Body'] = bodyContent;
}
else {
// Legacy mode: single alias prefix for all args (preserves existing behavior exactly).
const bodyPrefix = soapAnnotations.bodyAlias ?? 'body';
envelopeAttributes[`xmlns:${bodyPrefix}`] = soapAnnotations.bindingNamespace;
const headerPrefix = soapAnnotations.soapHeaders?.alias ?? soapAnnotations.bodyAlias ?? 'header';
if (soapAnnotations.soapHeaders?.headers) {
envelope['soap:Header'] = prefixWithAlias({
alias: headerPrefix,
obj: normalizeArgsForConverter(typeof soapAnnotations.soapHeaders.headers === 'string'
? JSON.parse(soapAnnotations.soapHeaders.headers)
: soapAnnotations.soapHeaders.headers),
resolverData,
});
if (soapAnnotations.soapHeaders?.namespace) {
envelopeAttributes[`xmlns:${headerPrefix}`] = soapAnnotations.soapHeaders.namespace;
}
}
envelope['soap:Body'] = prefixWithAlias({
alias: bodyPrefix,
obj: normalizeArgsForConverter(args),
resolverData,
});
}
const requestJson = {
'soap:Envelope': envelope,
};
const requestXML = jsonToXMLConverter.build(requestJson);
const currentFetchFn = context?.fetch || fetchFn;
const response = await currentFetchFn(soapAnnotations.endpoint, {
method: 'POST',
body: requestXML,
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: soapAnnotations.soapAction,
...operationHeadersFactory({
args,
context,
info,
env: process.env,
}),
},
}, context, info);
const responseXML = await response.text();
if (!response.ok) {
return createGraphQLError(`Upstream HTTP Error: ${response.status}`, {
extensions: {
code: 'DOWNSTREAM_SERVICE_ERROR',
serviceName: soapAnnotations.subgraph,
request: {
url: soapAnnotations.endpoint,
method: 'POST',
body: requestXML,
},
response: {
status: response.status,
statusText: response.statusText,
get headers() {
return Object.fromEntries(response.headers.entries());
},
body: responseXML,
},
},
});
}
try {
const responseJSON = xmlToJSONConverter.parse(responseXML, parseXmlOptions);
return normalizeResult(responseJSON.Envelope[0].Body[0][soapAnnotations.elementName]);
}
catch (e) {
return createGraphQLError(`Invalid SOAP response: ${e.message}`, {
extensions: {
subgraph: soapAnnotations.subgraph,
request: {
url: soapAnnotations.endpoint,
method: 'POST',
body: requestXML,
},
response: {
status: response.status,
statusText: response.statusText,
get headers() {
return Object.fromEntries(response.headers.entries());
},
body: responseXML,
},
},
});
}
};
}
function createRootValue(schema, fetchFn, operationHeaders, logger) {
const rootValue = {};
const rootTypes = getRootTypes(schema);
const jsonToXMLConverter = new JSONToXMLConverter({
attributeNamePrefix: '',
attributesGroupName: 'attributes',
textNodeName: 'innerText',
});
const xmlToJSONConverter = new XMLParser(parseXmlOptions);
const operationHeadersFactory = getInterpolatedHeadersFactory(operationHeaders);
for (const rootType of rootTypes) {
const rootFieldMap = rootType.getFields();
for (const fieldName in rootFieldMap) {
const fieldDirectives = getDirectiveExtensions(rootFieldMap[fieldName]);
const soapDirectives = fieldDirectives?.soap;
if (!soapDirectives?.length) {
// skip fields without @soap directive
// we have to skip Query.placeholder field when only mutations were created
continue;
}
for (const soapAnnotations of soapDirectives) {
rootValue[fieldName] = createRootValueMethod({
soapAnnotations,
fetchFn,
jsonToXMLConverter,
xmlToJSONConverter,
operationHeadersFactory,
logger,
});
}
}
}
return rootValue;
}
export function createExecutorFromSchemaAST(schema, fetchFn = defaultFetchFn, operationHeaders = {}, logger = new DefaultLogger()) {
let rootValue;
return function soapExecutor({ document, variables, context }) {
if (!rootValue) {
rootValue = createRootValue(schema, fetchFn, operationHeaders, logger);
}
return normalizedExecutor({
schema,
document,
rootValue,
contextValue: context,
variableValues: variables,
fieldResolver: defaultFieldResolver,
});
};
}