Repository: openwisp/openwisp-controller (language: python)

Issue:
[feature] REST API for main controller features
We need to implement a RESTful HTTP API for openwisp-controller, the API will allow to perform the following CRUD (Create, Retrieve, Update, Destroy) operations on the following objects:

- Device (including configuration details, assigned templates, and other points explained below) `/api/v1/controller/device/`
- Template: `/api/v1/controller/template/`
- VPN Server: `/api/v1/controller/vpn/`

Important notes about the Device API:

- The device API should provide not only the device data but also the configuration data (if present, please handle the case in which the Config object is not available) and templates.
- It should allow assigning/unassigning and changing the order of templates.
- It should take into account the new required templates feature, because trying to remove a required template will raise an exception, so the code shall catch this exception and return an error message gracefully.
  The required templates feature is at https://github.com/openwisp/openwisp-controller/pull/340, this means that we would branch off that feature branch.
- There should be an additional endpoint through which the configuration can be downloaded, eg:
  - `GET /api/v1/controller/device/{pk}/configuration/`
  - `GET /api/v1/controller/template/{pk}/configuration/`
  - `GET /api/v1/controller/vpn/{pk}/configuration/`
- The API endpoints must be paginated and respect the django permission system (DjangoPermission DRF class)
- They must be available to authenticated users only and respect multi-tenancy, we already have some work for this done in openwisp-users https://github.com/openwisp/openwisp-users#django-rest-framework-permission-classes (Please read the DRF permission classes and DRF mixins sections).

We also need to make sure the auto generated API documentation based on  OpenAPI (also known as swagger, we use drf_yasg), as well as basic documentation in prose added to the README of openwisp-controller (please see how we did it in openwisp-firmware-upgrader).

