diff --git a/conftest.py b/conftest.py index 988aa24..bbbcf72 100644 --- a/conftest.py +++ b/conftest.py @@ -2,10 +2,7 @@ from celery import Celery - app = Celery(task_always_eager=True) - pytest_plugins = ("capyc.pytest.rest_framework",) - diff --git a/docs/changelog/v1.2.1.md b/docs/changelog/v1.2.1.md new file mode 100644 index 0000000..6538a4c --- /dev/null +++ b/docs/changelog/v1.2.1.md @@ -0,0 +1,3 @@ +# v1.1.1 + +- Add some fixes to Capy Serializers. diff --git a/docs/serializers/cache-control.md b/docs/serializers/cache-control.md new file mode 100644 index 0000000..a4b8d6c --- /dev/null +++ b/docs/serializers/cache-control.md @@ -0,0 +1,12 @@ +# Cache control + +Capy Serializers supports cache control. You can set the `cache_control` attribute in the serializer to define the [cache control](https://developer.mozilla.org/es/docs/Web/HTTP/Headers/Cache-Control) headers, which can include directives such as `no-cache`, `no-store`, `must-revalidate`, `max-age`, and `public` or `private` to control how responses are cached by browsers and intermediate caches. + +## Example + +```python +import capyc.django.serializer as capy + +class PermissionSerializer(capy.Serializer): + cache_control = f"max-age={60 * 60}" # 1 hour +``` diff --git a/docs/serializers/cache-ttl.md b/docs/serializers/cache-ttl.md new file mode 100644 index 0000000..94d1cdc --- /dev/null +++ b/docs/serializers/cache-ttl.md @@ -0,0 +1,12 @@ +# Cache ttl + +Capy Serializers supports set a custom time to live for the cache, you can set `ttl` attribute in the serializer to set the cache ttl, after this time the cache will be invalidated. This value is in seconds. + +## Example + +```python +import capyc.django.serializer as capy + +class PermissionSerializer(capy.Serializer): + ttl = 60 * 60 # 1 hour +``` diff --git a/docs/serializers/cache.md b/docs/serializers/cache.md new file mode 100644 index 0000000..5c1a985 --- /dev/null +++ b/docs/serializers/cache.md @@ -0,0 +1,13 @@ +# Cache + +Capy Serializers supports caching out of the box, with caching enabled by default. This package utilizes [django-redis](https://github.com/jazzband/django-redis). + +## Settings + +```python +CAPYC = { + "cache": { + "enabled": True, + } +} +``` diff --git a/docs/serializers/compression.md b/docs/serializers/compression.md new file mode 100644 index 0000000..fc12cc9 --- /dev/null +++ b/docs/serializers/compression.md @@ -0,0 +1,14 @@ +# Compression + +Capy Serializers supports compression out of the box, with compression enabled by default. This package supports `gzip`, `deflate`, `brotli`, and `zstandard`. + +## Settings + +```python +CAPYC = { + "compression": { + "enabled": True, + "min_kb_size": 10, + } +} +``` diff --git a/docs/serializers/field-sets.md b/docs/serializers/field-sets.md new file mode 100644 index 0000000..750fa8f --- /dev/null +++ b/docs/serializers/field-sets.md @@ -0,0 +1,25 @@ +# Field sets + +Capy Serializers supports field sets, you can set `sets` attribute in the serializer, by default the fields provided in `default` set always are included in the response. + +## Request + +```http +GET /api/v1/users?sets=default,custom +``` + +## Serializer + +```python +import capyc.django.serializer as capy + +class PermissionSerializer(capy.Serializer): + fields = { + "default": ("id", "name"), + "extra": ("codename", "content_type"), + "ids": ("content_type", "groups"), + "lists": ("groups",), + "expand_ids": ("content_type[]",), + "expand_lists": ("groups[]",), + } +``` diff --git a/docs/serializers/fields-overrides.md b/docs/serializers/fields-overrides.md new file mode 100644 index 0000000..43115f1 --- /dev/null +++ b/docs/serializers/fields-overrides.md @@ -0,0 +1,22 @@ +# Fields and filters overrides. + +Capy Serializers supports overwrites fields names. + +## Example + +```python +import capyc.django.serializer as capy + +class PermissionSerializer(capy.Serializer): + model = Permission + fields = { + "default": ("id", "name"), + "lists": ("groups",), + "expand_lists": ("groups[]",), + } + rewrites = { + "group_set": "groups", + } + filters = ("groups",) + groups = GroupSerializer +``` diff --git a/docs/serializers/help.md b/docs/serializers/help.md new file mode 100644 index 0000000..2be9403 --- /dev/null +++ b/docs/serializers/help.md @@ -0,0 +1,7 @@ +# Help + +You can get info about the available field sets and filters by using the `help` query param. + +```http +GET /api/v1/users?help +``` diff --git a/docs/serializers/introduction.md b/docs/serializers/introduction.md new file mode 100644 index 0000000..331734d --- /dev/null +++ b/docs/serializers/introduction.md @@ -0,0 +1,44 @@ +# Capy Serializers + +Capy Serializers is a propose to replace DRF's Serializer, Serpy, and API View Extensions, the main difference respect to them is that Capy Serializers returns a Django Rest Framework compatible response. + +## Usage + +```python +import capyc.django.serializer as capy + +class PermissionSerializer(capy.Serializer): + model = Permission + path = "/permission" + fields = { + "default": ("id", "name"), + "extra": ("codename",), + "ids": ("content_type",), + "lists": ("groups",), + "expand_ids": ("content_type[]",), + "expand_lists": ("groups[]",), + } + rewrites = { + "group_set": "groups", + } + filters = ("name", "codename", "content_type", "groups") + depth = 2 + content_type = ContentTypeSerializer + groups = GroupSerializer + +``` + +## Features + +- [Field sets](field-sets.md). +- [Fields and filters overrides](fields-overrides.md). +- [Query params](query-params.md). +- [Pagination](pagination.md). +- [Sort by](sort-by.md). +- [Help](help.md). +- [Compression](compression.md). +- [Cache](cache.md). +- [Cache ttl](cache-ttl.md). +- [Cache control](cache-control.md). +- [Query optimizations](query-optimizations.md). +- [Query depth](query-depth.md). diff --git a/docs/serializers/pagination.md b/docs/serializers/pagination.md new file mode 100644 index 0000000..2493e90 --- /dev/null +++ b/docs/serializers/pagination.md @@ -0,0 +1,28 @@ +# Pagination + +Capy Serializers supports pagination out of the box, this feature cannot be disabled, you must provide the attribute `path` to the serializer to enable the links. This package supports `limit` and `offset` query params. Like GraphQL, all nested queries are automatically paginated. If you want to load more results, you can use the `next` link in the response. + +## Example + +```json +{ + "count": 100, + "previous": "http://localhost:8000/api/v1/users/?limit=10&offset=0", + "next": "http://localhost:8000/api/v1/users/?limit=10&offset=10", + "first": "http://localhost:8000/api/v1/users/?limit=10&offset=0", + "last": "http://localhost:8000/api/v1/users/?limit=10&offset=90", + "results": [ + ... + "nested_m2m": { + "count": 100, + "previous": "http://localhost:8000/api/v1/m2m/?limit=10&offset=0", + "next": "http://localhost:8000/api/v1/m2m/?limit=10&offset=10", + "first": "http://localhost:8000/api/v1/m2m/?limit=10&offset=0", + "last": "http://localhost:8000/api/v1/m2m/?limit=10&offset=90", + "results": [ + ... + ] + } + ] +} +``` diff --git a/docs/serializers/query-depth.md b/docs/serializers/query-depth.md new file mode 100644 index 0000000..eec4345 --- /dev/null +++ b/docs/serializers/query-depth.md @@ -0,0 +1,3 @@ +# Query depth + +Capy Serializers supports query depth, you can set `depth` attribute in the serializer to limit the depth of the query. Default depth is 2. diff --git a/docs/serializers/query-optimizations.md b/docs/serializers/query-optimizations.md new file mode 100644 index 0000000..ed5f351 --- /dev/null +++ b/docs/serializers/query-optimizations.md @@ -0,0 +1,3 @@ +# Query optimizations + +Capy Serializers provides some optimizations out of the box, reducing the number of queries and the amount of data transferred. diff --git a/docs/serializers/query-params.md b/docs/serializers/query-params.md new file mode 100644 index 0000000..85bcdbf --- /dev/null +++ b/docs/serializers/query-params.md @@ -0,0 +1,63 @@ +# Query params + +Capy Serializers supports automatic filtering from query params out of the box, this fields are limited from `filters` attribute and inherit the filters from its children. + +## Operations + +### Greater than + +```http +GET /api/v1/users?age>18 +``` + +### Less than + +```http +GET /api/v1/users?age<18 +``` + +### Greater than or equal to + +```http +GET /api/v1/users?age>=18 +``` + +### Less than or equal to + +```http +GET /api/v1/users?age<=18 +``` + +### Equal to + +```http +GET /api/v1/users?age=18 +``` + +### Not equal to + +```http +GET /api/v1/users?age!=18 +``` + +### In + +```http +GET /api/v1/users?age=18,20,22 +``` + +### Django Lookup + +The supported filters are `exact`, `iexact`, `contains`, `icontains`, `gt`, `gte`, `lt`, `lte`, `in`, `startswith`, `istartswith`, `endswith`, `iendswith`, `range`, `year`, `month`, `day`, `hour`, `minute`, `second`, `isnull`, `search`. + +```http +GET /api/v1/users?age[in]=18,20,22 +``` + +### Not + +This operator is able to negate all the supported operations previously mentioned. + +```http +GET /api/v1/users?age!=18 +``` diff --git a/docs/serializers/sort-by.md b/docs/serializers/sort-by.md new file mode 100644 index 0000000..b8c529b --- /dev/null +++ b/docs/serializers/sort-by.md @@ -0,0 +1,3 @@ +# Sort by + +Capy Serializers supports sort by, you can set `sort_by` attribute in the serializer to sort the results by a field. Default sort by is `pk`, you can override this value using `sort_by` query param. diff --git a/mkdocs.yml b/mkdocs.yml index adc01e4..51fda09 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,6 +36,20 @@ nav: - "feature-flags/variant.md" - "feature-flags/flags-file.md" - "feature-flags/reading-flags.md" + - Serializers: + - "serializers/introduction.md" + - "serializers/field-sets.md" + - "serializers/fields-overrides.md" + - "serializers/query-params.md" + - "serializers/pagination.md" + - "serializers/sort-by.md" + - "serializers/help.md" + - "serializers/compression.md" + - "serializers/cache.md" + - "serializers/cache-ttl.md" + - "serializers/cache-control.md" + - "serializers/query-optimizations.md" + - "serializers/query-depth.md" - Exceptions: - "exceptions/validation-exception.md" - "exceptions/payment-exception.md" @@ -43,6 +57,8 @@ nav: - "changelog/v1.0.0.md" - "changelog/v1.0.3.md" - "changelog/v1.1.0.md" + - "changelog/v1.2.0.md" + - "changelog/v1.2.1.md" - Fixtures: - circuitbreaker: - "fixtures/circuitbreaker/dont-close-the-circuit.md" diff --git a/pyproject.toml b/pyproject.toml index f008c86..d544dbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.26.1"] build-backend = "hatchling.build" [project] diff --git a/src/capyc/__about__.py b/src/capyc/__about__.py index 20fbc05..829822a 100644 --- a/src/capyc/__about__.py +++ b/src/capyc/__about__.py @@ -31,4 +31,4 @@ # SPDX-FileCopyrightText: 2024-present jefer94 # # SPDX-License-Identifier: MIT -__version__ = "1.2.0" +__version__ = "1.2.1" diff --git a/src/capyc/django/cache.py b/src/capyc/django/cache.py index 10de84d..8784d00 100644 --- a/src/capyc/django/cache.py +++ b/src/capyc/django/cache.py @@ -23,16 +23,22 @@ CAPYC = getattr(settings, "CAPYC", {}) if "cache" in CAPYC and isinstance(CAPYC["cache"], dict): is_cache_enabled = CAPYC["cache"].get("enabled", True) - min_compression_size = CAPYC["cache"].get("min_kb_size", 10) else: is_cache_enabled = os.getenv("CAPYC_CACHE", "True") not in FALSE_VALUES - min_compression_size = int(os.getenv("CAPYC_MIN_COMPRESSION_SIZE", "10")) +if "compression" in CAPYC and isinstance(CAPYC["compression"], dict): + is_compression_enabled = CAPYC["compression"].get("enabled", True) + min_compression_size = CAPYC["compression"].get("min_kb_size", 10) + +else: + is_compression_enabled = os.getenv("CAPYC_COMPRESSION", "True") not in FALSE_VALUES + min_compression_size = int(os.getenv("CAPYC_MIN_COMPRESSION_SIZE", "10")) settings = { "min_compression_size": min_compression_size, "is_cache_enabled": is_cache_enabled, + "is_compression_enabled": is_compression_enabled, # not used yet } @@ -127,9 +133,9 @@ def set_cache( elif cache_control: res["headers"]["Cache-Control"] = cache_control - elif ttl: - res["headers"]["Cache-Control"] = f"max-age={ttl}" - res["headers"]["Expires"] = (timezone.now() + timedelta(seconds=ttl)).isoformat() + # elif ttl: + # res["headers"]["Cache-Control"] = f"max-age={ttl}" + # res["headers"]["Expires"] = (timezone.now() + timedelta(seconds=ttl)).isoformat() else: res["headers"]["Cache-Control"] = "public" diff --git a/src/capyc/django/serializer.py b/src/capyc/django/serializer.py index 7096729..b55f58b 100644 --- a/src/capyc/django/serializer.py +++ b/src/capyc/django/serializer.py @@ -436,7 +436,31 @@ def get_related_attrs(field, name): else: field = field.related - queryattr = field.attname + if hasattr(field, "attname"): + queryattr = field.attname + else: + queryattr = field.accessor_name ## changed + + blank = False ## changed + if hasattr(field, "blank"): ## changed + blank = field.blank + + default = None ## changed + if hasattr(field, "default"): ## changed + default = field.default + + help_text = "" ## changed + if hasattr(field, "help_text"): ## changed + help_text = field.help_text + + primary_key = False ## changed + if hasattr(field, "primary_key"): ## changed + primary_key = field.primary_key + + unique = False ## changed + if hasattr(field, "unique"): ## changed + unique = field.unique + if queryattr.endswith("_id"): queryattr = queryattr[:-3] @@ -462,13 +486,13 @@ def get_related_attrs(field, name): nullable=field.null, related_model=related_model, query_handler=QUERY_REWRITES.get(type(field), None), - blank=field.blank, - default=field.default, - help_text=field.help_text, + blank=blank, ## changed + default=default, ## changed + help_text=help_text, ## changed editable=field.editable, is_relation=field.is_relation, - primary_key=field.primary_key, - unique=field.unique, + primary_key=primary_key, ## changed + unique=unique, ## changed query_param=queryattr, ) @@ -557,6 +581,7 @@ def _check_settings(cls): assert all(isinstance(x, str) for x in cls.fields.keys()), "fields key must be a strings" assert isinstance(cls.filters, Iterable), "filters must be an array of strings" assert all(isinstance(x, str) for x in cls.filters), "filters must be an array of strings" + assert all(isinstance(x, str) for x in cls.preselected), "preselected must be an array of strings" for field in cls.fields.values(): assert all(isinstance(x, str) for x in field), "fields value must be an array of strings" @@ -605,13 +630,22 @@ def _check_settings(cls): field = cls._rewrites.get(field, field) - if field in field_list or field + "_id" in id_list or field in m2m_list: + if field in field_list or field + "_id" in id_list or field in m2m_list or field in o2_list: continue assert ( 0 ), f"Field '{field}' not found in model '{cls.model.__name__}', available fields: {[x for x in vars(cls.model) if not x.startswith('_')]}" + for field in cls.preselected: + if field in field_list: + continue + + if field + "_id" in id_list: + continue + + assert 0, f"Preselected field '{field}' not found in model '{cls.model.__name__}'" + for filter in cls.filters: original_filter = filter filter = cls._rewrites.get(filter, filter) @@ -716,6 +750,9 @@ def _get_related_serializers(cls): @classmethod def _prepare_fields(cls): + if hasattr(cls, "preselected") is False: + cls.preselected = () + cls._lookups: dict[str, str] = {} if not hasattr(cls, "filters"): cls.filters: list[str] = [] @@ -731,7 +768,7 @@ def _prepare_fields(cls): class Serializer(SerializerMetaBuilder): _serializer_instances: dict[str, Type["Serializer"]] - sort_by = "pk" + sort_by: str = "pk" ttl: int | None = None cache_control: str | None = None @@ -766,7 +803,11 @@ def _prefetch(self, qs: QuerySet): only |= set([f"{key}__{x}" for x in x1]) selected.add(key) - qs = qs.select_related(*selected).only(*only) + for field in self.preselected: + only.add(field) + + # this doesn't work, the only must include + # qs = qs.select_related(*selected).only(*only) return qs def _serialize(self, instance: models.Model) -> dict: @@ -804,7 +845,7 @@ def _serialize(self, instance: models.Model) -> dict: data[key] = ser._instances( qs.all(), count, - extra={query_param: getattr(instance, "pk", None)}, + extra={query_param + ".pk": getattr(instance, "pk", None)}, ) else: @@ -826,7 +867,7 @@ def _serialize(self, instance: models.Model) -> dict: count, pks=True, path=path, - extra={query_param: getattr(instance, "pk", None)}, + extra={query_param + ".pk": getattr(instance, "pk", None)}, ) return data @@ -937,6 +978,8 @@ def _wraps_pagination( }, ) + obj["results"] = obj.pop("results") + return obj @classmethod @@ -1274,7 +1317,7 @@ def build_filter(filters: list[FilterOperation]): exclude_filters: list[FilterOperation] = [] for x in (self.request.META.get("QUERY_STRING") or "").split("&"): - if x.startswith("sets=") or x.startswith("sort="): + if x.startswith("sets=") or x.startswith("sort=") or x.startswith("limit=") or x.startswith("offset="): continue if "." in x.split("=")[0]: @@ -1452,7 +1495,7 @@ def get_rel_info(field: FieldRelatedDescriptor): } ) - result = {"sets": sets, "filters": sorted([*cls.filters, *inherited_filters])} + result = {"filters": sorted([*cls.filters, *inherited_filters]), "sets": sets} if original_depth is None: return HttpResponse(json.dumps(result), status=200, headers={"Content-Type": "application/json"}) @@ -1500,7 +1543,7 @@ def afilter(self, **kwargs: Any) -> List[dict[str, Any]]: def _verify_headers(self): accept = self.request.headers.get("Accept", "application/json") - for x in ["application/json"]: + for x in ["application/json", "*/*", "application/*", "*/json"]: if x in accept: return