feat/PRWLR-3996 implement /tenants endpoint and basic filters logic (#14)

* chore: PRWLR-3996 add drf-jsonapi library to project

* feat: PRWLR-3996 implement /tenants endpoint and basic JSON:API spec

* feat: PRWLR-3996 implement new middleware to ingest metadata on API responses

* chore: PRWLR-3996 annotate views with drf-spectacular

* feat: PRWLR-3996 add cache-control to tenants endpoints

* feat: PRWLR-3996 update views filters

* chore: PRWLR-3996 remove meta middleware in favour of meta fields in serializers

* test(API): PRWLR-3996 add unit tests for /tenants

* chore: PRWLR-3996 turn DEBUG on on devel environment

* test: PRWLR-3996 rework tenants unit tests

* chore: PRWLR-3996 refactor filter classes

* fix(Backend,API): PRWLR-3996 use correct filter backend
This commit is contained in:
Víctor Fernández Poyatos
2024-07-22 22:06:57 +02:00
committed by GitHub
parent 43f79663d9
commit 7cb3a4e16e
15 changed files with 407 additions and 91 deletions
+2
View File
@@ -6,6 +6,8 @@ DJANGO_DEBUG=False
DJANGO_SETTINGS_MODULE=config.django.[production|devel]
DJANGO_LOGGING_FORMATTER=[ndjson|human_readable]
DJANGO_WORKERS=4 # Defaults to the maximum available based on CPU cores if not set.
DJANGO_CACHE_MAX_AGE=3600
DJANGO_STALE_WHILE_REVALIDATE=60
# PostgreSQL settings
POSTGRES_HOST=postgres-db
Generated
+39 -4
View File
@@ -476,6 +476,20 @@ develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.dev0)", "py
docs = ["furo (>=2021.8.17b43,<2021.9.dev0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"]
testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"]
[[package]]
name = "django-filter"
version = "24.2"
description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically."
optional = false
python-versions = ">=3.8"
files = [
{file = "django-filter-24.2.tar.gz", hash = "sha256:48e5fc1da3ccd6ca0d5f9bb550973518ce977a4edde9d2a8a154a7f4f0b9f96e"},
{file = "django_filter-24.2-py3-none-any.whl", hash = "sha256:df2ee9857e18d38bed203c8745f62a803fa0f31688c9fe6f8e868120b1848e48"},
]
[package.dependencies]
Django = ">=4.2"
[[package]]
name = "django-guid"
version = "3.5.0"
@@ -504,6 +518,27 @@ files = [
[package.dependencies]
django = ">=4.2"
[[package]]
name = "djangorestframework-jsonapi"
version = "7.0.2"
description = "A Django REST framework API adapter for the JSON:API spec."
optional = false
python-versions = ">=3.8"
files = [
{file = "djangorestframework-jsonapi-7.0.2.tar.gz", hash = "sha256:d6c72a2bee539f1093dd86620e862af2d1a0e60408e38a710146286dbde71d75"},
{file = "djangorestframework_jsonapi-7.0.2-py2.py3-none-any.whl", hash = "sha256:be457adb50aac77eec8893048bf46ad6926dcd26204aa10965a1430610828d50"},
]
[package.dependencies]
django = ">=4.2"
djangorestframework = ">=3.14"
inflection = ">=0.5.0"
[package.extras]
django-filter = ["django-filter (>=2.4)"]
django-polymorphic = ["django-polymorphic (>=3.0)"]
openapi = ["pyyaml (>=5.4)", "uritemplate (>=3.0.1)"]
[[package]]
name = "docker"
version = "7.1.0"
@@ -1774,13 +1809,13 @@ files = [
[[package]]
name = "sqlparse"
version = "0.5.0"
version = "0.5.1"
description = "A non-validating SQL parser."
optional = false
python-versions = ">=3.8"
files = [
{file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"},
{file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"},
{file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"},
{file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"},
]
[package.extras]
@@ -1893,4 +1928,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "76aaacac73e4a835736c1c6550809e09481e405974e8ad89699cbcb52bcbe0e0"
content-hash = "17cd0c83d5aa272809743691d6b91ae3e7c3c98df8c7ce389aabc32f996b69d0"
+2
View File
@@ -14,8 +14,10 @@ version = "1.0.0"
django = "5.0.7"
django-cors-headers = "4.4.0"
django-environ = "0.11.2"
django-filter = "24.2"
django-guid = "3.5.0"
djangorestframework = "3.15.2"
djangorestframework-jsonapi = "7.0.2"
drf-spectacular = "0.27.2"
gunicorn = "22.0.0"
psycopg2-binary = "2.9.9"
+31
View File
@@ -0,0 +1,31 @@
from django_filters.rest_framework import FilterSet
from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
from api.models import Tenant
class CustomDjangoFilterBackend(DjangoFilterBackend):
def to_html(self, _request, _queryset, _view):
"""Override this method to use the Browsable API in dev environments.
This disables the HTML render for the default filter.
"""
return None
class BaseFilter(FilterSet):
class Meta:
model = None
fields = {
"inserted_at": ["exact", "gte", "lte"],
"updated_at": ["exact", "gte", "lte"],
}
class TenantFilter(BaseFilter):
class Meta(BaseFilter.Meta):
model = Tenant
fields = {
**BaseFilter.Meta.fields,
"name": ["exact", "icontains"],
}
+11 -5
View File
@@ -1,5 +1,6 @@
# Generated by Django 5.0.6 on 2024-07-04 16:53
# Generated by Django 5.0.6 on 2024-07-17 07:58
import uuid
from django.db import migrations, models
@@ -10,18 +11,23 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name="TestModel",
name="Tenant",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=100)),
],
options={
"abstract": False,
},
),
]
@@ -1,33 +0,0 @@
# Generated by Django 5.0.7 on 2024-07-12 16:45
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Tenant",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=100)),
],
options={
"abstract": False,
},
),
]
+2 -10
View File
@@ -1,6 +1,7 @@
from django.db import models
import uuid
from django.db import models
class Base(models.Model):
"""
@@ -22,12 +23,3 @@ class Tenant(Base):
"""
name = models.CharField(max_length=100)
class TestModel(models.Model):
"""To delete.
Use this model for development/testing purposes.
"""
name = models.CharField(max_length=100)
+6 -12
View File
@@ -1,20 +1,14 @@
from rest_framework import serializers
from rest_framework_json_api import serializers
from api.models import TestModel, Tenant
from api.models import Tenant
class TestModelSerializer(serializers.ModelSerializer):
"""To delete.
Use this serializer for development/testing purposes.
"""
class Meta:
model = TestModel
fields = "__all__"
class BaseSerializerV1(serializers.ModelSerializer):
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
class TenantSerializer(serializers.ModelSerializer):
class TenantSerializer(BaseSerializerV1):
"""
Serializer for the Tenant model.
"""
-3
View File
@@ -1,3 +0,0 @@
# from django.test import TestCase
# Create your tests here.
+179
View File
@@ -0,0 +1,179 @@
import pytest
from django.urls import reverse
from rest_framework import status
from api.models import Tenant
API_JSON_CONTENT_TYPE = "application/vnd.api+json"
@pytest.mark.django_db
class TestTenantViewSet:
@pytest.fixture
def tenants(self):
tenant1 = Tenant.objects.create(
name="Tenant One",
inserted_at="2023-01-01T00:00:00Z",
updated_at="2023-01-02T00:00:00Z",
)
tenant2 = Tenant.objects.create(
name="Tenant Two",
inserted_at="2023-01-03T00:00:00Z",
updated_at="2023-01-04T00:00:00Z",
)
return tenant1, tenant2
@pytest.fixture
def valid_tenant_payload(self):
return {
"name": "Tenant Three",
"inserted_at": "2023-01-05T00:00:00Z",
"updated_at": "2023-01-06T00:00:00Z",
}
@pytest.fixture
def invalid_tenant_payload(self):
return {
"name": "",
"inserted_at": "2023-01-05T00:00:00Z",
"updated_at": "2023-01-06T00:00:00Z",
}
def test_tenants_list(self, client, tenants):
response = client.get(reverse("tenant-list"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(tenants)
def test_tenants_retrieve(self, client, tenants):
tenant1, _ = tenants
response = client.get(reverse("tenant-detail", kwargs={"pk": tenant1.id}))
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["name"] == tenant1.name
def test_tenants_invalid_retrieve(self, client, tenants):
tenant1, _ = tenants
response = client.get(reverse("tenant-detail", kwargs={"pk": "random_id"}))
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_tenants_create(self, client, valid_tenant_payload):
response = client.post(
reverse("tenant-list"), data=valid_tenant_payload, format="json"
)
assert response.status_code == status.HTTP_201_CREATED
assert Tenant.objects.count() == 1
assert Tenant.objects.get().name == valid_tenant_payload["name"]
def test_tenants_invalid_create(self, client, invalid_tenant_payload):
response = client.post(
reverse("tenant-list"),
data=invalid_tenant_payload,
format="json",
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_tenants_partial_update(self, client, tenants):
tenant1, _ = tenants
new_name = "This is the new name"
payload = {
"data": {
"type": "Tenant",
"id": tenant1.id,
"attributes": {"name": new_name},
},
}
response = client.patch(
reverse("tenant-detail", kwargs={"pk": tenant1.id}),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_200_OK
tenant1.refresh_from_db()
assert tenant1.name == new_name
def test_tenants_partial_update_invalid_content_type(self, client, tenants):
tenant1, _ = tenants
response = client.patch(
reverse("tenant-detail", kwargs={"pk": tenant1.id}), data={}
)
assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
def test_tenants_partial_update_invalid_content(self, client, tenants):
tenant1, _ = tenants
new_name = "This is the new name"
payload = {"name": new_name}
response = client.patch(
reverse("tenant-detail", kwargs={"pk": tenant1.id}),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_tenants_delete(self, client, tenants):
tenant1, _ = tenants
response = client.delete(reverse("tenant-detail", kwargs={"pk": tenant1.id}))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert Tenant.objects.count() == 1
def test_tenants_delete_invalid(self, client):
response = client.delete(reverse("tenant-detail", kwargs={"pk": "random_id"}))
# To change if we implement RBAC
# (user might not have permissions to see if the tenant exists or not -> 200 empty)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_tenants_list_filter_search(self, client, tenants):
"""Search is applied to tenants name."""
tenant1, _ = tenants
response = client.get(reverse("tenant-list"), {"filter[search]": tenant1.name})
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
assert response.json()["data"][0]["attributes"]["name"] == tenant1.name
def test_tenants_list_query_param_name(self, client, tenants):
tenant1, _ = tenants
response = client.get(reverse("tenant-list"), {"name": tenant1.name})
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_tenants_list_invalid_query_param(self, client):
response = client.get(reverse("tenant-list"), {"random": "value"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_tenants_list_filter_name(self, client, tenants):
tenant1, _ = tenants
response = client.get(reverse("tenant-list"), {"filter[name]": tenant1.name})
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
assert response.json()["data"][0]["attributes"]["name"] == tenant1.name
def test_tenants_list_filter_invalid(self, client):
response = client.get(reverse("tenant-list"), {"filter[invalid]": "whatever"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_tenants_list_page_size(self, client, tenants):
page_size = 1
response = client.get(reverse("tenant-list"), {"page[size]": page_size})
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == page_size
assert response.json()["meta"]["pagination"]["page"] == 1
assert response.json()["meta"]["pagination"]["pages"] == len(tenants)
def test_tenants_list_page_number(self, client, tenants):
page_size = 1
page_number = 2
response = client.get(
reverse("tenant-list"),
{"page[size]": page_size, "page[number]": page_number},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == page_size
assert response.json()["meta"]["pagination"]["page"] == page_number
assert response.json()["meta"]["pagination"]["pages"] == len(tenants)
def test_tenants_list_sort_name(self, client, tenants):
_, tenant2 = tenants
response = client.get(reverse("tenant-list"), {"sort": "-name"})
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 2
assert response.json()["data"][0]["attributes"]["name"] == tenant2.name
+11 -11
View File
@@ -1,16 +1,16 @@
from django.urls import path
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from django.urls import path, include
from drf_spectacular.views import SpectacularRedocView
from rest_framework import routers
from api.views.v1.views import SchemaView, TenantViewSet
router = routers.DefaultRouter(trailing_slash=False)
router.register(r"tenants", TenantViewSet)
from api.views.v1 import views
urlpatterns = [
path("schema", SpectacularAPIView.as_view(), name="schema"),
path("", include(router.urls)),
path("schema", SchemaView.as_view(), name="schema"),
path("docs", SpectacularRedocView.as_view(url_name="schema"), name="docs"),
# To delete. Use this url for development/testing purposes
path("test", views.TestModelCreateView.as_view(), name="test-list"),
path(
"test/<int:pk>/",
views.TestModelRetrieveUpdateDestroyView.as_view(),
name="test-detail",
),
]
+23
View File
@@ -0,0 +1,23 @@
from rest_framework.filters import SearchFilter
from rest_framework_json_api import filters
from rest_framework_json_api.views import ModelViewSet
from api.filters import CustomDjangoFilterBackend
class BaseViewSet(ModelViewSet):
filter_backends = [
filters.QueryParameterValidationFilter,
filters.OrderingFilter,
CustomDjangoFilterBackend,
SearchFilter,
]
filterset_fields = []
search_fields = []
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self):
raise NotImplementedError
+61 -9
View File
@@ -1,14 +1,66 @@
from rest_framework import generics
from django.conf import settings as django_settings
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.views import SpectacularAPIView
from api.models import TestModel
from api.serializers import TestModelSerializer
from api.filters import TenantFilter
from api.models import Tenant
from api.serializers import TenantSerializer
from api.views.base_views import BaseViewSet
CACHE_DECORATOR = cache_control(
max_age=django_settings.CACHE_MAX_AGE,
stale_while_revalidate=django_settings.CACHE_STALE_WHILE_REVALIDATE,
)
class TestModelCreateView(generics.ListCreateAPIView):
queryset = TestModel.objects.all()
serializer_class = TestModelSerializer
@extend_schema(exclude=True)
class SchemaView(SpectacularAPIView):
serializer_class = None
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler RESTful API"
spectacular_settings.VERSION = "1.0.0"
spectacular_settings.DESCRIPTION = (
"Prowler RESTful API specification.\n\nThis file is auto-generated."
)
return super().get(request, *args, **kwargs)
class TestModelRetrieveUpdateDestroyView(generics.RetrieveUpdateDestroyAPIView):
queryset = TestModel.objects.all()
serializer_class = TestModelSerializer
@extend_schema_view(
list=extend_schema(
summary="List all tenants",
description="Retrieve a list of all tenants with options for filtering by various criteria.",
),
retrieve=extend_schema(
summary="Retrieve data from a tenant",
description="Fetch detailed information about a specific tenant by their ID.",
),
create=extend_schema(
summary="Create a new tenant",
description="Add a new tenant to the system by providing the required tenant details.",
),
partial_update=extend_schema(
summary="Partially update a tenant",
description="Update certain fields of an existing tenant's information without affecting other fields.",
),
destroy=extend_schema(
summary="Delete a tenant",
description="Remove a tenant from the system by their ID.",
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
class TenantViewSet(BaseViewSet):
queryset = Tenant.objects.all()
serializer_class = TenantSerializer
http_method_names = ["get", "post", "patch", "delete"]
filterset_class = TenantFilter
search_fields = ["name"]
ordering = ["inserted_at"]
ordering_fields = ["name", "inserted_at", "updated_at"]
def get_queryset(self):
return Tenant.objects.all()
+28 -4
View File
@@ -21,6 +21,7 @@ INSTALLED_APPS = [
"corsheaders",
"drf_spectacular",
"django_guid",
"rest_framework_json_api",
]
MIDDLEWARE = [
@@ -58,13 +59,32 @@ TEMPLATES = [
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"PAGE_SIZE": 10,
"EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler",
"DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination",
"DEFAULT_PARSER_CLASSES": (
"rest_framework_json_api.parsers.JSONParser",
"rest_framework.parsers.FormParser",
"rest_framework.parsers.MultiPartParser",
),
"DEFAULT_RENDERER_CLASSES": ("rest_framework_json_api.renderers.JSONRenderer",),
"DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata",
"DEFAULT_FILTER_BACKENDS": (
"rest_framework_json_api.filters.QueryParameterValidationFilter",
"rest_framework_json_api.filters.OrderingFilter",
"rest_framework_json_api.django_filters.backends.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
),
"SEARCH_PARAM": "filter[search]",
"TEST_REQUEST_RENDERER_CLASSES": (
"rest_framework_json_api.renderers.JSONRenderer",
),
"TEST_REQUEST_DEFAULT_FORMAT": "vnd.api+json",
"JSON_API_UNIFORM_EXCEPTIONS": True,
}
SPECTACULAR_SETTINGS = {
"TITLE": "Prowler RESTful API",
"DESCRIPTION": "Prowler RESTful API specification.\n\nThis file is auto-generated.",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": True,
"SERVE_INCLUDE_SCHEMA": False,
}
WSGI_APPLICATION = "config.wsgi.application"
@@ -122,3 +142,7 @@ STATIC_URL = "static/"
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Cache settings
CACHE_MAX_AGE = env.int("DJANGO_CACHE_MAX_AGE", 3600)
CACHE_STALE_WHILE_REVALIDATE = env.int("DJANGO_STALE_WHILE_REVALIDATE", 60)
+12
View File
@@ -16,3 +16,15 @@ DATABASES = {
"PORT": env("POSTGRES_PORT", default="5432"),
}
}
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405
render_class
for render_class in REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] # noqa: F405
) + ("rest_framework_json_api.renderers.BrowsableAPIRenderer",)
REST_FRAMEWORK["DEFAULT_FILTER_BACKENDS"] = tuple( # noqa: F405
filter_backend
for filter_backend in REST_FRAMEWORK["DEFAULT_FILTER_BACKENDS"] # noqa: F405
if "DjangoFilterBackend" not in filter_backend
) + ("api.filters.CustomDjangoFilterBackend",)