From bd3ca2b50fb2e687cddcf3b9f3452a49ce2dd7c4 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       | 441 +++++++++++
 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/fetch.json           | 262 +++++++
 .../data/plone_restapi/get_content_type.json  | 692 ++++++++++++++++++
 .../data/plone_restapi/get_content_types.json |  52 ++
 .../data/plone_restapi/get_field_choices.json |  74 ++
 tests/data/plone_restapi/id_search.json       | 266 +++++++
 tests/data/plone_restapi/q_search.json        | 629 ++++++++++++++++
 .../data/plone_restapi/workflow_publish.json  |   8 +
 tests/test_plone_restapi.py                   | 606 +++++++++++++++
 tests/utils.py                                |   4 +-
 18 files changed, 3293 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/fetch.json
 create mode 100644 tests/data/plone_restapi/get_content_type.json
 create mode 100644 tests/data/plone_restapi/get_content_types.json
 create mode 100644 tests/data/plone_restapi/get_field_choices.json
 create mode 100644 tests/data/plone_restapi/id_search.json
 create mode 100644 tests/data/plone_restapi/q_search.json
 create mode 100644 tests/data/plone_restapi/workflow_publish.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..6ff0b9b6
--- /dev/null
+++ b/passerelle/apps/plone_restapi/models.py
@@ -0,0 +1,441 @@
+# 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')
+    plone_keys_to_rename = ['@id', '@type', '@components']
+
+    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 adapt_id_and_type_plone_attributes(self, data):
+        """Rename keys starting with '@' from plone response
+        ex: '@id' is renammed into 'PLONE_id'"""
+        if isinstance(data, list):
+            for value in list(data):
+                self.adapt_id_and_type_plone_attributes(value)
+        elif isinstance(data, dict):
+            for key, value in list(data.items()):
+                self.adapt_id_and_type_plone_attributes(value)
+                if key in self.plone_keys_to_rename and key[0] == '@':
+                    data['PLONE_%s' % key[1:]] = value
+                    del data[key]
+
+    def adapt_payload(self, payload):
+        # convert image format
+        for file_field in payload.values():
+            if isinstance(file_field, dict) and file_field.get('filename'):
+                file_field['encoding'] = 'base64'
+                file_field['data'] = file_field['content']
+                file_field['content-type'] = file_field['content_type']
+                del file_field['content']
+
+    def adapt_record(
+        self,
+        record,
+        text_template='{{ id }}',
+        id_key='UID',
+    ):
+        self.adapt_id_and_type_plone_attributes(record)
+        for key, value in list(record.items()):
+            # backup original id and text fields
+            if key in ('id', 'text'):
+                key = 'original_%s' % key
+            record[key] = value
+        record['id'] = record.get(id_key)
+        record['text'] = render_to_string(text_template, record).strip()
+
+    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'
+        response = self.request(uri=uri, uid='@search', method='GET', params=params)
+
+        for record in response.get('items') or []:
+            self.adapt_record(record, text_template)
+        return response.get('items') or []
+
+    @endpoint(
+        perm='can_access',
+        description=_('Get content types'),
+        display_order=1,
+    )
+    def get_content_types(self, request):
+        response = self.request(uri='@types', method='GET')
+        for record in response or []:
+            self.adapt_record(record, '{{ title }}', id_key='PLONE_id')
+            record['id'] = record['id'].split('/')[-1]
+        return {'data': response or []}
+
+    @endpoint(
+        perm='can_access',
+        description=_('Get content type'),
+        parameters={
+            'id': {'description': _('Content type identifier'), 'example_value': 'imio.directory.Contact'}
+        },
+        display_order=2,
+    )
+    def get_content_type(self, request, id):
+        response = self.request(uri='@types', uid=id, method='GET')
+        return {'data': response}
+
+    @endpoint(
+        perm='can_access',
+        description=_('Get field choices'),
+        parameters={
+            'id': {'description': _('Field identifier'), 'example_value': 'imio.smartweb.vocabulary.Topics'}
+        },
+        display_order=3,
+    )
+    def get_field_choices(self, request, id):
+        response = self.request(uri='@vocabularies', uid=id, method='GET')
+        for record in response.get('items') or []:
+            self.adapt_record(record, '{{ title }}', id_key='token')
+        return {'data': response.get('items') or []}
+
+    @endpoint(
+        perm='can_access',
+        description=_('Fetch'),
+        parameters={
+            'uri': {'description': _('Uri')},
+            'uid': {'description': _('Uid')},
+            'text_template': {'description': _('Text template')},
+        },
+        display_order=4,
+    )
+    def fetch(self, request, uid, uri='', text_template=''):
+        response = self.request(uri=uri, uid=uid, method='GET')
+        self.adapt_record(response, text_template)
+        return {'data': response}
+
+    @endpoint(
+        perm='can_access',
+        description=_('Creates'),
+        parameters={
+            'uri': {'description': _('Uri')},
+            'publish': {'description': _('Do publish content (default is false)')},
+        },
+        methods=['post'],
+        display_order=5,
+    )
+    def create(self, request, uri, publish=False):
+        try:
+            post_data = json_loads(request.body)
+        except ValueError as e:
+            raise ParameterTypeError(str(e))
+        post_data = unflatten(post_data)
+        self.adapt_payload(post_data)
+        response = self.request(uri=uri, method='POST', json=post_data)
+        uid = response.get('UID')
+
+        review_state = None
+        if uid and bool(publish):
+            uri += '/%s' % uid
+            response = self.request(uri, uid='@workflow/publish', method='POST')
+            review_state = response.get('review_state')
+
+        return {'data': {'uid': uid, 'created': True, 'review_state': review_state}}
+
+    @endpoint(
+        perm='can_access',
+        description=_('Update'),
+        parameters={
+            'uri': {'description': _('Uri')},
+            'uid': {'description': _('Uid')},
+        },
+        methods=['post'],
+        display_order=6,
+    )
+    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.adapt_payload(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=7,
+    )
+    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/fetch.json b/tests/data/plone_restapi/fetch.json
new file mode 100644
index 00000000..3ad65ae3
--- /dev/null
+++ b/tests/data/plone_restapi/fetch.json
@@ -0,0 +1,262 @@
+{
+  "@components": {
+    "actions": {
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@actions"
+    },
+    "breadcrumbs": {
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@breadcrumbs"
+    },
+    "contextnavigation": {
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@contextnavigation"
+    },
+    "navigation": {
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@navigation"
+    },
+    "types": {
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@types"
+    },
+    "workflow": {
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@workflow"
+    }
+  },
+  "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287",
+  "@type": "imio.directory.Contact",
+  "UID": "dccd85d12cf54b6899dff41e5a56ee7f",
+  "allow_discussion": false,
+  "city": "Braine-l'Alleud",
+  "complement": null,
+  "country": {
+    "title": "Belgique",
+    "token": "be"
+  },
+  "created": "2021-07-28T07:53:01+00:00",
+  "description": "Ouvert du lundi au samedi toute l'ann\u00e9e.\r\n\r\nContact : Thierry Vou\u00e9",
+  "exceptional_closure": [],
+  "facilities": null,
+  "geolocation": {
+    "latitude": 50.4989185,
+    "longitude": 4.7184485
+  },
+  "iam": [
+    {
+      "title": "Jeune",
+      "token": "young"
+    }
+  ],
+  "id": "c44f1b32f0ce436eb7a042ca8933b287",
+  "image": null,
+  "image_caption": null,
+  "is_folderish": true,
+  "is_geolocated": true,
+  "items": [],
+  "items_total": 0,
+  "language": {
+    "title": "Fran\u00e7ais",
+    "token": "fr"
+  },
+  "layout": "view",
+  "logo": {
+    "content-type": "image/png",
+    "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/d4e7b99f-98c2-4c85-87fa-7fb0ebb31c16.png",
+    "filename": "maison jeunes le prisme.png",
+    "height": 1536,
+    "scales": {
+      "banner": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/02ef9609-1182-4a3d-9ce6-eb0449309b55.png",
+        "height": 590,
+        "width": 1920
+      },
+      "extralarge": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/5be772a5-dc46-417c-aab6-ae4352536a48.png",
+        "height": 405,
+        "width": 1320
+      },
+      "icon": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/21e0bb31-8a45-4e42-a4ef-60f8070d7ef9.png",
+        "height": 9,
+        "width": 32
+      },
+      "large": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/1cd53cba-e9ff-4abb-a43a-9e859c6959dc.png",
+        "height": 236,
+        "width": 768
+      },
+      "listing": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/09c2223f-0fb0-4fcb-bcb2-2501af8543cd.png",
+        "height": 4,
+        "width": 16
+      },
+      "medium": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/9db56c58-9cf9-4b72-afdf-1e2d167e7fee.png",
+        "height": 184,
+        "width": 600
+      },
+      "mini": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/67a49d61-159d-4ca7-9e06-0de39838e5c9.png",
+        "height": 61,
+        "width": 200
+      },
+      "preview": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/2ff94086-ce56-45cc-b293-fd796931dbe5.png",
+        "height": 123,
+        "width": 400
+      },
+      "thumb": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/0ac5cdc9-c5c2-4aed-8e1d-9260311236f2.png",
+        "height": 39,
+        "width": 128
+      },
+      "tile": {
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/e439acab-b379-46e4-b939-52f5a7ba67a6.png",
+        "height": 19,
+        "width": 64
+      }
+    },
+    "size": 1268077,
+    "width": 4995
+  },
+  "mails": [
+    {
+      "label": null,
+      "mail_address": "info@leprisme.be",
+      "type": "work"
+    }
+  ],
+  "modified": "2021-10-01T17:07:32+00:00",
+  "multi_schedule": [],
+  "next_item": {
+    "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c812043a3ed44e00815e342de34a61c9",
+    "@type": "imio.directory.Contact",
+    "description": "",
+    "title": "Parc \u00e0 conteneurs de Braine-l'Alleud"
+  },
+  "number": "103",
+  "parent": {
+    "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
+    "@type": "imio.directory.Entity",
+    "description": "",
+    "review_state": "published",
+    "title": "Braine-l'Alleud"
+  },
+  "phones": [
+    {
+      "label": null,
+      "number": "+3223870926",
+      "type": "work"
+    },
+    {
+      "label": null,
+      "number": "+32475916819",
+      "type": "cell"
+    }
+  ],
+  "previous_item": {
+    "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3d595a01fa814af09cf9aac35a11b9b0",
+    "@type": "imio.directory.Contact",
+    "description": "",
+    "title": "Kinepolis Imagibraine"
+  },
+  "review_state": "published",
+  "schedule": {
+    "friday": {
+      "afternoonend": "",
+      "afternoonstart": "",
+      "comment": "",
+      "morningend": "17:00",
+      "morningstart": "10:00"
+    },
+    "monday": {
+      "afternoonend": "",
+      "afternoonstart": "",
+      "comment": "",
+      "morningend": "17:00",
+      "morningstart": "10:00"
+    },
+    "saturday": {
+      "afternoonend": "",
+      "afternoonstart": "",
+      "comment": "",
+      "morningend": "",
+      "morningstart": ""
+    },
+    "sunday": {
+      "afternoonend": "",
+      "afternoonstart": "",
+      "comment": "",
+      "morningend": "",
+      "morningstart": ""
+    },
+    "thursday": {
+      "afternoonend": "",
+      "afternoonstart": "",
+      "comment": "",
+      "morningend": "17:00",
+      "morningstart": "10:00"
+    },
+    "tuesday": {
+      "afternoonend": "",
+      "afternoonstart": "",
+      "comment": "",
+      "morningend": "17:00",
+      "morningstart": "10:00"
+    },
+    "wednesday": {
+      "afternoonend": "",
+      "afternoonstart": "",
+      "comment": "",
+      "morningend": "17:00",
+      "morningstart": "10:00"
+    }
+  },
+  "selected_entities": [
+    {
+      "title": "Braine-l'Alleud",
+      "token": "f571b73a16f34832a5fdd3683533b3cc"
+    }
+  ],
+  "street": "Avenue Alphonse Allard",
+  "subjects": [
+    "mj"
+  ],
+  "subtitle": "Maison de Jeunes de Braine-l'Alleud",
+  "taxonomy_contact_category": [
+    {
+      "title": "Loisirs \u00bb Mouvements et associations \u00bb Jeunesse",
+      "token": "oqa05qwd45"
+    }
+  ],
+  "title": "Le Prisme",
+  "topics": [
+    {
+      "title": "Activit\u00e9s et divertissement",
+      "token": "entertainment"
+    },
+    {
+      "title": "Culture",
+      "token": "culture"
+    },
+    {
+      "title": "Sports",
+      "token": "sports"
+    },
+    {
+      "title": "Participation citoyenne",
+      "token": "citizen_participation"
+    }
+  ],
+  "type": {
+    "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
+    "token": "organization"
+  },
+  "urls": [
+    {
+      "type": "website",
+      "url": "http://www.leprisme.be/"
+    }
+  ],
+  "vat_number": null,
+  "version": "current",
+  "working_copy": null,
+  "working_copy_of": null,
+  "zipcode": 1420
+}
diff --git a/tests/data/plone_restapi/get_content_type.json b/tests/data/plone_restapi/get_content_type.json
new file mode 100644
index 00000000..42e839d7
--- /dev/null
+++ b/tests/data/plone_restapi/get_content_type.json
@@ -0,0 +1,692 @@
+{
+  "fieldsets": [
+    {
+      "behavior": "plone",
+      "fields": [
+        "title",
+        "description",
+        "type",
+        "subtitle",
+        "logo",
+        "image",
+        "image_caption",
+        "geolocation"
+      ],
+      "id": "default",
+      "title": "G\u00e9n\u00e9ral"
+    },
+    {
+      "behavior": "plone",
+      "fields": [
+        "street",
+        "number",
+        "complement",
+        "zipcode",
+        "city",
+        "country"
+      ],
+      "id": "address",
+      "title": "Adresse"
+    },
+    {
+      "behavior": "plone",
+      "fields": [
+        "vat_number",
+        "phones",
+        "mails",
+        "urls"
+      ],
+      "id": "contact_informations",
+      "title": "Coordonn\u00e9es"
+    },
+    {
+      "behavior": "plone.dexterity.schema.generated",
+      "fields": [
+        "selected_entities",
+        "facilities",
+        "taxonomy_contact_category",
+        "topics",
+        "iam",
+        "subjects",
+        "language"
+      ],
+      "id": "categorization",
+      "title": "Cat\u00e9gorisation"
+    },
+    {
+      "behavior": "plone",
+      "fields": [
+        "schedule",
+        "multi_schedule"
+      ],
+      "id": "multischedule",
+      "title": "Horaire multiple"
+    },
+    {
+      "behavior": "plone",
+      "fields": [
+        "exceptional_closure"
+      ],
+      "id": "exceptionalclosure",
+      "title": "Fermeture exceptionnelle"
+    }
+  ],
+  "layouts": [
+    "view"
+  ],
+  "properties": {
+    "city": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Text line (String)",
+      "title": "Commune",
+      "type": "string"
+    },
+    "complement": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Text line (String)",
+      "title": "Compl\u00e9ment",
+      "type": "string"
+    },
+    "country": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Choice",
+      "title": "Pays",
+      "type": "string",
+      "vocabulary": {
+        "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.smartweb.vocabulary.Countries"
+      }
+    },
+    "description": {
+      "behavior": "plone.basic",
+      "description": "Utilis\u00e9 dans les pages listant des \u00e9l\u00e9ments et les pages de r\u00e9sultats de recherche.",
+      "factory": "Text",
+      "maxLength": 700,
+      "title": "Description",
+      "type": "string",
+      "widget": "textarea"
+    },
+    "exceptional_closure": {
+      "additionalItems": true,
+      "behavior": "collective.schedulefield.behavior.IExceptionalClosureContent",
+      "description": "",
+      "factory": "Collection",
+      "items": {
+        "description": "",
+        "factory": "File",
+        "properties": {
+          "ExceptionalClosure.date": {
+            "description": "",
+            "factory": "Date",
+            "title": "Date",
+            "type": "string",
+            "widget": "date"
+          },
+          "ExceptionalClosure.title": {
+            "description": "",
+            "factory": "Text",
+            "title": "Titre",
+            "type": "string",
+            "widget": "textarea"
+          }
+        },
+        "title": "",
+        "type": "object"
+      },
+      "title": "Dates",
+      "type": "array",
+      "uniqueItems": false
+    },
+    "facilities": {
+      "additionalItems": true,
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "Important! Ces cat\u00e9gories permettent de mettre en avant et de g\u00e9olocaliser certains services de base",
+      "factory": "List",
+      "items": {
+        "description": "",
+        "factory": "Choice",
+        "title": "",
+        "type": "string",
+        "vocabulary": {
+          "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.directory.vocabulary.Facilities"
+        }
+      },
+      "title": "Facilit\u00e9s",
+      "type": "array",
+      "uniqueItems": true
+    },
+    "geolocation": {
+      "behavior": "geolocatable",
+      "description": "Cliquez sur la carte pour s\u00e9lectionner une localisation, ou utilisez le champ texte pour rechercher par adresse.",
+      "factory": "File",
+      "properties": {
+        "geolocation.latitude": {
+          "description": "",
+          "factory": "Floating-point number",
+          "title": "Latitude",
+          "type": "number"
+        },
+        "geolocation.longitude": {
+          "description": "",
+          "factory": "Floating-point number",
+          "title": "Longitude",
+          "type": "number"
+        }
+      },
+      "title": "G\u00e9olocalisation",
+      "type": "object"
+    },
+    "iam": {
+      "additionalItems": true,
+      "behavior": "imio.smartweb.iam",
+      "description": "Important! Ces cat\u00e9gories sont utilis\u00e9es pour cr\u00e9er des listes accessibles via le menu navigation",
+      "factory": "List",
+      "items": {
+        "description": "",
+        "factory": "Choice",
+        "title": "",
+        "type": "string",
+        "vocabulary": {
+          "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.smartweb.vocabulary.IAm"
+        }
+      },
+      "title": "Je suis",
+      "type": "array",
+      "uniqueItems": true
+    },
+    "image": {
+      "behavior": "plone.leadimage",
+      "description": "",
+      "factory": "Image",
+      "properties": {
+        "image.contentType": {
+          "default": "",
+          "description": "The content type identifies the type of data.",
+          "factory": "Text line (String)",
+          "title": "Content Type",
+          "type": "string"
+        },
+        "image.data": {
+          "default": "",
+          "description": "The actual content of the object.",
+          "factory": "Text line (String)",
+          "title": "Data",
+          "type": "string"
+        },
+        "image.filename": {
+          "description": "",
+          "factory": "Text line (String)",
+          "title": "Filename",
+          "type": "string"
+        }
+      },
+      "title": "Image principale",
+      "type": "object"
+    },
+    "image_caption": {
+      "behavior": "plone.leadimage",
+      "description": "",
+      "factory": "Text line (String)",
+      "title": "L\u00e9gende de l'image principale",
+      "type": "string"
+    },
+    "language": {
+      "behavior": "plone.categorization",
+      "default": "fr",
+      "description": "",
+      "factory": "Choice",
+      "title": "Langue",
+      "type": "string",
+      "vocabulary": {
+        "@id": "https://annuaire.preprod.imio.be/@vocabularies/plone.app.vocabularies.SupportedContentLanguages"
+      }
+    },
+    "logo": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Image",
+      "properties": {
+        "logo.contentType": {
+          "default": "",
+          "description": "The content type identifies the type of data.",
+          "factory": "Text line (String)",
+          "title": "Content Type",
+          "type": "string"
+        },
+        "logo.data": {
+          "default": "",
+          "description": "The actual content of the object.",
+          "factory": "Text line (String)",
+          "title": "Data",
+          "type": "string"
+        },
+        "logo.filename": {
+          "description": "",
+          "factory": "Text line (String)",
+          "title": "Filename",
+          "type": "string"
+        }
+      },
+      "title": "Logo",
+      "type": "object"
+    },
+    "mails": {
+      "additionalItems": true,
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "List",
+      "items": {
+        "description": "",
+        "factory": "File",
+        "properties": {
+          "label": {
+            "description": "",
+            "factory": "Text line (String)",
+            "title": "Intitul\u00e9 (Secr\u00e9tariat, Bureau de la direction, Ventes, ...)",
+            "type": "string"
+          },
+          "mail_address": {
+            "description": "",
+            "factory": "Email",
+            "title": "Adresse e-mail",
+            "type": "string",
+            "widget": "email"
+          },
+          "type": {
+            "description": "",
+            "factory": "Choice",
+            "title": "Type",
+            "type": "string",
+            "vocabulary": {
+              "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.directory.vocabulary.MailTypes"
+            }
+          }
+        },
+        "title": "Value",
+        "type": "object"
+      },
+      "title": "E-mails",
+      "type": "array",
+      "uniqueItems": false
+    },
+    "multi_schedule": {
+      "additionalItems": true,
+      "behavior": "collective.schedulefield.behavior.IMultiScheduledContent",
+      "description": "",
+      "factory": "Collection",
+      "items": {
+        "description": "",
+        "factory": "File",
+        "properties": {
+          "MultiSchedule.dates": {
+            "additionalItems": true,
+            "description": "",
+            "factory": "List",
+            "items": {
+              "description": "",
+              "factory": "File",
+              "properties": {
+                "DateRange.end_date": {
+                  "description": "",
+                  "factory": "Date",
+                  "title": "Date de fin",
+                  "type": "string",
+                  "widget": "date"
+                },
+                "DateRange.start_date": {
+                  "description": "",
+                  "factory": "Date",
+                  "title": "Date de d\u00e9but",
+                  "type": "string",
+                  "widget": "date"
+                }
+              },
+              "title": "",
+              "type": "object"
+            },
+            "title": "Dates",
+            "type": "array",
+            "uniqueItems": false
+          },
+          "MultiSchedule.schedule": {
+            "description": "",
+            "key_type": {
+              "additional": {},
+              "schema": {
+                "description": "",
+                "factory": "Text line (String)",
+                "title": "",
+                "type": "string"
+              }
+            },
+            "title": "Horaire",
+            "type": "dict",
+            "value_type": {
+              "additional": {
+                "key_type": {
+                  "additional": {},
+                  "schema": {
+                    "description": "",
+                    "factory": "Text line (String)",
+                    "title": "",
+                    "type": "string"
+                  }
+                },
+                "value_type": {
+                  "additional": {},
+                  "schema": {
+                    "description": "",
+                    "factory": "Text line (String)",
+                    "title": "",
+                    "type": "string"
+                  }
+                }
+              },
+              "schema": {
+                "description": "",
+                "key_type": {
+                  "additional": {},
+                  "schema": {
+                    "description": "",
+                    "factory": "Text line (String)",
+                    "title": "",
+                    "type": "string"
+                  }
+                },
+                "title": "",
+                "type": "dict",
+                "value_type": {
+                  "additional": {},
+                  "schema": {
+                    "description": "",
+                    "factory": "Text line (String)",
+                    "title": "",
+                    "type": "string"
+                  }
+                }
+              }
+            }
+          },
+          "MultiSchedule.title": {
+            "description": "",
+            "factory": "Text",
+            "title": "Titre",
+            "type": "string",
+            "widget": "textarea"
+          }
+        },
+        "title": "",
+        "type": "object"
+      },
+      "title": "Horaire multiple",
+      "type": "array",
+      "uniqueItems": false
+    },
+    "number": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Text line (String)",
+      "title": "Num\u00e9ro",
+      "type": "string"
+    },
+    "phones": {
+      "additionalItems": true,
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "List",
+      "items": {
+        "description": "",
+        "factory": "File",
+        "properties": {
+          "label": {
+            "description": "",
+            "factory": "Text line (String)",
+            "title": "Intitul\u00e9 (direction, num\u00e9ro principal, ...)",
+            "type": "string"
+          },
+          "number": {
+            "description": "",
+            "factory": "Text line (String)",
+            "title": "Num\u00e9ro (format: +32475010203)",
+            "type": "string"
+          },
+          "type": {
+            "description": "",
+            "factory": "Choice",
+            "title": "Type",
+            "type": "string",
+            "vocabulary": {
+              "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.directory.vocabulary.PhoneTypes"
+            }
+          }
+        },
+        "title": "Value",
+        "type": "object"
+      },
+      "title": "T\u00e9l\u00e9phones",
+      "type": "array",
+      "uniqueItems": false
+    },
+    "schedule": {
+      "behavior": "collective.schedulefield.behavior.IMultiScheduledContent",
+      "description": "",
+      "key_type": {
+        "additional": {},
+        "schema": {
+          "description": "",
+          "factory": "Text line (String)",
+          "title": "",
+          "type": "string"
+        }
+      },
+      "title": "Horaire",
+      "type": "dict",
+      "value_type": {
+        "additional": {
+          "key_type": {
+            "additional": {},
+            "schema": {
+              "description": "",
+              "factory": "Text line (String)",
+              "title": "",
+              "type": "string"
+            }
+          },
+          "value_type": {
+            "additional": {},
+            "schema": {
+              "description": "",
+              "factory": "Text line (String)",
+              "title": "",
+              "type": "string"
+            }
+          }
+        },
+        "schema": {
+          "description": "",
+          "key_type": {
+            "additional": {},
+            "schema": {
+              "description": "",
+              "factory": "Text line (String)",
+              "title": "",
+              "type": "string"
+            }
+          },
+          "title": "",
+          "type": "dict",
+          "value_type": {
+            "additional": {},
+            "schema": {
+              "description": "",
+              "factory": "Text line (String)",
+              "title": "",
+              "type": "string"
+            }
+          }
+        }
+      }
+    },
+    "selected_entities": {
+      "additionalItems": true,
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "default": [],
+      "description": "S\u00e9lectionnez les entit\u00e9s dans lesquelles ce contact sera affich\u00e9. L'entit\u00e9 courante sera toujours s\u00e9lectionn\u00e9e.",
+      "factory": "List",
+      "items": {
+        "description": "",
+        "factory": "Choice",
+        "title": "",
+        "type": "string",
+        "vocabulary": {
+          "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.directory.vocabulary.EntitiesUIDs"
+        }
+      },
+      "title": "Entit\u00e9s concern\u00e9es",
+      "type": "array",
+      "uniqueItems": true
+    },
+    "street": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Text line (String)",
+      "title": "Rue",
+      "type": "string"
+    },
+    "subjects": {
+      "additionalItems": true,
+      "behavior": "plone.categorization",
+      "description": "Les mots cl\u00e9s sont utilis\u00e9s couramment pour organiser le contenu de fa\u00e7on ad hoc.",
+      "factory": "Tuple",
+      "items": {
+        "description": "",
+        "factory": "Text line (String)",
+        "title": "",
+        "type": "string"
+      },
+      "title": "Mots cl\u00e9s",
+      "type": "array",
+      "uniqueItems": true,
+      "widgetOptions": {
+        "vocabulary": {
+          "@id": "https://annuaire.preprod.imio.be/@vocabularies/plone.app.vocabularies.Keywords"
+        }
+      }
+    },
+    "subtitle": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Text line (String)",
+      "title": "Sous-titre",
+      "type": "string"
+    },
+    "taxonomy_contact_category": {
+      "additionalItems": true,
+      "behavior": "collective.taxonomy.generated.contact_category",
+      "description": "Important! Ces cat\u00e9gories sont utilis\u00e9es pour am\u00e9liorer la recherche et filtrer les fiches dans l'annuaire",
+      "factory": "List",
+      "items": {
+        "description": "",
+        "factory": "Choice",
+        "title": "",
+        "type": "string",
+        "vocabulary": {
+          "@id": "https://annuaire.preprod.imio.be/@vocabularies/collective.taxonomy.contact_category"
+        }
+      },
+      "title": "Cat\u00e9gorie",
+      "type": "array",
+      "uniqueItems": true
+    },
+    "title": {
+      "behavior": "plone.basic",
+      "description": "",
+      "factory": "Text line (String)",
+      "title": "Titre",
+      "type": "string"
+    },
+    "topics": {
+      "additionalItems": true,
+      "behavior": "imio.smartweb.topics",
+      "description": "Important! Les th\u00e9matiques sont utilis\u00e9es pour filtrer les r\u00e9sultats de la recherche et cr\u00e9er des listes",
+      "factory": "List",
+      "items": {
+        "description": "",
+        "factory": "Choice",
+        "title": "",
+        "type": "string",
+        "vocabulary": {
+          "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.smartweb.vocabulary.Topics"
+        }
+      },
+      "title": "Th\u00e9matiques",
+      "type": "array",
+      "uniqueItems": true
+    },
+    "type": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Choice",
+      "title": "Type",
+      "type": "string",
+      "vocabulary": {
+        "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.directory.vocabulary.ContactTypes"
+      }
+    },
+    "urls": {
+      "additionalItems": true,
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "List",
+      "items": {
+        "description": "",
+        "factory": "File",
+        "properties": {
+          "type": {
+            "description": "",
+            "factory": "Choice",
+            "title": "Type",
+            "type": "string",
+            "vocabulary": {
+              "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.directory.vocabulary.SiteTypes"
+            }
+          },
+          "url": {
+            "description": "",
+            "factory": "URL",
+            "title": "URL",
+            "type": "string",
+            "widget": "url"
+          }
+        },
+        "title": "Value",
+        "type": "object"
+      },
+      "title": "URLs",
+      "type": "array",
+      "uniqueItems": false
+    },
+    "vat_number": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Text line (String)",
+      "title": "Num\u00e9ro d'entreprise / TVA",
+      "type": "string"
+    },
+    "zipcode": {
+      "behavior": "imio.directory.core.contents.contact.content.IContact",
+      "description": "",
+      "factory": "Integer",
+      "title": "Code postal",
+      "type": "integer"
+    }
+  },
+  "required": [
+    "title",
+    "type"
+  ],
+  "title": "Contact",
+  "type": "object"
+}
diff --git a/tests/data/plone_restapi/get_content_types.json b/tests/data/plone_restapi/get_content_types.json
new file mode 100644
index 00000000..1b2cf812
--- /dev/null
+++ b/tests/data/plone_restapi/get_content_types.json
@@ -0,0 +1,52 @@
+[
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/News Item",
+    "addable": false,
+    "title": "Actualit\u00e9"
+  },
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/Collection",
+    "addable": false,
+    "title": "Collection"
+  },
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/imio.directory.Contact",
+    "addable": false,
+    "title": "Contact"
+  },
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/Folder",
+    "addable": false,
+    "title": "Dossier"
+  },
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/imio.directory.Entity",
+    "addable": true,
+    "title": "Entit\u00e9"
+  },
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/File",
+    "addable": false,
+    "title": "Fichier"
+  },
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/Image",
+    "addable": false,
+    "title": "Image"
+  },
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/Link",
+    "addable": false,
+    "title": "Lien"
+  },
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/Document",
+    "addable": false,
+    "title": "Page Web"
+  },
+  {
+    "@id": "https://annuaire.preprod.imio.be/@types/Event",
+    "addable": false,
+    "title": "\u00c9v\u00e9nement"
+  }
+]
diff --git a/tests/data/plone_restapi/get_field_choices.json b/tests/data/plone_restapi/get_field_choices.json
new file mode 100644
index 00000000..9546afef
--- /dev/null
+++ b/tests/data/plone_restapi/get_field_choices.json
@@ -0,0 +1,74 @@
+{
+  "@id": "https://annuaire.preprod.imio.be/@vocabularies/imio.smartweb.vocabulary.Topics",
+  "items": [
+    {
+      "title": "Activit\u00e9s et divertissement",
+      "token": "entertainment"
+    },
+    {
+      "title": "Agriculture",
+      "token": "agriculture"
+    },
+    {
+      "title": "Citoyennet\u00e9",
+      "token": "citizenship"
+    },
+    {
+      "title": "Culture",
+      "token": "culture"
+    },
+    {
+      "title": "\u00c9conomie",
+      "token": "economics"
+    },
+    {
+      "title": "\u00c9ducation",
+      "token": "education"
+    },
+    {
+      "title": "Environnement",
+      "token": "environment"
+    },
+    {
+      "title": "Habitat et urbanisme",
+      "token": "habitat_town_planning"
+    },
+    {
+      "title": "Mobilit\u00e9",
+      "token": "mobility"
+    },
+    {
+      "title": "Participation citoyenne",
+      "token": "citizen_participation"
+    },
+    {
+      "title": "Politique",
+      "token": "politics"
+    },
+    {
+      "title": "Sant\u00e9",
+      "token": "health"
+    },
+    {
+      "title": "S\u00e9curit\u00e9 et pr\u00e9vention",
+      "token": "safety_prevention"
+    },
+    {
+      "title": "Social",
+      "token": "social"
+    },
+    {
+      "title": "Sports",
+      "token": "sports"
+    },
+    {
+      "title": "Territoire et espace public",
+      "token": "territory_public_space"
+    },
+    {
+      "title": "Tourisme",
+      "token": "tourism"
+    }
+  ],
+  "items_total": 17
+}
diff --git a/tests/data/plone_restapi/id_search.json b/tests/data/plone_restapi/id_search.json
new file mode 100644
index 00000000..f925681a
--- /dev/null
+++ b/tests/data/plone_restapi/id_search.json
@@ -0,0 +1,266 @@
+{
+  "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?UID=23a32197d6c841259963b43b24747854&fullobjects=y",
+  "items": [
+    {
+      "@components": {
+        "actions": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@actions"
+        },
+        "breadcrumbs": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@breadcrumbs"
+        },
+        "contextnavigation": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@contextnavigation"
+        },
+        "navigation": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@navigation"
+        },
+        "types": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@types"
+        },
+        "workflow": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@workflow"
+        }
+      },
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb",
+      "@type": "imio.directory.Contact",
+      "UID": "23a32197d6c841259963b43b24747854",
+      "allow_discussion": false,
+      "city": "Braine-l'Alleud",
+      "complement": null,
+      "country": {
+        "title": "Belgique",
+        "token": "be"
+      },
+      "created": "2021-07-28T07:10:02+00:00",
+      "description": "Contact : Jean-Pascal Hinnekens (directeur)",
+      "exceptional_closure": [],
+      "facilities": null,
+      "geolocation": {
+        "latitude": 50.4989185,
+        "longitude": 4.7184485
+      },
+      "iam": [
+        {
+          "title": "Jeune",
+          "token": "young"
+        },
+        {
+          "title": "Nouvel arrivant",
+          "token": "newcomer"
+        },
+        {
+          "title": "Parent",
+          "token": "parent"
+        }
+      ],
+      "id": "3378d97243854ddfa90510f6ceb9fcdb",
+      "image": null,
+      "image_caption": null,
+      "is_folderish": true,
+      "is_geolocated": true,
+      "language": {
+        "title": "Fran\u00e7ais",
+        "token": "fr"
+      },
+      "layout": "view",
+      "logo": {
+        "content-type": "image/png",
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/b5785773-138a-4907-9ec6-b29100f18e85.png",
+        "filename": "acad\u00e9mie musique braine-l'alleud.png",
+        "height": 591,
+        "scales": {
+          "banner": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/64373ca2-151b-439d-a489-288c83a94de6.png",
+            "height": 591,
+            "width": 559
+          },
+          "extralarge": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/cb8f259c-1255-4d5d-9b1a-fdace828f74c.png",
+            "height": 591,
+            "width": 559
+          },
+          "icon": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/77276cc0-e251-450b-8a4c-968e1d5c4ac4.png",
+            "height": 32,
+            "width": 31
+          },
+          "large": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/6ec18ddf-645a-4764-b7b4-fedfe7c1d9e0.png",
+            "height": 591,
+            "width": 559
+          },
+          "listing": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/07b35265-e56f-4fa0-9717-b1982604f9b4.png",
+            "height": 16,
+            "width": 16
+          },
+          "medium": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/4470f78e-19fb-41aa-b897-4f9b0f5f356e.png",
+            "height": 591,
+            "width": 559
+          },
+          "mini": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/548c616b-d37b-43be-b931-42c17b462127.png",
+            "height": 200,
+            "width": 189
+          },
+          "preview": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/84394ebe-2d4d-4894-b331-1636e93ccd38.png",
+            "height": 400,
+            "width": 379
+          },
+          "thumb": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/087c5b17-5ae3-4e9d-a794-a95440e5aa43.png",
+            "height": 128,
+            "width": 121
+          },
+          "tile": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/34278749-0375-4000-924f-b56993076bbd.png",
+            "height": 64,
+            "width": 61
+          }
+        },
+        "size": 232832,
+        "width": 559
+      },
+      "mails": [
+        {
+          "label": null,
+          "mail_address": "academie.musique@braine-lalleud.be",
+          "type": "work"
+        }
+      ],
+      "modified": "2021-10-14T10:48:57+00:00",
+      "multi_schedule": [],
+      "next_item": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442",
+        "@type": "imio.directory.Contact",
+        "description": "\u00ab Accueil et Orientation Volontariat \u00bb guide et conseille les candidats volontaires.  Cette ASBL est pluraliste et poursuit les objectifs suivants :  - faciliter et encourager la pratique de volontariat aupr\u00e8s des associations locales, - informer et recruter des candidats en vue d\u2019un volontariat citoyen de qualit\u00e9.  Si vous d\u00e9sirez vous engager dans une action de volontariat, vous pouvez contacter le centre d\u2019orientation afin de prendre rendez-vous. Vous serez re\u00e7u par 2 volontaires et pourrez choisir les activit\u00e9s convenant le mieux \u00e0 vos aspirations, \u00e0 vos comp\u00e9tences et vos disponibilit\u00e9s.  Permanences le vendredi matin (uniquement sur rendez-vous) \u00e0 l\u2019H\u00f4tel communal (Maison des Associations)",
+        "title": "Accueil et Orientation Volontariat (A.O.V.)"
+      },
+      "number": "49",
+      "parent": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
+        "@type": "imio.directory.Entity",
+        "description": "",
+        "review_state": "published",
+        "title": "Braine-l'Alleud"
+      },
+      "phones": [
+        {
+          "label": null,
+          "number": "+3228540720",
+          "type": "work"
+        },
+        {
+          "label": null,
+          "number": "+3228540729",
+          "type": "fax"
+        }
+      ],
+      "previous_item": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47",
+        "@type": "imio.directory.Contact",
+        "description": "Contact :  Vinciane Vrielinck",
+        "title": "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud"
+      },
+      "review_state": "published",
+      "schedule": {
+        "friday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "monday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "saturday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "sunday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "thursday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "tuesday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "wednesday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        }
+      },
+      "selected_entities": [
+        {
+          "title": "Braine-l'Alleud",
+          "token": "f571b73a16f34832a5fdd3683533b3cc"
+        }
+      ],
+      "street": "Rue du Ch\u00e2teau",
+      "subjects": [
+        "\u00e9cole"
+      ],
+      "subtitle": null,
+      "taxonomy_contact_category": [
+        {
+          "title": "Loisirs \u00bb Cours et activit\u00e9s \u00bb Musique",
+          "token": "3qaeiq8v2p"
+        }
+      ],
+      "title": "Acad\u00e9mie de Musique de Braine-l'Alleud",
+      "topics": [
+        {
+          "title": "Culture",
+          "token": "culture"
+        },
+        {
+          "title": "\u00c9ducation",
+          "token": "education"
+        }
+      ],
+      "type": {
+        "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
+        "token": "organization"
+      },
+      "urls": [
+        {
+          "type": "website",
+          "url": "http://academie-de-musique.braine-lalleud.be/"
+        }
+      ],
+      "vat_number": null,
+      "version": "current",
+      "working_copy": null,
+      "working_copy_of": null,
+      "zipcode": 1420
+    }
+  ],
+  "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..36d8f5a3
--- /dev/null
+++ b/tests/data/plone_restapi/q_search.json
@@ -0,0 +1,629 @@
+{
+  "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?portal_type=imio.directory.Contact&review_state=published&fullobjects=y",
+  "batching": {
+    "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?b_size=3&portal_type=imio.directory.Contact&review_state=published&fullobjects=y",
+    "first": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?b_start=0&b_size=3&portal_type=imio.directory.Contact&review_state=published&fullobjects=y",
+    "last": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?b_start=261&b_size=3&portal_type=imio.directory.Contact&review_state=published&fullobjects=y",
+    "next": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?b_start=3&b_size=3&portal_type=imio.directory.Contact&review_state=published&fullobjects=y"
+  },
+  "items": [
+    {
+      "@components": {
+        "actions": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@actions"
+        },
+        "breadcrumbs": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@breadcrumbs"
+        },
+        "contextnavigation": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@contextnavigation"
+        },
+        "navigation": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@navigation"
+        },
+        "types": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@types"
+        },
+        "workflow": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@workflow"
+        }
+      },
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47",
+      "@type": "imio.directory.Contact",
+      "UID": "dea9d26baab944beb7e54d4024d35a33",
+      "allow_discussion": false,
+      "city": "Braine-l'Alleud",
+      "complement": null,
+      "country": {
+        "title": "Belgique",
+        "token": "be"
+      },
+      "created": "2021-07-28T07:23:24+00:00",
+      "description": "Contact :  Vinciane Vrielinck",
+      "exceptional_closure": [],
+      "facilities": null,
+      "geolocation": {
+        "latitude": 50.4989185,
+        "longitude": 4.7184485
+      },
+      "iam": [
+        {
+          "title": "Nouvel arrivant",
+          "token": "newcomer"
+        }
+      ],
+      "id": "30bc56007a5140358de0a5ad897b7a47",
+      "image": null,
+      "image_caption": null,
+      "is_folderish": true,
+      "is_geolocated": true,
+      "language": {
+        "title": "Fran\u00e7ais",
+        "token": "fr"
+      },
+      "layout": "view",
+      "logo": null,
+      "mails": [
+        {
+          "label": null,
+          "mail_address": "bourgmestre@braine-lalleud.be",
+          "type": "work"
+        }
+      ],
+      "modified": "2021-09-22T13:15:16+00:00",
+      "multi_schedule": [],
+      "next_item": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb",
+        "@type": "imio.directory.Contact",
+        "description": "Contact : Jean-Pascal Hinnekens (directeur)",
+        "title": "Acad\u00e9mie de Musique de Braine-l'Alleud"
+      },
+      "number": "1",
+      "parent": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
+        "@type": "imio.directory.Entity",
+        "description": "",
+        "review_state": "published",
+        "title": "Braine-l'Alleud"
+      },
+      "phones": [
+        {
+          "label": null,
+          "number": "+3228540500",
+          "type": "work"
+        }
+      ],
+      "previous_item": {},
+      "review_state": "published",
+      "schedule": {
+        "friday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "monday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "saturday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "sunday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "thursday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "tuesday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "wednesday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        }
+      },
+      "selected_entities": [
+        {
+          "title": "Braine-l'Alleud",
+          "token": "f571b73a16f34832a5fdd3683533b3cc"
+        }
+      ],
+      "street": "Avenue du 21 Juillet",
+      "subjects": [
+        "scourneau",
+        "mr"
+      ],
+      "subtitle": null,
+      "taxonomy_contact_category": [
+        {
+          "title": "Service public \u00bb Administration communale",
+          "token": "xhowidw6kd"
+        }
+      ],
+      "title": "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud",
+      "topics": [
+        {
+          "title": "Politique",
+          "token": "politics"
+        }
+      ],
+      "type": {
+        "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
+        "token": "organization"
+      },
+      "urls": [],
+      "vat_number": null,
+      "version": "current",
+      "working_copy": null,
+      "working_copy_of": null,
+      "zipcode": 1420
+    },
+    {
+      "@components": {
+        "actions": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@actions"
+        },
+        "breadcrumbs": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@breadcrumbs"
+        },
+        "contextnavigation": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@contextnavigation"
+        },
+        "navigation": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@navigation"
+        },
+        "types": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@types"
+        },
+        "workflow": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@workflow"
+        }
+      },
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb",
+      "@type": "imio.directory.Contact",
+      "UID": "23a32197d6c841259963b43b24747854",
+      "allow_discussion": false,
+      "city": "Braine-l'Alleud",
+      "complement": null,
+      "country": {
+        "title": "Belgique",
+        "token": "be"
+      },
+      "created": "2021-07-28T07:10:02+00:00",
+      "description": "Contact : Jean-Pascal Hinnekens (directeur)",
+      "exceptional_closure": [],
+      "facilities": null,
+      "geolocation": {
+        "latitude": 50.4989185,
+        "longitude": 4.7184485
+      },
+      "iam": [
+        {
+          "title": "Jeune",
+          "token": "young"
+        },
+        {
+          "title": "Nouvel arrivant",
+          "token": "newcomer"
+        },
+        {
+          "title": "Parent",
+          "token": "parent"
+        }
+      ],
+      "id": "3378d97243854ddfa90510f6ceb9fcdb",
+      "image": null,
+      "image_caption": null,
+      "is_folderish": true,
+      "is_geolocated": true,
+      "language": {
+        "title": "Fran\u00e7ais",
+        "token": "fr"
+      },
+      "layout": "view",
+      "logo": {
+        "content-type": "image/png",
+        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/b5785773-138a-4907-9ec6-b29100f18e85.png",
+        "filename": "acad\u00e9mie musique braine-l'alleud.png",
+        "height": 591,
+        "scales": {
+          "banner": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/64373ca2-151b-439d-a489-288c83a94de6.png",
+            "height": 591,
+            "width": 559
+          },
+          "extralarge": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/cb8f259c-1255-4d5d-9b1a-fdace828f74c.png",
+            "height": 591,
+            "width": 559
+          },
+          "icon": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/77276cc0-e251-450b-8a4c-968e1d5c4ac4.png",
+            "height": 32,
+            "width": 31
+          },
+          "large": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/6ec18ddf-645a-4764-b7b4-fedfe7c1d9e0.png",
+            "height": 591,
+            "width": 559
+          },
+          "listing": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/07b35265-e56f-4fa0-9717-b1982604f9b4.png",
+            "height": 16,
+            "width": 16
+          },
+          "medium": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/4470f78e-19fb-41aa-b897-4f9b0f5f356e.png",
+            "height": 591,
+            "width": 559
+          },
+          "mini": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/548c616b-d37b-43be-b931-42c17b462127.png",
+            "height": 200,
+            "width": 189
+          },
+          "preview": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/84394ebe-2d4d-4894-b331-1636e93ccd38.png",
+            "height": 400,
+            "width": 379
+          },
+          "thumb": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/087c5b17-5ae3-4e9d-a794-a95440e5aa43.png",
+            "height": 128,
+            "width": 121
+          },
+          "tile": {
+            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/34278749-0375-4000-924f-b56993076bbd.png",
+            "height": 64,
+            "width": 61
+          }
+        },
+        "size": 232832,
+        "width": 559
+      },
+      "mails": [
+        {
+          "label": null,
+          "mail_address": "academie.musique@braine-lalleud.be",
+          "type": "work"
+        }
+      ],
+      "modified": "2021-10-14T10:48:57+00:00",
+      "multi_schedule": [],
+      "next_item": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442",
+        "@type": "imio.directory.Contact",
+        "description": "\u00ab Accueil et Orientation Volontariat \u00bb guide et conseille les candidats volontaires.  Cette ASBL est pluraliste et poursuit les objectifs suivants :  - faciliter et encourager la pratique de volontariat aupr\u00e8s des associations locales, - informer et recruter des candidats en vue d\u2019un volontariat citoyen de qualit\u00e9.  Si vous d\u00e9sirez vous engager dans une action de volontariat, vous pouvez contacter le centre d\u2019orientation afin de prendre rendez-vous. Vous serez re\u00e7u par 2 volontaires et pourrez choisir les activit\u00e9s convenant le mieux \u00e0 vos aspirations, \u00e0 vos comp\u00e9tences et vos disponibilit\u00e9s.  Permanences le vendredi matin (uniquement sur rendez-vous) \u00e0 l\u2019H\u00f4tel communal (Maison des Associations)",
+        "title": "Accueil et Orientation Volontariat (A.O.V.)"
+      },
+      "number": "49",
+      "parent": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
+        "@type": "imio.directory.Entity",
+        "description": "",
+        "review_state": "published",
+        "title": "Braine-l'Alleud"
+      },
+      "phones": [
+        {
+          "label": null,
+          "number": "+3228540720",
+          "type": "work"
+        },
+        {
+          "label": null,
+          "number": "+3228540729",
+          "type": "fax"
+        }
+      ],
+      "previous_item": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47",
+        "@type": "imio.directory.Contact",
+        "description": "Contact :  Vinciane Vrielinck",
+        "title": "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud"
+      },
+      "review_state": "published",
+      "schedule": {
+        "friday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "monday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "saturday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "sunday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "thursday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "tuesday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "wednesday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        }
+      },
+      "selected_entities": [
+        {
+          "title": "Braine-l'Alleud",
+          "token": "f571b73a16f34832a5fdd3683533b3cc"
+        }
+      ],
+      "street": "Rue du Ch\u00e2teau",
+      "subjects": [
+        "\u00e9cole"
+      ],
+      "subtitle": null,
+      "taxonomy_contact_category": [
+        {
+          "title": "Loisirs \u00bb Cours et activit\u00e9s \u00bb Musique",
+          "token": "3qaeiq8v2p"
+        }
+      ],
+      "title": "Acad\u00e9mie de Musique de Braine-l'Alleud",
+      "topics": [
+        {
+          "title": "Culture",
+          "token": "culture"
+        },
+        {
+          "title": "\u00c9ducation",
+          "token": "education"
+        }
+      ],
+      "type": {
+        "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
+        "token": "organization"
+      },
+      "urls": [
+        {
+          "type": "website",
+          "url": "http://academie-de-musique.braine-lalleud.be/"
+        }
+      ],
+      "vat_number": null,
+      "version": "current",
+      "working_copy": null,
+      "working_copy_of": null,
+      "zipcode": 1420
+    },
+    {
+      "@components": {
+        "actions": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@actions"
+        },
+        "breadcrumbs": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@breadcrumbs"
+        },
+        "contextnavigation": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@contextnavigation"
+        },
+        "navigation": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@navigation"
+        },
+        "types": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@types"
+        },
+        "workflow": {
+          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@workflow"
+        }
+      },
+      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442",
+      "@type": "imio.directory.Contact",
+      "UID": "f82d2c079131433ea6ab20f9f7f49442",
+      "allow_discussion": false,
+      "city": "Braine-l'Alleud",
+      "complement": null,
+      "country": {
+        "title": "Belgique",
+        "token": "be"
+      },
+      "created": "2021-08-20T09:27:46+00:00",
+      "description": "\u00ab Accueil et Orientation Volontariat \u00bb guide et conseille les candidats volontaires.\r\n\r\nCette ASBL est pluraliste et poursuit les objectifs suivants :\r\n\r\n- faciliter et encourager la pratique de volontariat aupr\u00e8s des associations locales,\r\n- informer et recruter des candidats en vue d\u2019un volontariat citoyen de qualit\u00e9.\r\n\r\nSi vous d\u00e9sirez vous engager dans une action de volontariat, vous pouvez contacter le centre d\u2019orientation afin de prendre rendez-vous. Vous serez re\u00e7u par 2 volontaires et pourrez choisir les activit\u00e9s convenant le mieux \u00e0 vos aspirations, \u00e0 vos comp\u00e9tences et vos disponibilit\u00e9s.\r\n\r\nPermanences le vendredi matin (uniquement sur rendez-vous) \u00e0 l\u2019H\u00f4tel communal (Maison des Associations)",
+      "exceptional_closure": [],
+      "facilities": null,
+      "geolocation": {
+        "latitude": 50.4989185,
+        "longitude": 4.7184485
+      },
+      "iam": [
+        {
+          "title": "Jeune",
+          "token": "young"
+        },
+        {
+          "title": "Nouvel arrivant",
+          "token": "newcomer"
+        }
+      ],
+      "id": "f82d2c079131433ea6ab20f9f7f49442",
+      "image": null,
+      "image_caption": null,
+      "is_folderish": true,
+      "is_geolocated": true,
+      "language": {
+        "title": "Fran\u00e7ais",
+        "token": "fr"
+      },
+      "layout": "view",
+      "logo": null,
+      "mails": [],
+      "modified": "2021-09-23T11:50:05+00:00",
+      "multi_schedule": [],
+      "next_item": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/813f4b02118d498ab779ec8542315e66",
+        "@type": "imio.directory.Contact",
+        "description": "",
+        "title": "Association des commer\u00e7ants et artisans de Braine-l\u2019Alleud"
+      },
+      "number": "3",
+      "parent": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
+        "@type": "imio.directory.Entity",
+        "description": "",
+        "review_state": "published",
+        "title": "Braine-l'Alleud"
+      },
+      "phones": [
+        {
+          "label": null,
+          "number": "+3223846945",
+          "type": "work"
+        }
+      ],
+      "previous_item": {
+        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb",
+        "@type": "imio.directory.Contact",
+        "description": "Contact : Jean-Pascal Hinnekens (directeur)",
+        "title": "Acad\u00e9mie de Musique de Braine-l'Alleud"
+      },
+      "review_state": "published",
+      "schedule": {
+        "friday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "monday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "saturday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "sunday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "thursday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "tuesday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        },
+        "wednesday": {
+          "afternoonend": "",
+          "afternoonstart": "",
+          "comment": "",
+          "morningend": "",
+          "morningstart": ""
+        }
+      },
+      "selected_entities": [
+        {
+          "title": "Braine-l'Alleud",
+          "token": "f571b73a16f34832a5fdd3683533b3cc"
+        }
+      ],
+      "street": "Grand-Place Baudouin 1er",
+      "subjects": [
+        "b\u00e9n\u00e9volat"
+      ],
+      "subtitle": null,
+      "taxonomy_contact_category": [
+        {
+          "title": "Loisirs \u00bb Mouvements et associations",
+          "token": "13drlsiykl"
+        }
+      ],
+      "title": "Accueil et Orientation Volontariat (A.O.V.)",
+      "topics": [
+        {
+          "title": "Participation citoyenne",
+          "token": "citizen_participation"
+        }
+      ],
+      "type": {
+        "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
+        "token": "organization"
+      },
+      "urls": [
+        {
+          "type": "website",
+          "url": "https://www.aovolontariat.be/"
+        }
+      ],
+      "vat_number": null,
+      "version": "current",
+      "working_copy": null,
+      "working_copy_of": null,
+      "zipcode": 1420
+    }
+  ],
+  "items_total": 264
+}
diff --git a/tests/data/plone_restapi/workflow_publish.json b/tests/data/plone_restapi/workflow_publish.json
new file mode 100644
index 00000000..4dacdb02
--- /dev/null
+++ b/tests/data/plone_restapi/workflow_publish.json
@@ -0,0 +1,8 @@
+{
+  "action": "publish",
+  "actor": "brainelalleud-iateleservice",
+  "comments": "",
+  "review_state": "published",
+  "time": "2021-10-18T12:40:11+00:00",
+  "title": "Publi\u00e9"
+}
diff --git a/tests/test_plone_restapi.py b/tests/test_plone_restapi.py
new file mode 100644
index 00000000..5bfe69d0
--- /dev/null
+++ b/tests/test_plone_restapi.py
@@ -0,0 +1,606 @@
+# -*- 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",
+}
+
+
+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="Annuaire de Braine-l'Alleud",
+        uri='braine-l-alleud',
+        text_template='{{ title }} ({{ PLONE_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_adapt_id_and_type_plone_attributes(connector):
+    plone_response = {
+        '@type': '@value',
+        '@dict': {
+            '@array': [
+                {
+                    '@id': '123',
+                    '@type': '@value',
+                }
+            ]
+        },
+    }
+    connector.adapt_id_and_type_plone_attributes(plone_response)
+    assert plone_response == {
+        'PLONE_type': '@value',
+        '@dict': {'@array': [{'PLONE_id': '123', 'PLONE_type': '@value'}]},
+    }
+
+
+def test_adapt_record(connector, token):
+    record = {
+        '@id': 'plone id',
+        'UID': 'plone uid',
+        'id': 'foo',
+        'text': 'bar',
+    }
+    template = '{{ PLONE_id }}, {{ id }}, {{original_id }}, {{ original_text }}'
+    connector.adapt_record(record, template)
+    assert record == {
+        'PLONE_id': 'plone id',
+        'UID': 'plone uid',
+        'id': 'plone uid',
+        'text': 'plone id, plone uid, foo, bar',
+        'original_id': 'foo',
+        'original_text': 'bar',
+    }
+
+
+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 + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
+    params = {
+        'uid': 'dccd85d12cf54b6899dff41e5a56ee7f',
+        'uri': 'braine-l-alleud',
+        'text_template': '{{ title }} ({{ topics.0.title }})',
+    }
+    with utils.mock_url(url=url, response=json_get_data('fetch')):
+        resp = app.get(endpoint, params=params)
+    assert not resp.json['err']
+    assert resp.json['data']['id'] == 'dccd85d12cf54b6899dff41e5a56ee7f'
+    assert resp.json['data']['text'] == 'Le Prisme (Activités et divertissement)'
+    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 + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
+    params = {
+        'uid': 'dccd85d12cf54b6899dff41e5a56ee7f',
+        'uri': 'braine-l-alleud',
+        'text_template': '{{ title }} ({{ topics.0.title }})',
+    }
+    with utils.mock_url(url=url, response=json_get_data('fetch')):
+        resp = app.get(endpoint, params=params)
+    assert not resp.json['err']
+    assert resp.json['data']['id'] == 'dccd85d12cf54b6899dff41e5a56ee7f'
+    assert resp.json['data']['text'] == 'Le Prisme (Activités et divertissement)'
+    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 + '/braine-l-alleud/plop'
+    params = {
+        'uid': 'plop',
+        'uri': 'braine-l-alleud',
+        'text_template': '{{ title }} ({{ PLONE_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_get_content_types(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'get_content_types', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/get_content_types'
+    url = connector.service_url + '/@types'
+    with utils.mock_url(url=url, response=json_get_data('get_content_types')):
+        resp = app.get(endpoint)
+    assert not resp.json['err']
+    assert len(resp.json['data']) == 10
+    assert resp.json['data'][2]['id'] == 'imio.directory.Contact'
+    assert resp.json['data'][2]['text'] == 'Contact'
+
+
+def test_get_content_type(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'get_content_type', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/get_content_type'
+    url = connector.service_url + '/@types/imio.directory.Contact'
+    params = {'id': 'imio.directory.Contact'}
+    with utils.mock_url(url=url, response=json_get_data('get_content_type')):
+        resp = app.get(endpoint, params=params)
+    assert not resp.json['err']
+    assert resp.json['data']['title'] == 'Contact'
+    assert resp.json['data']['required'] == ['title', 'type']
+    assert len(resp.json['data']['properties']) == 28
+    assert (
+        resp.json['data']['properties']['topics']['items']['vocabulary']['@id']
+        == 'https://annuaire.preprod.imio.be/@vocabularies/imio.smartweb.vocabulary.Topics'
+    )
+
+
+def test_get_field_choices(app, connector, token):
+    endpoint = utils.generic_endpoint_url('plone-restapi', 'get_field_choices', slug=connector.slug)
+    assert endpoint == '/plone-restapi/my_connector/get_field_choices'
+    url = connector.service_url + '/@vocabularies/imio.smartweb.vocabulary.Topics'
+    params = {'id': 'imio.smartweb.vocabulary.Topics'}
+    with utils.mock_url(url=url, response=json_get_data('get_field_choices')):
+        resp = app.get(endpoint, params=params)
+    assert not resp.json['err']
+    assert len(resp.json['data']) == 17
+    assert resp.json['data'][16]['id'] == 'tourism'
+    assert resp.json['data'][16]['text'] == 'Tourisme'
+
+
+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 + '/braine-l-alleud'
+    payload = {
+        '@type': 'imio.directory.Contact',
+        'title': "Test Entr'ouvert",
+        'type': 'organization',
+        'schedule': {},
+        'topics/0/title': 'Tourisme',
+        'topics/0/token': 'tourism',
+        'image': {'filename': 'foo.jpg', 'content_type': 'image/jpeg', 'content': '...'},
+    }
+    with utils.mock_url(url=url, response=json_get_data('fetch'), status_code=201) as mocked:
+        resp = app.post_json(endpoint + '?uri=braine-l-alleud&publish=false', params=payload)
+        body = json.loads(mocked.handlers[0].call['requests'][1].body)
+        assert body['topics'] == [{'title': 'Tourisme', 'token': 'tourism'}]
+        assert body['image'] == {
+            'filename': 'foo.jpg',
+            'content_type': 'image/jpeg',
+            'encoding': 'base64',
+            'data': '...',
+            'content-type': 'image/jpeg',
+        }
+    assert not resp.json['err']
+    assert resp.json['data'] == {
+        'uid': 'dccd85d12cf54b6899dff41e5a56ee7f',
+        'created': True,
+        'review_state': None,
+    }
+
+
+def test_create_and_publish(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 + '/braine-l-alleud'
+    payload = {
+        '@type': 'imio.directory.Contact',
+        'title': "Test Entr'ouvert",
+        'type': 'organization',
+        'schedule': {},
+        'topics/0/title': 'Tourisme',
+        'topics/0/token': 'tourism',
+        'image': {'filename': 'foo.jpg', 'content_type': 'image/jpeg', 'content': '...'},
+    }
+    publish_url = url + '/%s/@workflow/publish' % 'dccd85d12cf54b6899dff41e5a56ee7f'
+    with utils.mock_url(url=url, response=json_get_data('fetch'), status_code=201) as mocked:
+        with utils.mock_url(url=publish_url, response=json_get_data('workflow_publish'), status_code=200):
+
+            resp = app.post_json(endpoint + '?uri=braine-l-alleud&publish=true', params=payload)
+            body = json.loads(mocked.handlers[0].call['requests'][1].body)
+            assert body['topics'] == [{'title': 'Tourisme', 'token': 'tourism'}]
+            assert body['image'] == {
+                'filename': 'foo.jpg',
+                'content_type': 'image/jpeg',
+                'encoding': 'base64',
+                'data': '...',
+                'content-type': 'image/jpeg',
+            }
+    assert not resp.json['err']
+    assert resp.json['data'] == {
+        'uid': 'dccd85d12cf54b6899dff41e5a56ee7f',
+        'created': True,
+        'review_state': 'published',
+    }
+
+
+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 + '/braine-l-alleud'
+    payload = 'not json'
+    resp = app.post(endpoint + '?uri=braine-l-alleud', 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 + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
+    query_string = '?uri=braine-l-alleud&uid=dccd85d12cf54b6899dff41e5a56ee7f'
+    payload = {
+        'title': 'Test update',
+        'topics/0/token': 'social',
+        'image': {'filename': 'foo.jpg', 'content_type': 'image/jpeg', 'content': '...'},
+    }
+    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 body['image'] == {
+            'filename': 'foo.jpg',
+            'content_type': 'image/jpeg',
+            'encoding': 'base64',
+            'data': '...',
+            'content-type': 'image/jpeg',
+        }
+    assert not resp.json['err']
+    assert resp.json['data'] == {'uid': 'dccd85d12cf54b6899dff41e5a56ee7f', '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 + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
+    query_string = '?uri=braine-l-alleud&uid=dccd85d12cf54b6899dff41e5a56ee7f'
+    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 + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
+    query_string = '?uri=braine-l-alleud&uid=dccd85d12cf54b6899dff41e5a56ee7f'
+    with utils.mock_url(url=url, response='', status_code=204):
+        resp = app.delete(endpoint + query_string)
+    assert resp.json['data'] == {'uid': 'dccd85d12cf54b6899dff41e5a56ee7f', '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 + '/braine-l-alleud/@search'
+    params = {
+        'uri': 'braine-l-alleud',
+        'text_template': '{{ title }} ({{ PLONE_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']] == [
+        (
+            'dea9d26baab944beb7e54d4024d35a33',
+            "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud (imio.directory.Contact)",
+        ),
+        (
+            '23a32197d6c841259963b43b24747854',
+            "Académie de Musique de Braine-l'Alleud (imio.directory.Contact)",
+        ),
+        (
+            'f82d2c079131433ea6ab20f9f7f49442',
+            'Accueil et Orientation Volontariat (A.O.V.) (imio.directory.Contact)',
+        ),
+    ]
+
+
+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 + '/braine-l-alleud/@search'
+    params = {
+        'uri': 'braine-l-alleud',
+        'text_template': '{{ title }} ({{ PLONE_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']] == [
+        (
+            'dea9d26baab944beb7e54d4024d35a33',
+            "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud (imio.directory.Contact)",
+        ),
+        (
+            '23a32197d6c841259963b43b24747854',
+            "Académie de Musique de Braine-l'Alleud (imio.directory.Contact)",
+        ),
+        (
+            'f82d2c079131433ea6ab20f9f7f49442',
+            'Accueil et Orientation Volontariat (A.O.V.) (imio.directory.Contact)',
+        ),
+    ]
+
+
+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 + '/braine-l-alleud/@search'
+    params = {
+        'uri': 'braine-l-alleud',
+        'text_template': '{{ title }} ({{ PLONE_type }})',
+        'id': '9fbb2afd499e465983434f974fce8404',
+    }
+    qs = {}
+    with utils.mock_url(url=url, response=json_get_data('id_search'), qs=qs):
+        resp = app.get(endpoint, params=params)
+    assert qs == {'UID': '9fbb2afd499e465983434f974fce8404', 'fullobjects': 'y'}
+    assert len(resp.json['data']) == 1
+    assert resp.json['data'][0]['text'] == "Académie de Musique de Braine-l'Alleud (imio.directory.Contact)"
+
+
+def test_query_q(app, query, token):
+    endpoint = '/plone-restapi/my_connector/q/my_query/'
+    url = query.resource.service_url + '/braine-l-alleud/@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 resp.json['meta'] == {'label': 'demo query', 'description': "Annuaire de Braine-l'Alleud"}
+
+
+def test_query_q_using_q(app, query, token):
+    endpoint = '/plone-restapi/my_connector/q/my_query/'
+    url = query.resource.service_url + '/braine-l-alleud/@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 len(resp.json['data']) == 3
+    assert resp.json['meta'] == {'label': 'demo query', 'description': "Annuaire de Braine-l'Alleud"}
+
+
+def test_query_q_using_id(app, query, token):
+    endpoint = '/plone-restapi/my_connector/q/my_query/'
+    url = query.resource.service_url + '/braine-l-alleud/@search'
+    params = {
+        'id': '9fbb2afd499e465983434f974fce8404',
+    }
+    qs = {}
+    with utils.mock_url(url=url, response=json_get_data('id_search'), qs=qs):
+        resp = app.get(endpoint, params=params)
+    assert qs == {
+        'UID': '9fbb2afd499e465983434f974fce8404',
+        'fullobjects': 'y',
+    }
+    assert len(resp.json['data']) == 1
+    assert resp.json['data'][0]['text'] == "Académie de Musique de Braine-l'Alleud (imio.directory.Contact)"
+    assert resp.json['meta'] == {'label': 'demo query', 'description': "Annuaire de Braine-l'Alleud"}
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