We need tests (add a new file `test_api` and basic docs like we have in firmware-upgrader, let's just copy the structure of the REST API docs of firmware-upgrader: https://github.com/openwisp/openwisp-firmware-upgrader/#rest-api.




Discussion / hints:
I would love to work on this @nemesisdesign, Can I be assigned on this?
> * Device (including configuration details, assigned templates, and other points explained below) `/api/v1/controller/device/`
> * Template: `/api/v1/controller/template/`
> * VPN Server: `/api/v1/controller/device/`

@nemesisdesign for the VPN Server shouldn't the endpoint be as `/api/v1/vpn/` :thinking: 

Fix patch:
diff --git a/README.rst b/README.rst
index 6e8804730..5b8f80b68 100755
--- a/README.rst
+++ b/README.rst
@@ -593,6 +593,251 @@ For example, if we want to change the verbose name to "Hotspot", we could write:
 
     OPENWISP_CONTROLLER_DEVICE_VERBOSE_NAME = ('Hotspot', 'Hotspots')
 
+``OPENWISP_CONTROLLER_API``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
++--------------+-----------+
+| **type**:    | ``bool``  |
++--------------+-----------+
+| **default**: | ``True``  |
++--------------+-----------+
+
+Indicates whether the API for Openwisp Controller is enabled or not.
+To disable the API by default add `OPENWISP_CONTROLLER_API = False` in `settings.py` file.
+
+REST API
+--------
+
+Live documentation
+~~~~~~~~~~~~~~~~~~
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/live-docu-api.png
+
+A general live API documentation (following the OpenAPI specification) at ``/api/v1/docs/``.
+
+Browsable web interface
+~~~~~~~~~~~~~~~~~~~~~~~
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/browsable-api-ui.png
+
+Additionally, opening any of the endpoints `listed below <#list-of-endpoints>`_
+directly in the browser will show the `browsable API interface of Django-REST-Framework
+<https://www.django-rest-framework.org/topics/browsable-api/>`_,
+which makes it even easier to find out the details of each endpoint.
+
+Authentication
+~~~~~~~~~~~~~~
+
+See openwisp-users: `authenticating with the user token
+<https://github.com/openwisp/openwisp-users#authenticating-with-the-user-token>`_.
+
+When browsing the API via the `Live documentation <#live-documentation>`_
+or the `Browsable web page <#browsable-web-interface>`_, you can also use
+the session authentication by logging in the django admin.
+
+Pagination
+~~~~~~~~~~
+
+All *list* endpoints support the ``page_size`` parameter that allows paginating
+the results in conjunction with the ``page`` parameter.
+
+.. code-block:: text
+
+    GET /api/v1/controller/template/?page_size=10
+    GET /api/v1/controller/template/?page_size=10&page=2
+
+List of endpoints
+~~~~~~~~~~~~~~~~~
+
+Since the detailed explanation is contained in the `Live documentation <#live-documentation>`_
+and in the `Browsable web page <#browsable-web-interface>`_ of each point,
+here we'll provide just a list of the available endpoints,
+for further information please open the URL of the endpoint in your browser.
+
+List devices
+^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/device/
+
+Create device
+^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/device/
+
+Get device detail
+^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/device/{pk}/
+
+Download device configuration
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/device/{pk}/configuration/
+
+The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific device.
+
+Change details of device
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PUT /api/v1/controller/device/{pk}/
+
+Patch details of device
+^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PATCH /api/v1/controller/device/{pk}/
+
+**Note**: To assign, unassign, and change the order of the assigned templates add, 
+remove, and change the order of the ``{pk}`` of the templates under the ``config`` field in the JSON response respectively.
+Moreover, you can also select and unselect templates in the HTML Form of the Browsable API.
+
+The required template(s) from the organization(s) of the device will added automatically 
+to the ``config`` and cannot be removed.
+
+**Example usage**: For assigning template(s) add the/their {pk} to the config of a device,
+
+.. code-block:: shell
+
+    echo '{"config":{"templates": ["4791fa4c-2cef-4f42-8bb4-c86018d71bd3"]}}' | \
+    http PATCH http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \
+    "Authorization: Bearer 9b5e40da02d107cfdb9d6b69b26dc00332ec2fbc"
+
+**Example usage**: For removing assigned templates, simply remove the/their {pk} from the config of a device,
+
+.. code-block:: shell
+
+    echo '{"config":{"templates": []}}' | \
+    http PATCH http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \
+    "Authorization: Bearer 9b5e40da02d107cfdb9d6b69b26dc00332ec2fbc"
+
+**Example usage**: For reordering the templates simply change their order from the config of a device,
+
+.. code-block:: shell
+
+    echo '{"config":{"templates": ["c5bbc697-170e-44bc-8eb7-b944b55ee88f","4791fa4c-2cef-4f42-8bb4-c86018d71bd3"]}}' | \
+    http PATCH http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \
+    "Authorization: Bearer 9b5e40da02d107cfdb9d6b69b26dc00332ec2fbc"
+
+Delete device
+^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    DELETE /api/v1/controller/device/{pk}/
+
+List templates
+^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/template/
+
+Create template
+^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/template/
+
+Get template detail
+^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/template/{pk}/
+
+Download template configuration
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/template/{pk}/configuration/
+
+The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific template.
+
+Change details of template
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PUT /api/v1/controller/template/{pk}/
+
+Patch details of template
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PATCH /api/v1/controller/template/{pk}/
+
+Delete template
+^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    DELETE /api/v1/controller/template/{pk}/
+
+List VPNs
+^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/vpn/
+
+Create VPN
+^^^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/vpn/
+
+Get VPN detail
+^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/vpn/{pk}/
+
+Download VPN configuration
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/vpn/{pk}/configuration/
+
+The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific VPN.
+
+Change details of VPN
+^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PUT /api/v1/controller/vpn/{pk}/
+
+Patch details of VPN
+^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PATCH /api/v1/controller/vpn/{pk}/
+
+Delete VPN
+^^^^^^^^^^
+
+.. code-block:: text
+
+    DELETE /api/v1/controller/vpn/{pk}/
+
 Default Alerts / Notifications
 ------------------------------
 
diff --git a/docs/browsable-api-ui.png b/docs/browsable-api-ui.png
new file mode 100644
index 000000000..7a58bf98d
Binary files /dev/null and b/docs/browsable-api-ui.png differ
diff --git a/docs/live-docu-api.png b/docs/live-docu-api.png
new file mode 100644
index 000000000..dde1c0d05
Binary files /dev/null and b/docs/live-docu-api.png differ
diff --git a/openwisp_controller/config/api/__init__.py b/openwisp_controller/config/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/openwisp_controller/config/api/serializers.py b/openwisp_controller/config/api/serializers.py
new file mode 100644
index 000000000..60a0359e8
--- /dev/null
+++ b/openwisp_controller/config/api/serializers.py
@@ -0,0 +1,258 @@
+from django.db import transaction
+from django.db.models import Q
+from django.utils.translation import ugettext_lazy as _
+from rest_framework import serializers
+from swapper import load_model
+
+from openwisp_users.api.mixins import FilterSerializerByOrgManaged
+from openwisp_utils.api.serializers import ValidatedModelSerializer
+
+Template = load_model('config', 'Template')
+Vpn = load_model('config', 'Vpn')
+Device = load_model('config', 'Device')
+Config = load_model('config', 'Config')
+Organization = load_model('openwisp_users', 'Organization')
+
+
+class BaseMeta:
+    read_only_fields = ['created', 'modified']
+
+
+class BaseSerializer(FilterSerializerByOrgManaged, ValidatedModelSerializer):
+    pass
+
+
+class TemplateSerializer(BaseSerializer):
+    config = serializers.JSONField(initial={}, required=False)
+    tags = serializers.StringRelatedField(many=True, read_only=True)
+    default_values = serializers.JSONField(required=False, initial={})
+    include_shared = True
+
+    class Meta(BaseMeta):
+        model = Template
+        fields = [
+            'id',
+            'name',
+            'organization',
+            'type',
+            'backend',
+            'vpn',
+            'tags',
+            'default',
+            'required',
+            'default_values',
+            'config',
+            'created',
+            'modified',
+        ]
+
+    def validate_vpn(self, value):
+        """
+        Ensure that VPN can't be added when
+        template `Type` is set to `Generic`.
+        """
+        if self.initial_data.get('type') == 'generic' and value is not None:
+            raise serializers.ValidationError(
+                _("To select a VPN, set the template type to 'VPN-client'")
+            )
+        return value
+
+    def validate_config(self, value):
+        """
+        Display appropriate field name.
+        """
+        if self.initial_data.get('type') == 'generic' and value == {}:
+            raise serializers.ValidationError(
+                _('The configuration field cannot be empty.')
+            )
+        return value
+
+
+class VpnSerializer(BaseSerializer):
+    config = serializers.JSONField(initial={})
+    include_shared = True
+
+    class Meta(BaseMeta):
+        model = Vpn
+        fields = [
+            'id',
+            'name',
+            'host',
+            'organization',
+            'key',
+            'ca',
+            'cert',
+            'backend',
+            'notes',
+            'dh',
+            'config',
+            'created',
+            'modified',
+        ]
+
+
+class FilterTemplatesByOrganization(serializers.PrimaryKeyRelatedField):
+    def get_queryset(self):
+        user = self.context['request'].user
+        if user.is_superuser:
+            queryset = Template.objects.all()
+        else:
+            queryset = Template.objects.filter(
+                Q(organization__in=user.organizations_managed)
+                | Q(organization__isnull=True)
+            )
+        return queryset
+
+
+class BaseConfigSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Config
+        fields = ['status', 'backend', 'templates', 'context', 'config']
+        extra_kwargs = {'status': {'read_only': True}}
+
+
+class DeviceListConfigSerializer(BaseConfigSerializer):
+    config = serializers.JSONField(
+        initial={}, help_text=_('Configuration in NetJSON format'), write_only=True
+    )
+    context = serializers.JSONField(
+        initial={},
+        help_text=_('Configuration variables in JSON format'),
+        write_only=True,
+    )
+    templates = FilterTemplatesByOrganization(many=True, write_only=True)
+
+
+class DeviceListSerializer(FilterSerializerByOrgManaged, serializers.ModelSerializer):
+    config = DeviceListConfigSerializer(required=False)
+
+    class Meta(BaseMeta):
+        model = Device
+        fields = [
+            'id',
+            'name',
+            'organization',
+            'mac_address',
+            'key',
+            'last_ip',
+            'management_ip',
+            'model',
+            'os',
+            'system',
+            'notes',
+            'config',
+            'created',
+            'modified',
+        ]
+        extra_kwargs = {
+            'last_ip': {'allow_blank': True},
+            'management_ip': {'allow_blank': True},
+        }
+
+    def create(self, validated_data):
+        config_data = None
+        if validated_data.get('config'):
+            config_data = validated_data.pop('config')
+            config_templates = [
+                template.pk for template in config_data.pop('templates')
+            ]
+
+        with transaction.atomic():
+            device = Device.objects.create(**validated_data)
+            if config_data:
+                config = Config.objects.create(device=device, **config_data)
+                config.templates.add(*config_templates)
+        return device
+
+
+class DeviceDetailConfigSerializer(BaseConfigSerializer):
+    config = serializers.JSONField(
+        initial={}, help_text=_('Configuration in NetJSON format')
+    )
+    context = serializers.JSONField(
+        initial={}, help_text=_('Configuration variables in JSON format')
+    )
+    templates = FilterTemplatesByOrganization(many=True)
+
+
+class DeviceDetailSerializer(BaseSerializer):
+    config = DeviceDetailConfigSerializer(allow_null=True)
+
+    class Meta(BaseMeta):
+        model = Device
+        fields = [
+            'id',
+            'name',
+            'organization',
+            'mac_address',
+            'key',
+            'last_ip',
+            'management_ip',
+            'model',
+            'os',
+            'system',
+            'notes',
+            'config',
+            'created',
+            'modified',
+        ]
+
+    def update(self, instance, validated_data):
+        config_data = None
+
+        if self.initial_data.get('config.backend') and instance._has_config() is False:
+            config_data = dict(validated_data.pop('config'))
+            config_templates = [
+                template.pk for template in config_data.pop('templates')
+            ]
+            with transaction.atomic():
+                config = Config.objects.create(device=instance, **config_data)
+                config.templates.add(*config_templates)
+                config.full_clean()
+                config.save()
+            return super().update(instance, validated_data)
+
+        if validated_data.get('config'):
+            config_data = validated_data.pop('config')
+            instance.config.backend = config_data.get(
+                'backend', instance.config.backend
+            )
+            instance.config.context = config_data.get(
+                'context', instance.config.context
+            )
+            instance.config.config = config_data.get('config', instance.config.config)
+
+            if 'templates' in config_data:
+                if config_data.get('templates'):
+                    new_config_templates = [
+                        template.pk for template in config_data.get('templates')
+                    ]
+                    old_config_templates = [
+                        template
+                        for template in instance.config.templates.values_list(
+                            'pk', flat=True
+                        )
+                    ]
+                    if new_config_templates != old_config_templates:
+                        with transaction.atomic():
+                            vpn_list = instance.config.templates.filter(
+                                type='vpn'
+                            ).values_list('vpn')
+                            if vpn_list:
+                                instance.config.vpnclient_set.exclude(
+                                    vpn__in=vpn_list
+                                ).delete()
+                            instance.config.templates.clear()
+                            instance.config.templates.add(*new_config_templates)
+                else:
+                    vpn_list = instance.config.templates.filter(type='vpn').values_list(
+                        'vpn'
+                    )
+                    if vpn_list:
+                        instance.config.vpnclient_set.exclude(vpn__in=vpn_list).delete()
+                    instance.config.templates.clear()
+                    instance.config.templates.add(*[])
+
+            instance.config.full_clean()
+            instance.config.save()
+        return super().update(instance, validated_data)
diff --git a/openwisp_controller/config/api/urls.py b/openwisp_controller/config/api/urls.py
new file mode 100644
index 000000000..3519c1e9c
--- /dev/null
+++ b/openwisp_controller/config/api/urls.py
@@ -0,0 +1,55 @@
+from django.conf import settings
+from django.urls import path
+
+from . import views as api_views
+
+app_name = 'openwisp_controller'
+
+
+def get_api_urls(api_views):
+    """
+    returns:: all the API urls of the config app
+    """
+    if getattr(settings, 'OPENWISP_CONTROLLER_API', True):
+        return [
+            path(
+                'controller/template/',
+                api_views.template_list,
+                name='api_template_list',
+            ),
+            path(
+                'controller/template/<str:pk>/',
+                api_views.template_detail,
+                name='api_template_detail',
+            ),
+            path(
+                'controller/template/<str:pk>/configuration/',
+                api_views.download_template_config,
+                name='api_download_template_config',
+            ),
+            path('controller/vpn/', api_views.vpn_list, name='api_vpn_list',),
+            path(
+                'controller/vpn/<str:pk>/', api_views.vpn_detail, name='api_vpn_detail',
+            ),
+            path(
+                'controller/vpn/<str:pk>/configuration/',
+                api_views.download_vpn_config,
+                name='api_download_vpn_config',
+            ),
+            path('controller/device/', api_views.device_list, name='api_device_list',),
+            path(
+                'controller/device/<str:pk>/',
+                api_views.device_detail,
+                name='api_device_detail',
+            ),
+            path(
+                'controller/device/<str:pk>/configuration/',
+                api_views.download_device_config,
+                name='api_download_device_config',
+            ),
+        ]
+    else:
+        return []
+
+
+urlpatterns = get_api_urls(api_views)
diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py
new file mode 100644
index 000000000..055b1b2f9
--- /dev/null
+++ b/openwisp_controller/config/api/views.py
@@ -0,0 +1,120 @@
+from rest_framework import pagination
+from rest_framework.authentication import SessionAuthentication
+from rest_framework.generics import (
+    ListCreateAPIView,
+    RetrieveAPIView,
+    RetrieveUpdateDestroyAPIView,
+)
+from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated
+from swapper import load_model
+
+from openwisp_users.api.authentication import BearerAuthentication
+from openwisp_users.api.mixins import FilterByOrganizationManaged
+
+from ..admin import BaseConfigAdmin
+from .serializers import (
+    DeviceDetailSerializer,
+    DeviceListSerializer,
+    TemplateSerializer,
+    VpnSerializer,
+)
+
+Template = load_model('config', 'Template')
+Vpn = load_model('config', 'Vpn')
+Device = load_model('config', 'Device')
+Config = load_model('config', 'Config')
+
+
+class ListViewPagination(pagination.PageNumberPagination):
+    page_size = 10
+    page_size_query_param = 'page_size'
+    max_page_size = 100
+
+
+class ProtectedAPIMixin(FilterByOrganizationManaged):
+    authentication_classes = [BearerAuthentication, SessionAuthentication]
+    permission_classes = [
+        IsAuthenticated,
+        DjangoModelPermissions,
+    ]
+
+
+class TemplateListCreateView(ProtectedAPIMixin, ListCreateAPIView):
+    serializer_class = TemplateSerializer
+    queryset = Template.objects.order_by('-created')
+    pagination_class = ListViewPagination
+
+
+class TemplateDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
+    serializer_class = TemplateSerializer
+    queryset = Template.objects.all()
+
+
+class DownloadTemplateconfiguration(ProtectedAPIMixin, RetrieveAPIView):
+    serializer_class = TemplateSerializer
+    queryset = Template.objects.none()
+    model = Template
+
+    def retrieve(self, request, *args, **kwargs):
+        return BaseConfigAdmin.download_view(self, request, pk=kwargs['pk'])
+
+
+class VpnListCreateView(ProtectedAPIMixin, ListCreateAPIView):
+    serializer_class = VpnSerializer
+    queryset = Vpn.objects.order_by('-created')
+    pagination_class = ListViewPagination
+
+
+class VpnDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
+    serializer_class = VpnSerializer
+    queryset = Vpn.objects.all()
+
+
+class DownloadVpnView(ProtectedAPIMixin, RetrieveAPIView):
+    serializer_class = VpnSerializer
+    queryset = Vpn.objects.none()
+    model = Vpn
+
+    def retrieve(self, request, *args, **kwargs):
+        return BaseConfigAdmin.download_view(self, request, pk=kwargs['pk'])
+
+
+class DeviceListCreateView(ProtectedAPIMixin, ListCreateAPIView):
+    """
+    Templates: Templates flagged as required will be added automatically
+               to the `config` of a device and cannot be unassigned.
+    """
+
+    serializer_class = DeviceListSerializer
+    queryset = Device.objects.select_related('config').order_by('-created')
+    pagination_class = ListViewPagination
+
+
+class DeviceDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
+    """
+    Templates: Templates flagged as _required_ will be added automatically
+               to the `config` of a device and cannot be unassigned.
+    """
+
+    serializer_class = DeviceDetailSerializer
+    queryset = Device.objects.select_related('config')
+
+
+class DownloadDeviceView(ProtectedAPIMixin, RetrieveAPIView):
+    serializer_class = DeviceListSerializer
+    queryset = Device.objects.none()
+    model = Device
+
+    def retrieve(self, request, *args, **kwargs):
+        return BaseConfigAdmin.download_view(self, request, pk=kwargs['pk'])
+
+
+template_list = TemplateListCreateView.as_view()
+template_detail = TemplateDetailView.as_view()
+download_template_config = DownloadTemplateconfiguration.as_view()
+vpn_list = VpnListCreateView.as_view()
+vpn_detail = VpnDetailView.as_view()
+download_vpn_config = DownloadVpnView.as_view()
+device_list = DeviceListCreateView.as_view()
+device_detail = DeviceDetailView.as_view()
+download_device_config = DownloadDeviceView().as_view()
diff --git a/openwisp_controller/urls.py b/openwisp_controller/urls.py
index fde5a3db3..4dba6738e 100644
--- a/openwisp_controller/urls.py
+++ b/openwisp_controller/urls.py
@@ -44,6 +44,12 @@
         'app': 'openwisp_controller.config',
         'include': {'module': '{app}.urls', 'namespace': 'config'},
     },
