From 3fc1f3e046c02107d7fecf367756c7196fbe6ce1 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Tue, 7 Jan 2025 18:15:33 -0500 Subject: [PATCH] feat(soap): support SOAP headers and customize aliases (#8196) * feat(soap): support SOAP headers * chore(dependencies): updated changesets for modified dependencies * More * Go * No fetch for header test * More * chore(dependencies): updated changesets for modified dependencies * chore(dependencies): updated changesets for modified dependencies * lets go * Tests * Hmm * Go * Envelopeee * Leftover * Docs * Headers * Body alias documentation * Lockfile --------- Co-authored-by: github-actions[bot] --- .../@omnigraph_soap-8196-dependencies.md | 5 + .changeset/funny-kangaroos-think.md | 75 ++++++ .../__snapshots__/soap-demo.test.ts.snap | 10 + e2e/utils/leftoverStack.ts | 11 +- packages/legacy/handlers/soap/src/index.ts | 2 + .../legacy/handlers/soap/yaml-config.graphql | 29 +++ packages/legacy/types/src/config-schema.json | 29 +++ packages/legacy/types/src/config.ts | 30 +++ packages/loaders/soap/package.json | 1 + packages/loaders/soap/src/SOAPLoader.ts | 58 +++++ packages/loaders/soap/src/index.ts | 62 +++-- packages/loaders/soap/src/utils.ts | 6 + .../test/__snapshots__/examples.test.ts.snap | 60 ++++- .../soap/test/__snapshots__/soap.test.ts.snap | 12 +- .../getTypeResolverForAbstractType.ts | 1 + .../rest/src/directives/httpOperation.ts | 2 + packages/transports/soap/src/executor.ts | 93 ++++++- .../tests/__snapshots__/headers.spec.ts.snap | 45 ++++ .../soap/tests/fixtures/globalweather.wsdl | 231 ++++++++++++++++++ .../transports/soap/tests/headers.spec.ts | 78 ++++++ .../SoapHandler.generated.md | 13 +- website/src/pages/docs/handlers/soap.mdx | 55 +++++ website/src/pages/v1/source-handlers/soap.mdx | 78 ++++++ yarn.lock | 3 +- 24 files changed, 937 insertions(+), 52 deletions(-) create mode 100644 .changeset/@omnigraph_soap-8196-dependencies.md create mode 100644 .changeset/funny-kangaroos-think.md create mode 100644 packages/transports/soap/tests/__snapshots__/headers.spec.ts.snap create mode 100644 packages/transports/soap/tests/fixtures/globalweather.wsdl create mode 100644 packages/transports/soap/tests/headers.spec.ts diff --git a/.changeset/@omnigraph_soap-8196-dependencies.md b/.changeset/@omnigraph_soap-8196-dependencies.md new file mode 100644 index 0000000000000..13e075d488777 --- /dev/null +++ b/.changeset/@omnigraph_soap-8196-dependencies.md @@ -0,0 +1,5 @@ +--- +"@omnigraph/soap": patch +--- +dependencies updates: + - Added dependency [`@graphql-mesh/transport-common@^0.7.25` ↗︎](https://www.npmjs.com/package/@graphql-mesh/transport-common/v/0.7.25) (to `dependencies`) diff --git a/.changeset/funny-kangaroos-think.md b/.changeset/funny-kangaroos-think.md new file mode 100644 index 0000000000000..b4889bca1b08a --- /dev/null +++ b/.changeset/funny-kangaroos-think.md @@ -0,0 +1,75 @@ +--- +'@graphql-mesh/transport-soap': patch +'@omnigraph/soap': patch +'@graphql-mesh/types': patch +--- + +- You can now choose the name of the alias you want to use for SOAP body; + +```ts filename="mesh.config.ts" {4} +import { defineConfig } from '@graphql-mesh/compose-cli' + +export const composeConfig = defineConfig({ + sources: [ + { + sourceHandler: loadSOAPSubgraph('CountryInfo', { + source: + 'http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso?WSDL', + bodyAlias: 'my-body' + }) + } + ] +}) +``` + +- Then it will generate a body like below by using the alias; + +```xml + + + + baz + + + +``` + +If you want to add SOAP headers to the request body like below; + +```xml + + + + user + password + + +``` + +You can add the headers to the configuration like below; + +```ts filename="mesh.config.ts" {2,7-9} +import { defineConfig } from '@graphql-mesh/compose-cli' +import { loadSOAPSubgraph } from '@omnigraph/soap' + +export const composeConfig = defineConfig({ + subgraphs: [ + { + sourceHandler: loadSOAPSubgraph('CountryInfo', { + source: + 'http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso?WSDL', + soapHeaders: { + alias: 'header', + namespace: 'http://foo.com', + headers: { + MyHeader: { + UserName: 'user', + Password: 'password' + } + } + } + }) + } + ] +}) +``` diff --git a/e2e/soap-demo/__snapshots__/soap-demo.test.ts.snap b/e2e/soap-demo/__snapshots__/soap-demo.test.ts.snap index feb4e05a13ed7..823528321ac5e 100644 --- a/e2e/soap-demo/__snapshots__/soap-demo.test.ts.snap +++ b/e2e/soap-demo/__snapshots__/soap-demo.test.ts.snap @@ -89,6 +89,8 @@ directive @soap( bindingNamespace: String endpoint: String subgraph: String + bodyAlias: String + soapHeaders: SOAPHeaders ) repeatable on FIELD_DEFINITION directive @extraSchemaDefinitionDirective(directives: _DirectiveExtensions) repeatable on OBJECT @@ -108,6 +110,8 @@ The \`JSON\` scalar type represents JSON values as specified by [ECMA-404](http: """ scalar JSON @join__type(graph: SOAP_DEMO) +scalar ObjMap @join__type(graph: SOAP_DEMO) + scalar _DirectiveExtensions @join__type(graph: SOAP_DEMO) type Query @extraSchemaDefinitionDirective(directives: {transport: [{kind: "soap", subgraph: "soap-demo"}]}) @join__type(graph: SOAP_DEMO) { @@ -294,6 +298,12 @@ input s0_DivideInteger_Input @join__type(graph: SOAP_DEMO) { input s0_LookupCity_Input @join__type(graph: SOAP_DEMO) { zip: String } + +input SOAPHeaders @join__type(graph: SOAP_DEMO) { + namespace: String + alias: String + headers: ObjMap +} " `; diff --git a/e2e/utils/leftoverStack.ts b/e2e/utils/leftoverStack.ts index e94c2e4fa3f25..72dc6246155ba 100644 --- a/e2e/utils/leftoverStack.ts +++ b/e2e/utils/leftoverStack.ts @@ -17,16 +17,13 @@ function handleSuppressedError(e: any) { } if (typeof afterAll === 'function') { - afterAll(() => { + afterAll(async () => { try { - const disposeRes$ = leftoverStack.disposeAsync(); - leftoverStack = new AsyncDisposableStack(); - if (disposeRes$?.catch) { - disposeRes$.catch(handleSuppressedError); - } + await leftoverStack.disposeAsync(); } catch (e) { handleSuppressedError(e); + } finally { + leftoverStack = new AsyncDisposableStack(); } - leftoverStack = new AsyncDisposableStack(); }); } diff --git a/packages/legacy/handlers/soap/src/index.ts b/packages/legacy/handlers/soap/src/index.ts index c6df64b6326dd..95080ed786421 100644 --- a/packages/legacy/handlers/soap/src/index.ts +++ b/packages/legacy/handlers/soap/src/index.ts @@ -64,6 +64,8 @@ export default class SoapHandler implements MeshHandler { logger: this.logger, schemaHeaders: this.config.schemaHeaders, operationHeaders: this.config.operationHeaders, + soapHeaders: this.config.soapHeaders, + bodyAlias: this.config.bodyAlias, }); const wsdlLocation = this.config.source; const wsdl = await readFileOrUrl(wsdlLocation, { diff --git a/packages/legacy/handlers/soap/yaml-config.graphql b/packages/legacy/handlers/soap/yaml-config.graphql index a791ad9a3b42a..0b8d8ae38d1ef 100644 --- a/packages/legacy/handlers/soap/yaml-config.graphql +++ b/packages/legacy/handlers/soap/yaml-config.graphql @@ -19,4 +19,33 @@ type SoapHandler @md { JSON object representing the Headers to add to the runtime of the API calls only for operation during runtime """ operationHeaders: JSON + """ + The name of the alias to be used in the envelope for body components + + default: `body` + """ + bodyAlias: String + """ + SOAP Headers to be added to the request + """ + soapHeaders: SOAPHeaders +} + +type SOAPHeaders { + """ + The name of the alias to be used in the envelope + + default: `header` + """ + alias: String + """ + The namespace of the SOAP Header + For example: `http://www.example.com/namespace` + """ + namespace: String! + """ + The content of the SOAP Header + For example: { "key": "value" } then the content will be `value` + """ + headers: JSON! } diff --git a/packages/legacy/types/src/config-schema.json b/packages/legacy/types/src/config-schema.json index 267134f53cc60..ad6d77bdc9a89 100644 --- a/packages/legacy/types/src/config-schema.json +++ b/packages/legacy/types/src/config-schema.json @@ -3004,10 +3004,39 @@ "type": "object", "properties": {}, "description": "JSON object representing the Headers to add to the runtime of the API calls only for operation during runtime" + }, + "bodyAlias": { + "type": "string", + "description": "The name of the alias to be used in the envelope for body components\n\ndefault: `body`" + }, + "soapHeaders": { + "$ref": "#/definitions/SOAPHeaders", + "description": "SOAP Headers to be added to the request" } }, "required": ["source"] }, + "SOAPHeaders": { + "additionalProperties": false, + "type": "object", + "title": "SOAPHeaders", + "properties": { + "alias": { + "type": "string", + "description": "The name of the alias to be used in the envelope\n\ndefault: `header`" + }, + "namespace": { + "type": "string", + "description": "The namespace of the SOAP Header\nFor example: `http://www.example.com/namespace`" + }, + "headers": { + "type": "object", + "properties": {}, + "description": "The content of the SOAP Header\nFor example: { \"key\": \"value\" } then the content will be `value`" + } + }, + "required": ["namespace", "headers"] + }, "SupergraphHandler": { "additionalProperties": false, "type": "object", diff --git a/packages/legacy/types/src/config.ts b/packages/legacy/types/src/config.ts index e1287fb3f7528..19ecf8ea41bab 100644 --- a/packages/legacy/types/src/config.ts +++ b/packages/legacy/types/src/config.ts @@ -1015,6 +1015,36 @@ export interface SoapHandler { operationHeaders?: { [k: string]: any; }; + /** + * The name of the alias to be used in the envelope for body components + * + * default: `body` + */ + bodyAlias?: string; + soapHeaders?: SOAPHeaders; +} +/** + * SOAP Headers to be added to the request + */ +export interface SOAPHeaders { + /** + * The name of the alias to be used in the envelope + * + * default: `header` + */ + alias?: string; + /** + * The namespace of the SOAP Header + * For example: `http://www.example.com/namespace` + */ + namespace: string; + /** + * The content of the SOAP Header + * For example: { "key": "value" } then the content will be `value` + */ + headers: { + [k: string]: any; + }; } export interface SupergraphHandler { /** diff --git a/packages/loaders/soap/package.json b/packages/loaders/soap/package.json index e42f4cb05c819..c008b11bb6115 100644 --- a/packages/loaders/soap/package.json +++ b/packages/loaders/soap/package.json @@ -37,6 +37,7 @@ "dependencies": { "@graphql-mesh/cross-helpers": "^0.4.9", "@graphql-mesh/string-interpolation": "^0.5.7", + "@graphql-mesh/transport-common": "^0.7.25", "@graphql-mesh/transport-soap": "^0.8.10", "@graphql-mesh/types": "^0.103.10", "@graphql-mesh/utils": "^0.103.10", diff --git a/packages/loaders/soap/src/SOAPLoader.ts b/packages/loaders/soap/src/SOAPLoader.ts index 0e727688b0d9c..9e46ec23f4102 100644 --- a/packages/loaders/soap/src/SOAPLoader.ts +++ b/packages/loaders/soap/src/SOAPLoader.ts @@ -5,6 +5,7 @@ import { GraphQLBoolean, GraphQLDirective, GraphQLFloat, + GraphQLInputObjectType, GraphQLInt, GraphQLString, } from 'graphql'; @@ -39,6 +40,7 @@ import { import { process } from '@graphql-mesh/cross-helpers'; import type { ResolverDataBasedFactory } from '@graphql-mesh/string-interpolation'; import { getInterpolatedHeadersFactory } from '@graphql-mesh/string-interpolation'; +import { ObjMapScalar } from '@graphql-mesh/transport-common'; import type { Logger, MeshFetch } from '@graphql-mesh/types'; import { defaultImportFn, @@ -71,10 +73,50 @@ export interface SOAPLoaderOptions { logger?: Logger; schemaHeaders?: Record; operationHeaders?: Record; + soapHeaders?: SOAPHeaders; endpoint?: string; cwd?: string; + bodyAlias?: string; } +export interface SOAPHeaders { + /** + * The namespace of the SOAP Header + * + * @example http://www.example.com/namespace + */ + namespace: string; + /** + * The name of the alias to be used in the envelope + * + * @default header + */ + alias?: string; + /** + * The content of the SOAP Header + * + * @example { "key": "value" } + * + * then the content will be `value` in XML + */ + headers: unknown; +} + +const SOAPHeadersInput = new GraphQLInputObjectType({ + name: 'SOAPHeaders', + fields: { + namespace: { + type: GraphQLString, + }, + alias: { + type: GraphQLString, + }, + headers: { + type: ObjMapScalar, + }, + }, +}); + const soapDirective = new GraphQLDirective({ name: 'soap', locations: [DirectiveLocation.FIELD_DEFINITION], @@ -91,6 +133,12 @@ const soapDirective = new GraphQLDirective({ subgraph: { type: GraphQLString, }, + bodyAlias: { + type: GraphQLString, + }, + soapHeaders: { + type: SOAPHeadersInput, + }, }, }); @@ -143,6 +191,8 @@ export class SOAPLoader { private logger: Logger; private endpoint?: string; private cwd: string; + private soapHeaders: SOAPHeaders; + private bodyAlias?: string; constructor(options: SOAPLoaderOptions) { this.fetchFn = options.fetch || defaultFetchFn; @@ -153,6 +203,8 @@ export class SOAPLoader { this.schemaHeadersFactory = getInterpolatedHeadersFactory(options.schemaHeaders || {}); this.endpoint = options.endpoint; this.cwd = options.cwd; + this.soapHeaders = options.soapHeaders; + this.bodyAlias = options.bodyAlias; } loadXMLSchemaNamespace() { @@ -452,6 +504,12 @@ export class SOAPLoader { endpoint: this.endpoint || portObj.address[0].attributes.location, subgraph: this.subgraphName, }; + if (this.bodyAlias) { + soapAnnotations.bodyAlias = this.bodyAlias; + } + if (this.soapHeaders) { + soapAnnotations.soapHeaders = this.soapHeaders; + } rootTC.addFields({ [operationFieldName]: { type, diff --git a/packages/loaders/soap/src/index.ts b/packages/loaders/soap/src/index.ts index db5912429773a..076561585f6d1 100644 --- a/packages/loaders/soap/src/index.ts +++ b/packages/loaders/soap/src/index.ts @@ -1,45 +1,71 @@ import type { Logger, MeshFetch } from '@graphql-mesh/types'; -import { defaultImportFn, DefaultLogger, readFileOrUrl } from '@graphql-mesh/utils'; -import { SOAPLoader } from './SOAPLoader.js'; +import { defaultImportFn, mapMaybePromise, readFileOrUrl } from '@graphql-mesh/utils'; +import { SOAPLoader, type SOAPHeaders } from './SOAPLoader.js'; export * from './SOAPLoader.js'; export type * from './types.js'; export * from '@graphql-mesh/transport-soap'; export interface SOAPSubgraphLoaderOptions { + /** + * A url to your WSDL or generated SDL with annotations + */ source?: string; + /** + * SOAP endpoint to use for the API calls + */ endpoint?: string; - fetch?: MeshFetch; - logger?: Logger; + /** + * JSON object representing the Headers to add to the runtime of the API calls only for schema introspection + * You can also provide `.js` or `.ts` file path that exports schemaHeaders as an object + */ schemaHeaders?: Record; + /** + * JSON object representing the Headers to add to the runtime of the API calls only for operation calls + */ operationHeaders?: Record; + /** + * SOAP Headers configuration + */ + soapHeaders?: SOAPHeaders; + /** + * The name of the alias to be used in the envelope for body components + + * @default body + */ + bodyAlias?: string; } export function loadSOAPSubgraph(subgraphName: string, options: SOAPSubgraphLoaderOptions) { return ({ cwd, fetch, logger }: { cwd: string; fetch: MeshFetch; logger: Logger }) => { const soapLoader = new SOAPLoader({ subgraphName, - fetch: options.fetch || fetch, - logger: options.logger || logger, + fetch, + logger, + cwd, + // Configuration from the user schemaHeaders: options.schemaHeaders, operationHeaders: options.operationHeaders, endpoint: options.endpoint, - cwd, + bodyAlias: options.bodyAlias, + soapHeaders: options.soapHeaders, }); return { name: subgraphName, - schema$: readFileOrUrl(options.source, { - allowUnknownExtensions: true, - cwd, - fetch: options.fetch || fetch, - importFn: defaultImportFn, - logger: new DefaultLogger(`SOAP Subgraph ${subgraphName}`), - }) - .then(wsdl => soapLoader.loadWSDL(wsdl)) - .then(object => { - soapLoader.loadedLocations.set(options.source, object); - return soapLoader.buildSchema(); + schema$: mapMaybePromise( + readFileOrUrl(options.source, { + allowUnknownExtensions: true, + cwd, + fetch, + importFn: defaultImportFn, + logger, }), + wsdl => + mapMaybePromise(soapLoader.loadWSDL(wsdl), object => { + soapLoader.loadedLocations.set(options.source, object); + return soapLoader.buildSchema(); + }), + ), }; }; } diff --git a/packages/loaders/soap/src/utils.ts b/packages/loaders/soap/src/utils.ts index 9dc145c6a26bb..b439e617c8cd3 100644 --- a/packages/loaders/soap/src/utils.ts +++ b/packages/loaders/soap/src/utils.ts @@ -5,6 +5,12 @@ export interface SoapAnnotations { elementName: string; bindingNamespace: string; endpoint: string; + bodyAlias?: string; + soapHeaders?: { + alias?: string; + namespace: string; + headers: unknown; + }; } export const PARSE_XML_OPTIONS: Partial = { diff --git a/packages/loaders/soap/test/__snapshots__/examples.test.ts.snap b/packages/loaders/soap/test/__snapshots__/examples.test.ts.snap index 9ccbea04a7544..b63ed60716c56 100644 --- a/packages/loaders/soap/test/__snapshots__/examples.test.ts.snap +++ b/packages/loaders/soap/test/__snapshots__/examples.test.ts.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Examples should generate schema for axis: axis 1`] = ` -"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String) on FIELD_DEFINITION +"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String, bodyAlias: String, soapHeaders: SOAPHeaders) on FIELD_DEFINITION type Query { placeholder: Void @@ -116,11 +116,19 @@ input impl_createTestRequestType_Input { input impl_weekDays_Input { option: [impl_weekday] -}" +} + +input SOAPHeaders { + namespace: String + alias: String + headers: ObjMap +} + +scalar ObjMap" `; exports[`Examples should generate schema for example1: example1 1`] = ` -"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String) on FIELD_DEFINITION +"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String, bodyAlias: String, soapHeaders: SOAPHeaders) on FIELD_DEFINITION type Query { TodoService_TodoService_BasicHttpBinding_ITodoService_GetTodos(GetTodos: JSON = ""): TodoService_GetTodosResponse @@ -244,11 +252,19 @@ input TodoService_FaultAddTodo_Input { type TodoService_FailGetTodoResponse { FailGetTodoResult: String -}" +} + +input SOAPHeaders { + namespace: String + alias: String + headers: ObjMap +} + +scalar ObjMap" `; exports[`Examples should generate schema for example2: example2 1`] = ` -"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String) on FIELD_DEFINITION +"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String, bodyAlias: String, soapHeaders: SOAPHeaders) on FIELD_DEFINITION type Query { AdminServiceType_AdminServiceType_BasicHttpBinding_IAdminService_GetServiceHealth(GetServiceHealth: JSON = ""): tns_GetServiceHealthResponse @@ -470,11 +486,19 @@ input AdminServiceType_DeleteFileClass_Input { input AdminServiceType_DeleteEntityClass_Input { entityclassIntID: BigInt -}" +} + +input SOAPHeaders { + namespace: String + alias: String + headers: ObjMap +} + +scalar ObjMap" `; exports[`Examples should generate schema for greeting: greeting 1`] = ` -"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String) on FIELD_DEFINITION +"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String, bodyAlias: String, soapHeaders: SOAPHeaders) on FIELD_DEFINITION type Query { placeholder: Void @@ -507,11 +531,19 @@ type NumberConversion_NumberToDollarsResponse { input NumberConversion_NumberToDollars_Input { dNum: Float -}" +} + +input SOAPHeaders { + namespace: String + alias: String + headers: ObjMap +} + +scalar ObjMap" `; exports[`Examples should generate schema for tempconvert: tempconvert 1`] = ` -"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String) on FIELD_DEFINITION +"directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String, bodyAlias: String, soapHeaders: SOAPHeaders) on FIELD_DEFINITION type Query { placeholder: Void @@ -543,5 +575,13 @@ type tns_CelsiusToFahrenheitResponse { input tns_CelsiusToFahrenheit_Input { Celsius: String -}" +} + +input SOAPHeaders { + namespace: String + alias: String + headers: ObjMap +} + +scalar ObjMap" `; diff --git a/packages/loaders/soap/test/__snapshots__/soap.test.ts.snap b/packages/loaders/soap/test/__snapshots__/soap.test.ts.snap index 9e741fdbaa932..2bb3e055fe5ae 100644 --- a/packages/loaders/soap/test/__snapshots__/soap.test.ts.snap +++ b/packages/loaders/soap/test/__snapshots__/soap.test.ts.snap @@ -6,7 +6,7 @@ exports[`SOAP Loader should create executor for a service with mutations and que mutation: Mutation } -directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String) on FIELD_DEFINITION +directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String, bodyAlias: String, soapHeaders: SOAPHeaders) on FIELD_DEFINITION type Query { placeholder: Void @@ -39,5 +39,13 @@ type NumberConversion_NumberToDollarsResponse { input NumberConversion_NumberToDollars_Input { dNum: Float -}" +} + +input SOAPHeaders { + namespace: String + alias: String + headers: ObjMap +} + +scalar ObjMap" `; diff --git a/packages/transports/rest/src/directives/getTypeResolverForAbstractType.ts b/packages/transports/rest/src/directives/getTypeResolverForAbstractType.ts index c34714ad07446..86d8c5ea8f5db 100644 --- a/packages/transports/rest/src/directives/getTypeResolverForAbstractType.ts +++ b/packages/transports/rest/src/directives/getTypeResolverForAbstractType.ts @@ -107,6 +107,7 @@ export function getTypeResolverForAbstractType({ if (data.$response) { const error = createGraphQLError(`Upstream HTTP Error: ${data.$statusCode}`, { extensions: { + code: 'DOWNSTREAM_SERVICE_ERROR', request: { url: data.$url, method: data.$method, diff --git a/packages/transports/rest/src/directives/httpOperation.ts b/packages/transports/rest/src/directives/httpOperation.ts index c22a04dca461d..7285573da0acd 100644 --- a/packages/transports/rest/src/directives/httpOperation.ts +++ b/packages/transports/rest/src/directives/httpOperation.ts @@ -346,6 +346,7 @@ export function addHTTPRootFieldResolver( `Upstream HTTP Error: ${response.status}, Could not invoke operation ${httpMethod} ${path}`, { extensions: { + code: 'DOWNSTREAM_SERVICE_ERROR', subgraph: sourceName, request: { url: fullPath, @@ -371,6 +372,7 @@ export function addHTTPRootFieldResolver( `Upstream HTTP Error: ${response.status}, Could not invoke operation ${httpMethod} ${path}`, { extensions: { + code: 'DOWNSTREAM_SERVICE_ERROR', subgraph: sourceName, request: { url: fullPath, diff --git a/packages/transports/soap/src/executor.ts b/packages/transports/soap/src/executor.ts index c8b3a60637f47..014871c6e0cae 100644 --- a/packages/transports/soap/src/executor.ts +++ b/packages/transports/soap/src/executor.ts @@ -7,8 +7,11 @@ import type { } from 'graphql'; import { isListType, isNonNullType } from 'graphql'; import { process } from '@graphql-mesh/cross-helpers'; -import type { ResolverDataBasedFactory } from '@graphql-mesh/string-interpolation'; -import { getInterpolatedHeadersFactory } from '@graphql-mesh/string-interpolation'; +import type { ResolverData, ResolverDataBasedFactory } from '@graphql-mesh/string-interpolation'; +import { + getInterpolatedHeadersFactory, + stringInterpolator, +} from '@graphql-mesh/string-interpolation'; import type { MeshFetch } from '@graphql-mesh/types'; import { normalizedExecutor } from '@graphql-tools/executor'; import { @@ -86,6 +89,12 @@ interface SoapAnnotations { endpoint: string; bindingNamespace: string; elementName: string; + bodyAlias?: string; + soapHeaders?: { + alias?: string; + namespace: string; + headers: Record; + }; } interface CreateRootValueMethodOpts { @@ -96,6 +105,33 @@ interface CreateRootValueMethodOpts { operationHeadersFactory: ResolverDataBasedFactory>; } +function prefixWithAlias({ + alias, + obj, + resolverData, +}: { + alias: string; + obj: unknown; + resolverData?: ResolverData; +}): Record { + if (typeof obj === 'object' && obj !== null) { + const prefixedHeaderObj: Record = {}; + 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; +} + function createRootValueMethod({ soapAnnotations, fetchFn, @@ -104,18 +140,48 @@ function createRootValueMethod({ operationHeadersFactory, }: CreateRootValueMethodOpts): RootValueMethod { return async function rootValueMethod(args: any, context: any, info: GraphQLResolveInfo) { + const envelopeAttributes: Record = { + 'xmlns:soap': 'http://www.w3.org/2003/05/soap-envelope', + }; + const envelope: Record = { + attributes: envelopeAttributes, + }; + const resolverData: ResolverData = { + args, + context, + info, + env: process.env, + }; + + 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; + } + } + + const body = prefixWithAlias({ + alias: bodyPrefix, + obj: normalizeArgsForConverter(args), + resolverData, + }); + envelope['soap:Body'] = body; + const requestJson = { - 'soap:Envelope': { - attributes: { - 'xmlns:soap': 'http://www.w3.org/2003/05/soap-envelope', - }, - 'soap:Body': { - attributes: { - xmlns: soapAnnotations.bindingNamespace, - }, - ...normalizeArgsForConverter(args), - }, - }, + 'soap:Envelope': envelope, }; const requestXML = jsonToXMLConverter.build(requestJson); const currentFetchFn = context?.fetch || fetchFn; @@ -141,6 +207,7 @@ function createRootValueMethod({ if (!response.ok) { return createGraphQLError(`Upstream HTTP Error: ${response.status}`, { extensions: { + code: 'DOWNSTREAM_SERVICE_ERROR', subgraph: soapAnnotations.subgraph, request: { url: soapAnnotations.endpoint, diff --git a/packages/transports/soap/tests/__snapshots__/headers.spec.ts.snap b/packages/transports/soap/tests/__snapshots__/headers.spec.ts.snap new file mode 100644 index 0000000000000..21412ae61fd2b --- /dev/null +++ b/packages/transports/soap/tests/__snapshots__/headers.spec.ts.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SOAP Headers should pass headers to the executor: soap-with-headers 1`] = ` +"schema @transport(kind: "soap", subgraph: "Test") { + query: Query +} + +directive @soap(elementName: String, bindingNamespace: String, endpoint: String, subgraph: String, bodyAlias: String, soapHeaders: SOAPHeaders) on FIELD_DEFINITION + +type Query { + tns_GlobalWeather_GlobalWeatherSoap_GetWeather(GetWeather: tns_GetWeather_Input): tns_GetWeatherResponse @soap(elementName: "GetWeatherResponse", bindingNamespace: "http://www.webserviceX.NET", endpoint: "http://www.webservicex.com/globalweather.asmx", subgraph: "Test", bodyAlias: "guild", soapHeaders: {namespace: "https://the-guild.dev", alias: "guild", headers: "{\\"MyHeader\\":{\\"UserName\\":\\"{context.USER_NAME}\\",\\"Password\\":\\"{context.PASSWORD}\\"}}"}) + tns_GlobalWeather_GlobalWeatherSoap_GetCitiesByCountry(GetCitiesByCountry: tns_GetCitiesByCountry_Input): tns_GetCitiesByCountryResponse @soap(elementName: "GetCitiesByCountryResponse", bindingNamespace: "http://www.webserviceX.NET", endpoint: "http://www.webservicex.com/globalweather.asmx", subgraph: "Test", bodyAlias: "guild", soapHeaders: {namespace: "https://the-guild.dev", alias: "guild", headers: "{\\"MyHeader\\":{\\"UserName\\":\\"{context.USER_NAME}\\",\\"Password\\":\\"{context.PASSWORD}\\"}}"}) + tns_GlobalWeather_GlobalWeatherSoap12_GetWeather(GetWeather: tns_GetWeather_Input): tns_GetWeatherResponse @soap(elementName: "GetWeatherResponse", bindingNamespace: "http://www.webserviceX.NET", endpoint: "http://www.webservicex.com/globalweather.asmx", subgraph: "Test", bodyAlias: "guild", soapHeaders: {namespace: "https://the-guild.dev", alias: "guild", headers: "{\\"MyHeader\\":{\\"UserName\\":\\"{context.USER_NAME}\\",\\"Password\\":\\"{context.PASSWORD}\\"}}"}) + tns_GlobalWeather_GlobalWeatherSoap12_GetCitiesByCountry(GetCitiesByCountry: tns_GetCitiesByCountry_Input): tns_GetCitiesByCountryResponse @soap(elementName: "GetCitiesByCountryResponse", bindingNamespace: "http://www.webserviceX.NET", endpoint: "http://www.webservicex.com/globalweather.asmx", subgraph: "Test", bodyAlias: "guild", soapHeaders: {namespace: "https://the-guild.dev", alias: "guild", headers: "{\\"MyHeader\\":{\\"UserName\\":\\"{context.USER_NAME}\\",\\"Password\\":\\"{context.PASSWORD}\\"}}"}) + tns_GlobalWeather_GlobalWeatherHttpGet_GetWeather(CityName: String = "", CountryName: String = ""): String @soap(elementName: "string", bindingNamespace: "http://www.webserviceX.NET", endpoint: "http://www.webservicex.com/globalweather.asmx", subgraph: "Test", bodyAlias: "guild", soapHeaders: {namespace: "https://the-guild.dev", alias: "guild", headers: "{\\"MyHeader\\":{\\"UserName\\":\\"{context.USER_NAME}\\",\\"Password\\":\\"{context.PASSWORD}\\"}}"}) + tns_GlobalWeather_GlobalWeatherHttpGet_GetCitiesByCountry(CountryName: String = ""): String @soap(elementName: "string", bindingNamespace: "http://www.webserviceX.NET", endpoint: "http://www.webservicex.com/globalweather.asmx", subgraph: "Test", bodyAlias: "guild", soapHeaders: {namespace: "https://the-guild.dev", alias: "guild", headers: "{\\"MyHeader\\":{\\"UserName\\":\\"{context.USER_NAME}\\",\\"Password\\":\\"{context.PASSWORD}\\"}}"}) + tns_GlobalWeather_GlobalWeatherHttpPost_GetWeather(CityName: String = "", CountryName: String = ""): String @soap(elementName: "string", bindingNamespace: "http://www.webserviceX.NET", endpoint: "http://www.webservicex.com/globalweather.asmx", subgraph: "Test", bodyAlias: "guild", soapHeaders: {namespace: "https://the-guild.dev", alias: "guild", headers: "{\\"MyHeader\\":{\\"UserName\\":\\"{context.USER_NAME}\\",\\"Password\\":\\"{context.PASSWORD}\\"}}"}) + tns_GlobalWeather_GlobalWeatherHttpPost_GetCitiesByCountry(CountryName: String = ""): String @soap(elementName: "string", bindingNamespace: "http://www.webserviceX.NET", endpoint: "http://www.webservicex.com/globalweather.asmx", subgraph: "Test", bodyAlias: "guild", soapHeaders: {namespace: "https://the-guild.dev", alias: "guild", headers: "{\\"MyHeader\\":{\\"UserName\\":\\"{context.USER_NAME}\\",\\"Password\\":\\"{context.PASSWORD}\\"}}"}) +} + +type tns_GetWeatherResponse { + GetWeatherResult: String +} + +input tns_GetWeather_Input { + CityName: String + CountryName: String +} + +type tns_GetCitiesByCountryResponse { + GetCitiesByCountryResult: String +} + +input tns_GetCitiesByCountry_Input { + CountryName: String +} + +input SOAPHeaders { + namespace: String + alias: String + headers: ObjMap +} + +scalar ObjMap" +`; diff --git a/packages/transports/soap/tests/fixtures/globalweather.wsdl b/packages/transports/soap/tests/fixtures/globalweather.wsdl new file mode 100644 index 0000000000000..7065646f0c606 --- /dev/null +++ b/packages/transports/soap/tests/fixtures/globalweather.wsdl @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Get weather report for all major cities around the world. + + + + + + Get all major + cities by country name(full / part). + + + + + + + + Get weather report for all major cities around the world. + + + + + + Get all major + cities by country name(full / part). + + + + + + + + Get weather report for all major cities around the world. + + + + + + Get all major + cities by country name(full / part). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/transports/soap/tests/headers.spec.ts b/packages/transports/soap/tests/headers.spec.ts new file mode 100644 index 0000000000000..a2e8dd400222c --- /dev/null +++ b/packages/transports/soap/tests/headers.spec.ts @@ -0,0 +1,78 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { parse } from 'graphql'; +import type { MeshFetch } from '@graphql-mesh/types'; +import { printSchemaWithDirectives } from '@graphql-tools/utils'; +import { createExecutorFromSchemaAST, SOAPLoader } from '@omnigraph/soap'; +import { fetch, Response } from '@whatwg-node/fetch'; +import { dummyLogger as logger } from '../../../testing/dummyLogger'; + +describe('SOAP Headers', () => { + it('should pass headers to the executor', async () => { + const soapLoader = new SOAPLoader({ + subgraphName: 'Test', + fetch, + logger, + bodyAlias: 'guild', + soapHeaders: { + alias: 'guild', + namespace: 'https://the-guild.dev', + headers: { + MyHeader: { + UserName: '{context.USER_NAME}', + Password: '{context.PASSWORD}', + }, + }, + }, + }); + await soapLoader.loadWSDL( + readFileSync(join(__dirname, './fixtures/globalweather.wsdl'), 'utf-8'), + ); + const schema = soapLoader.buildSchema(); + expect(printSchemaWithDirectives(schema)).toMatchSnapshot('soap-with-headers'); + const fetchSpy = jest.fn((_url: string, _init: RequestInit) => Response.error()); + const executor = createExecutorFromSchemaAST(schema, fetchSpy); + await executor({ + document: parse(/* GraphQL */ ` + { + tns_GlobalWeather_GlobalWeatherSoap_GetWeather( + GetWeather: { CityName: "Rome", CountryName: "Italy" } + ) { + GetWeatherResult + } + } + `), + context: { + USER_NAME: 'user', + PASSWORD: 'password', + }, + }); + expect(fetchSpy.mock.calls[0][1].body).toBe( + ` +${` + + + + user + + + password + + + + + + + Rome + + + Italy + + + ` + .trim() + .replace(/\n\s+/g, '')} + `.trim(), + ); + }); +}); diff --git a/website/src/generated-markdown/SoapHandler.generated.md b/website/src/generated-markdown/SoapHandler.generated.md index f08c2c7c03c64..3faa58b01ec89 100644 --- a/website/src/generated-markdown/SoapHandler.generated.md +++ b/website/src/generated-markdown/SoapHandler.generated.md @@ -2,4 +2,15 @@ * `source` (type: `String`, required) - A url to your WSDL or generated SDL with annotations * `schemaHeaders` (type: `Any`) - JSON object representing the Headers to add to the runtime of the API calls only for schema introspection You can also provide `.js` or `.ts` file path that exports schemaHeaders as an object -* `operationHeaders` (type: `JSON`) - JSON object representing the Headers to add to the runtime of the API calls only for operation during runtime \ No newline at end of file +* `operationHeaders` (type: `JSON`) - JSON object representing the Headers to add to the runtime of the API calls only for operation during runtime +* `bodyAlias` (type: `String`) - The name of the alias to be used in the envelope for body components + +default: `body` +* `soapHeaders` (type: `Object`) - SOAP Headers to be added to the request: + * `alias` (type: `String`) - The name of the alias to be used in the envelope + +default: `header` + * `namespace` (type: `String`, required) - The namespace of the SOAP Header +For example: `http://www.example.com/namespace` + * `headers` (type: `JSON`, required) - The content of the SOAP Header +For example: { "key": "value" } then the content will be `value` \ No newline at end of file diff --git a/website/src/pages/docs/handlers/soap.mdx b/website/src/pages/docs/handlers/soap.mdx index 6b0c71709e0bb..f3c1664ed1fa4 100644 --- a/website/src/pages/docs/handlers/soap.mdx +++ b/website/src/pages/docs/handlers/soap.mdx @@ -27,6 +27,61 @@ sources: source: http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso?WSDL ``` +## Headers + +If you want to add SOAP headers to the request body like below; + +```xml + + + + user + password + + +``` + +You can add the headers to the configuration like below; + +```yaml +sources: + - name: CountryInfo + handler: + soap: + source: ... + soapHeaders: + namespace: http://foo.com + headers: + MyHeader: + UserName: user + Password: password +``` + +## Body Alias + +You can now choose the name of the alias you want to use for SOAP body; + +```yaml filename=".meshrc.yaml" {4} +sources: + - name: CountryInfo + handler: + soap: + source: ... + bodyAlias: my-body +``` + +Then it will generate a body like below by using the alias; + +```xml + + + + baz + + + +``` + ## CodeSandBox Example You can check out our example that uses SOAP Handler. diff --git a/website/src/pages/v1/source-handlers/soap.mdx b/website/src/pages/v1/source-handlers/soap.mdx index 9601e4c097cbf..1fc3350ea8a28 100644 --- a/website/src/pages/v1/source-handlers/soap.mdx +++ b/website/src/pages/v1/source-handlers/soap.mdx @@ -37,7 +37,85 @@ export const composeConfig = defineConfig({ }) ``` +## Headers + +If you want to add SOAP headers to the request body like below; + +```xml + + + + user + password + + +``` + +You can add the headers to the configuration like below; + +```ts filename="mesh.config.ts" {2,7-9} +import { defineConfig } from '@graphql-mesh/compose-cli' +import { loadSOAPSubgraph } from '@omnigraph/soap' + +export const composeConfig = defineConfig({ + subgraphs: [ + { + sourceHandler: loadSOAPSubgraph('CountryInfo', { + source: + 'http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso?WSDL', + soapHeaders: { + // The name of the alias to be used in the envelope for header components + alias: 'header', + namespace: 'http://foo.com', + headers: { + MyHeader: { + UserName: 'user', + Password: 'password' + // You can also use environment variables, so it will get the value on runtime + Password: '{env.SOAP_PASSWORD}' + } + } + } + }) + } + ] +}) +``` + [You can find a working example here]( https://github.com/ardatan/graphql-mesh/tree/main/examples/v1-next/soap-demo ) + +## Custom Body Alias + +You can now choose the name of the alias you want to use for SOAP body; + +```ts filename="mesh.config.ts" {4} +import { defineConfig } from '@graphql-mesh/compose-cli' +import { loadSOAPSubgraph } from '@omnigraph/soap' + +export const composeConfig = defineConfig({ + subgraphs: [ + { + sourceHandler: loadSOAPSubgraph('CountryInfo', { + source: + 'http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso?WSDL', + bodyAlias: 'my-body' + }) + } + ] +}) +``` + +Then it will generate a body like below by using the alias; + +```xml + + + + baz + + + +``` diff --git a/yarn.lock b/yarn.lock index 2295d2d24a67e..395f3efd71985 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7218,7 +7218,7 @@ __metadata: languageName: unknown linkType: soft -"@graphql-mesh/transport-common@npm:^0.7.13, @graphql-mesh/transport-common@npm:^0.7.26": +"@graphql-mesh/transport-common@npm:^0.7.13, @graphql-mesh/transport-common@npm:^0.7.25, @graphql-mesh/transport-common@npm:^0.7.26": version: 0.7.26 resolution: "@graphql-mesh/transport-common@npm:0.7.26" dependencies: @@ -10387,6 +10387,7 @@ __metadata: dependencies: "@graphql-mesh/cross-helpers": "npm:^0.4.9" "@graphql-mesh/string-interpolation": "npm:^0.5.7" + "@graphql-mesh/transport-common": "npm:^0.7.25" "@graphql-mesh/transport-soap": "npm:^0.8.10" "@graphql-mesh/types": "npm:^0.103.10" "@graphql-mesh/utils": "npm:^0.103.10"