carl: add carl connector (#86683)
gitea/passerelle/pipeline/head This commit looks good Details

This commit is contained in:
Yann Weber 2024-02-06 11:37:18 +01:00
parent 08b82d398d
commit f32d06b474
6 changed files with 2043 additions and 0 deletions

View File

View File

@ -0,0 +1,87 @@
# Generated by Django 3.2.18 on 2024-02-28 09:13
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='Carl',
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'),
),
(
'service_url',
models.URLField(
help_text='Base webservice URL (such as https://carlsource.server.com/gmaoCS02/',
verbose_name='Service URL',
),
),
(
'carl_username',
models.CharField(
blank=True, max_length=128, verbose_name='Carl token authentication username'
),
),
(
'carl_password',
models.CharField(
blank=True, max_length=128, verbose_name='Carl token authentication password'
),
),
(
'users',
models.ManyToManyField(
blank=True,
related_name='_carl_carl_users_+',
related_query_name='+',
to='base.ApiUser',
),
),
],
options={
'verbose_name': 'Carl',
},
),
]

View File

@ -0,0 +1,725 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2024 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 json
import urllib
import requests
from django.core.cache import cache
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.conversion import exception_to_text
from passerelle.utils.jsonresponse import APIError
# Allows to create entity and related entities
OBJ_CREATE_SCHEMA = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'title': _('Carl object creation'),
'unflatten': True,
'description': _('Allows carl object (and related objects) creation'),
'type': 'object',
'required': ['attrs'],
'properties': {
'attrs': {
'description': _('The attributes of the Carl object we want to create'),
'type': 'object',
},
'related': {
'description': _('The list of related object we want to create/link when creating'),
'type': 'object',
'patternProperties': {
'^.+$': {
'type': 'object',
'description': _('Describe the entity we want to create/link'),
'required': ['type', 'attrs'],
'properties': {
'type': {
'type': 'string',
'description': _('A Carl object name'),
'examples': ['wo', 'site', 'mr'],
},
'attrs': {
'type': 'object',
'description': _('The attributes of the Carl object'),
},
},
'examples': {
'NMaddress': {
'type': 'addressees',
'attrs': {
'internalNumber': '42',
'addresse1': 'rue des fleurs',
'...': '...',
},
},
},
},
},
},
'linked': {
'description': _('The list of linked existing entities'),
'type': 'object',
'patternProperties': {
'^.*$': {
'type': 'object',
'required': ['type'],
'examples': {
'site': {'type': 'site', 'id': '0123456789a-bc'},
'site2': {'type': 'site', 'filter': {'code': '10'}},
},
'properties': {
'type': {
'description': _('The entity type name'),
'type': 'string',
},
'id': {
'description': _('The Carl\'s id of the entity'),
'type': 'string',
},
'filter': {
'description': _('Fetch the Carl\'s id from given filters'),
'type': 'object',
'properties': {
'FIELDNAME': {
'description': _('Filter on given field name/value'),
'type': 'string',
},
},
},
},
},
},
},
},
}
class Carl(BaseResource, HTTPResource):
service_url = models.URLField(
blank=False,
verbose_name=_('Service URL'),
help_text=_('Base webservice URL (such as https://carlsource.server.com/gmaoCS02/'),
)
carl_username = models.CharField(
max_length=128, verbose_name=_('Carl token authentication username'), blank=True
)
carl_password = models.CharField(
max_length=128, verbose_name=_('Carl token authentication password'), blank=True
)
category = _('Business Process Connectors')
class Meta:
verbose_name = _('Carl')
@endpoint(
name='entity',
pattern=r'^(?P<carl_type>[^/]+)$',
example_pattern='wo',
methods=['post'],
description=_('Create a new entity'),
post={'request_body': {'schema': {'application/json': OBJ_CREATE_SCHEMA}}},
json_schema_response={
'$schema': 'http://json-schema.org/draft-04/schema#',
'type': 'object',
'required': ['carl_id', 'carl_type'],
'patternProperties': {
'^.+$': {
'description': _('A field from the entity or from a related entity'),
},
},
'properties': {
'carl_id': {'type': 'string'},
'carl_type': {'type': 'string'},
},
},
)
def create_entities(self, request, carl_type, post_data):
req = {'type': carl_type, 'attributes': post_data['attrs'], 'relationships': {}}
# Detect payload format errors in order to failfast
errors = {}
linked = post_data.get('linked', {})
for fieldname, link in linked.items():
if 'related' in post_data and fieldname in post_data['related']:
errors[fieldname] = _(
'Field present in both "linked" and "related": cannot be specified twice'
)
if 'filter' in link and 'id' in link:
errors[fieldname] = _('Cannot give both "id" and "filter" keys')
elif 'filter' not in link and 'id' not in link:
errors[fieldname] = _('A linked field must have an "id" or a "filter"')
if errors:
raise APIError(
_('Error with specified linked fields: %s')
% (', '.join(['%s (%s)' % elt for elt in errors.items()])),
http_status=400,
err_code='creation-error',
)
# Payload seems ok, trying to gather linked fields informations
errors = {}
for fieldname, link in linked.items():
if 'id' in link:
link_id = link['id']
else:
# Looking for id with given filters
try:
entities = self.filter_entities(link['type'], link['filter'])
except APIError as expt:
errors[fieldname] = exception_to_text(expt)
continue
if len(entities['data']) > 1:
errors[fieldname] = _(
'More than one (%d) entities returned with given filters (%s) for field %r'
) % (
len(entities['data']),
_(' AND ').join(['%s=%r' % elt for elt in link['filter'].items()]),
fieldname,
)
continue
if len(entities['data']) == 0:
errors[fieldname] = _('No entities returned with given filters (%s) for field %r') % (
_(' AND ').join(['%s=%r' % elt for elt in link['filter'].items()]),
fieldname,
)
continue
link_id = entities['data'][0]['id']
# Adding link to json request body
req['relationships'][fieldname] = {'data': {'id': link_id, 'type': link['type']}}
if errors:
raise APIError(
_('Error looking for linked fields: %s')
% (', '.join(['%r (%s)' % elt for elt in errors.items()])),
err_code='creation-errror',
)
# Create needed related entities
errors = {}
to_clean = {} # will contain carl_id as key and carl_type as value
related = post_data.get('related', {})
for fieldname, reldata in related.items():
if errors:
continue # do not attempt to create anything: already failed
try:
response = self.create_entity(
{'data': {'type': reldata['type'], 'attributes': reldata['attrs']}}
)
except APIError as expt:
errors[fieldname] = exception_to_text(expt)
continue
req['relationships'][fieldname] = {
'data': {'id': response['data']['id'], 'type': response['data']['type']}
}
# creation OK add to clean list in case further error occurs
to_clean[response['data']['id']] = response['data']['type']
if errors:
# rollback created linked entities
cleanup_err = None
try:
self.cleanup_entities(to_clean)
except APIError as expt:
cleanup_err = expt
err_msg = _('Error with related fields %s') % (', '.join(errors.keys()))
err_data = {
'errors': '. '.join([_('Error with field %s (%s)') % e for e in errors.items()]),
}
if cleanup_err is not None:
err_msg += '. On rollback: %s' % str(cleanup_err)
cleanup_err.data.update(err_data)
err_data = cleanup_err.data
raise APIError(err_msg, log_error=True, err_code='creation-error[rollback]', data=err_data)
# Finally, attempt to create the wanted entity
try:
entity = self.create_entity({'data': req})
except APIError as expt:
# rollback created linked entities
cleanup_err = None
try:
self.cleanup_entities(to_clean)
except APIError as e_cleanup:
cleanup_err = e_cleanup
if cleanup_err is None:
raise expt
data = expt.data
data.update(cleanup_err.data)
raise APIError(
_('Error creating entity: %s. Then error during related entities rollback: %s')
% (exception_to_text(expt), exception_to_text(cleanup_err)),
log_error=True,
err_code='creation-error[rollback]',
data=data,
)
return {'data': entity['data']}
@endpoint(
name='entities',
pattern=r'^(?P<carl_type>[^/]+)$',
example_pattern='wo',
methods=['get'],
parameters={
'text_attrname': {
'description': _('The q parameter will filters on values from given attribute'),
'example_value': 'description',
},
'id': {
'description': _('Only return the entity with corresponding ID'),
},
'q': {
'description': _('Filter returned entity with given value'),
},
'relationships': {
'description': _(
'Comma separated list of fieldname pointing on foreign entity we want data to be merged in the reply'
),
'example_value': 'site,NMaddress',
},
r'^filter_.*$': {
'description': 'filter_XXXX=YYYY will return data from the entity where field "XXXX"="YYYY"',
},
},
json_schema_response={
'$schema': 'http://json-schema.org/draft-04/schema#',
'type': 'array',
'items': {'type': 'string'},
},
)
def entities(
self,
request,
carl_type,
text_attrname='description',
id=None,
q=None,
relationships='',
**filters,
):
if id is not None:
result = self.entity_by_id(carl_type=carl_type, carl_id=id, relationships=relationships)
result = [result]
else:
if q:
filters_like = {text_attrname: q}
else:
filters_like = None
filter_prefix = 'filter_'
filters = {k[len(filter_prefix) :]: v for k, v in filters.items() if k.startswith(filter_prefix)}
entities = self.filter_entities(carl_type, filters, filters_like)
result = entities['data']
for ent in result:
ent['text'] = ent['attributes'].get(text_attrname)
return {'data': result}
#
# Helpers: properties
#
@property
def token_authentication(self):
'''Is True when token authentication wanted'''
return bool(self.carl_username and self.carl_password)
@property
def token_header_name(self):
'''The name of the header containing the authentication token'''
return 'X-CS-Access-Token'
@property
def token_cache_key(self):
'''The key used in cache to store/fetch authentication token's informations'''
return 'passerelle-%s-%s-token' % (self.__class__.__name__, self.slug)
#
# High level helpers
#
def filter_entities(self, entity_type, filters_eq, filters_like=None):
'''Fetch a list of entities from Carl
Arguments:
- entity_type: The name of the entity type
- filters_eq: A dict with fieldname as key and wanted value as value. Values
are compared using equality comparison
- filters_like: A dict with fieldname as key and wanted value as value.
Values are compared using a LIKE comparison (using % as wildcard)
Returns: a dict with 'data' being a list of Carl entities
Note: Carl may reply a paginated result. In that case, the list of carl entities
will not be complete (only first "page" returned)
'''
url = self.entity_url(entity_type)
params = {'filter[%s]' % fname: fval for fname, fval in filters_eq.items() if fname}
if filters_like:
params.update({'filter[%s][LIKE]' % fname: fval for fname, fval in filters_like.items()})
return self.json_request('get', url, params=params)
def entity_by_id(self, carl_type, carl_id, relationships=''):
'''Fetch an entity given it's type and id
Argument:
- carl_type: str The type of entitie we want to fetch
- carl_id: str The entitie's id
- relationships: str ',' separated list of related entities we want data to be fetched
'''
related = [rel.strip() for rel in relationships.split(',') if rel.strip()]
url = self.entity_url(carl_type, carl_id)
# Maximum time for a request (+3 for unexpected token expiration)
timeout = 20 / (len(related) + 3)
result = self.json_request('get', url, timeout=timeout)
relations = result['data'].get('relationships', {})
result = result['data']
for relattr in related:
if relattr not in relations:
continue
rel_url = relations[relattr]['links']['related']
rel_data = self.json_request('get', rel_url, timeout=timeout)
if 'related' not in result:
result['related'] = {}
result['relationships'][relattr]['data'] = rel_data['data']
return result
def create_entity(self, entity):
'''Given a well formated dict create the corresponding entity
Arguments:
- entity: a dict representing an entity in Carl format
Returns: a dict representing the created Carl entity
'''
url = self.entity_url(entity['data']['type'])
headers = {'Content-Type': 'application/vnd.api+json'}
return self.json_request('post', url, headers=headers, json=entity)
def delete_entity(self, e_type, e_id):
'''Delete an entity
Arguments:
- e_type: Entitie type name
- e_id: Entitie ID
'''
url = self.entity_url(e_type, e_id)
try:
response = self.requests.delete(url, headers=self.auth_headers())
except requests.RequestException as expt:
raise APIError(
_('Error sending deletion request (%s/%s) to Carl: %s')
% (e_type, e_id, exception_to_text(expt)),
log_error=True,
err_code='http-error',
data={
'http-method': 'delete',
'url': url,
'errors': str(expt),
},
)
if not response.ok:
raise self.carlerror_to_apierror(
_('HTTP error (%%(status)d) during entity (%s/%s) deletion: %%(detail)s') % (e_type, e_id),
response,
)
def cleanup_entities(self, to_clean):
'''Delete given entities
Arguments:
- to_clean: a dict {carl_id: entity_type}
'''
expts = []
cleaned = []
not_cleaned = []
for e_id, e_type in to_clean.items():
try:
self.delete_entity(e_type, e_id)
except APIError as expt:
expts.append(expt)
not_cleaned.append('%s(%s)' % (e_type, e_id))
else:
cleaned.append('%s(%s)' % (e_type, e_id))
if expts:
raise APIError(
_('Error during entities cleanup: %s') % (', '.join([exception_to_text(ex) for ex in expts])),
log_error=True,
err_code='rollback-error',
data={
'cleaned_entities': cleaned,
'not_cleaned_entitites': not_cleaned,
},
)
#
# Low level helpers
#
def entity_url(self, entity_type=None, entity_id=None, related_fieldname=None):
'''Forge an entity URL of the form
SERVICE_URL/api/entities/v1/[entity_type[/entity_id[/related_fieldname]]]
'''
url = urllib.parse.urljoin(self.service_url, 'api/entities/v1/')
for part in (entity_type, entity_id, related_fieldname):
if part is None:
break
url = urllib.parse.urljoin(url + '/', urllib.parse.quote(str(part)))
return url
def carlerror_to_apierror(self, err_fmt, response):
'''Instanciate an APIError given an error response from Carl
Arguments:
- err_fmt: Format string describing the error message. It should contains
two named parameters "%(status)s" and %(details)s"
- response: The requests.models.Response instance from Carl
Returns an instance of APIError that can be raised as it
'''
err_code = 'carl-error'
if response.status_code in (401, 403):
err_code = 'authentication-error'
elif response.status_code == 404:
err_code = 'not-found'
try:
err_data = response.json()
if not isinstance(err_data, dict):
raise KeyError('not a dict')
errors = []
for error in err_data['errors']:
if 'source' in error:
src = ' '.join(['%s %s' % elt for elt in error['source'].items()])
src = '(%s)' % src
else:
src = ''
if error['title'] == error['detail']:
errors.append('%s%s' % (error['title'], src))
else:
errors.append('%s%s %s' % (error['title'], src, error['detail']))
errors = ' ;\n'.join(errors)
return APIError(
err_fmt % {'status': response.status_code, 'detail': errors},
log_error=True,
err_code=err_code,
data={
'http-method': response.request.method,
'url': response.request.url,
'status-code': response.status_code,
},
)
except (json.JSONDecodeError, KeyError):
pass
# Not able to parse error, raising generic HTTP error
return APIError(
err_fmt % {'status': response.status_code, 'detail': 'no details'},
log_error=True,
err_code=err_code,
data={
'http-method': response.request.method,
'url': response.request.url,
'status-code': response.status_code,
'raw-response': response.text,
},
)
def json_request(self, http_method, url, **kwargs):
'''Make an HTTP request when json is expected as response body.
Handles common error cases.
Handles token authentication.
Defaults timeout to 2s.
Arguments:
- http_method: Name of the HTTP method to use (get, post, put, ...)
- url: The URL to request,
- **kwargs: extra argument to self.requests method
Returns: a dict (with 'data' key present) representing decoded JSON
response from Carl
'''
return self._json_request(http_method, url, retry_auth=True, **kwargs)
def _json_request(self, http_method, url, retry_auth=False, **kwargs):
if 'headers' in kwargs:
kwargs['headers'].update(self.auth_headers())
else:
kwargs['headers'] = self.auth_headers()
if 'timeout' not in kwargs['headers']:
kwargs['timeout'] = 2
method_fun = getattr(self.requests, http_method)
try:
response = method_fun(url, **kwargs)
except requests.RequestException as expt:
raise APIError(
_('Error sending request to Carl: %s') % exception_to_text(expt),
log_error=True,
err_code='http-error',
data={
'http-method': http_method,
'url': url,
'error': str(expt),
},
)
if response.ok:
try:
data = response.json() # Everything seems fine: return
except json.JSONDecodeError as expt:
raise APIError(
_('Invalid reply from Carl, unable to parse JSON: %s') % exception_to_text(expt),
log_error=True,
err_code='json-error',
data={
'http-method': http_method,
'url': url,
'status-code': response.status_code,
'error': str(expt),
'raw-response': response.text,
},
)
if isinstance(data, dict) and 'errors' in data and len(data['errors']) > 0:
err_fmt = _('Carl replies with HTTP %(status)d but errors set: %(detail)s')
elif not isinstance(data, dict) or 'data' not in data:
err_fmt = _('Unexpected JSON received , without data (HTTP %(status)d): %(detail)s')
else:
return data
else:
err_fmt = _('Got an HTTP error code (%(status)d) from Carl: %(detail)s')
if response.status_code in (401, 403) and retry_auth and self.token_authentication:
# handling early/unexpected token expiration
self.invalidate_token()
self._json_request(http_method, url, retry_auth=False, **kwargs)
raise self.carlerror_to_apierror(err_fmt, response)
#
# Token authentication helpers
#
def auth_headers(self):
'''When needed modify given headers by reference adding authentication token
Arguments:
- headers A dict with HTTP headers or None
Returns: The modificated dict or a new instance with authentication token
'''
if self.token_authentication:
return {self.token_header_name: self.authentication_token()}
else:
return {}
def authentication_token(self):
'''Return an usable authentication token (from cache or fetch a new one)
Returns: a string representing the token's value
'''
if not self.token_authentication:
return False # Maybe raise ?
key = self.token_cache_key
token = cache.get(key)
if token is None:
token, expires_in = self.fetch_token()
cache.set(key, token, expires_in)
return token
def invalidate_token(self):
'''Invalidate token in cache'''
cache.delete(self.token_cache_key)
def fetch_token(self):
'''Authenticate using carl_login & carl_password
Returns: a tuple(TOKEN, EXPIRES_IN) with TOKEN a str with token's value and
EXPIRES_IN an expiration delay in seconds
'''
if not self.token_authentication:
raise RuntimeError(_('Trying to renew authentication token when no login/password set'))
url = urllib.parse.urljoin(self.service_url, 'api/auth/v1/authenticate')
data = {'login': self.carl_username, 'password': self.carl_password, 'origin': 'publik'}
try:
response = self.requests.post(url, data=data, timeout=2)
except requests.RequestException as expt:
raise APIError(
_('Failed to fetch token: %s') % exception_to_text(expt),
log_error=True,
err_code='authentication-error[token]',
data={'url': url, 'error': str(expt)},
)
if not response.ok:
data = {
'status-code': response.status_code,
'url': url,
'raw-response': str(response.text),
}
if response.status_code == 401:
msg = _('Not authorized (HTTP 401) when trying to authenticate: bad credentials ?')
else:
msg = _('Got an HTTP error code (%d) when trying to authenticate') % response.status_code
raise APIError(msg, log_error=True, err_code='authentication-error[token]', data=data)
try:
data = response.json()
except json.JSONDecodeError as e:
raise APIError(
_('Unexpected reply from Carl when authenticating. JSON invalid: %s') % exception_to_text(e),
log_error=True,
err_code='authentication-error[token]',
data={
'status-code': response.status_code,
'url': url,
'error': str(e),
'invalid_json': response.text,
},
)
err_fmt = _('Got an invalid token when authentication on Carl: %s')
for key in (self.token_header_name, 'expires_in'):
if self.token_header_name not in data:
raise APIError(
err_fmt % (_('Missing information: %s') % key),
log_error=True,
err_code='authentication-error[token]',
data={
'status-code': response.status_code,
'url': url,
'invalid_token': data,
},
)
token = data[self.token_header_name]
try:
expires_in = int(data['expires_in'])
except ValueError:
raise APIError(
err_fmt % (_('Expected "expires_in" to be an int but got %r' % data['expires_in']),),
log_error=True,
err_code='authentication-error[token]',
data={
'status-code': response.status_code,
'url': url,
'invalid_token': data,
},
)
expires_in /= 1000 # returning seconds not ms
return token, expires_in

View File

@ -144,6 +144,7 @@ INSTALLED_APPS = (
'passerelle.apps.bbb',
'passerelle.apps.bdp',
'passerelle.apps.caldav',
'passerelle.apps.carl',
'passerelle.apps.cartads_cs',
'passerelle.apps.choosit',
'passerelle.apps.cityweb',

1230
tests/test_carl.py Normal file

File diff suppressed because it is too large Load Diff