+    # openwisp_controller.config.api
+    {
+        'regexp': r'^api/v1/',
+        'app': 'openwisp_controller.config',
+        'include': {'module': '{app}.api.urls', 'namespace': 'controller_config'},
+    },
 ]
 
 urlpatterns = []


Test patch:
diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py
new file mode 100644
index 000000000..7d1c1c8a1
--- /dev/null
+++ b/openwisp_controller/config/tests/test_api.py
@@ -0,0 +1,512 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from django.urls import reverse
+from swapper import load_model
+
+from openwisp_controller.tests.utils import TestAdminMixin
+from openwisp_users.tests.utils import TestOrganizationMixin
+
+from .utils import CreateConfigTemplateMixin, TestVpnX509Mixin
+
+Template = load_model('config', 'Template')
+Vpn = load_model('config', 'Vpn')
+Device = load_model('config', 'Device')
+
+
+class TestConfigApi(
+    TestAdminMixin,
+    TestOrganizationMixin,
+    CreateConfigTemplateMixin,
+    TestVpnX509Mixin,
+    TestCase,
+):
+    def setUp(self):
+        super().setUp()
+        self._login()
+
+    _get_template_data = {
+        'name': 'test-template',
+        'organization': None,
+        'backend': 'netjsonconfig.OpenWrt',
+        'config': {'interfaces': [{'name': 'eth0', 'type': 'ethernet'}]},
+    }
+
+    _get_vpn_data = {
+        'name': 'vpn-test',
+        'host': 'vpn.testing.com',
+        'organization': None,
+        'ca': None,
+        'backend': 'openwisp_controller.vpn_backends.OpenVpn',
+        'config': {
+            'openvpn': [
+                {
+                    'ca': 'ca.pem',
+                    'cert': 'cert.pem',
+                    'dev': 'tap0',
+                    'dev_type': 'tap',
+                    'dh': 'dh.pem',
+                    'key': 'key.pem',
+                    'mode': 'server',
+                    'name': 'example-vpn',
+                    'proto': 'udp',
+                    'tls_server': True,
+                }
+            ]
+        },
+    }
+
+    _get_device_data = {
+        'name': 'change-test-device',
+        'organization': None,
+        'mac_address': '00:11:22:33:44:55',
+        'config': {
+            'backend': 'netjsonconfig.OpenWrt',
+            'status': 'modified',
+            'templates': [],
+            'context': '{}',
+            'config': '{}',
+        },
+    }
+
+    def test_device_create_with_config_api(self):
+        self.assertEqual(Device.objects.count(), 0)
+        path = reverse('controller_config:api_device_list')
+        data = self._get_device_data.copy()
+        org = self._get_org()
+        data['organization'] = org.pk
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Device.objects.count(), 1)
+
+    def test_device_create_no_config_api(self):
+        self.assertEqual(Device.objects.count(), 0)
+        path = reverse('controller_config:api_device_list')
+        data = self._get_device_data.copy()
+        org = self._get_org()
+        data['organization'] = org.pk
+        data.pop('config')
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Device.objects.count(), 1)
+
+    def test_device_create_with_invalid_name_api(self):
+        path = reverse('controller_config:api_device_list')
+        data = self._get_device_data.copy()
+        org = self._get_org()
+        data.pop('config')
+        data['name'] = 'T E S T'
+        data['organization'] = org.pk
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 400)
+        self.assertIn('Must be either a valid hostname or mac address.', str(r.content))
+
+    # POST request should fail with validation error
+    def test_device_post_with_templates_of_different_org(self):
+        path = reverse('controller_config:api_device_list')
+        data = self._get_device_data.copy()
+        org_1 = self._get_org()
+        data['organization'] = org_1.pk
+        org_2 = self._create_org(name='test org2', slug='test-org2')
+        t1 = self._create_template(name='t1', organization=org_2)
+        data['config']['templates'] += [str(t1.pk)]
+        with self.assertRaises(ValidationError) as error:
+            self.client.post(path, data, content_type='application/json')
+        validation_msg = '''
+                            The following templates are owned by
+                            organizations which do not match the
+                            organization of this configuration: t1
+                        '''
+        self.assertTrue(' '.join(validation_msg.split()) in error.exception.message)
+
+    def test_device_list_api(self):
+        self._create_device()
+        path = reverse('controller_config:api_device_list')
+        with self.assertNumQueries(4):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+
+    def test_device_filter_templates(self):
+        org1 = self._create_org(name='org1')
+        org2 = self._create_org(name='org2')
+        test_user = self._create_operator(organizations=[org1])
+        self.client.force_login(test_user)
+        self._create_template(name='t0', organization=None)
+        self._create_template(name='t1', organization=org1)
+        self._create_template(name='t11', organization=org1)
+        self._create_template(name='t2', organization=org2)
+        path = reverse('controller_config:api_device_list')
+        r = self.client.get(path, {'format': 'api'})
+        self.assertEqual(r.status_code, 200)
+        self.assertContains(r, 't0</option>')
+        self.assertContains(r, 't1</option>')
+        self.assertContains(r, 't11</option>')
+        self.assertNotContains(r, 't2</option>')
+
+    # Device detail having no config
+    def test_device_detail_api(self):
+        d1 = self._create_device()
+        path = reverse('controller_config:api_device_detail', args=[d1.pk])
+        with self.assertNumQueries(3):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['config'], None)
+
+    # Device detail having config
+    def test_device_detail_config_api(self):
+        d1 = self._create_device()
+        self._create_config(device=d1)
+        path = reverse('controller_config:api_device_detail', args=[d1.pk])
+        with self.assertNumQueries(4):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertNotEqual(r.data['config'], None)
+
+    def test_device_put_api(self):
+        d1 = self._create_device(name='test-device')
+        self._create_config(device=d1)
+        path = reverse('controller_config:api_device_detail', args=[d1.pk])
+        org = self._get_org()
+        data = {
+            'name': 'change-test-device',
+            'organization': org.pk,
+            'mac_address': d1.mac_address,
+            'config': {
+                'backend': 'netjsonconfig.OpenWrt',
+                'status': 'modified',
+                'templates': [],
+                'context': '{}',
+                'config': '{}',
+            },
+        }
+
+        r = self.client.put(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'change-test-device')
+        self.assertEqual(r.data['organization'], org.pk)
+
+    # test device with VPN-client type template assigned to it
+    def test_device_with_vpn_client_template_assigned(self):
+        org1 = self._create_org(name='testorg')
+        v1 = self._create_vpn(name='vpn1org', organization=org1)
+        t1 = self._create_template(name='t1', organization=org1, type='vpn', vpn=v1)
+        t2 = self._create_template(name='t2', organization=org1)
+        d1 = self._create_device(name='d1', organization=org1)
+        self._create_config(device=d1)
+        path = reverse('controller_config:api_device_detail', args=[d1.pk])
+        self.assertEqual(d1.config.vpnclient_set.count(), 0)
+        data = {'config': {'templates': [str(t1.id), str(t2.id)]}}
+        self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(d1.config.vpnclient_set.count(), 1)
+        data1 = {'config': {'templates': []}}
+        self.client.patch(path, data1, content_type='application/json')
+        self.assertEqual(d1.config.vpnclient_set.count(), 0)
+
+    def test_device_patch_with_templates_of_same_org(self):
+        org1 = self._create_org(name='testorg')
+        d1 = self._create_device(name='org1-config', organization=org1)
+        self._create_config(device=d1)
+        self.assertEqual(d1.config.templates.count(), 0)
+        path = reverse('controller_config:api_device_detail', args=[d1.pk])
+        t1 = self._create_template(name='t1', organization=None)
+        t2 = self._create_template(name='t2', organization=org1)
+        data = {'config': {'templates': [str(t1.id), str(t2.id)]}}
+        r = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(d1.config.templates.count(), 2)
+        self.assertEqual(r.data['config']['templates'], [t1.id, t2.id])
+
+    def test_device_patch_with_templates_of_different_org(self):
+        org1 = self._create_org(name='testorg')
+        d1 = self._create_device(name='org1-config', organization=org1)
+        self._create_config(device=d1)
+        self.assertEqual(d1.config.templates.count(), 0)
+        path = reverse('controller_config:api_device_detail', args=[d1.pk])
+        t1 = self._create_template(name='t1', organization=None)
+        t2 = self._create_template(name='t2', organization=org1)
+        t3 = self._create_template(
+            name='t3', organization=self._create_org(name='org2')
+        )
+        data = {'config': {'templates': [str(t1.id), str(t2.id), str(t3.id)]}}
+        with self.assertRaises(ValidationError) as error:
+            self.client.patch(path, data, content_type='application/json')
+        validation_msg = '''
+                            The following templates are owned by
+                            organizations which do not match the
+                            organization of this configuration: t3
+                        '''
+        self.assertTrue(' '.join(validation_msg.split()) in error.exception.message)
+
+    def test_device_patch_api(self):
+        d1 = self._create_device(name='test-device')
+        path = reverse('controller_config:api_device_detail', args=[d1.pk])
+        data = dict(name='change-test-device')
+        r = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'change-test-device')
+
+    def test_device_download_api(self):
+        d1 = self._create_device()
+        self._create_config(device=d1)
+        path = reverse('controller_config:api_download_device_config', args=[d1.pk])
+        with self.assertNumQueries(6):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+
+    def test_device_delete_api(self):
+        d1 = self._create_device()
+        self._create_config(device=d1)
+        path = reverse('controller_config:api_device_detail', args=[d1.pk])
+        r = self.client.delete(path)
+        self.assertEqual(r.status_code, 204)
+        self.assertEqual(Device.objects.count(), 0)
+
+    def test_template_create_no_org_api(self):
+        self.assertEqual(Template.objects.count(), 0)
+        path = reverse('controller_config:api_template_list')
+        data = self._get_template_data.copy()
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(Template.objects.count(), 1)
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(r.data['organization'], None)
+
+    def test_template_create_vpn_with_type_as_generic(self):
+        path = reverse('controller_config:api_template_list')
+        test_user = self._create_operator(organizations=[self._get_org()])
+        self.client.force_login(test_user)
+        vpn1 = self._create_vpn(name='vpn1', organization=self._get_org())
+        data = self._get_template_data.copy()
+        data['organization'] = self._get_org().pk
+        data['type'] = 'generic'
+        data['vpn'] = vpn1.id
+        r = self.client.post(path, data, content_type='application/json')
+        validation_msg = "To select a VPN, set the template type to 'VPN-client'"
+        self.assertIn(validation_msg, r.data['vpn'])
+        self.assertEqual(r.status_code, 400)
+
+    def test_template_create_api(self):
+        self.assertEqual(Template.objects.count(), 0)
+        org = self._get_org()
+        path = reverse('controller_config:api_template_list')
+        data = self._get_template_data.copy()
+        data['organization'] = org.pk
+        data['required'] = True
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(Template.objects.count(), 1)
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(r.data['organization'], org.pk)
+
+    def test_template_create_of_vpn_type(self):
+        org = self._get_org()
+        vpn1 = self._create_vpn(name='vpn1', organization=org)
+        path = reverse('controller_config:api_template_list')
+        data = self._get_template_data.copy()
+        data['type'] = 'vpn'
+        data['vpn'] = vpn1.id
+        data['organization'] = org.pk
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(Template.objects.count(), 1)
+        self.assertEqual(r.status_code, 201)
+
+    def test_template_create_with_shared_vpn(self):
+        org1 = self._get_org()
+        test_user = self._create_operator(organizations=[org1])
+        self.client.force_login(test_user)
+        vpn1 = self._create_vpn(name='vpn1', organization=None)
+        path = reverse('controller_config:api_template_list')
+        data = self._get_template_data.copy()
+        data['type'] = 'vpn'
+        data['vpn'] = vpn1.id
+        data['organization'] = org1.pk
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Template.objects.count(), 1)
+        self.assertEqual(r.data['vpn'], vpn1.id)
+
+    def test_template_creation_with_no_org_by_operator(self):
+        path = reverse('controller_config:api_template_list')
+        data = self._get_template_data.copy()
+        test_user = self._create_operator(organizations=[self._get_org()])
+        self.client.force_login(test_user)
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 400)
+        self.assertIn('This field may not be null.', str(r.content))
+
+    def test_template_create_with_empty_config(self):
+        path = reverse('controller_config:api_template_list')
+        data = self._get_template_data.copy()
+        data['config'] = {}
+        data['organization'] = self._get_org().pk
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 400)
+        self.assertIn('The configuration field cannot be empty.', str(r.content))
+
+    def test_template_list_api(self):
+        org1 = self._get_org()
+        self._create_template(name='t1', organization=org1)
+        path = reverse('controller_config:api_template_list')
+        with self.assertNumQueries(5):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(Template.objects.count(), 1)
+
+    def test_template_list_for_shared_objects(self):
+        org1 = self._get_org()
+        self._create_vpn(name='shared-vpn', organization=None)
+        test_user = self._create_operator(organizations=[org1])
+        self.client.force_login(test_user)
+        path = reverse('controller_config:api_template_list')
+        r = self.client.get(path, {'format': 'api'})
+        self.assertEqual(r.status_code, 200)
+        self.assertContains(r, 'shared-vpn</option>')
+
+    # template-detail having no Org
+    def test_template_detail_api(self):
+        t1 = self._create_template(name='t1')
+        path = reverse('controller_config:api_template_detail', args=[t1.pk])
+        with self.assertNumQueries(4):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['organization'], None)
+
+    def test_template_put_api(self):
+        t1 = self._create_template(name='t1', organization=None)
+        path = reverse('controller_config:api_template_detail', args=[t1.pk])
+        org = self._get_org()
+        data = {
+            'name': 'New t1',
+            'required': True,
+            'organization': org.pk,
+            'backend': 'netjsonconfig.OpenWrt',
+            'config': {'interfaces': [{'name': 'eth0', 'type': 'ethernet'}]},
+        }
+        r = self.client.put(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'New t1')
+        self.assertEqual(r.data['organization'], org.pk)
+        self.assertEqual(r.data['required'], True)
+
+    def test_template_patch_api(self):
+        t1 = self._create_template(name='t1')
+        path = reverse('controller_config:api_template_detail', args=[t1.pk])
+        data = dict(name='New t1')
+        r = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'New t1')
+
+    def test_template_download_api(self):
+        t1 = self._create_template(name='t1')
+        path = reverse('controller_config:api_download_template_config', args=[t1.pk])
+        with self.assertNumQueries(3):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+
+    def test_template_delete_api(self):
+        t1 = self._create_template(name='t1')
+        path = reverse('controller_config:api_template_detail', args=[t1.pk])
+        r = self.client.delete(path)
+        self.assertEqual(r.status_code, 204)
+        self.assertEqual(Template.objects.count(), 0)
+
+    def test_vpn_create_api(self):
+        self.assertEqual(Vpn.objects.count(), 0)
+        path = reverse('controller_config:api_vpn_list')
+        ca1 = self._create_ca()
+        data = self._get_vpn_data.copy()
+        data['ca'] = ca1.pk
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Vpn.objects.count(), 1)
+
+    def test_vpn_create_with_shared_objects(self):
+        org1 = self._get_org()
+        shared_ca = self._create_ca(name='shared_ca', organization=None)
+        test_user = self._create_operator(organizations=[org1])
+        self.client.force_login(test_user)
+        data = self._get_vpn_data.copy()
+        data['organization'] = org1.pk
+        data['ca'] = shared_ca.pk
+        path = reverse('controller_config:api_vpn_list')
+        r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(Vpn.objects.count(), 1)
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(r.data['ca'], shared_ca.pk)
+
+    def test_vpn_list_api(self):
+        org = self._get_org()
+        self._create_vpn(organization=org)
+        path = reverse('controller_config:api_vpn_list')
+        with self.assertNumQueries(4):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+
+    def test_vpn_list_for_shared_objects(self):
+        self._create_ca(name='shared_ca', organization=None)
+        self._create_cert(name='shared_cert', organization=None)
+        org1 = self._get_org()
+        test_user = self._create_operator(organizations=[org1])
+        self.client.force_login(test_user)
+        path = reverse('controller_config:api_vpn_list')
+        r = self.client.get(path, {'format': 'api'})
+        self.assertEqual(r.status_code, 200)
+        self.assertContains(r, 'shared_ca</option>')
+        self.assertContains(r, 'shared_cert</option>')
+
+    # VPN detail having no Org
+    def test_vpn_detail_no_org_api(self):
+        vpn1 = self._create_vpn(name='test-vpn')
+        path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+        with self.assertNumQueries(3):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['organization'], None)
+
+    # VPN detail having Org
+    def test_vpn_detail_with_org_api(self):
+        org = self._get_org()
+        vpn1 = self._create_vpn(name='test-vpn', organization=org)
+        path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+        with self.assertNumQueries(3):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['organization'], org.pk)
+
+    def test_vpn_put_api(self):
+        vpn1 = self._create_vpn(name='test-vpn')
+        path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+        org = self._get_org()
+        ca1 = self._create_ca()
+        data = {
+            'name': 'change-test-vpn',
+            'host': 'vpn1.changetest.com',
+            'organization': org.pk,
+            'ca': ca1.pk,
+            'backend': vpn1.backend,
+            'config': vpn1.config,
+        }
+        r = self.client.put(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'change-test-vpn')
+        self.assertEqual(r.data['ca'], ca1.pk)
+        self.assertEqual(r.data['organization'], org.pk)
+
+    def test_vpn_patch_api(self):
+        vpn1 = self._create_vpn(name='test-vpn')
+        path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+        data = dict(name='test-vpn-change')
+        r = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'test-vpn-change')
+
+    def test_vpn_download_api(self):
+        vpn1 = self._create_vpn(name='test-vpn')
+        path = reverse('controller_config:api_download_vpn_config', args=[vpn1.pk])
+        with self.assertNumQueries(5):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+
+    def test_vpn_delete_api(self):
+        vpn1 = self._create_vpn(name='test-vpn')
+        path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+        r = self.client.delete(path)
+        self.assertEqual(r.status_code, 204)
+        self.assertEqual(Vpn.objects.count(), 0)
diff --git a/tests/openwisp2/sample_config/api/views.py b/tests/openwisp2/sample_config/api/views.py
new file mode 100644
index 000000000..a3dd6e74b
--- /dev/null
+++ b/tests/openwisp2/sample_config/api/views.py
@@ -0,0 +1,70 @@
+from openwisp_controller.config.api.views import (
+    DeviceDetailView as BaseDeviceDetailView,
+)
+from openwisp_controller.config.api.views import (
+    DeviceListCreateView as BaseDeviceListCreateView,
+)
+from openwisp_controller.config.api.views import (
+    DownloadDeviceView as BaseDownloadDeviceView,
+)
+from openwisp_controller.config.api.views import (
+    DownloadTemplateconfiguration as BaseDownloadTemplateconfiguration,
+)
+from openwisp_controller.config.api.views import DownloadVpnView as BaseDownloadVpnView
+from openwisp_controller.config.api.views import (
+    TemplateDetailView as BaseTemplateDetailView,
+)
+from openwisp_controller.config.api.views import (
+    TemplateListCreateView as BaseTemplateListCreateView,
+)
+from openwisp_controller.config.api.views import VpnDetailView as BaseVpnDetailView
+from openwisp_controller.config.api.views import (
+    VpnListCreateView as BaseVpnListCreateView,
+)
+
+
+class TemplateListCreateView(BaseTemplateListCreateView):
+    pass
+
+
+class TemplateDetailView(BaseTemplateDetailView):
+    pass
+
+
+class DownloadTemplateconfiguration(BaseDownloadTemplateconfiguration):
+    pass
+
+
+class VpnListCreateView(BaseVpnListCreateView):
+    pass
+
+
+class VpnDetailView(BaseVpnDetailView):
+    pass
+
+
+class DownloadVpnView(BaseDownloadVpnView):
+    pass
+
+
+class DeviceListCreateView(BaseDeviceListCreateView):
+    pass
+
+
+class DeviceDetailView(BaseDeviceDetailView):
+    pass
+
+
+class DownloadDeviceView(BaseDownloadDeviceView):
+    pass
+
+
+template_list = TemplateListCreateView.as_view()
+template_detail = TemplateDetailView.as_view()
+download_template_config = DownloadTemplateconfiguration.as_view()
+vpn_list = VpnListCreateView.as_view()
+vpn_detail = VpnDetailView.as_view()
+download_vpn_config = DownloadVpnView.as_view()
+device_list = DeviceListCreateView.as_view()
+device_detail = DeviceDetailView.as_view()
+download_device_config = DownloadDeviceView().as_view()
diff --git a/tests/openwisp2/sample_config/tests.py b/tests/openwisp2/sample_config/tests.py
index 005a5cb4d..5b2f4cf94 100644
--- a/tests/openwisp2/sample_config/tests.py
+++ b/tests/openwisp2/sample_config/tests.py
@@ -1,4 +1,5 @@
 from openwisp_controller.config.tests.test_admin import TestAdmin as BaseTestAdmin
+from openwisp_controller.config.tests.test_api import TestConfigApi as BaseTestConfigApi
 from openwisp_controller.config.tests.test_apps import TestApps as BaseTestApps
 from openwisp_controller.config.tests.test_config import TestConfig as BaseTestConfig
 from openwisp_controller.config.tests.test_controller import (
@@ -70,6 +71,10 @@ class TestApps(BaseTestApps):
     pass
 
 
+class TestConfigApi(BaseTestConfigApi):
+    pass
+
+
 del BaseTestAdmin
 del BaseTestConfig
 del BaseTestController
@@ -82,3 +87,4 @@ class TestApps(BaseTestApps):
 del BaseTestVpn
 del BaseTestVpnTransaction
 del BaseTestApps
+del BaseTestConfigApi
diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py
index eb353711c..978e65826 100644
--- a/tests/openwisp2/settings.py
+++ b/tests/openwisp2/settings.py
@@ -51,7 +51,10 @@
     'flat_json_widget',
     # rest framework
     'rest_framework',
+    'rest_framework.authtoken',
     'rest_framework_gis',
+    'django_filters',
+    'drf_yasg',
     # channels
     'channels',
     # 'debug_toolbar',
diff --git a/tests/openwisp2/urls.py b/tests/openwisp2/urls.py
index 3abf5ab28..c5dee7642 100644
--- a/tests/openwisp2/urls.py
+++ b/tests/openwisp2/urls.py
@@ -1,17 +1,19 @@
 import os
 
 from django.conf import settings
-from django.conf.urls import include, url
+from django.conf.urls import url
 from django.conf.urls.static import static
 from django.contrib import admin
 from django.contrib.staticfiles.urls import staticfiles_urlpatterns
-from django.urls import path, reverse_lazy
+from django.urls import include, path, reverse_lazy
 from django.views.generic import RedirectView
 
+from openwisp_controller.config.api.urls import get_api_urls
 from openwisp_controller.config.utils import get_controller_urls
 from openwisp_controller.geo.utils import get_geo_urls
 
 from .sample_config import views as config_views
+from .sample_config.api import views as api_views
 from .sample_geo import views as geo_views
 
 redirect_view = RedirectView.as_view(url=reverse_lazy('admin:index'))
@@ -32,6 +34,13 @@
             include(('openwisp_controller.config.urls', 'config'), namespace='config'),
         ),
         url(r'^geo/', include((get_geo_urls(geo_views), 'geo'), namespace='geo')),
+        url(
+            r'^api/v1/',
+            include(
+                (get_api_urls(api_views), 'controller_config'),
+                namespace='controller_config',
+            ),
+        ),
     ]
 
 urlpatterns += [
@@ -39,6 +48,7 @@
     url(r'^admin/', admin.site.urls),
     url(r'', include('openwisp_controller.urls')),
     path('accounts/', include('openwisp_users.accounts.urls')),
+    path('api/v1/', include('openwisp_utils.api.urls')),
 ]
 
 urlpatterns += staticfiles_urlpatterns()