This commit is contained in:
parent
08b82d398d
commit
f32d06b474
|
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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',
|
||||
|
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue