Enhanced time synchronization for drf views.
tightened up the types and readme
7 files changed, +90 -125
+7-0LICENSE
| @@ -0,0 +1,7 @@ | ||
| 1 | +Copyright 2025 huncholane | |
| 2 | + | |
| 3 | +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: | |
| 4 | + | |
| 5 | +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. | |
| 6 | + | |
| 7 | +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
+65-96README.md
| @@ -1,6 +1,6 @@ | ||
| 1 | -# PlusRequest | |
| 1 | +# TimeCheck | |
| 2 | 2 | |
| 3 | -**PlusRequest** is a Django REST Framework (DRF) extension that enhances the standard `Request` with first-class support for timestamp-based synchronization and typed metadata access. | |
| 3 | +**TimeCheck** is a Django REST Framework (DRF) extension that wraps the standard `Request` with first-class support for timestamp-based synchronization. | |
| 4 | 4 | |
| 5 | 5 | It adds a structured way to compare client and server timestamps, detect when updates are needed, and manage API headers in a consistent, testable manner. |
| 6 | 6 | |
| @@ -8,10 +8,8 @@ It adds a structured way to compare client and server timestamps, detect when up | ||
| 8 | 8 | |
| 9 | 9 | ## Features |
| 10 | 10 | |
| 11 | -- ✅ `PlusRequest`: subclass of DRF’s `Request` with timestamp parsing & comparison | |
| 12 | -- 🧠 `TimestampOp`: context-aware timestamp diff logic (`raise_get`, etc.) | |
| 13 | -- ⚙️ Configurable via `PlusRequestConf` (env vars or `settings.PLUSREQUEST_CONF`) | |
| 14 | -- 🧾 Typed request metadata with `Meta`, `PlusMeta`, and `DefaultMeta` | |
| 11 | +- 🧠 `TimeCheck`: context-aware timestamp diff logic (`raise_get`, etc.) | |
| 12 | +- ⚙️ Configurable via `TimeCheckConf` (env vars or `settings.TIMECHECK_CONF`) | |
| 15 | 13 | - 🔒 Built-in error handling for missing or invalid timestamps |
| 16 | 14 | |
| 17 | 15 | --- |
| @@ -19,29 +17,9 @@ It adds a structured way to compare client and server timestamps, detect when up | ||
| 19 | 17 | ## Installation |
| 20 | 18 | |
| 21 | 19 | ```bash |
| 22 | -pip install git+https://github.com/huncholane/django-plusrequest | |
| 20 | +pip install git+https://github.com/huncholane/django-timecheck | |
| 23 | 21 | ```` |
| 24 | 22 | |
| 25 | -Make sure your project has a valid `setup.py` or `pyproject.toml`. | |
| 26 | - | |
| 27 | ---- | |
| 28 | - | |
| 29 | -Add to middleware. It's safe to add anywhere in the middleware since it only effects typing, | |
| 30 | -and the functionality is lazy. | |
| 31 | - | |
| 32 | -```python | |
| 33 | -MIDDLEWARE = [ | |
| 34 | - "django.middleware.security.SecurityMiddleware", | |
| 35 | - "django.contrib.sessions.middleware.SessionMiddleware", | |
| 36 | - "django.middleware.common.CommonMiddleware", | |
| 37 | - "django.middleware.csrf.CsrfViewMiddleware", | |
| 38 | - "django.contrib.auth.middleware.AuthenticationMiddleware", | |
| 39 | - "django.middleware.clickjacking.XFrameOptionsMiddleware", | |
| 40 | - "plusrequest.middleware.PlusRequestMiddleware", | |
| 41 | -] | |
| 42 | - | |
| 43 | -``` | |
| 44 | - | |
| 45 | 23 | --- |
| 46 | 24 | |
| 47 | 25 | ## Usage |
| @@ -49,103 +27,94 @@ MIDDLEWARE = [ | ||
| 49 | 27 | ### Example View |
| 50 | 28 | |
| 51 | 29 | ```python |
| 52 | -from plusrequest.request import PlusRequest | |
| 53 | - | |
| 54 | -def view(request: PlusRequest): | |
| 55 | - op = request.ts_builder(instance=model) | |
| 56 | - if op.client_is_newer: | |
| 57 | - return Response("No update needed.") | |
| 58 | - # else send new data | |
| 59 | - return Response(data) | |
| 30 | +from timecheck import TimeCheck | |
| 31 | +from rest_framework.request import Request | |
| 32 | +from rest_framework.response import Response | |
| 33 | +from rest_framework.views import APIView | |
| 34 | +from .models import MyModel | |
| 35 | + | |
| 36 | +class MyView(APIView): | |
| 37 | + def get(request: Request, pk:int): | |
| 38 | + instance = MyModel.objects.get(pk=pk) | |
| 39 | + TimeCheck(request,instance).check_time() # throws rest framework exception | |
| 40 | + ... | |
| 41 | + return Response(data) | |
| 42 | + | |
| 43 | + def put(request: Request, pk:int): | |
| 44 | + instance = MyModel.objects.get(pk=pk) | |
| 45 | + TimeCheck(request,instance).check_update() # throws rest framework exception | |
| 46 | + ... | |
| 47 | + return Response(data) | |
| 60 | 48 | ``` |
| 61 | 49 | |
| 62 | 50 | --- |
| 63 | 51 | |
| 64 | -## Timestamp Comparison | |
| 52 | +### Parameters | |
| 65 | 53 | |
| 66 | -The `TimestampOp` class is accessed via `request.top_builder(...)`. It automatically: | |
| 54 | +TimeCheck(request, ...) has the following arguments: | |
| 67 | 55 | |
| 68 | -- Extracts server timestamps from a model instance | |
| 69 | -- Parses client timestamps from headers or body | |
| 70 | -- Validates datetime format | |
| 71 | -- Raises errors or returns status flags | |
| 56 | +- **request** `rest_framework.request.Request`: A request to extract the client timestamp from. (required) | |
| 57 | +- **instance** `models.Model`: The database instance to extract the server timestamp from using the instance field. (optional) | |
| 58 | +- **server_timestamp** `dt.datetime`: A manual server timestamp to override TimeCheck parsing.(optional) | |
| 59 | +- **client_timestamp** `dt.datetime`: A manual client timestamp to override TimeCheck parsing. (optional) | |
| 60 | +- **header_field** `str`: Field to find client timestamp in the header (defaults to config) | |
| 61 | +- **body_field** `str`: Field to find client timestamp in request body (defaults to config) | |
| 62 | +- **instance_field** `str`: Field to find server timestamp in database model (defaults to config) | |
| 63 | +- **missing_action**: `"noupdate" | "continue"` (defaults to config) | |
| 64 | + - `noupdate` will raise a drf exception to indicate the view should stop early if the client does not provide a timestamp | |
| 65 | + - `continue` allows the view to continue processing data when the client does not provide a timestamp | |
| 66 | +- **noupdate_code** `int` The response code for exceptions raised to indicate the view should stop processing | |
| 67 | +- **dt_fmt** `str` The datetime format used to normalize timestamps. Useful when the client and server use mismatched time depths (defaults to config) | |
| 72 | 68 | |
| 73 | -### Supported behaviors | |
| 69 | +### TimeCheck Methods | |
| 74 | 70 | |
| 75 | -- `raise_get` | |
| 76 | -- `raise_update` | |
| 71 | +- `check_get` Raises a `NoUpdate` exception when the client timestamp is newer than or equal to the server timestamp | |
| 72 | +- `raise_update` Raises a `NoUpdate` exception when the client timestamp is older than or equal to the server timestamp | |
| 77 | 73 | |
| 78 | 74 | --- |
| 79 | 75 | |
| 80 | 76 | ## Configuration |
| 81 | 77 | |
| 82 | -Set via environment variables or in `settings.PLUSREQUEST_CONF`: | |
| 83 | - | |
| 84 | -| Key | Default | Description | | |
| 85 | -| -------------------------------------- | --------------------- | ------------------------------------------------------------- | | |
| 86 | -| `PLUSREQUEST_HEADER_TIMESTAMP_FIELD` | `lastUpdated` | Header field used for client timestamp | | |
| 87 | -| `PLUSREQUEST_BODY_TIMESTAMP_FIELD` | `lastUpdated` | Body field used for client timestamp | | |
| 88 | -| `PLUSREQUEST_INSTANCE_TIMESTAMP_FIELD` | `lastUpdated` | Model field used for server timestamp | | |
| 89 | -| `PLUSREQUEST_NOUPDATE_CODE` | `418` | Error code raised when update is unnecessary | | |
| 90 | -| `PLUSREQUEST_MISSING_ACTION` | `noupdate` | Action if timestamp is missing (`error`, `allow`, `noupdate`) | | |
| 91 | -| `PLUSREQUEST_DATETIME_FORMAT` | `%Y-%m-%dT%H:%M:%S%z` | Timestamp parsing format | | |
| 78 | +TimeCheck is customizable through the django settings, env, and on a per usage basis. | |
| 92 | 79 | |
| 93 | ---- | |
| 94 | - | |
| 95 | -## Typed Metadata Access | |
| 96 | - | |
| 97 | -Use `Meta` for strongly-typed access to `request.META`. | |
| 80 | +### Django Settings Dictionairy Defaults | |
| 98 | 81 | |
| 99 | 82 | ```python |
| 100 | -from plusrequest.meta import Meta | |
| 101 | - | |
| 102 | -def view(request: PlusRequest): | |
| 103 | - meta: Meta = request.META | |
| 104 | - user_agent = meta.get("HTTP_USER_AGENT") | |
| 105 | - ip = meta.get("HTTP_X_REAL_IP") or meta.get("REMOTE_ADDR") | |
| 83 | +TIMECHECK_CONF = { | |
| 84 | + "body_field": "lastUpdated", | |
| 85 | + "dt_fmt": "%Y-%m-%dT%H:%M:%S%z", | |
| 86 | + "header_field": "lastUpdated", | |
| 87 | + "instance_field": "lastUpdated", | |
| 88 | + "missing_action": "noupdate", | |
| 89 | + "noupdate_code": 418, | |
| 90 | +} | |
| 106 | 91 | ``` |
| 107 | 92 | |
| 108 | -### Meta Types | |
| 93 | +### | |
| 109 | 94 | |
| 110 | -- `DefaultMeta`: Core Django fields (e.g. `HTTP_HOST`, `REQUEST_METHOD`) | |
| 111 | -- `PlusMeta`: Common API headers (e.g. `HTTP_AUTHORIZATION`, `HTTP_X_APP_VERSION`) | |
| 112 | -- `Meta`: Union of both | |
| 95 | +Set via environment variables or in `settings.PLUSREQUEST_CONF`: | |
| 96 | + | |
| 97 | +| Key | Default | Description | | |
| 98 | +| -------------------------------------- | --------------------- | ------------------------------------------------------------- | | |
| 99 | +| `TIMECHECK_HEADER_FIELD` | `lastUpdated` | Header field used for client timestamp (str) | | |
| 100 | +| `TIMECHECK_BODY_FIELD` | `lastUpdated` | Body field used for client timestamp (str) | | |
| 101 | +| `TIMECHECK_INSTANCE_FIELD` | `lastUpdated` | Model field used for server timestamp (str) | | |
| 102 | +| `TIMECHECK_NOUPDATE_CODE` | `418` | Error code raised when update is unnecessary (int) | | |
| 103 | +| `TIMECHECK_MISSING_ACTION` | `noupdate` | Action if timestamp is missing (`continue`, `noupdate`) | | |
| 104 | +| `TIMECHECK_DT_FMT` | `%Y-%m-%dT%H:%M:%S%z` | Timestamp parsing format (str) | | |
| 113 | 105 | |
| 114 | 106 | --- |
| 115 | 107 | |
| 116 | 108 | ## Exceptions |
| 117 | 109 | |
| 118 | -- `InvalidClientDatetimeField` | |
| 119 | -- `InvalidServerDatetimeField` | |
| 120 | -- `NoUpdate` | |
| 110 | +- `InvalidClientDatetimeField (400)` When the client timestamp is found but cannot be parsed. | |
| 111 | +- `InvalidServerDatetimeField (500)` When the server cannot make up a timestamp. | |
| 112 | +- `NoUpdate (Custom)` Custom status code used to represent no update. | |
| 121 | 113 | |
| 122 | 114 | Raised when timestamps are missing, malformatted, or update should be skipped. |
| 123 | 115 | |
| 124 | 116 | --- |
| 125 | 117 | |
| 126 | -## Project Structure | |
| 127 | - | |
| 128 | -``` | |
| 129 | -plusrequest/ | |
| 130 | -├── request.py # PlusRequest class | |
| 131 | -├── timestamp_op.py # TimestampOp logic | |
| 132 | -├── meta.py # TypedDict for request.META | |
| 133 | -├── settings.py # Loads PlusRequestConf | |
| 134 | -├── types.py # Custom enums or aliases | |
| 135 | -``` | |
| 136 | - | |
| 137 | ---- | |
| 138 | - | |
| 139 | 118 | ## License |
| 140 | 119 | |
| 141 | -MIT License | |
| 142 | - | |
| 143 | -``` | |
| 144 | - | |
| 145 | ---- | |
| 146 | - | |
| 147 | -Let me know if you want: | |
| 148 | -- Examples for writing tests with `PlusRequest` | |
| 149 | -- Sphinx or `mkdocs` setup | |
| 150 | -- DRF `APIView` integration patterns | |
| 151 | -``` | |
| 120 | +This project is licensed under the [MIT License](LICENSE). |
+4-7timecheck/settings.py
| @@ -18,14 +18,11 @@ def getval(t: type, k: str, d: Any): | ||
| 18 | 18 | |
| 19 | 19 | conf = TimeCheckConf( |
| 20 | 20 | { |
| 21 | - "body_timestamp_field": getval(str, "body_timestamp_field", "lastUpdated"), | |
| 22 | - "datetime_format": getval(str, "datetime_format", "%Y-%m-%dT%H:%M:%S%z"), | |
| 23 | - "header_timestamp_field": getval(str, "header_timestamp_field", "lastUpdated"), | |
| 24 | - "instance_timestamp_field": getval( | |
| 25 | - str, "instance_timestamp_field", "lastUpdated" | |
| 26 | - ), | |
| 21 | + "body_field": getval(str, "body_field", "lastUpdated"), | |
| 22 | + "dt_fmt": getval(str, "dt_fmt", "%Y-%m-%dT%H:%M:%S%z"), | |
| 23 | + "header_field": getval(str, "header_field", "lastUpdated"), | |
| 24 | + "instance_field": getval(str, "instance_field", "lastUpdated"), | |
| 27 | 25 | "missing_action": getval(str, "missing_action", "noupdate"), |
| 28 | 26 | "noupdate_code": getval(int, "noupdate_code", 418), |
| 29 | - "replace_with_z": getval(bool, "replace_with_z", True), | |
| 30 | 27 | } |
| 31 | 28 | ) |
+4-1timecheck/tests.py
| @@ -3,7 +3,10 @@ from rest_framework.test import APIClient | ||
| 3 | 3 | from django.test import TestCase |
| 4 | 4 | from example_app.models import Post |
| 5 | 5 | from timecheck.settings import conf |
| 6 | -from timecheck.utils import fmt_dt | |
| 6 | + | |
| 7 | + | |
| 8 | +def fmt_dt(t: dt.datetime): | |
| 9 | + return dt.datetime.strftime(t, conf["dt_fmt"]) | |
| 7 | 10 | |
| 8 | 11 | |
| 9 | 12 | class TimeCheckTests(TestCase): |
+4-4timecheck/timecheck.py
| @@ -30,12 +30,12 @@ class TimeCheckPrivate: | ||
| 30 | 30 | instance: models.Model | None = None, |
| 31 | 31 | server_timestamp: dt.datetime | None = None, |
| 32 | 32 | client_timestamp: dt.datetime | None = None, |
| 33 | - header_field=conf["header_timestamp_field"], | |
| 34 | - body_field=conf["body_timestamp_field"], | |
| 35 | - instance_field=conf["instance_timestamp_field"], | |
| 33 | + header_field=conf["header_field"], | |
| 34 | + body_field=conf["body_field"], | |
| 35 | + instance_field=conf["instance_field"], | |
| 36 | 36 | missing_action: MissingAction = conf["missing_action"], |
| 37 | 37 | noupdate_code=conf["noupdate_code"], |
| 38 | - dt_fmt=conf["datetime_format"], | |
| 38 | + dt_fmt=conf["dt_fmt"], | |
| 39 | 39 | ): |
| 40 | 40 | self.request = request |
| 41 | 41 | self._header_field = header_field |
+5-5timecheck/types.py
| @@ -6,15 +6,15 @@ MissingAction = Literal["continue", "noupdate"] | ||
| 6 | 6 | class TimeCheckConf(TypedDict): |
| 7 | 7 | """Dataclass for configuring TimeCheck""" |
| 8 | 8 | |
| 9 | - header_timestamp_field: str | |
| 9 | + header_field: str | |
| 10 | 10 | """Field in headers to use for client timestamp""" |
| 11 | - body_timestamp_field: str | |
| 11 | + body_field: str | |
| 12 | 12 | """Field in request body to use for client timestamp""" |
| 13 | - instance_timestamp_field: str | |
| 13 | + instance_field: str | |
| 14 | 14 | """Field in model instance to use for server timestamp""" |
| 15 | 15 | noupdate_code: int |
| 16 | 16 | """Status code for when the client is newer on a GET or client is older on a POST""" |
| 17 | 17 | missing_action: MissingAction |
| 18 | 18 | """What do do when the client does not provide a timestamp""" |
| 19 | - datetime_format: str | |
| 20 | - replace_with_z: bool | |
| 19 | + dt_fmt: str | |
| 20 | + """Format used to normalize datetimes.""" |
+1-12timecheck/utils.py
| @@ -8,16 +8,5 @@ def parse_dt(s: str): | ||
| 8 | 8 | return serializers.DateTimeField().to_internal_value(s) |
| 9 | 9 | |
| 10 | 10 | |
| 11 | -def fmt_dt( | |
| 12 | - time: dt.datetime, | |
| 13 | - fmt: str = conf["datetime_format"], | |
| 14 | - replace_with_z=conf["replace_with_z"], | |
| 15 | -): | |
| 16 | - s = dt.datetime.strftime(time, fmt) | |
| 17 | - if replace_with_z: | |
| 18 | - return s.replace("+0000", "Z") | |
| 19 | - return s | |
| 20 | - | |
| 21 | - | |
| 22 | -def normalize_dt(t: dt.datetime, fmt=conf["datetime_format"]): | |
| 11 | +def normalize_dt(t: dt.datetime, fmt=conf["dt_fmt"]): | |
| 23 | 12 | return dt.datetime.strptime(t.strftime(fmt), fmt) |