add matrix42 connector (#81490)
gitea/passerelle/pipeline/head This commit looks good Details

This commit is contained in:
Thomas NOËL 2023-09-22 11:53:23 +02:00 committed by Thomas NOËL
parent 62c0b91ac4
commit 6f7acc1489
6 changed files with 537 additions and 0 deletions

View File

View File

@ -0,0 +1,76 @@
# Generated by Django 3.2.18 on 2023-09-22 13:03
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('base', '0030_resourcelog_base_resour_appname_298cbc_idx'),
]
operations = [
migrations.CreateModel(
name='Matrix42',
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'),
),
(
'base_url',
models.URLField(
help_text='Example: https://xxx.m42cloud.com/m42Services/api/',
verbose_name='Webservice Base URL',
),
),
('token', models.CharField(max_length=512, verbose_name='Authorization Token')),
(
'users',
models.ManyToManyField(
blank=True,
related_name='_matrix42_matrix42_users_+',
related_query_name='+',
to='base.ApiUser',
),
),
],
options={
'verbose_name': 'Matrix42 Public API',
},
),
]

View File

@ -0,0 +1,200 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2023 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/>.
from urllib.parse import urljoin
from django.db import models
from django.utils.translation import gettext_lazy as _
from passerelle.base.models import BaseResource, HTTPResource
from passerelle.utils.api import endpoint
from passerelle.utils.jsonresponse import APIError
from passerelle.utils.templates import render_to_string
DICT_SCHEMA = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'type': 'object',
'additionalProperties': True,
'unflatten': True,
}
class Matrix42(BaseResource, HTTPResource):
category = _('Business Process Connectors')
class Meta:
verbose_name = _('Matrix42 Public API')
base_url = models.URLField(
_('Webservice Base URL'), help_text=_('Example: https://xxx.m42cloud.com/m42Services/api/')
)
token = models.CharField(max_length=512, verbose_name=_('Authorization Token'))
def get_authorization_headers(self):
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + self.token,
}
token = self.request('ApiToken/GenerateAccessTokenFromApiToken', headers=headers, method='POST')
if 'RawToken' not in token:
raise APIError('Matrix42 not returned a RawToken: %s' % token)
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token['RawToken'],
}
def request(self, uri, params=None, json=None, headers=None, method=None, dict_response=True):
if headers is None:
headers = self.get_authorization_headers()
if method is None:
method = 'GET' if json is None else 'POST'
url = urljoin(self.base_url, uri)
response = self.requests.request(method, url, params=params, json=json, headers=headers)
status_code = response.status_code
try:
response = response.json()
except ValueError:
raise APIError(
'Matrix42 returned %s response with invalid JSON content: %r'
% (status_code, response.content)
)
if dict_response:
if not isinstance(response, dict):
raise APIError(
'Matrix42 returned %s response, not returned a dict: %r' % (status_code, response),
data=response,
)
if isinstance(response, dict) and 'ExceptionName' in response:
message = response.get('Message') or '(no message)'
raise APIError(
'Matrix42 returned %s response, ExceptionName "%s": %s'
% (status_code, response['ExceptionName'], message),
data=response,
)
if status_code // 100 != 2:
raise APIError('Matrix42 returned status code %s' % status_code, data=response)
return response
@endpoint(
name='fragment',
description=_('Fragment Query'),
display_category=_('Fragments'),
parameters={
'ddname': {
'description': _('Technical name of the Data Definition'),
'example_value': 'SPSUserClassBase',
},
'columns': {
'description': _('Columns in the result set, separated by comma'),
'example_value': 'ID,[Expression-ObjectID] as EOID,LastName,FirstName,MailAddress',
},
'template': {
'description': _(
'Django template for text attribute - if none, use DisplayString|DisplayName|Name'
),
'example_value': '{{ FirstName }} {{ LastName }} ({{ MailAddress }})',
},
'id_template': {
'description': _('Django template for id attribute - if none, use ID'),
'example_value': '{{ ID }}',
},
'search_column': {
'description': _('Column for "q" search'),
},
'q': {'description': _('Search text in search column')},
'id': {'description': _('Get the whole fragment with this ID')},
},
)
def fragment(
self,
request,
ddname,
columns=None,
template=None,
id_template=None,
search_column=None,
q=None,
id=None,
):
def add_id_and_text(result):
if id_template:
result['id'] = render_to_string(id_template, result)
else:
result['id'] = result.get('ID')
if template:
result['text'] = render_to_string(template, result)
else:
result['text'] = (
result.get('DisplayString') or result.get('DisplayName') or result.get('Name') or ''
)
if id:
uri = 'data/fragments/%s/%s' % (ddname, id)
result = self.request(uri)
add_id_and_text(result)
return {'data': [result]}
if q is not None and not search_column:
raise APIError('q needs a search_column parameter', http_status=400)
uri = urljoin(self.base_url, 'data/fragments/%s/schema-info' % ddname)
params = {}
if columns:
params['columns'] = columns
if q is not None:
params['where'] = "%s LIKE '%%%s%%'" % (search_column, q.replace("'", "''"))
results = self.request(uri, params=params).get('Result') or []
for result in results:
add_id_and_text(result)
return {'data': results}
@endpoint(
name='get-object',
description=_('Get an object'),
display_category=_('Objects'),
methods=['get'],
pattern=r'^(?P<ciname>.+)/(?P<object_id>.+)$',
example_pattern='SPSActivityTypeTicket/01b02f7d-adb6-49e6-aae3-66251ecbf98e',
)
def get_object(
self,
request,
ciname,
object_id,
):
uri = urljoin(self.base_url, 'data/objects/%s/%s' % (ciname, object_id))
return {'data': self.request(uri)}
@endpoint(
name='create-object',
display_category=_('Objects'),
methods=['post'],
pattern=r'^(?P<ciname>.+)$',
example_pattern='SPSActivityTypeTicket',
post={
'description': _('Create an new object'),
'request_body': {'schema': {'application/json': DICT_SCHEMA}},
},
)
def create_object(
self,
request,
ciname,
post_data,
):
uri = urljoin(self.base_url, 'data/objects/%s' % ciname)
return {'data': self.request(uri, json=post_data, dict_response=False)}

