From c70eece683693e011a6ddb34b436b675fa63b816 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  | 153 ++++++++
 .../apps/plone_restapi/migrations/__init__.py |   0
 passerelle/apps/plone_restapi/models.py       | 248 ++++++++++++
 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                   | 367 ++++++++++++++++++
 tests/utils.py                                |   4 +-
 13 files changed, 1208 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..6f0100fd
--- /dev/null
+++ b/passerelle/apps/plone_restapi/migrations/0001_initial.py
@@ -0,0 +1,153 @@
+# Generated by Django 2.2 on 2021-09-27 09:52
+
+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')),
+                (
+                    '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.CharField(
+                        help_text='ex: https://demo.plone.org', max_length=256, verbose_name='Site URL'
+                    ),
+                ),
+                (
+                    '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 refine and exclude facet expressions separated lines',
+                        verbose_name='filter',
+                    ),
+                ),
+                (
+                    'sort',
+                    models.CharField(
+                        blank=True,
+                        help_text='Sorts results by the specified field. A minus sign - may be used to perform an ascending sort.',
+                        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', 'slug'), ('resource', 'name')},
+            },
+        ),
+    ]
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..627ce3d7
--- /dev/null
+++ b/passerelle/apps/plone_restapi/models.py
@@ -0,0 +1,248 @@
+# 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.db import models
+from django.shortcuts import get_object_or_404
+from django.urls import reverse
+from django.utils.six.moves.urllib import parse as urlparse
+from django.utils.translation import ugettext_lazy as _
+from requests import RequestException
+
+from passerelle.base.models import BaseQuery, BaseResource, HTTPResource
+from passerelle.utils.api import endpoint
+from passerelle.utils.jsonresponse import APIError
+from passerelle.utils.templates import render_to_string, validate_template
+
+
+class PloneRestApi(BaseResource, HTTPResource):
+    service_url = models.CharField(
+        _('Site URL'),
+        max_length=256,
+        blank=False,
+        help_text=_('ex: https://demo.plone.org'),
+    )
+
+    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
+
+    def call_search(
+        self,
+        uri='',
+        text_template='',
+        filter_expression='',
+        sort=None,
+        order=True,
+        limit=None,
+        id=None,
+        q=None,
+    ):
+        scheme, netloc, path, query, fragment = urlparse.urlsplit(self.service_url)
+        path = '/'.join(x for x in [path.rstrip('/'), uri.strip('/'), '@search'] if x)
+        url = urlparse.urlunsplit((scheme, netloc, path, '', fragment))
+        params = dict(urlparse.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(urlparse.parse_qsl(filter_expression))
+        params['fullobjects'] = 'y'
+
+        headers = {'Accept': 'application/json'}
+        try:
+            response = self.requests.get(url, headers=headers, params=params)
+        except RequestException as e:
+            raise APIError('PloneRestApi error: %s' % e)
+        try:
+            json_response = response.json()
+        except ValueError as e:
+            raise APIError('PloneRestApi error: bad JSON response')
+        try:
+            response.raise_for_status()
+        except RequestException as e:
+            raise APIError('PloneRestApi error: %s "%s"' % (e, json_response))
+
+        def replace_arobase_keys(data):
+            if isinstance(data, list):
+                for value in list(data):
+                    replace_arobase_keys(value)
+            elif isinstance(data, dict):
+                for key, value in list(data.items()):
+                    replace_arobase_keys(value)
+                    if key[0] == '@':
+                        data['portal_%s' % key[1:]] = value
+                        del data[key]
+
+        replace_arobase_keys(json_response)
+        result = []
+        for record in json_response.get('items'):
+            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()
+            result.append(data)
+
+        return result
+
+    @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 refine and exclude facet expressions separated lines'),
+        blank=True,
+    )
+    sort = models.CharField(
+        verbose_name=_('Sort field'),
+        help_text=_(
+            "Sorts results by the specified field. A minus sign - may be used to perform an ascending sort."
+        ),
+        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..0df0b9a2
--- /dev/null
+++ b/tests/test_plone_restapi.py
@@ -0,0 +1,367 @@
+# -*- 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 mock
+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
+
+pytestmark = pytest.mark.django_db
+
+TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'plone_restapi')
+
+
+# test data comes from https://demo.plone.org/en
+# TODO : gérer les accents
+# $ curl 'https://demo.plone.org/es/@search?b_size=5' -H "Accept: application/json
+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',
+        )
+    )
+
+
+@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 Query.objects.count() == 1
+
+
+def test_call_search_errors(app, connector):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/search'
+    url = connector.service_url + '/@search'
+
+    # Connection error
+    exception = ConnectionError('Remote end closed connection without response')
+    with utils.mock_url(url=url, exception=exception):
+        resp = app.get(endpoint)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == 'PloneRestApi error: Remote end closed connection without response'
+
+    # HTTP error
+    json_response = {"message": "Resource not found: https://demo.plone.org/es/@searh", "type": "NotFound"}
+    with utils.mock_url(url=url, response=json_response, status_code=404):
+        resp = app.get(endpoint)
+    assert resp.json['err']
+    assert 'PloneRestApi error: 404 Client Error' in resp.json['err_desc']
+    assert 'Resource not found' in resp.json['err_desc']
+
+    # bad JSON response
+    with utils.mock_url(url=url, response='not a json content', status_code=200):
+        resp = app.get(endpoint)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == 'PloneRestApi error: bad JSON response'
+
+
+def test_call_search_normalize_keys(app, connector):
+    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(app, connector):
+    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 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_search_using_q(app, connector):
+    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': False,
+        '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': '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_search_using_id(app, connector):
+    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):
+    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):
+    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):
+    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

