From 4d7ffaac11af145cbcf1a5a7802f39a9bee75563 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Tue, 21 Sep 2021 17:16:06 +0200
Subject: [PATCH] plone-restapi: add a plone.restapi connector (#57258)

---
 passerelle/apps/plone_restapi/__init__.py     |   0
 passerelle/apps/plone_restapi/forms.py        |  28 +
 .../plone_restapi/migrations/0001_initial.py  | 152 +++++
 .../apps/plone_restapi/migrations/__init__.py |   0
 passerelle/apps/plone_restapi/models.py       | 381 ++++++++++++
 passerelle/apps/plone_restapi/urls.py         |  31 +
 passerelle/apps/plone_restapi/views.py        |  44 ++
 passerelle/settings.py                        |   1 +
 passerelle/static/css/style.css               |   4 +
 tests/data/plone_restapi/id_search.json       |  89 +++
 tests/data/plone_restapi/q_search.json        | 240 ++++++++
 tests/test_plone_restapi.py                   | 550 ++++++++++++++++++
 tests/utils.py                                |   4 +-
 13 files changed, 1523 insertions(+), 1 deletion(-)
 create mode 100644 passerelle/apps/plone_restapi/__init__.py
 create mode 100644 passerelle/apps/plone_restapi/forms.py
 create mode 100644 passerelle/apps/plone_restapi/migrations/0001_initial.py
 create mode 100644 passerelle/apps/plone_restapi/migrations/__init__.py
 create mode 100644 passerelle/apps/plone_restapi/models.py
 create mode 100644 passerelle/apps/plone_restapi/urls.py
 create mode 100644 passerelle/apps/plone_restapi/views.py
 create mode 100644 tests/data/plone_restapi/id_search.json
 create mode 100644 tests/data/plone_restapi/q_search.json
 create mode 100644 tests/test_plone_restapi.py

diff --git a/passerelle/apps/plone_restapi/__init__.py b/passerelle/apps/plone_restapi/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/plone_restapi/forms.py b/passerelle/apps/plone_restapi/forms.py
new file mode 100644
index 00000000..a0cbfff7
--- /dev/null
+++ b/passerelle/apps/plone_restapi/forms.py
@@ -0,0 +1,28 @@
+# passerelle - uniform access to multiple data sources and services
+# 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/>.
+
+from django import forms
+
+from passerelle.base.forms import BaseQueryFormMixin
+
+from . import models
+
+
+class QueryForm(BaseQueryFormMixin, forms.ModelForm):
+    class Meta:
+        model = models.Query
+        fields = '__all__'
+        exclude = ['resource']
diff --git a/passerelle/apps/plone_restapi/migrations/0001_initial.py b/passerelle/apps/plone_restapi/migrations/0001_initial.py
new file mode 100644
index 00000000..a7685eff
--- /dev/null
+++ b/passerelle/apps/plone_restapi/migrations/0001_initial.py
@@ -0,0 +1,152 @@
+# Generated by Django 2.2.19 on 2021-10-15 10:15
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import passerelle.utils.templates
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('base', '0029_auto_20210202_1627'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PloneRestApi',
+            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')),
+                (
+                    'service_url',
+                    models.CharField(
+                        help_text='ex: https://demo.plone.org', max_length=256, verbose_name='Site URL'
+                    ),
+                ),
+                (
+                    'token_ws_url',
+                    models.CharField(
+                        blank=True,
+                        help_text='ex: https://IDP/idp/oidc/token/ or unset for anonymous acces',
+                        max_length=256,
+                        verbose_name='Token webservice URL',
+                    ),
+                ),
+                (
+                    'client_id',
+                    models.CharField(
+                        blank=True,
+                        help_text='OIDC id of the connector',
+                        max_length=128,
+                        verbose_name='OIDC id',
+                    ),
+                ),
+                (
+                    'client_secret',
+                    models.CharField(
+                        blank=True,
+                        help_text='Share secret secret for webservice call authentication',
+                        max_length=128,
+                        verbose_name='Shared secret',
+                    ),
+                ),
+                ('username', models.CharField(blank=True, max_length=128, verbose_name='Username')),
+                ('password', models.CharField(blank=True, max_length=128, verbose_name='Password')),
+                (
+                    'users',
+                    models.ManyToManyField(
+                        blank=True,
+                        related_name='_plonerestapi_users_+',
+                        related_query_name='+',
+                        to='base.ApiUser',
+                    ),
+                ),
+            ],
+            options={
+                'verbose_name': 'Plone REST API Web Service',
+            },
+        ),
+        migrations.CreateModel(
+            name='Query',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+                ('name', models.CharField(max_length=128, verbose_name='Name')),
+                ('slug', models.SlugField(max_length=128, verbose_name='Slug')),
+                ('description', models.TextField(blank=True, verbose_name='Description')),
+                (
+                    'uri',
+                    models.CharField(
+                        blank=True, help_text='uri to query', max_length=128, verbose_name='Uri'
+                    ),
+                ),
+                (
+                    'text_template',
+                    models.TextField(
+                        blank=True,
+                        help_text="Use Django's template syntax. Attributes can be accessed through {{ attributes.name }}",
+                        validators=[passerelle.utils.templates.validate_template],
+                        verbose_name='Text template',
+                    ),
+                ),
+                (
+                    'filter_expression',
+                    models.TextField(
+                        blank=True,
+                        help_text='Specify more URL parameters (key=value) separated by lines',
+                        verbose_name='filter',
+                    ),
+                ),
+                (
+                    'sort',
+                    models.CharField(
+                        blank=True,
+                        help_text='Sorts results by the specified field',
+                        max_length=256,
+                        verbose_name='Sort field',
+                    ),
+                ),
+                (
+                    'order',
+                    models.BooleanField(
+                        default=True,
+                        help_text='Unset to use descending sort order',
+                        verbose_name='Ascending sort order',
+                    ),
+                ),
+                (
+                    'limit',
+                    models.PositiveIntegerField(
+                        default=10,
+                        help_text='Number of results to return in a single call',
+                        verbose_name='Limit',
+                    ),
+                ),
+                (
+                    'resource',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='queries',
+                        to='plone_restapi.PloneRestApi',
+                        verbose_name='Resource',
+                    ),
+                ),
+            ],
+            options={
+                'verbose_name': 'Query',
+                'ordering': ['name'],
+                'abstract': False,
+                'unique_together': {('resource', 'name'), ('resource', 'slug')},
+            },
+        ),
+    ]
diff --git a/passerelle/apps/plone_restapi/migrations/__init__.py b/passerelle/apps/plone_restapi/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/plone_restapi/models.py b/passerelle/apps/plone_restapi/models.py
new file mode 100644
index 00000000..842d41fe
--- /dev/null
+++ b/passerelle/apps/plone_restapi/models.py
@@ -0,0 +1,381 @@
+# passerelle - uniform access to multiple data sources and services
+# 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/>.
+
+from urllib.parse import parse_qsl, urlsplit, urlunsplit
+
+from django.core.cache import cache
+from django.db import models
+from django.shortcuts import get_object_or_404
+from django.urls import reverse
+from django.utils.translation import ugettext_lazy as _
+from requests import RequestException
+
+from passerelle.base.models import BaseQuery, BaseResource
+from passerelle.compat import json_loads
+from passerelle.utils.api import endpoint
+from passerelle.utils.http_authenticators import HttpBearerAuth
+from passerelle.utils.json import unflatten
+from passerelle.utils.jsonresponse import APIError
+from passerelle.utils.templates import render_to_string, validate_template
+
+
+class ParameterTypeError(Exception):
+    http_status = 400
+    log_error = False
+
+
+class PloneRestApi(BaseResource):
+    service_url = models.CharField(
+        _('Site URL'),
+        max_length=256,
+        blank=False,
+        help_text=_('ex: https://demo.plone.org'),
+    )
+    token_ws_url = models.CharField(
+        _('Token webservice URL'),
+        max_length=256,
+        blank=True,
+        help_text=_('ex: https://IDP/idp/oidc/token/ or unset for anonymous acces'),
+    )
+    client_id = models.CharField(
+        _('OIDC id'),
+        max_length=128,
+        blank=True,
+        help_text=_('OIDC id of the connector'),
+    )
+    client_secret = models.CharField(
+        _('Shared secret'),
+        max_length=128,
+        blank=True,
+        help_text=_('Share secret secret for webservice call authentication'),
+    )
+    username = models.CharField(_('Username'), max_length=128, blank=True)
+    password = models.CharField(_('Password'), max_length=128, blank=True)
+
+    category = _('Data Sources')
+
+    class Meta:
+        verbose_name = _('Plone REST API Web Service')
+
+    def export_json(self):
+        data = super(PloneRestApi, self).export_json()
+        data['queries'] = [query.export_json() for query in self.queries.all()]
+        return data
+
+    @classmethod
+    def import_json_real(cls, overwrite, instance, data, **kwargs):
+        data_queries = data.pop('queries', [])
+        instance = super(PloneRestApi, cls).import_json_real(overwrite, instance, data, **kwargs)
+        queries = []
+        if instance and overwrite:
+            Query.objects.filter(resource=instance).delete()
+        for data_query in data_queries:
+            query = Query.import_json(data_query)
+            query.resource = instance
+            queries.append(query)
+        Query.objects.bulk_create(queries)
+        return instance
+
+    @classmethod
+    def normalize_keys(cls, data):
+        """ Rename keys from @foo to portal_foo """
+        if isinstance(data, list):
+            for value in list(data):
+                cls.normalize_keys(value)
+        elif isinstance(data, dict):
+            for key, value in list(data.items()):
+                cls.normalize_keys(value)
+                if key[0] == '@':
+                    data['portal_%s' % key[1:]] = value
+                    del data[key]
+
+    @classmethod
+    def normalize_record(cls, text_template, record):
+        cls.normalize_keys(record)
+        data = {}
+        for key, value in record.items():
+            if key in ('id', 'text'):
+                key = 'original_%s' % key
+            data[key] = value
+        data['id'] = record.get('UID')
+        data['text'] = render_to_string(text_template, data).strip()
+        return data
+
+    def get_token(self, renew=False):
+        token_key = 'plone-restapi-%s-token' % self.id
+        if not renew and cache.get(token_key):
+            return cache.get(token_key)
+        payload = {
+            'grant_type': 'password',
+            'client_id': str(self.client_id),
+            'client_secret': str(self.client_secret),
+            'username': self.username,
+            'password': self.password,
+            'scope': ['openid'],
+        }
+        headers = {
+            'Content-Type': 'application/x-www-form-urlencoded',
+        }
+        response = self.requests.post(self.token_ws_url, headers=headers, data=payload)
+        if not response.status_code // 100 == 2:
+            raise APIError(response.content)
+        token = response.json().get('id_token')
+        cache.set(token_key, token, 30)
+        return token
+
+    def request(self, uri='', uid='', method='GET', params=None, json=None):
+        scheme, netloc, path, query, fragment = urlsplit(self.service_url)
+        if uri:
+            path += '/%s' % uri
+        if uid:
+            path += '/%s' % uid
+        url = urlunsplit((scheme, netloc, path, '', fragment))
+        headers = {'Accept': 'application/json'}
+        auth = HttpBearerAuth(self.get_token()) if self.token_ws_url else None
+        try:
+            response = self.requests.request(
+                method=method, url=url, headers=headers, params=params, json=json, auth=auth
+            )
+        except RequestException as e:
+            raise APIError('PloneRestApi: %s' % e)
+        json_response = None
+        if response.status_code != 204:  # No Content
+            try:
+                json_response = response.json()
+            except ValueError as e:
+                raise APIError('PloneRestApi: bad JSON response')
+        try:
+            response.raise_for_status()
+        except RequestException as e:
+            raise APIError('PloneRestApi: %s "%s"' % (e, json_response))
+        return json_response
+
+    def call_search(
+        self,
+        uri='',
+        text_template='',
+        filter_expression='',
+        sort=None,
+        order=True,
+        limit=None,
+        id=None,
+        q=None,
+    ):
+        query = urlsplit(self.service_url).query
+        params = dict(parse_qsl(query))
+        if id:
+            params['UID'] = id
+        else:
+            if q is not None:
+                params['SearchableText'] = q
+            if sort:
+                params['sort_on'] = sort
+                if order:
+                    params['sort_order'] = 'ascending'
+                else:
+                    params['sort_order'] = 'descending'
+            if limit:
+                params['b_size'] = limit
+            params.update(parse_qsl(filter_expression))
+        params['fullobjects'] = 'y'
+        json_response = self.request(uri=uri, uid='@search', method='GET', params=params)
+
+        result = []
+        for record in json_response.get('items'):
+            data = self.normalize_record(text_template, record)
+            result.append(data)
+        return result
+
+    @endpoint(
+        perm='can_access',
+        description=_('Fetch'),
+        parameters={
+            'uri': {'description': _('Uri')},
+            'uid': {'description': _('Uid')},
+            'text_template': {'description': _('Text template')},
+        },
+        display_order=1,
+    )
+    def fetch(self, request, uid, uri='', text_template=''):
+        json_response = self.request(uri=uri, uid=uid, method='GET')
+        data = self.normalize_record(text_template, json_response)
+        return {'data': data}
+
+    @endpoint(
+        perm='can_access',
+        description=_('Creates'),
+        parameters={
+            'uri': {'description': _('Uri')},
+        },
+        methods=['post'],
+        display_order=2,
+    )
+    def create(self, request, uri):
+        try:
+            post_data = json_loads(request.body)
+        except ValueError as e:
+            raise ParameterTypeError(str(e))
+        post_data = unflatten(post_data)
+        response = self.request(uri=uri, method='POST', json=post_data)
+        return {'data': {'uid': response['UID'], 'created': True}}
+
+    @endpoint(
+        perm='can_access',
+        description=_('Update'),
+        parameters={
+            'uri': {'description': _('Uri')},
+            'uid': {'description': _('Uid')},
+        },
+        methods=['post'],
+        display_order=3,
+    )
+    def update(self, request, uid, uri=''):
+        try:
+            post_data = json_loads(request.body)
+        except ValueError as e:
+            raise ParameterTypeError(str(e))
+        post_data = unflatten(post_data)
+        self.request(uri=uri, uid=uid, method='PATCH', json=post_data)
+        return {'data': {'uid': uid, 'updated': True}}
+
+    @endpoint(
+        perm='can_access',
+        description=_('Remove'),
+        parameters={
+            'uri': {'description': _('Uri')},
+            'uid': {'description': _('Uid')},
+        },
+        methods=['delete'],
+        display_order=4,
+    )
+    def remove(self, request, uid, uri=''):
+        self.request(method='DELETE', uri=uri, uid=uid)
+        return {'data': {'uid': uid, 'removed': True}}
+
+    @endpoint(
+        perm='can_access',
+        description=_('Search'),
+        parameters={
+            'uri': {'description': _('Uri')},
+            'text_template': {'description': _('Text template')},
+            'sort': {'description': _('Sort field')},
+            'order': {'description': _('Ascending sort order'), 'type': 'bool'},
+            'limit': {'description': _('Maximum items')},
+            'id': {'description': _('Record identifier')},
+            'q': {'description': _('Full text query')},
+        },
+    )
+    def search(
+        self,
+        request,
+        uri='',
+        text_template='',
+        sort=None,
+        order=True,
+        limit=None,
+        id=None,
+        q=None,
+        **kwargs,
+    ):
+        result = self.call_search(uri, text_template, '', sort, order, limit, id, q)
+        return {'data': result}
+
+    @endpoint(
+        name='q',
+        description=_('Query'),
+        pattern=r'^(?P<query_slug>[\w:_-]+)/$',
+        perm='can_access',
+        show=False,
+    )
+    def q(self, request, query_slug, **kwargs):
+        query = get_object_or_404(Query, resource=self, slug=query_slug)
+        result = query.q(request, **kwargs)
+        meta = {'label': query.name, 'description': query.description}
+        return {'data': result, 'meta': meta}
+
+    def create_query_url(self):
+        return reverse('plone-restapi-query-new', kwargs={'slug': self.slug})
+
+
+class Query(BaseQuery):
+    resource = models.ForeignKey(
+        to=PloneRestApi, related_name='queries', verbose_name=_('Resource'), on_delete=models.CASCADE
+    )
+    uri = models.CharField(
+        verbose_name=_('Uri'),
+        max_length=128,
+        help_text=_('uri to query'),
+        blank=True,
+    )
+    text_template = models.TextField(
+        verbose_name=_('Text template'),
+        help_text=_("Use Django's template syntax. Attributes can be accessed through {{ attributes.name }}"),
+        validators=[validate_template],
+        blank=True,
+    )
+    filter_expression = models.TextField(
+        verbose_name=_('filter'),
+        help_text=_('Specify more URL parameters (key=value) separated by lines'),
+        blank=True,
+    )
+    sort = models.CharField(
+        verbose_name=_('Sort field'),
+        help_text=_('Sorts results by the specified field'),
+        max_length=256,
+        blank=True,
+    )
+    order = models.BooleanField(
+        verbose_name=_('Ascending sort order'),
+        help_text=_("Unset to use descending sort order"),
+        default=True,
+    )
+    limit = models.PositiveIntegerField(
+        default=10,
+        verbose_name=_('Limit'),
+        help_text=_('Number of results to return in a single call'),
+    )
+
+    delete_view = 'plone-restapi-query-delete'
+    edit_view = 'plone-restapi-query-edit'
+
+    def q(self, request, **kwargs):
+        return self.resource.call_search(
+            uri=self.uri,
+            text_template=self.text_template,
+            filter_expression='&'.join(
+                [x.strip() for x in str(self.filter_expression).splitlines() if x.strip()]
+            ),
+            sort=self.sort,
+            order=self.order,
+            limit=self.limit,
+            id=kwargs.get('id'),
+            q=kwargs.get('q'),
+        )
+
+    def as_endpoint(self):
+        endpoint = super(Query, self).as_endpoint(path=self.resource.q.endpoint_info.name)
+
+        search_endpoint = self.resource.search.endpoint_info
+        endpoint.func = search_endpoint.func
+        endpoint.show_undocumented_params = False
+
+        # Copy generic params descriptions from original endpoint
+        # if they are not overloaded by the query
+        for param in search_endpoint.parameters:
+            if param in ('uri', 'text_template') and getattr(self, param):
+                continue
+            endpoint.parameters[param] = search_endpoint.parameters[param]
+        return endpoint
diff --git a/passerelle/apps/plone_restapi/urls.py b/passerelle/apps/plone_restapi/urls.py
new file mode 100644
index 00000000..23967c68
--- /dev/null
+++ b/passerelle/apps/plone_restapi/urls.py
@@ -0,0 +1,31 @@
+# passerelle - uniform access to multiple data sources and services
+# 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/>.
+
+from django.conf.urls import url
+
+from . import views
+
+management_urlpatterns = [
+    url(r'^(?P<slug>[\w,-]+)/query/new/$', views.QueryNew.as_view(), name='plone-restapi-query-new'),
+    url(
+        r'^(?P<slug>[\w,-]+)/query/(?P<pk>\d+)/$', views.QueryEdit.as_view(), name='plone-restapi-query-edit'
+    ),
+    url(
+        r'^(?P<slug>[\w,-]+)/query/(?P<pk>\d+)/delete/$',
+        views.QueryDelete.as_view(),
+        name='plone-restapi-query-delete',
+    ),
+]
diff --git a/passerelle/apps/plone_restapi/views.py b/passerelle/apps/plone_restapi/views.py
new file mode 100644
index 00000000..530b7992
--- /dev/null
+++ b/passerelle/apps/plone_restapi/views.py
@@ -0,0 +1,44 @@
+# passerelle - uniform access to multiple data sources and services
+# 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/>.
+
+from django.views.generic import CreateView, DeleteView, UpdateView
+
+from passerelle.base.mixins import ResourceChildViewMixin
+
+from . import models
+from .forms import QueryForm
+
+
+class QueryNew(ResourceChildViewMixin, CreateView):
+    model = models.Query
+    form_class = QueryForm
+    template_name = "passerelle/manage/resource_child_form.html"
+
+    def get_form_kwargs(self):
+        kwargs = super(QueryNew, self).get_form_kwargs()
+        kwargs['instance'] = self.model(resource=self.resource)
+        return kwargs
+
+
+class QueryEdit(ResourceChildViewMixin, UpdateView):
+    model = models.Query
+    form_class = QueryForm
+    template_name = "passerelle/manage/resource_child_form.html"
+
+
+class QueryDelete(ResourceChildViewMixin, DeleteView):
+    model = models.Query
+    template_name = "passerelle/manage/resource_child_confirm_delete.html"
diff --git a/passerelle/settings.py b/passerelle/settings.py
index f98c5fd0..8267b1b8 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -154,16 +154,17 @@ INSTALLED_APPS = (
     'passerelle.apps.okina',
     'passerelle.apps.opendatasoft',
     'passerelle.apps.opengis',
     'passerelle.apps.orange',
     'passerelle.apps.ovh',
     'passerelle.apps.oxyd',
     'passerelle.apps.phonecalls',
     'passerelle.apps.photon',
+    'passerelle.apps.plone_restapi',
     'passerelle.apps.sector',
     'passerelle.apps.solis',
     'passerelle.apps.twilio',
     'passerelle.apps.vivaticket',
     # backoffice templates and static
     'gadjo',
 )
 