View File

@ -163,6 +163,7 @@ INSTALLED_APPS = (
'passerelle.apps.jsondatastore',
'passerelle.apps.ldap',
'passerelle.apps.litteralis',
'passerelle.apps.matrix42',
'passerelle.apps.mdel',
'passerelle.apps.mdel_ddpacs',
'passerelle.apps.mobyt',

260
tests/test_matrix42.py Normal file
View File

@ -0,0 +1,260 @@
from unittest import mock
import pytest
from django.contrib.contenttypes.models import ContentType
from passerelle.apps.matrix42.models import Matrix42
from passerelle.base.models import AccessRight, ApiUser
from tests.utils import FakedResponse, generic_endpoint_url
pytestmark = pytest.mark.django_db
TOKEN = '{"RawToken": "token2","LifeTime":"2200-09-23T06:39:31.5285469Z"}'
USERS = (
'{"Result":[{"ID":"a9386c3e-cb7a-ed11-a3bb-000d3aaa0172","DisplayString":"User1, Leo",'
'"Expression-TypeCase":"46c86c68-42ae-4089-8398-6e4140fe8658",'
'"Expression-TypeID":"46c86c68-42ae-4089-8398-6e4140fe8658"},'
'{"ID":"12386c3e-cb7a-ed11-a3bb-00bd3aaa0111","DisplayString":"User2, Blah",'
'"Expression-TypeCase":"46c86c68-42ae-4089-8398-6e4140fe8658",'
'"Expression-TypeID":"46c86c68-42ae-4089-8398-6e4140fe8658"}],'
'"Schema":[{"ColumnName":"ID","ColumnType":"GuidType","Localizable":false},'
'{"ColumnName":"DisplayString","ColumnType":"StringType","Localizable":false}]}'
)
USER = '{"ID":"a9386c3e-cb7a-ed11-a3bb-000d3aaa0172","DisplayString":"User1, Leo"}'
OBJECT = '{"ID":"424242","SPSActivityClassBase":{"TicketNumber":"TCK0000153","TimeStamp":"AAAAAAHlWr4="}}'
@pytest.fixture
def matrix42():
return Matrix42.objects.create(slug='test', base_url='https://matrix42.example.net/api/', token='token1')
@mock.patch('passerelle.utils.Request.request')
def test_matrix42_fragment(mocked_request, app, matrix42):
endpoint = generic_endpoint_url('matrix42', 'fragment', slug=matrix42.slug)
assert endpoint == '/matrix42/test/fragment'
params = {'ddname': 'SPSUserClassBase'}
mocked_request.side_effect = [
FakedResponse(content=TOKEN, status_code=200),
FakedResponse(content=USERS, status_code=200),
]
resp = app.get(endpoint, params=params, status=403)
assert mocked_request.call_count == 0
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'django.core.exceptions.PermissionDenied'
# open access
api = ApiUser.objects.create(username='all', keytype='', key='')
obj_type = ContentType.objects.get_for_model(matrix42)
AccessRight.objects.create(
codename='can_access', apiuser=api, resource_type=obj_type, resource_pk=matrix42.pk
)
# get all users
resp = app.get(endpoint, params=params, status=200)
assert mocked_request.call_count == 2
get_token, get_users = mocked_request.call_args_list
assert get_token[0] == (
'POST',
'https://matrix42.example.net/api/ApiToken/GenerateAccessTokenFromApiToken',
)
assert get_token[1]['json'] == get_token[1]['params'] == None
assert get_token[1]['headers']['Authorization'] == 'Bearer token1'
assert get_users[0] == (
'GET',
'https://matrix42.example.net/api/data/fragments/SPSUserClassBase/schema-info',
)
assert get_users[1]['json'] is None
assert get_users[1]['params'] == {}
assert get_users[1]['headers']['Authorization'] == 'Bearer token2'
assert resp.json['err'] == 0
assert len(resp.json['data']) == 2
assert resp.json['data'][0]['id'] == resp.json['data'][0]['ID'] == 'a9386c3e-cb7a-ed11-a3bb-000d3aaa0172'
assert resp.json['data'][0]['text'] == resp.json['data'][0]['DisplayString'] == 'User1, Leo'
# get all users, with parameters
params['id_template'] = 'id:{{ID}}'
params['template'] = 'ds:{{DisplayString}}'
mocked_request.side_effect = [
FakedResponse(content=TOKEN, status_code=200),
FakedResponse(content=USERS, status_code=200),
]
resp = app.get(endpoint, params=params, status=200)
assert resp.json['err'] == 0
assert len(resp.json['data']) == 2
assert resp.json['data'][0]['id'] == 'id:a9386c3e-cb7a-ed11-a3bb-000d3aaa0172'
assert resp.json['data'][0]['text'] == 'ds:User1, Leo'
# search user
params['q'] = 'User'
resp = app.get(endpoint, params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'q needs a search_column parameter'
params['search_column'] = 'DisplayString'
params['columns'] = 'DisplayString'
mocked_request.reset_mock()
mocked_request.side_effect = [
FakedResponse(content=TOKEN, status_code=200),
FakedResponse(content=USERS, status_code=200),
]
resp = app.get(endpoint, params=params, status=200)
_, get_users = mocked_request.call_args_list
assert get_users[0] == (
'GET',
'https://matrix42.example.net/api/data/fragments/SPSUserClassBase/schema-info',
)
assert get_users[1]['params'] == {'columns': 'DisplayString', 'where': "DisplayString LIKE '%User%'"}
assert resp.json['err'] == 0
assert len(resp.json['data']) == 2
assert resp.json['data'][0]['id'] == 'id:a9386c3e-cb7a-ed11-a3bb-000d3aaa0172'
assert resp.json['data'][0]['text'] == 'ds:User1, Leo'
# get one user
del params['q']
params['id'] = 'a9386c3e-cb7a-ed11-a3bb-000d3aaa0172'
mocked_request.reset_mock()
mocked_request.side_effect = [
FakedResponse(content=TOKEN, status_code=200),
FakedResponse(content=USER, status_code=200),
]
resp = app.get(endpoint, params=params, status=200)
_, get_users = mocked_request.call_args_list
assert get_users[0] == (
'GET',
'https://matrix42.example.net/api/data/fragments/SPSUserClassBase/a9386c3e-cb7a-ed11-a3bb-000d3aaa0172',
)
assert resp.json['err'] == 0
assert len(resp.json['data']) == 1
assert resp.json['data'][0]['id'] == 'id:a9386c3e-cb7a-ed11-a3bb-000d3aaa0172'
assert resp.json['data'][0]['text'] == 'ds:User1, Leo'
@mock.patch('passerelle.utils.Request.request')
def test_matrix42_bad_rawtoken(mocked_request, app, matrix42):
endpoint = generic_endpoint_url('matrix42', 'fragment', slug=matrix42.slug)
params = {'ddname': 'SPSUserClassBase'}
# open access
api = ApiUser.objects.create(username='all', keytype='', key='')
obj_type = ContentType.objects.get_for_model(matrix42)
AccessRight.objects.create(
codename='can_access', apiuser=api, resource_type=obj_type, resource_pk=matrix42.pk
)
# no RawToken
mocked_request.side_effect = [
FakedResponse(content='{}', status_code=200),
]
resp = app.get(endpoint, params=params, status=200)
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
assert resp.json['err_desc'] == 'Matrix42 not returned a RawToken: {}'
# bad JSON
mocked_request.side_effect = [
FakedResponse(content='crashme', status_code=200),
]
resp = app.get(endpoint, params=params, status=200)
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
assert 'invalid JSON' in resp.json['err_desc']
# not a dict
mocked_request.side_effect = [
FakedResponse(content='"crashme"', status_code=200),
]
resp = app.get(endpoint, params=params, status=200)
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
assert 'not returned a dict' in resp.json['err_desc']
# Matrix42 error
mocked_request.side_effect = [
FakedResponse(content='{"ExceptionName":"NotFound","Message":"4o4"}', status_code=404),
]
resp = app.get(endpoint, params=params, status=200)
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
assert resp.json['err_desc'] == 'Matrix42 returned 404 response, ExceptionName "NotFound": 4o4'
mocked_request.side_effect = [
FakedResponse(content=TOKEN, status_code=500),
]
resp = app.get(endpoint, params=params, status=200)
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
assert resp.json['err_desc'] == 'Matrix42 returned status code 500'
@mock.patch('passerelle.utils.Request.request')
def test_matrix42_object(mocked_request, app, matrix42):
api = ApiUser.objects.create(username='all', keytype='', key='')
obj_type = ContentType.objects.get_for_model(matrix42)
AccessRight.objects.create(
codename='can_access', apiuser=api, resource_type=obj_type, resource_pk=matrix42.pk
)
# create-object
mocked_request.side_effect = [
FakedResponse(content=TOKEN, status_code=200),
FakedResponse(content='"424242"', status_code=200),
]
endpoint = generic_endpoint_url('matrix42', 'create-object', slug=matrix42.slug)
endpoint += '/SPSActivityTypeTicket'
payload = {
'SPSActivityClassBase/Subject': 'incident subject',
'SPSActivityClassBase/Category': 'category-id',
}
resp = app.post_json(endpoint, params=payload, status=200)
assert mocked_request.call_count == 2
get_token, post_object = mocked_request.call_args_list
assert get_token[0] == (
'POST',
'https://matrix42.example.net/api/ApiToken/GenerateAccessTokenFromApiToken',
)
assert get_token[1]['json'] == get_token[1]['params'] == None
assert get_token[1]['headers']['Authorization'] == 'Bearer token1'
assert post_object[0] == (
'POST',
'https://matrix42.example.net/api/data/objects/SPSActivityTypeTicket',
)
assert post_object[1]['json'] == {
'SPSActivityClassBase': {
'Subject': 'incident subject',
'Category': 'category-id',
}
}
assert post_object[1]['params'] is None
assert post_object[1]['headers']['Authorization'] == 'Bearer token2'
assert resp.json['err'] == 0
assert resp.json['data'] == '424242'
# get-object
mocked_request.reset_mock()
mocked_request.side_effect = [
FakedResponse(content=TOKEN, status_code=200),
FakedResponse(content=OBJECT, status_code=200),
]
endpoint = generic_endpoint_url('matrix42', 'get-object', slug=matrix42.slug)
endpoint += '/SPSActivityTypeTicket/424242' # ciName + id
resp = app.get(endpoint, status=200)
assert mocked_request.call_count == 2
get_token, get_object = mocked_request.call_args_list
assert get_token[0] == (
'POST',
'https://matrix42.example.net/api/ApiToken/GenerateAccessTokenFromApiToken',
)
assert get_token[1]['json'] == get_token[1]['params'] == None
assert get_token[1]['headers']['Authorization'] == 'Bearer token1'
assert get_object[0] == (
'GET',
'https://matrix42.example.net/api/data/objects/SPSActivityTypeTicket/424242',
)
assert get_object[1]['json'] == get_object[1]['params'] == None
assert get_object[1]['headers']['Authorization'] == 'Bearer token2'
assert resp.json['err'] == 0
assert resp.json['data'] == {
'ID': '424242',
'SPSActivityClassBase': {'TicketNumber': 'TCK0000153', 'TimeStamp': 'AAAAAAHlWr4='},
}