UNPKG

@graphql-mesh/transport-soap

Version:
461 lines (460 loc) 21.6 kB
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, }); }; }