add a generic soap connector (#60836)
This commit is contained in:
parent
498813542f
commit
1f748deb73
|
@ -0,0 +1,91 @@
|
||||||
|
# Generated by Django 2.2.24 on 2022-01-19 16:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('base', '0029_auto_20210202_1627'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SOAPConnector',
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'id',
|
||||||
|
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||||
|
),
|
||||||
|
('title', models.CharField(max_length=50, verbose_name='Title')),
|
||||||
|
('slug', models.SlugField(unique=True, verbose_name='Identifier')),
|
||||||
|
('description', models.TextField(verbose_name='Description')),
|
||||||
|
(
|
||||||
|
'basic_auth_username',
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=128, verbose_name='Basic authentication username'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'basic_auth_password',
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=128, verbose_name='Basic authentication password'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'client_certificate',
|
||||||
|
models.FileField(
|
||||||
|
blank=True, null=True, upload_to='', verbose_name='TLS client certificate'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'trusted_certificate_authorities',
|
||||||
|
models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'verify_cert',
|
||||||
|
models.BooleanField(blank=True, default=True, verbose_name='TLS verify certificates'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'http_proxy',
|
||||||
|
models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'wsdl_url',
|
||||||
|
models.URLField(
|
||||||
|
help_text='URL of the WSDL file', max_length=400, verbose_name='WSDL URL'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'zeep_strict',
|
||||||
|
models.BooleanField(default=True, verbose_name='Be strict with returned XML'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'zeep_xsd_ignore_sequence_order',
|
||||||
|
models.BooleanField(default=False, verbose_name='Ignore sequence order'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'zeep_wsse_username',
|
||||||
|
models.CharField(max_length=256, blank=True, default='', verbose_name='WSSE Username'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'zeep_wsse_password',
|
||||||
|
models.CharField(max_length=256, blank=True, default='', verbose_name='WSSE Password'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'users',
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name='_soapconnector_users_+',
|
||||||
|
related_query_name='+',
|
||||||
|
to='base.ApiUser',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'SOAP connector',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,224 @@
|
||||||
|
# passerelle - uniform access to multiple data sources and services
|
||||||
|
# Copyright (C) 2019 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import collections
|
||||||
|
|
||||||
|
import zeep
|
||||||
|
import zeep.exceptions
|
||||||
|
import zeep.helpers
|
||||||
|
import zeep.xsd
|
||||||
|
from django.db import models
|
||||||
|
from django.forms import ValidationError
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from zeep.wsse.username import UsernameToken
|
||||||
|
|
||||||
|
from passerelle.base.models import BaseResource, HTTPResource
|
||||||
|
from passerelle.utils.api import endpoint
|
||||||
|
from passerelle.utils.conversion import exception_to_text
|
||||||
|
from passerelle.utils.json import unflatten
|
||||||
|
from passerelle.utils.jsonresponse import APIError
|
||||||
|
|
||||||
|
|
||||||
|
class SOAPConnector(BaseResource, HTTPResource):
|
||||||
|
wsdl_url = models.URLField(
|
||||||
|
max_length=400, verbose_name=_('WSDL URL'), help_text=_('URL of the WSDL file')
|
||||||
|
)
|
||||||
|
zeep_strict = models.BooleanField(default=False, verbose_name=_('Be strict with returned XML'))
|
||||||
|
zeep_xsd_ignore_sequence_order = models.BooleanField(
|
||||||
|
default=True, verbose_name=_('Ignore sequence order')
|
||||||
|
)
|
||||||
|
zeep_wsse_username = models.CharField(
|
||||||
|
max_length=256, blank=True, default='', verbose_name=_('WSSE Username')
|
||||||
|
)
|
||||||
|
zeep_wsse_password = models.CharField(
|
||||||
|
max_length=256, blank=True, default='', verbose_name=_('WSSE Password')
|
||||||
|
)
|
||||||
|
category = _('Business Process Connectors')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('SOAP connector')
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
try:
|
||||||
|
self.operations_and_schemas
|
||||||
|
except Exception as e:
|
||||||
|
raise ValidationError(e)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_manager_form_class(cls, **kwargs):
|
||||||
|
exclude = kwargs.get('exclude')
|
||||||
|
form_class = super().get_manager_form_class(**kwargs)
|
||||||
|
fields = list(form_class.base_fields.items())
|
||||||
|
if exclude and 'slug' in exclude:
|
||||||
|
form_class.base_fields = collections.OrderedDict(fields[:2] + fields[-5:] + fields[2:-5])
|
||||||
|
else:
|
||||||
|
form_class.base_fields = collections.OrderedDict(fields[:3] + fields[-5:] + fields[3:-5])
|
||||||
|
return form_class
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def client(self):
|
||||||
|
kwargs = {}
|
||||||
|
if self.zeep_wsse_username:
|
||||||
|
kwargs['wsse'] = UsernameToken(self.zeep_wsse_username, self.zeep_wsse_password)
|
||||||
|
return self.soap_client(
|
||||||
|
wsdl_url=self.wsdl_url,
|
||||||
|
settings=zeep.Settings(
|
||||||
|
strict=self.zeep_strict, xsd_ignore_sequence_order=self.zeep_xsd_ignore_sequence_order
|
||||||
|
),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
@endpoint(
|
||||||
|
methods=['post'],
|
||||||
|
perm='can_access',
|
||||||
|
name='method',
|
||||||
|
pattern=r'^(?P<method_name>\w+)/$',
|
||||||
|
example_pattern='method_name/',
|
||||||
|
description_get=_('Call a SOAP method'),
|
||||||
|
description_post=_('Call a SOAP method'),
|
||||||
|
post_json_schema={'type': 'object'},
|
||||||
|
)
|
||||||
|
def method(self, request, method_name, post_data=None, **kwargs):
|
||||||
|
def jsonify(data):
|
||||||
|
if isinstance(data, (dict, collections.OrderedDict)):
|
||||||
|
# ignore _raw_elements, zeep put there nodes not maching the
|
||||||
|
# XSD when strict parsing is disabled.
|
||||||
|
return {
|
||||||
|
jsonify(k): jsonify(v)
|
||||||
|
for k, v in data.items()
|
||||||
|
if (self.zeep_strict or k != '_raw_elements')
|
||||||
|
}
|
||||||
|
elif isinstance(data, (list, tuple, collections.deque)):
|
||||||
|
return [jsonify(item) for item in data]
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
for k in request.GET:
|
||||||
|
if k == 'raise':
|
||||||
|
continue
|
||||||
|
value = request.GET.getlist(k)
|
||||||
|
if len(value) > 1:
|
||||||
|
payload[k] = value
|
||||||
|
else:
|
||||||
|
payload[k] = value[0]
|
||||||
|
payload.update(post_data or {})
|
||||||
|
payload = unflatten(payload)
|
||||||
|
try:
|
||||||
|
soap_response = getattr(self.client.service, method_name)(**payload)
|
||||||
|
except zeep.exceptions.Fault as e:
|
||||||
|
fault_details = {}
|
||||||
|
for attrib in ['actor', 'code', 'message', 'subcode']:
|
||||||
|
fault_details[attrib] = getattr(e, attrib, None)
|
||||||
|
raise APIError('soap:Fault', data=fault_details)
|
||||||
|
except zeep.exceptions.ValidationError as e:
|
||||||
|
e.status_code = 400
|
||||||
|
raise e
|
||||||
|
serialized = zeep.helpers.serialize_object(soap_response)
|
||||||
|
json_response = jsonify(serialized)
|
||||||
|
return {'err': 0, 'data': json_response}
|
||||||
|
|
||||||
|
method.endpoint_info.methods.append('get')
|
||||||
|
|
||||||
|
def get_endpoints_infos(self):
|
||||||
|
endpoints = super().get_endpoints_infos()
|
||||||
|
|
||||||
|
try:
|
||||||
|
operations_and_schemas = self.operations_and_schemas
|
||||||
|
except Exception as e:
|
||||||
|
self.set_availability_status('down', message=exception_to_text(e)[:500])
|
||||||
|
return endpoints
|
||||||
|
|
||||||
|
for name, input_schema, output_schema in operations_and_schemas:
|
||||||
|
kwargs = dict(
|
||||||
|
name='method',
|
||||||
|
pattern=f'{name}/',
|
||||||
|
example_pattern=f'{name}/',
|
||||||
|
description=f'Method {name}',
|
||||||
|
json_schema_response={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': collections.OrderedDict(
|
||||||
|
[
|
||||||
|
('err', {'type': 'integer'}),
|
||||||
|
('data', output_schema),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if input_schema:
|
||||||
|
kwargs['post_json_schema'] = input_schema
|
||||||
|
endpoints.append(endpoint(**kwargs))
|
||||||
|
endpoints[-1].object = self
|
||||||
|
endpoints[-1].func = lambda request: None
|
||||||
|
if input_schema and input_schema.get('properties'):
|
||||||
|
endpoints[-1].http_method = 'post'
|
||||||
|
else:
|
||||||
|
endpoints[-1].http_method = 'get'
|
||||||
|
return endpoints
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operations_and_schemas(self):
|
||||||
|
operations = self.client.service._binding._operations
|
||||||
|
operations_and_schemas = []
|
||||||
|
for name in operations:
|
||||||
|
operation = operations[name]
|
||||||
|
input_type = operation.input.body.type
|
||||||
|
output_type = operation.output.body.type
|
||||||
|
input_schema = self.type2schema(input_type, keep_root=True)
|
||||||
|
output_schema = self.type2schema(output_type, compress=True)
|
||||||
|
operations_and_schemas.append((name, input_schema, output_schema))
|
||||||
|
return operations_and_schemas
|
||||||
|
|
||||||
|
def type2schema(self, xsd_type, keep_root=False, compress=False):
|
||||||
|
# simplify schema: when a type contains a unique element, it will try
|
||||||
|
# to match any dict or list with it on input and will flatten the
|
||||||
|
# schema on output.
|
||||||
|
if (
|
||||||
|
isinstance(xsd_type, zeep.xsd.ComplexType)
|
||||||
|
and len(xsd_type.elements) == 1
|
||||||
|
and not keep_root
|
||||||
|
and compress
|
||||||
|
):
|
||||||
|
if xsd_type.elements[0][1].max_occurs != 1:
|
||||||
|
schema = {
|
||||||
|
'type': 'array',
|
||||||
|
'items': self.type2schema(xsd_type.elements[0][1].type, compress=compress),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
schema = self.type2schema(xsd_type.elements[0][1].type, compress=compress)
|
||||||
|
elif isinstance(xsd_type, zeep.xsd.ComplexType):
|
||||||
|
properties = collections.OrderedDict()
|
||||||
|
schema = {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': properties,
|
||||||
|
}
|
||||||
|
for key, element in xsd_type.elements:
|
||||||
|
if element.min_occurs > 0:
|
||||||
|
schema.setdefault('required', []).append(key)
|
||||||
|
element_schema = self.type2schema(element.type, compress=compress)
|
||||||
|
if element.max_occurs == 'unbounded' or element.max_occurs > 1:
|
||||||
|
element_schema = {'type': 'array', 'items': element_schema}
|
||||||
|
properties[key] = element_schema
|
||||||
|
if not properties:
|
||||||
|
schema = {'type': 'null'}
|
||||||
|
elif isinstance(xsd_type, zeep.xsd.BuiltinType):
|
||||||
|
schema = {'type': 'string'}
|
||||||
|
else:
|
||||||
|
schema = {}
|
||||||
|
if xsd_type.qname:
|
||||||
|
schema['description'] = str(xsd_type.qname).replace('{http://www.w3.org/2001/XMLSchema}', 'xsd:')
|
||||||
|
return schema
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% extends "passerelle/manage/service_view.html" %}
|
||||||
|
{% load i18n passerelle %}
|
|
@ -162,6 +162,7 @@ INSTALLED_APPS = (
|
||||||
'passerelle.apps.plone_restapi',
|
'passerelle.apps.plone_restapi',
|
||||||
'passerelle.apps.sector',
|
'passerelle.apps.sector',
|
||||||
'passerelle.apps.sfr_dmc',
|
'passerelle.apps.sfr_dmc',
|
||||||
|
'passerelle.apps.soap',
|
||||||
'passerelle.apps.solis',
|
'passerelle.apps.solis',
|
||||||
'passerelle.apps.sp_fr',
|
'passerelle.apps.sp_fr',
|
||||||
'passerelle.apps.twilio',
|
'passerelle.apps.twilio',
|
||||||
|
|
|
@ -13,97 +13,342 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import mock
|
import base64
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
|
||||||
from django.utils.encoding import force_bytes
|
|
||||||
from zeep import Settings
|
|
||||||
from zeep.exceptions import TransportError, XMLParseError
|
|
||||||
from zeep.plugins import Plugin
|
|
||||||
|
|
||||||
from passerelle.utils.soap import SOAPClient
|
from passerelle.apps.soap.models import SOAPConnector
|
||||||
|
|
||||||
WSDL = 'tests/data/soap.wsdl'
|
from . import utils
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
class FooPlugin(Plugin):
|
class SOAP11:
|
||||||
pass
|
VERSION = '1.1'
|
||||||
|
ENDPOINT_URL = 'https://www.examples.com/SayHello/'
|
||||||
|
WSDL_CONTENT = '''\
|
||||||
|
<definitions name = "HelloService"
|
||||||
|
targetNamespace = "http://www.examples.com/wsdl/HelloService.wsdl"
|
||||||
|
xmlns = "http://schemas.xmlsoap.org/wsdl/"
|
||||||
|
xmlns:soap = "http://schemas.xmlsoap.org/wsdl/soap/"
|
||||||
|
xmlns:tns = "http://www.examples.com/wsdl/HelloService.wsdl"
|
||||||
|
xmlns:xsd = "http://www.w3.org/2001/XMLSchema">
|
||||||
|
|
||||||
|
<types>
|
||||||
|
<schema targetNamespace="http://www.examples.com/wsdl/HelloService.wsdl" xmlns="http://www.w3.org/2001/XMLSchema">
|
||||||
|
<element name="firstName">
|
||||||
|
<complexType name="listofstring">
|
||||||
|
<sequence>
|
||||||
|
<element name="string" type="string" maxOccurs="unbounded"/>
|
||||||
|
</sequence>
|
||||||
|
</complexType>
|
||||||
|
</element>
|
||||||
|
</schema>
|
||||||
|
</types>
|
||||||
|
|
||||||
|
<message name = "SayHelloRequest">
|
||||||
|
<part name = "firstName" element="tns:firstName"/>
|
||||||
|
<part name = "lastName" type = "xsd:string"/>
|
||||||
|
</message>
|
||||||
|
|
||||||
|
<message name = "SayHelloResponse">
|
||||||
|
<part name = "greeting" type = "xsd:string"/>
|
||||||
|
<part name = "who" type = "xsd:string"/>
|
||||||
|
</message>
|
||||||
|
|
||||||
|
<portType name = "Hello_PortType">
|
||||||
|
<operation name = "sayHello">
|
||||||
|
<input message = "tns:SayHelloRequest"/>
|
||||||
|
<output message = "tns:SayHelloResponse"/>
|
||||||
|
</operation>
|
||||||
|
</portType>
|
||||||
|
|
||||||
|
<binding name = "Hello_Binding" type = "tns:Hello_PortType">
|
||||||
|
<soap:binding style = "rpc"
|
||||||
|
transport = "http://schemas.xmlsoap.org/soap/http"/>
|
||||||
|
<operation name = "sayHello">
|
||||||
|
<soap:operation soapAction = "sayHello"/>
|
||||||
|
<input>
|
||||||
|
<soap:body
|
||||||
|
encodingStyle = "http://schemas.xmlsoap.org/soap/encoding/"
|
||||||
|
namespace = "urn:examples:helloservice"
|
||||||
|
use = "encoded"/>
|
||||||
|
</input>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
<soap:body
|
||||||
|
encodingStyle = "http://schemas.xmlsoap.org/soap/encoding/"
|
||||||
|
namespace = "urn:examples:helloservice"
|
||||||
|
use = "encoded"/>
|
||||||
|
</output>
|
||||||
|
</operation>
|
||||||
|
</binding>
|
||||||
|
|
||||||
|
<service name = "Hello_Service">
|
||||||
|
<documentation>WSDL File for HelloService</documentation>
|
||||||
|
<port binding = "tns:Hello_Binding" name = "Hello_Port">
|
||||||
|
<soap:address
|
||||||
|
location = "http://www.examples.com/SayHello/" />
|
||||||
|
</port>
|
||||||
|
</service>
|
||||||
|
</definitions>'''
|
||||||
|
WSDL_URL = 'https://example.com/service.wsdl'
|
||||||
|
SOAP_RESPONSE = '''\
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||||
|
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap:Body>
|
||||||
|
<SayHelloResponse xmlns="urn:examples:helloservice">
|
||||||
|
<greeting>Hello</greeting>
|
||||||
|
<who>John!</who>
|
||||||
|
</SayHelloResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>'''
|
||||||
|
INPUT_SCHEMA = {
|
||||||
|
'properties': {
|
||||||
|
'firstName': {
|
||||||
|
'description': '{http://www.examples.com/wsdl/HelloService.wsdl}firstName',
|
||||||
|
'properties': {
|
||||||
|
'string': {'items': {'type': 'string', 'description': 'xsd:string'}, 'type': 'array'},
|
||||||
|
},
|
||||||
|
'required': ['string'],
|
||||||
|
'type': 'object',
|
||||||
|
},
|
||||||
|
'lastName': {'type': 'string', 'description': 'xsd:string'},
|
||||||
|
},
|
||||||
|
'required': ['firstName', 'lastName'],
|
||||||
|
'type': 'object',
|
||||||
|
}
|
||||||
|
OUTPUT_SCHEMA = {
|
||||||
|
'properties': {
|
||||||
|
'greeting': {'type': 'string', 'description': 'xsd:string'},
|
||||||
|
'who': {'type': 'string', 'description': 'xsd:string'},
|
||||||
|
},
|
||||||
|
'required': ['greeting', 'who'],
|
||||||
|
'type': 'object',
|
||||||
|
}
|
||||||
|
INPUT_DATA = {
|
||||||
|
'firstName/string/0': 'John',
|
||||||
|
'firstName/string/1': 'Bill',
|
||||||
|
'lastName': 'Doe',
|
||||||
|
}
|
||||||
|
OUTPUT_DATA = {
|
||||||
|
'greeting': 'Hello',
|
||||||
|
'who': 'John!',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class BarPlugin(Plugin):
|
class SOAP12(SOAP11):
|
||||||
pass
|
VERSION = '1.2'
|
||||||
|
ENDPOINT_URL = 'https://www.examples.com/SayHello/'
|
||||||
|
WSDL_CONTENT = f'''\
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
|
||||||
|
xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/"
|
||||||
|
xmlns:tns="urn:examples:helloservice"
|
||||||
|
targetNamespace="urn:examples:helloservice">
|
||||||
|
|
||||||
|
<wsdl:types>
|
||||||
|
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||||
|
xmlns:tns="urn:examples:helloservice"
|
||||||
|
targetNamespace="urn:examples:helloservice">
|
||||||
|
<xsd:element name="sayHello">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="firstName" type="xsd:string" maxOccurs="unbounded"/>
|
||||||
|
<xsd:element name="lastName" type="xsd:string"/>
|
||||||
|
</xsd:sequence>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="sayHelloResponse">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="greeting" type="xsd:string"/>
|
||||||
|
<xsd:element name="who" type="xsd:string" maxOccurs="unbounded"/>
|
||||||
|
</xsd:sequence>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
</wsdl:types>
|
||||||
|
|
||||||
|
<wsdl:message name="sayHello">
|
||||||
|
<wsdl:part name="sayHelloInputPart" element="tns:sayHello"/>
|
||||||
|
</wsdl:message>
|
||||||
|
<wsdl:message name="sayHelloResponse">
|
||||||
|
<wsdl:part name="sayHelloOutputPart" element="tns:sayHelloResponse"/>
|
||||||
|
</wsdl:message>
|
||||||
|
|
||||||
|
<wsdl:portType name="sayHelloPortType">
|
||||||
|
<wsdl:operation name="sayHello">
|
||||||
|
<wsdl:input name="sayHello" message="tns:sayHello"/>
|
||||||
|
<wsdl:output name="sayHelloResponse" message="tns:sayHelloResponse"/>
|
||||||
|
</wsdl:operation>
|
||||||
|
</wsdl:portType>
|
||||||
|
|
||||||
|
<wsdl:binding name="sayHelloBinding"
|
||||||
|
type="tns:sayHelloPortType">
|
||||||
|
<soap12:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
|
||||||
|
<wsdl:operation name="sayHello">
|
||||||
|
<soap12:operation style="document"/>
|
||||||
|
<wsdl:input name="sayHello">
|
||||||
|
<soap12:body use="literal"/>
|
||||||
|
</wsdl:input>
|
||||||
|
<wsdl:output name="sayHelloResponse">
|
||||||
|
<soap12:body use="literal"/>
|
||||||
|
</wsdl:output>
|
||||||
|
</wsdl:operation>
|
||||||
|
</wsdl:binding>
|
||||||
|
|
||||||
|
<wsdl:service name="sayHelloService">
|
||||||
|
<wsdl:port name="sayHelloPort"
|
||||||
|
binding="tns:sayHelloBinding">
|
||||||
|
<soap12:address location="{ENDPOINT_URL}"/>
|
||||||
|
</wsdl:port>
|
||||||
|
</wsdl:service>
|
||||||
|
</wsdl:definitions>'''
|
||||||
|
SOAP_RESPONSE = '''\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||||
|
xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:ser="http://www.herongyang.com/Service/">
|
||||||
|
<soap:Header/>
|
||||||
|
<soap:Body>
|
||||||
|
<sayHelloResponse xmlns="urn:examples:helloservice">
|
||||||
|
<greeting>Hello</greeting>
|
||||||
|
<who>John!</who>
|
||||||
|
</sayHelloResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>'''
|
||||||
|
INPUT_SCHEMA = {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'firstName': {'type': 'array', 'items': {'type': 'string', 'description': 'xsd:string'}},
|
||||||
|
'lastName': {'type': 'string', 'description': 'xsd:string'},
|
||||||
|
},
|
||||||
|
'required': ['firstName', 'lastName'],
|
||||||
|
'description': '{urn:examples:helloservice}sayHello',
|
||||||
|
}
|
||||||
|
OUTPUT_SCHEMA = {
|
||||||
|
'description': '{urn:examples:helloservice}sayHelloResponse',
|
||||||
|
'properties': {
|
||||||
|
'greeting': {'type': 'string', 'description': 'xsd:string'},
|
||||||
|
'who': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {'type': 'string', 'description': 'xsd:string'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'required': ['greeting', 'who'],
|
||||||
|
'type': 'object',
|
||||||
|
}
|
||||||
|
INPUT_DATA = {
|
||||||
|
'firstName/0': 'John',
|
||||||
|
'firstName/1': 'Bill',
|
||||||
|
'lastName': 'Doe',
|
||||||
|
}
|
||||||
|
OUTPUT_DATA = {
|
||||||
|
'greeting': 'Hello',
|
||||||
|
'who': ['John!'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SOAPResource:
|
class BrokenSOAP12(SOAP12):
|
||||||
def __init__(self):
|
WSDL_CONTENT = SOAP12.WSDL_CONTENT[-100:] # truncate the WSDL to break it
|
||||||
self.requests = requests.Session()
|
|
||||||
self.wsdl_url = WSDL
|
|
||||||
|
|
||||||
|
|
||||||
def test_soap_client():
|
@pytest.fixture(params=[SOAP11, SOAP12])
|
||||||
soap_resource = SOAPResource()
|
def soap(request):
|
||||||
plugins = [FooPlugin, BarPlugin]
|
p = request.param()
|
||||||
client = SOAPClient(soap_resource, plugins=plugins)
|
with utils.mock_url(p.WSDL_URL, response=p.WSDL_CONTENT):
|
||||||
assert client.wsdl.location.endswith(WSDL)
|
with utils.mock_url(p.ENDPOINT_URL, response=p.SOAP_RESPONSE) as mock:
|
||||||
assert client.transport.session == soap_resource.requests
|
p.endpoint_mock = mock
|
||||||
assert client.transport.cache
|
yield p
|
||||||
assert client.plugins == plugins
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch('requests.sessions.Session.post')
|
class TestManage:
|
||||||
def test_disable_strict_mode(mocked_post):
|
@pytest.fixture
|
||||||
response = requests.Response()
|
def app(self, app, admin_user):
|
||||||
response.status_code = 200
|
from .test_manager import login
|
||||||
response._content = force_bytes(
|
|
||||||
'''<?xml version='1.0' encoding='utf-8'?>
|
login(app)
|
||||||
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
|
return app
|
||||||
<soap-env:Body>
|
|
||||||
<ns0:TradePrice xmlns:ns0="http://example.com/stockquote.xsd">
|
def test_homepage(self, app, connector, soap):
|
||||||
<price>4.20</price>
|
response = app.get(f'/soap/{connector.slug}/')
|
||||||
</ns0:TradePrice>
|
assert 'Method sayHello' in response
|
||||||
</soap-env:Body>
|
|
||||||
</soap-env:Envelope>'''
|
@pytest.mark.parametrize('soap', [BrokenSOAP12], indirect=True)
|
||||||
|
def test_homepage_broken_wsdl(self, app, connector, soap):
|
||||||
|
response = app.get(f'/soap/{connector.slug}/')
|
||||||
|
response = app.get(f'/soap/{connector.slug}/')
|
||||||
|
assert response.pyquery('.down')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def connector(db, soap):
|
||||||
|
return utils.setup_access_rights(
|
||||||
|
SOAPConnector.objects.create(
|
||||||
|
slug='test', wsdl_url=soap.WSDL_URL, zeep_strict=True, zeep_xsd_ignore_sequence_order=False
|
||||||
|
)
|
||||||
)
|
)
|
||||||
mocked_post.return_value = response
|
|
||||||
|
|
||||||
soap_resource = SOAPResource()
|
|
||||||
client = SOAPClient(soap_resource)
|
|
||||||
match = "Unexpected element %s, expected %s" % (repr('price'), repr('skipMe'))
|
|
||||||
with pytest.raises(XMLParseError, match=match):
|
|
||||||
client.service.GetLastTradePrice(tickerSymbol='banana')
|
|
||||||
|
|
||||||
client = SOAPClient(soap_resource, settings=Settings(strict=False))
|
|
||||||
result = client.service.GetLastTradePrice(tickerSymbol='banana')
|
|
||||||
assert len(result) == 2
|
|
||||||
assert result['skipMe'] is None
|
|
||||||
assert result['price'] == 4.2
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch('requests.sessions.Session.post')
|
def test_schemas(connector, soap):
|
||||||
def test_remove_first_bytes_for_xml(mocked_post):
|
assert list(connector.operations_and_schemas) == [('sayHello', soap.INPUT_SCHEMA, soap.OUTPUT_SCHEMA)]
|
||||||
response = requests.Response()
|
|
||||||
response.status_code = 200
|
|
||||||
response._content = force_bytes(
|
|
||||||
'''blabla \n<?xml version='1.0' encoding='utf-8'?>
|
|
||||||
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
|
|
||||||
<soap-env:Body>
|
|
||||||
<ns0:TradePrice xmlns:ns0="http://example.com/stockquote.xsd">
|
|
||||||
<skipMe>1.2</skipMe>
|
|
||||||
<price>4.20</price>
|
|
||||||
</ns0:TradePrice>
|
|
||||||
</soap-env:Body>
|
|
||||||
</soap-env:Envelope>\n bloublou'''
|
|
||||||
)
|
|
||||||
mocked_post.return_value = response
|
|
||||||
|
|
||||||
soap_resource = SOAPResource()
|
|
||||||
|
|
||||||
client = SOAPClient(soap_resource)
|
def test_say_hello_method_validation_error(connector, app):
|
||||||
with pytest.raises(TransportError):
|
resp = app.get('/soap/test/method/sayHello/', status=500)
|
||||||
client.service.GetLastTradePrice(tickerSymbol='banana')
|
assert dict(resp.json, err_desc=None) == {
|
||||||
|
'err': 1,
|
||||||
|
'err_class': 'zeep.exceptions.ValidationError',
|
||||||
|
'err_desc': None,
|
||||||
|
'data': None,
|
||||||
|
}
|
||||||
|
|
||||||
client = SOAPClient(soap_resource, transport_kwargs={'remove_first_bytes_for_xml': True})
|
|
||||||
result = client.service.GetLastTradePrice(tickerSymbol='banana')
|
def test_say_hello_method_ok_get(connector, app, caplog, soap):
|
||||||
assert len(result) == 2
|
resp = app.get('/soap/test/method/sayHello/?' + urllib.parse.urlencode(soap.INPUT_DATA))
|
||||||
assert result['skipMe'] == 1.2
|
assert '>John<' in soap.endpoint_mock.handlers[0].call['requests'][-1].body.decode()
|
||||||
assert result['price'] == 4.2
|
assert '>Bill<' in soap.endpoint_mock.handlers[0].call['requests'][-1].body.decode()
|
||||||
|
|
||||||
|
assert '>Doe<' in soap.endpoint_mock.handlers[0].call['requests'][-1].body.decode()
|
||||||
|
assert resp.json == {'data': soap.OUTPUT_DATA, 'err': 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_say_hello_method_ok_post_json(connector, app, caplog, soap):
|
||||||
|
resp = app.post_json('/soap/test/method/sayHello/', params=soap.INPUT_DATA)
|
||||||
|
assert '>John<' in soap.endpoint_mock.handlers[0].call['requests'][-1].body.decode()
|
||||||
|
assert '>Bill<' in soap.endpoint_mock.handlers[0].call['requests'][-1].body.decode()
|
||||||
|
assert '>Doe<' in soap.endpoint_mock.handlers[0].call['requests'][-1].body.decode()
|
||||||
|
assert resp.json == {'data': soap.OUTPUT_DATA, 'err': 0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('soap', [SOAP12], indirect=True)
|
||||||
|
class TestAuthencation:
|
||||||
|
def test_basic_auth(self, connector, app, caplog, soap):
|
||||||
|
connector.basic_auth_username = 'username'
|
||||||
|
connector.basic_auth_password = 'password'
|
||||||
|
connector.save()
|
||||||
|
|
||||||
|
app.post_json('/soap/test/method/sayHello/', params=soap.INPUT_DATA)
|
||||||
|
assert (
|
||||||
|
base64.b64decode(
|
||||||
|
soap.endpoint_mock.handlers[0].call['requests'][1].headers['Authorization'].split()[1]
|
||||||
|
)
|
||||||
|
== b'username:password'
|
||||||
|
)
|
||||||
|
assert b'wsse:UsernameToken' not in soap.endpoint_mock.handlers[0].call['requests'][1].body
|
||||||
|
|
||||||
|
def test_username_token(self, connector, app, caplog, soap):
|
||||||
|
connector.zeep_wsse_username = 'username'
|
||||||
|
connector.zeep_wsse_password = 'password'
|
||||||
|
connector.save()
|
||||||
|
|
||||||
|
app.post_json('/soap/test/method/sayHello/', params=soap.INPUT_DATA)
|
||||||
|
assert 'Authorization' not in soap.endpoint_mock.handlers[0].call['requests'][1].headers
|
||||||
|
assert b'wsse:UsernameToken' in soap.endpoint_mock.handlers[0].call['requests'][1].body
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Copyright (C) 2021 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import mock
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
from zeep import Settings
|
||||||
|
from zeep.exceptions import TransportError, XMLParseError
|
||||||
|
from zeep.plugins import Plugin
|
||||||
|
|
||||||
|
from passerelle.utils.soap import SOAPClient
|
||||||
|
|
||||||
|
WSDL = 'tests/data/soap.wsdl'
|
||||||
|
|
||||||
|
|
||||||
|
class FooPlugin(Plugin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BarPlugin(Plugin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SOAPResource:
|
||||||
|
def __init__(self):
|
||||||
|
self.requests = requests.Session()
|
||||||
|
self.wsdl_url = WSDL
|
||||||
|
|
||||||
|
|
||||||
|
def test_soap_client():
|
||||||
|
soap_resource = SOAPResource()
|
||||||
|
plugins = [FooPlugin, BarPlugin]
|
||||||
|
client = SOAPClient(soap_resource, plugins=plugins)
|
||||||
|
assert client.wsdl.location.endswith(WSDL)
|
||||||
|
assert client.transport.session == soap_resource.requests
|
||||||
|
assert client.transport.cache
|
||||||
|
assert client.plugins == plugins
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('requests.sessions.Session.post')
|
||||||
|
def test_disable_strict_mode(mocked_post):
|
||||||
|
response = requests.Response()
|
||||||
|
response.status_code = 200
|
||||||
|
response._content = force_bytes(
|
||||||
|
'''<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap-env:Body>
|
||||||
|
<ns0:TradePrice xmlns:ns0="http://example.com/stockquote.xsd">
|
||||||
|
<price>4.20</price>
|
||||||
|
</ns0:TradePrice>
|
||||||
|
</soap-env:Body>
|
||||||
|
</soap-env:Envelope>'''
|
||||||
|
)
|
||||||
|
mocked_post.return_value = response
|
||||||
|
|
||||||
|
soap_resource = SOAPResource()
|
||||||
|
client = SOAPClient(soap_resource)
|
||||||
|
match = "Unexpected element %s, expected %s" % (repr('price'), repr('skipMe'))
|
||||||
|
with pytest.raises(XMLParseError, match=match):
|
||||||
|
client.service.GetLastTradePrice(tickerSymbol='banana')
|
||||||
|
|
||||||
|
client = SOAPClient(soap_resource, settings=Settings(strict=False))
|
||||||
|
result = client.service.GetLastTradePrice(tickerSymbol='banana')
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result['skipMe'] is None
|
||||||
|
assert result['price'] == 4.2
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('requests.sessions.Session.post')
|
||||||
|
def test_remove_first_bytes_for_xml(mocked_post):
|
||||||
|
response = requests.Response()
|
||||||
|
response.status_code = 200
|
||||||
|
response._content = force_bytes(
|
||||||
|
'''blabla \n<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap-env:Body>
|
||||||
|
<ns0:TradePrice xmlns:ns0="http://example.com/stockquote.xsd">
|
||||||
|
<skipMe>1.2</skipMe>
|
||||||
|
<price>4.20</price>
|
||||||
|
</ns0:TradePrice>
|
||||||
|
</soap-env:Body>
|
||||||
|
</soap-env:Envelope>\n bloublou'''
|
||||||
|
)
|
||||||
|
mocked_post.return_value = response
|
||||||
|
|
||||||
|
soap_resource = SOAPResource()
|
||||||
|
|
||||||
|
client = SOAPClient(soap_resource)
|
||||||
|
with pytest.raises(TransportError):
|
||||||
|
client.service.GetLastTradePrice(tickerSymbol='banana')
|
||||||
|
|
||||||
|
client = SOAPClient(soap_resource, transport_kwargs={'remove_first_bytes_for_xml': True})
|
||||||
|
result = client.service.GetLastTradePrice(tickerSymbol='banana')
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result['skipMe'] == 1.2
|
||||||
|
assert result['price'] == 4.2
|
Loading…
Reference in New Issue