diff --git a/passerelle/static/css/style.css b/passerelle/static/css/style.css
index c55eee07..0a637383 100644
--- a/passerelle/static/css/style.css
+++ b/passerelle/static/css/style.css
@@ -181,16 +181,20 @@ li.connector.apientreprise a::before {
 li.connector.dpark a::before {
 	content: "\f1b9";  /* car */
 }
 
 li.connector.cryptor a::before {
 	content: "\f023";  /* lock */
 }
 
+li.connector.plonerestapi a::before {
+	content: "\f1c0"; /* database */
+}
+
 li.connector.opendatasoft a::before {
 	content: "\f1c0"; /* database */
 }
 
 
 li.connector.status-down span.connector-name::after {
 	font-family: FontAwesome;
 	content: "\f00d"; /* times */
diff --git a/tests/data/plone_restapi/id_search.json b/tests/data/plone_restapi/id_search.json
new file mode 100644
index 00000000..7f07cc9d
--- /dev/null
+++ b/tests/data/plone_restapi/id_search.json
@@ -0,0 +1,89 @@
+{
+  "@id": "https://demo.plone.org/es/@search?UID=19f0cd24fec847c09744fbc85aace167",
+  "items": [
+    {
+      "@components": {
+        "actions": {
+          "@id": "https://demo.plone.org/es/frontpage/@actions"
+        },
+        "breadcrumbs": {
+          "@id": "https://demo.plone.org/es/frontpage/@breadcrumbs"
+        },
+        "contextnavigation": {
+          "@id": "https://demo.plone.org/es/frontpage/@contextnavigation"
+        },
+        "navigation": {
+          "@id": "https://demo.plone.org/es/frontpage/@navigation"
+        },
+        "translations": {
+          "@id": "https://demo.plone.org/es/frontpage/@translations"
+        },
+        "types": {
+          "@id": "https://demo.plone.org/es/frontpage/@types"
+        },
+        "workflow": {
+          "@id": "https://demo.plone.org/es/frontpage/@workflow"
+        }
+      },
+      "@id": "https://demo.plone.org/es/frontpage",
+      "@type": "Document",
+      "UID": "19f0cd24fec847c09744fbc85aace167",
+      "allow_discussion": false,
+      "blocks": {},
+      "blocks_layout": {
+        "items": []
+      },
+      "changeNote": "",
+      "contributors": [],
+      "created": "2021-09-23T20:05:00+00:00",
+      "creators": [
+        "admin"
+      ],
+      "description": "El Sistema de Gesti\u00f3n de Contenido de Fuentes Abiertas",
+      "effective": null,
+      "exclude_from_nav": false,
+      "expires": null,
+      "id": "frontpage",
+      "is_folderish": false,
+      "language": {
+        "title": "Espa\u00f1ol",
+        "token": "es"
+      },
+      "layout": "document_view",
+      "modified": "2021-09-23T20:05:00+00:00",
+      "next_item": {
+        "@id": "https://demo.plone.org/es/demo",
+        "@type": "Folder",
+        "description": "Vestibulum dignissim erat id eros mollis vitae tempus leo ultricies. Cras dapibus suscipit consectetur. Integer tincidunt feugiat tristique. Sed et arcu risus. Nam venenatis, tortor ac tincidunt amet.",
+        "title": "Demo"
+      },
+      "parent": {
+        "@id": "https://demo.plone.org/es",
+        "@type": "LRF",
+        "description": "",
+        "review_state": "published",
+        "title": "Espa\u00f1ol"
+      },
+      "previous_item": {
+        "@id": "https://demo.plone.org/es/recursos",
+        "@type": "LIF",
+        "description": "",
+        "title": "Recursos"
+      },
+      "relatedItems": [],
+      "review_state": "published",
+      "rights": "",
+      "subjects": [],
+      "table_of_contents": null,
+      "text": {
+        "content-type": "text/html",
+        "data": "<p>\u00a1Edita esta p\u00e1gina y prueba Plone ahora!</p>",
+        "encoding": "utf-8"
+      },
+      "title": "Bienvenido a Plone",
+      "version": "current",
+      "versioning_enabled": true
+    }
+  ],
+  "items_total": 1
+}
diff --git a/tests/data/plone_restapi/q_search.json b/tests/data/plone_restapi/q_search.json
new file mode 100644
index 00000000..b6eb4415
--- /dev/null
+++ b/tests/data/plone_restapi/q_search.json
@@ -0,0 +1,240 @@
+{
+  "@id": "https://demo.plone.org/es/@search?bsize=3&portal_type=Document&review_state=published",
+  "items": [
+    {
+      "@components": {
+        "actions": {
+          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@actions"
+        },
+        "breadcrumbs": {
+          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@breadcrumbs"
+        },
+        "contextnavigation": {
+          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@contextnavigation"
+        },
+        "navigation": {
+          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@navigation"
+        },
+        "translations": {
+          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@translations"
+        },
+        "types": {
+          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@types"
+        },
+        "workflow": {
+          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@workflow"
+        }
+      },
+      "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta",
+      "@type": "Document",
+      "UID": "593cf5489fce493a95b59bb4f3ef9ee1",
+      "allow_discussion": false,
+      "blocks": {},
+      "blocks_layout": {
+        "items": []
+      },
+      "changeNote": "",
+      "contributors": [],
+      "created": "2018-08-27T11:20:58+00:00",
+      "creators": [
+        "admin"
+      ],
+      "description": "Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum.",
+      "effective": "2018-08-27T13:21:10",
+      "exclude_from_nav": false,
+      "expires": null,
+      "id": "una-pagina-dentro-de-una-carpeta",
+      "is_folderish": false,
+      "language": {
+        "title": "Espa\u00f1ol",
+        "token": "es"
+      },
+      "layout": "document_view",
+      "modified": "2021-09-23T20:05:01+00:00",
+      "next_item": {},
+      "parent": {
+        "@id": "https://demo.plone.org/es/demo/una-carpeta",
+        "@type": "Folder",
+        "description": "",
+        "review_state": "published",
+        "title": "Una carpeta"
+      },
+      "previous_item": {},
+      "relatedItems": [],
+      "review_state": "published",
+      "rights": null,
+      "subjects": [],
+      "table_of_contents": false,
+      "text": {
+        "content-type": "text/html",
+        "data": "Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Cras mattis consectetur purus sit amet fermentum. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Maecenas faucibus mollis interdum.&#13;\n&#13;\nPraesent commodo cursus magna, vel scelerisque nisl consectetur et. Nulla vitae elit libero, a pharetra augue. Maecenas sed diam eget risus varius blandit sit amet non magna. Donec ullamcorper nulla non metus auctor fringilla. Vestibulum id ligula porta felis euismod semper. Cras mattis consectetur purus sit amet fermentum.&#13;\n&#13;\nNullam quis risus eget urna mollis ornare vel eu leo. Donec ullamcorper nulla non metus auctor fringilla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla vitae elit libero, a pharetra augue. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Nullam id dolor id nibh ultricies vehicula ut id elit. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.",
+        "encoding": "utf-8"
+      },
+      "title": "Una P\u00e1gina dentro de una carpeta",
+      "version": "current",
+      "versioning_enabled": true
+    },
+    {
+      "@components": {
+        "actions": {
+          "@id": "https://demo.plone.org/es/frontpage/@actions"
+        },
+        "breadcrumbs": {
+          "@id": "https://demo.plone.org/es/frontpage/@breadcrumbs"
+        },
+        "contextnavigation": {
+          "@id": "https://demo.plone.org/es/frontpage/@contextnavigation"
+        },
+        "navigation": {
+          "@id": "https://demo.plone.org/es/frontpage/@navigation"
+        },
+        "translations": {
+          "@id": "https://demo.plone.org/es/frontpage/@translations"
+        },
+        "types": {
+          "@id": "https://demo.plone.org/es/frontpage/@types"
+        },
+        "workflow": {
+          "@id": "https://demo.plone.org/es/frontpage/@workflow"
+        }
+      },
+      "@id": "https://demo.plone.org/es/frontpage",
+      "@type": "Document",
+      "UID": "19f0cd24fec847c09744fbc85aace167",
+      "allow_discussion": false,
+      "blocks": {},
+      "blocks_layout": {
+        "items": []
+      },
+      "changeNote": "",
+      "contributors": [],
+      "created": "2021-09-23T20:05:00+00:00",
+      "creators": [
+        "admin"
+      ],
+      "description": "El Sistema de Gesti\u00f3n de Contenido de Fuentes Abiertas",
+      "effective": null,
+      "exclude_from_nav": false,
+      "expires": null,
+      "id": "frontpage",
+      "is_folderish": false,
+      "language": {
+        "title": "Espa\u00f1ol",
+        "token": "es"
+      },
+      "layout": "document_view",
+      "modified": "2021-09-23T20:05:00+00:00",
+      "next_item": {
+        "@id": "https://demo.plone.org/es/demo",
+        "@type": "Folder",
+        "description": "Vestibulum dignissim erat id eros mollis vitae tempus leo ultricies. Cras dapibus suscipit consectetur. Integer tincidunt feugiat tristique. Sed et arcu risus. Nam venenatis, tortor ac tincidunt amet.",
+        "title": "Demo"
+      },
+      "parent": {
+        "@id": "https://demo.plone.org/es",
+        "@type": "LRF",
+        "description": "",
+        "review_state": "published",
+        "title": "Espa\u00f1ol"
+      },
+      "previous_item": {
+        "@id": "https://demo.plone.org/es/recursos",
+        "@type": "LIF",
+        "description": "",
+        "title": "Recursos"
+      },
+      "relatedItems": [],
+      "review_state": "published",
+      "rights": "",
+      "subjects": [],
+      "table_of_contents": null,
+      "text": {
+        "content-type": "text/html",
+        "data": "<p>\u00a1Edita esta p\u00e1gina y prueba Plone ahora!</p>",
+        "encoding": "utf-8"
+      },
+      "title": "Bienvenido a Plone",
+      "version": "current",
+      "versioning_enabled": true
+    },
+    {
+      "@components": {
+        "actions": {
+          "@id": "https://demo.plone.org/es/demo/una-pagina/@actions"
+        },
+        "breadcrumbs": {
+          "@id": "https://demo.plone.org/es/demo/una-pagina/@breadcrumbs"
+        },
+        "contextnavigation": {
+          "@id": "https://demo.plone.org/es/demo/una-pagina/@contextnavigation"
+        },
+        "navigation": {
+          "@id": "https://demo.plone.org/es/demo/una-pagina/@navigation"
+        },
+        "translations": {
+          "@id": "https://demo.plone.org/es/demo/una-pagina/@translations"
+        },
+        "types": {
+          "@id": "https://demo.plone.org/es/demo/una-pagina/@types"
+        },
+        "workflow": {
+          "@id": "https://demo.plone.org/es/demo/una-pagina/@workflow"
+        }
+      },
+      "@id": "https://demo.plone.org/es/demo/una-pagina",
+      "@type": "Document",
+      "UID": "13f909b522a94443823e187ea9ebab0b",
+      "allow_discussion": false,
+      "blocks": {},
+      "blocks_layout": {
+        "items": []
+      },
+      "changeNote": "",
+      "contributors": [],
+      "created": "2018-08-27T11:12:41+00:00",
+      "creators": [
+        "admin"
+      ],
+      "description": "Aenean dictum auctor elit, in volutpat ipsum venenatis at. Quisque lobortis augue et enim venenatis interdum. In egestas, est at condimentum ultrices, tortor enim malesuada nulla; vel sagittis nullam.",
+      "effective": "2018-08-27T13:16:45",
+      "exclude_from_nav": false,
+      "expires": null,
+      "id": "una-pagina",
+      "is_folderish": false,
+      "language": {
+        "title": "Espa\u00f1ol",
+        "token": "es"
+      },
+      "layout": "document_view",
+      "modified": "2021-09-23T20:05:00+00:00",
+      "next_item": {
+        "@id": "https://demo.plone.org/es/demo/un-evento",
+        "@type": "Event",
+        "description": "Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Maecenas sed diam eget risus varius blandit sit amet non magna.",
+        "title": "Un Evento"
+      },
+      "parent": {
+        "@id": "https://demo.plone.org/es/demo",
+        "@type": "Folder",
+        "description": "Vestibulum dignissim erat id eros mollis vitae tempus leo ultricies. Cras dapibus suscipit consectetur. Integer tincidunt feugiat tristique. Sed et arcu risus. Nam venenatis, tortor ac tincidunt amet.",
+        "review_state": "published",
+        "title": "Demo"
+      },
+      "previous_item": {},
+      "relatedItems": [],
+      "review_state": "published",
+      "rights": null,
+      "subjects": [],
+      "table_of_contents": false,
+      "text": {
+        "content-type": "text/html",
+        "data": "Fusce vel ante vel dolor feugiat vulputate? Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque luctus pretium tellus, id porttitor erat volutpat et. Donec vehicula accumsan ornare. Mauris quis vulputate dui. Duis convallis congue ornare. Vivamus cursus vestibulum neque at fermentum. Mauris sed velit in enim scelerisque luctus ac vel mauris. Etiam imperdiet tempor lorem, quis ultrices quam blandit ultricies. Ut sodales lacinia purus hendrerit lobortis. Pellentesque blandit; sem at aliquam pulvinar, felis diam tincidunt nisi, ac varius ligula eros eget tortor.&#13;\n&#13;\nVivamus leo ipsum&#13;\nDictum sed luctus elementum, ornare quis justo? Nam sagittis mattis turpis, eu varius sapien pulvinar non. Etiam in enim eget odio cursus condimentum! Nullam porta, quam ut sagittis auctor, dui urna ullamcorper urna, semper facilisis purus velit nec leo. Nam libero sem, auctor vitae pretium sit amet, posuere eget arcu.&#13;\n&#13;\nMaecenas ultrices&#13;\nNeque in porttitor scelerisque, nunc nunc semper libero, ac dapibus leo lectus et dui. Praesent sem urna, malesuada in volutpat ac, tincidunt sit amet dolor. Ut dignissim ante vel sem semper venenatis? Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. In hac habitasse platea dictumst. Aenean vitae lectus leo; non bibendum purus.&#13;\n&#13;\nQuisque sit amet aliquam elit&#13;\nAenean odio urna, congue eu sollicitudin ac&#13;\nInterdum ac lorem&#13;\nPellentesque habitant morbi tristique&#13;\nAliquam mattis purus vel nunc tempor sed tempus turpis imperdiet. Pellentesque tincidunt gravida eros at adipiscing. Sed ut tempus nibh. Suspendisse euismod, metus sed lobortis luctus, odio nulla malesuada turpis, in aliquam elit lorem id ante? Pellentesque a elementum dui! Morbi id tellus eget lacus sollicitudin dignissim. Praesent venenatis pellentesque dolor, nec vestibulum felis consectetur non? Sed facilisis, velit vel auctor aliquam, lorem mauris euismod libero, non pellentesque urna mauris vel sem. Aenean eget diam at sem auctor lacinia nec non nisl. Integer sodales fringilla vulputate. Duis massa ante, aliquet id interdum at; placerat nec magna. Ut eleifend sem ut mi elementum eget pellentesque urna dignissim!",
+        "encoding": "utf-8"
+      },
+      "title": "Una p\u00e1gina",
+      "version": "current",
+      "versioning_enabled": true
+    }
+  ],
+  "items_total": 3
+}
diff --git a/tests/test_plone_restapi.py b/tests/test_plone_restapi.py
new file mode 100644
index 00000000..82bd121c
--- /dev/null
+++ b/tests/test_plone_restapi.py
@@ -0,0 +1,550 @@
+# -*- coding: utf-8 -*-
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 202  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 os
+
+import pytest
+import utils
+from requests.exceptions import ConnectionError
+from test_manager import login
+
+from passerelle.apps.plone_restapi.models import PloneRestApi, Query
+from passerelle.utils import import_site
+from passerelle.utils.jsonresponse import APIError
+
+pytestmark = pytest.mark.django_db
+
+TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'plone_restapi')
+
+TOKEN_RESPONSE = {
+    'access_token': 'd319258e-48b9-4853-88e8-7a2ad6883c7f',
+    'token_type': 'Bearer',
+    'expires_in': 28800,
+    'id_token': 'acd...def',
+}
+
+TOKEN_ERROR_RESPONSE = {
+    "error": "access_denied",
+    "error_description": "Mauvaises informations de connexion de l'utilisateur",
+}
+
+
+# test data comes from https://demo.plone.org/en
+def json_get_data(filename):
+    with open(os.path.join(TEST_BASE_DIR, "%s.json" % filename)) as fd:
+        return json.load(fd)
+
+
+@pytest.fixture
+def connector():
+    return utils.setup_access_rights(
+        PloneRestApi.objects.create(
+            slug='my_connector',
+            service_url='http://www.example.net',
+            token_ws_url='http://www.example.net/idp/oidc/token/',
+            client_id='aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+            client_secret='11111111-2222-3333-4444-555555555555',
+            username='jdoe',
+            password='secret',
+        )
+    )
+
+
+@pytest.fixture
+def token(connector):
+    with utils.mock_url(url=connector.token_ws_url, response=TOKEN_RESPONSE) as mocked:
+        yield mocked
+
+
+@pytest.fixture
+def query(connector):
+    return Query.objects.create(
+        resource=connector,
+        name='demo query',
+        slug='my_query',
+        description='Spanish published documents',
+        uri='es',
+        text_template='{{ title }} ({{ portal_type }})',
+        filter_expression='''
+portal_type=Document
+review_state=published
+''',
+        sort='UID',
+        order=False,
+        limit=3,
+    )
+
+
+def test_views(db, admin_user, app, connector):
+    app = login(app)
+    resp = app.get('/plone-restapi/my_connector/', status=200)
+    resp = resp.click('New Query')
+    resp.form['name'] = 'my query'
+    resp.form['slug'] = 'my-query'
+    resp.form['uri'] = 'my-uri'
+    resp = resp.form.submit()
+    resp = resp.follow()
+    assert resp.html.find('div', {'id': 'queries'}).ul.li.a.text == 'my query'
+
+
+def test_views_query_unicity(admin_user, app, connector, query):
+    connector2 = PloneRestApi.objects.create(
+        slug='my_connector2',
+    )
+    Query.objects.create(
+        resource=connector2,
+        slug='foo-bar',
+        name='Foo Bar',
+    )
+
+    # create
+    app = login(app)
+    resp = app.get('/manage/plone-restapi/%s/query/new/' % connector.slug)
+    resp.form['slug'] = query.slug
+    resp.form['name'] = 'Foo Bar'
+    resp = resp.form.submit()
+    assert resp.status_code == 200
+    assert 'A query with this slug already exists' in resp.text
+    assert Query.objects.filter(resource=connector).count() == 1
+    resp.form['slug'] = 'foo-bar'
+    resp.form['name'] = query.name
+    resp = resp.form.submit()
+    assert resp.status_code == 200
+    assert 'A query with this name already exists' in resp.text
+    assert Query.objects.filter(resource=connector).count() == 1
+    resp.form['slug'] = 'foo-bar'
+    resp.form['name'] = 'Foo Bar'
+    resp = resp.form.submit()
+    assert resp.status_code == 302
+    assert Query.objects.filter(resource=connector).count() == 2
+    new_query = Query.objects.latest('pk')
+    assert new_query.resource == connector
+    assert new_query.slug == 'foo-bar'
+    assert new_query.name == 'Foo Bar'
+
+    # update
+    resp = app.get('/manage/plone-restapi/%s/query/%s/' % (connector.slug, new_query.pk))
+    resp.form['slug'] = query.slug
+    resp.form['name'] = 'Foo Bar'
+    resp = resp.form.submit()
+    assert resp.status_code == 200
+    assert 'A query with this slug already exists' in resp.text
+    resp.form['slug'] = 'foo-bar'
+    resp.form['name'] = query.name
+    resp = resp.form.submit()
+    assert resp.status_code == 200
+    assert 'A query with this name already exists' in resp.text
+    resp.form['slug'] = 'foo-bar'
+    resp.form['name'] = 'Foo Bar'
+    resp.form['uri'] = 'fr'
+    resp = resp.form.submit()
+    assert resp.status_code == 302
+    query = Query.objects.get(resource=connector, slug='foo-bar')
+    assert query.uri == 'fr'
+
+
+def test_export_import(query):
+    assert PloneRestApi.objects.count() == 1
+    assert Query.objects.count() == 1
+    serialization = {'resources': [query.resource.export_json()]}
+    PloneRestApi.objects.all().delete()
+    assert PloneRestApi.objects.count() == 0
+    assert Query.objects.count() == 0
+    import_site(serialization)
+    assert PloneRestApi.objects.count() == 1
+    assert str(PloneRestApi.objects.get().client_id) == 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
+    assert Query.objects.count() == 1
+
+
+def test_normalize_keys():
+    plone_response = {
+        '@type': 'Document',
+        '@dict': {
+            '@array': [
+                {
+                    '@key1': 'value1',
+                    '@key2': 'value2',
+                },
+                '@value_to_keep',
+            ]
+        },
+    }
+    PloneRestApi.normalize_keys(plone_response)
+    assert plone_response == {
+        'portal_type': 'Document',
+        'portal_dict': {
+            'portal_array': [{'portal_key1': 'value1', 'portal_key2': 'value2'}, '@value_to_keep']
+        },
+    }
+
+
+def test_normalize_record():
+    data = {
+        'UID': 'abc',
+        'id': '123',
+        'text': 'foo',
+        'portal_dict': {'portal_array': [{'portal_key': 'value'}]},
+    }
+    template = '{{ id }} {{original_id }} {{ original_text }} {{ portal_dict.portal_array.0.portal_key }}'
+    assert PloneRestApi.normalize_record(template, data) == {
+        'UID': 'abc',
+        'original_id': '123',
+        'original_text': 'foo',
+        'portal_dict': {'portal_array': [{'portal_key': 'value'}]},
+        'id': 'abc',
+        'text': 'abc 123 foo value',
+    }
+
+
+def test_get_token(app, connector):
+    with pytest.raises(APIError):
+        with utils.mock_url(url=connector.token_ws_url, response=TOKEN_ERROR_RESPONSE, status_code=404):
+            connector.get_token()
+    with utils.mock_url(url=connector.token_ws_url, response=TOKEN_RESPONSE) as mocked:
+        result = connector.get_token()
+        assert mocked.handlers[0].call['count'] == 1
+        assert 'secret' in mocked.handlers[0].call['requests'][0].body
+        assert result == 'acd...def'
+
+        # make sure the token from cache is used
+        connector.get_token()
+        assert mocked.handlers[0].call['count'] == 1
+        connector.get_token(True)
+        assert mocked.handlers[0].call['count'] == 2
+
+
+def test_fetch(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'fetch', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/fetch'
+    url = connector.service_url + '/es/19f0cd24fec847c09744fbc85aace167'
+    params = {
+        'uid': '19f0cd24fec847c09744fbc85aace167',
+        'uri': 'es',
+        'text_template': '{{ title }} ({{ parent.portal_type }})',
+    }
+    with utils.mock_url(url=url, response=json_get_data('id_search')['items'][0]):
+        resp = app.get(endpoint, params=params)
+    assert not resp.json['err']
+    assert resp.json['data']['id'] == '19f0cd24fec847c09744fbc85aace167'
+    assert resp.json['data']['text'] == 'Bienvenido a Plone (LRF)'
+    assert token.handlers[0].call['count'] == 1
+
+
+def test_request_anonymously(app, connector, token):
+    connector.token_ws_url = ''
+    connector.save()
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'fetch', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/fetch'
+    url = connector.service_url + '/es/19f0cd24fec847c09744fbc85aace167'
+    params = {
+        'uid': '19f0cd24fec847c09744fbc85aace167',
+        'uri': 'es',
+        'text_template': '{{ title }} ({{ parent.portal_type }})',
+    }
+    with utils.mock_url(url=url, response=json_get_data('id_search')['items'][0]):
+        resp = app.get(endpoint, params=params)
+    assert not resp.json['err']
+    assert resp.json['data']['id'] == '19f0cd24fec847c09744fbc85aace167'
+    assert resp.json['data']['text'] == 'Bienvenido a Plone (LRF)'
+    assert token.handlers[0].call['count'] == 0
+
+
+@pytest.mark.parametrize(
+    'exception, status_code, response, err_desc',
+    [
+        [ConnectionError('plop'), None, None, 'plop'],
+        [None, 200, 'not json', 'bad JSON response'],
+        [None, 404, {'message': 'Resource not found: ...', 'type': 'NotFound'}, '404 Client Error'],
+    ],
+)
+def test_request_error(app, connector, token, exception, status_code, response, err_desc):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'fetch', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/fetch'
+    url = connector.service_url + '/es/plop'
+    params = {
+        'uid': 'plop',
+        'uri': 'es',
+        'text_template': '{{ title }} ({{ portal_type }})',
+    }
+    with utils.mock_url(url=url, response=response, status_code=status_code, exception=exception):
+        resp = app.get(endpoint, params=params)
+    assert resp.json['err']
+    assert err_desc in resp.json['err_desc']
+
+
+def test_create(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'create', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/create'
+    url = connector.service_url + '/es'
+    payload = {
+        '@type': 'imio.directory.Contact',
+        'title': "Test Entr'ouvert",
+        'type': 'organization',
+        'schedule': {},
+        'topics/0/title': 'Tourisme',
+        'topics/0/token': 'tourism',
+    }
+    with utils.mock_url(url=url, response=json_get_data('id_search')['items'][0], status_code=201) as mocked:
+        resp = app.post_json(endpoint + '?uri=es', params=payload)
+        body = json.loads(mocked.handlers[0].call['requests'][1].body)
+        assert body['topics'] == [{'title': 'Tourisme', 'token': 'tourism'}]
+    assert not resp.json['err']
+    assert resp.json['data'] == {'uid': '19f0cd24fec847c09744fbc85aace167', 'created': True}
+
+
+def test_create_wrong_payload(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'create', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/create'
+    url = connector.service_url + '/es'
+    payload = 'not json'
+    resp = app.post(endpoint + '?uri=es', params=payload, status=400)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == 'Expecting value: line 1 column 1 (char 0)'
+    assert resp.json['err_class'] == 'passerelle.apps.plone_restapi.models.ParameterTypeError'
+
+
+def test_update(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'update', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/update'
+    url = connector.service_url + '/es/19f0cd24fec847c09744fbc85aace167'
+    query_string = '?uri=es&uid=19f0cd24fec847c09744fbc85aace167'
+    payload = {
+        'title': 'Test update',
+        'topics/0/token': 'social',
+    }
+    with utils.mock_url(url=url, response='', status_code=204) as mocked:
+        resp = app.post_json(endpoint + query_string, params=payload)
+        body = json.loads(mocked.handlers[0].call['requests'][1].body)
+        assert body['topics'] == [{'token': 'social'}]
+    assert not resp.json['err']
+    assert resp.json['data'] == {'uid': '19f0cd24fec847c09744fbc85aace167', 'updated': True}
+
+
+def test_update_wrong_payload(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'update', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/update'
+    url = connector.service_url + '/es/19f0cd24fec847c09744fbc85aace167'
+    query_string = '?uri=es&uid=19f0cd24fec847c09744fbc85aace167'
+    payload = 'not json'
+    resp = app.post(endpoint + query_string, params=payload, status=400)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == 'Expecting value: line 1 column 1 (char 0)'
+    assert resp.json['err_class'] == 'passerelle.apps.plone_restapi.models.ParameterTypeError'
+
+
+def test_remove(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'remove', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/remove'
+    url = connector.service_url + '/es/19f0cd24fec847c09744fbc85aace167'
+    query_string = '?uri=es&uid=19f0cd24fec847c09744fbc85aace167'
+    with utils.mock_url(url=url, response='', status_code=204):
+        resp = app.delete(endpoint + query_string)
+    assert resp.json['data'] == {'uid': '19f0cd24fec847c09744fbc85aace167', 'removed': True}
+    assert not resp.json['err']
+
+
+def test_search(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/search'
+    url = connector.service_url + '/es/@search'
+    params = {
+        'uri': 'es',
+        'text_template': '{{ title }} ({{ portal_type }})',
+        'sort': 'UID',
+        'order': False,
+        'limit': 3,
+    }
+    qs = {}
+    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
+        resp = app.get(endpoint, params=params)
+    assert token.handlers[0].call['count'] == 1
+    assert qs == {'sort_on': 'UID', 'sort_order': 'descending', 'b_size': '3', 'fullobjects': 'y'}
+    assert not resp.json['err']
+    assert len(resp.json['data']) == 3
+    assert [(x['id'], x['text']) for x in resp.json['data']] == [
+        ('593cf5489fce493a95b59bb4f3ef9ee1', 'Una Página dentro de una carpeta (Document)'),
+        ('19f0cd24fec847c09744fbc85aace167', 'Bienvenido a Plone (Document)'),
+        ('13f909b522a94443823e187ea9ebab0b', 'Una página (Document)'),
+    ]
+
+
+def test_call_search_normalize_keys(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/search'
+    url = connector.service_url + '/@search'
+
+    plone_response = json_get_data('q_search')
+    assert [x['id'] for x in plone_response['items']] == [
+        'una-pagina-dentro-de-una-carpeta',
+        'frontpage',
+        'una-pagina',
+    ]
+    assert plone_response['items'][1]['text'] == {
+        'content-type': 'text/html',
+        'data': '<p>¡Edita esta página y prueba Plone ahora!</p>',
+        'encoding': 'utf-8',
+    }
+    assert plone_response['items'][1]['parent'] == {
+        '@id': 'https://demo.plone.org/es',
+        '@type': 'LRF',
+        'description': '',
+        'review_state': 'published',
+        'title': 'Español',
+    }
+
+    with utils.mock_url(url=url, response=plone_response):
+        resp = app.get(
+            endpoint,
+            params={
+                'text_template': '{{ original_id }} {{ original_text.encoding }} {{ parent.portal_type }}',
+            },
+        )
+    assert not resp.json['err']
+    assert resp.json['data'][1]['text'] == 'frontpage utf-8 LRF'
+
+    # original 'id' and 'text' keys are renamed
+    assert [x['original_id'] for x in resp.json['data']] == [x['id'] for x in plone_response['items']]
+    assert resp.json['data'][1]['original_text'] == plone_response['items'][1]['text']
+    # key prefixed with '@' are renamed
+    assert resp.json['data'][1]['parent'] == {
+        'description': '',
+        'review_state': 'published',
+        'title': 'Español',
+        'portal_id': 'https://demo.plone.org/es',
+        'portal_type': 'LRF',
+    }
+
+
+def test_search_using_q(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/search'
+    url = connector.service_url + '/es/@search'
+    params = {
+        'uri': 'es',
+        'text_template': '{{ title }} ({{ portal_type }})',
+        'sort': 'title',
+        'order': True,
+        'limit': '3',
+        'q': 'Página dentro',
+    }
+    qs = {}
+    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
+        resp = app.get(endpoint, params=params)
+    assert qs == {
+        'SearchableText': 'Página dentro',
+        'sort_on': 'title',
+        'sort_order': 'ascending',
+        'b_size': '3',
+        'fullobjects': 'y',
+    }
+    assert not resp.json['err']
+    assert len(resp.json['data']) == 3
+    assert [(x['id'], x['text']) for x in resp.json['data']] == [
+        ('593cf5489fce493a95b59bb4f3ef9ee1', 'Una Página dentro de una carpeta (Document)'),
+        ('19f0cd24fec847c09744fbc85aace167', 'Bienvenido a Plone (Document)'),
+        ('13f909b522a94443823e187ea9ebab0b', 'Una página (Document)'),
+    ]
+
+
+def test_search_using_id(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/search'
+    url = connector.service_url + '/es/@search'
+    params = {
+        'uri': 'es',
+        'text_template': '{{ title }} ({{ portal_type }})',
+        'id': '19f0cd24fec847c09744fbc85aace167',
+    }
+    qs = {}
+    with utils.mock_url(url=url, response=json_get_data('id_search'), qs=qs):
+        resp = app.get(endpoint, params=params)
+    assert qs == {'UID': '19f0cd24fec847c09744fbc85aace167', 'fullobjects': 'y'}
+    assert len(resp.json['data']) == 1
+    assert resp.json['data'][0]['text'] == 'Bienvenido a Plone (Document)'
+
+
+def test_query_q(app, query, token):
+    endpoint = '/plone-restapi/my_connector/q/my_query/'
+    url = query.resource.service_url + '/es/@search'
+    params = {
+        'limit': 3,
+    }
+    qs = {}
+    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
+        resp = app.get(endpoint, params=params)
+        assert qs == {
+            'sort_on': 'UID',
+            'sort_order': 'descending',
+            'b_size': '3',
+            'portal_type': 'Document',
+            'review_state': 'published',
+            'fullobjects': 'y',
+        }
+    assert not resp.json['err']
+    assert len(resp.json['data']) == 3
+    assert [(x['id'], x['text']) for x in resp.json['data']] == [
+        ('593cf5489fce493a95b59bb4f3ef9ee1', 'Una Página dentro de una carpeta (Document)'),
+        ('19f0cd24fec847c09744fbc85aace167', 'Bienvenido a Plone (Document)'),
+        ('13f909b522a94443823e187ea9ebab0b', 'Una página (Document)'),
+    ]
+    assert resp.json['meta'] == {'label': 'demo query', 'description': 'Spanish published documents'}
+
+
+def test_query_q_using_q(app, query, token):
+    endpoint = '/plone-restapi/my_connector/q/my_query/'
+    url = query.resource.service_url + '/es/@search'
+    params = {
+        'q': 'Página dentro',
+    }
+    qs = {}
+    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
+        resp = app.get(endpoint, params=params)
+        assert qs == {
+            'SearchableText': 'Página dentro',
+            'sort_on': 'UID',
+            'sort_order': 'descending',
+            'b_size': '3',
+            'portal_type': 'Document',
+            'review_state': 'published',
+            'fullobjects': 'y',
+        }
+    assert not resp.json['err']
+    assert [(x['id'], x['text']) for x in resp.json['data']] == [
+        ('593cf5489fce493a95b59bb4f3ef9ee1', 'Una Página dentro de una carpeta (Document)'),
+        ('19f0cd24fec847c09744fbc85aace167', 'Bienvenido a Plone (Document)'),
+        ('13f909b522a94443823e187ea9ebab0b', 'Una página (Document)'),
+    ]
+    assert resp.json['meta'] == {'label': 'demo query', 'description': 'Spanish published documents'}
+
+
+def test_query_q_using_id(app, query, token):
+    endpoint = '/plone-restapi/my_connector/q/my_query/'
+    url = query.resource.service_url + '/es/@search'
+    params = {
+        'id': '19f0cd24fec847c09744fbc85aace167',
+    }
+    qs = {}
+    with utils.mock_url(url=url, response=json_get_data('id_search'), qs=qs):
+        resp = app.get(endpoint, params=params)
+    assert qs == {
+        'UID': '19f0cd24fec847c09744fbc85aace167',
+        'fullobjects': 'y',
+    }
+    assert len(resp.json['data']) == 1
+    assert resp.json['data'][0]['text'] == 'Bienvenido a Plone (Document)'
+    assert resp.json['meta'] == {'label': 'demo query', 'description': 'Spanish published documents'}
diff --git a/tests/utils.py b/tests/utils.py
index 867675f8..1ace90b9 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -23,31 +23,33 @@ def setup_access_rights(obj):
 
 class FakedResponse(mock.Mock):
     headers = {}
 
     def json(self):
         return json_loads(self.content)
 
 
-def mock_url(url=None, response='', status_code=200, headers=None, reason=None, exception=None):
+def mock_url(url=None, response='', status_code=200, headers=None, reason=None, exception=None, qs=None):
     urlmatch_kwargs = {}
     if url:
         parsed = urlparse.urlparse(url)
         if parsed.netloc:
             urlmatch_kwargs['netloc'] = parsed.netloc
         if parsed.path:
             urlmatch_kwargs['path'] = parsed.path
 
     if not isinstance(response, str):
         response = json.dumps(response)
 
     @httmock.remember_called
     @httmock.urlmatch(**urlmatch_kwargs)
     def mocked(url, request):
+        if qs is not None:
+            qs.update(urlparse.parse_qsl(url.query))
         if exception:
             raise exception
         return httmock.response(status_code, response, headers, reason, request=request)
 
     return httmock.HTTMock(mocked)
 
 
 def make_resource(model_class, **kwargs):
-- 
2.30.2

