irongit

Enhanced time synchronization for drf views.

initial commit

huncholanehuncholaneauthored
commit e0ecc6691b005c7afd1d03f9fc88c2b2251135d9Browse files

17 files changed, +505 -0

+133-0README.md
@@ -0,0 +1,133 @@
1+# PlusRequest
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.
4+
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+
7+---
8+
9+## Features
10+
11+- ✅ `PlusRequest`: subclass of DRF’s `Request` with timestamp parsing & comparison
12+- 🧠 `TimestampOp`: context-aware timestamp diff logic (`client_is_newer`, etc.)
13+- ⚙️ Configurable via `PlusRequestConf` (env vars or `settings.PLUSREQUEST_CONF`)
14+- 🧾 Typed request metadata with `Meta`, `PlusMeta`, and `DefaultMeta`
15+- 🔒 Built-in error handling for missing or invalid timestamps
16+
17+---
18+
19+## Installation
20+
21+```bash
22+pip install git+https://github.com/huncholane/django-plusrequest
23+````
24+
25+Make sure your project has a valid `setup.py` or `pyproject.toml`.
26+
27+---
28+
29+## Usage
30+
31+### Example View
32+
33+```python
34+from plusrequest.request import PlusRequest
35+
36+def view(request: PlusRequest):
37+ op = request.ts_builder(instance=model)
38+ if op.client_is_newer:
39+ return Response("No update needed.")
40+ # else send new data
41+ return Response(data)
42+```
43+
44+---
45+
46+## Timestamp Comparison
47+
48+The `TimestampOp` class is accessed via `request.top_builder(...)`. It automatically:
49+
50+* Extracts server timestamps from a model instance
51+* Parses client timestamps from headers or body
52+* Validates datetime format
53+* Raises errors or returns status flags
54+
55+### Supported behaviors
56+
57+* `raise_get`
58+* `raise_update`
59+
60+---
61+
62+## Configuration
63+
64+Set via environment variables or in `settings.PLUSREQUEST_CONF`:
65+
66+| Key | Default | Description |
67+| -------------------------------------- | --------------------- | ------------------------------------------------------------- |
68+| `PLUSREQUEST_HEADER_TIMESTAMP_FIELD` | `lastUpdated` | Header field used for client timestamp |
69+| `PLUSREQUEST_BODY_TIMESTAMP_FIELD` | `lastUpdated` | Body field used for client timestamp |
70+| `PLUSREQUEST_INSTANCE_TIMESTAMP_FIELD` | `lastUpdated` | Model field used for server timestamp |
71+| `PLUSREQUEST_NOUPDATE_CODE` | `418` | Error code raised when update is unnecessary |
72+| `PLUSREQUEST_MISSING_ACTION` | `noupdate` | Action if timestamp is missing (`error`, `allow`, `noupdate`) |
73+| `PLUSREQUEST_DATETIME_FORMAT` | `%Y-%m-%dT%H:%M:%S%z` | Timestamp parsing format |
74+
75+---
76+
77+## Typed Metadata Access
78+
79+Use `Meta` for strongly-typed access to `request.META`.
80+
81+```python
82+from plusrequest.meta import Meta
83+
84+def view(request: PlusRequest):
85+ meta: Meta = request.META
86+ user_agent = meta.get("HTTP_USER_AGENT")
87+ ip = meta.get("HTTP_X_REAL_IP") or meta.get("REMOTE_ADDR")
88+```
89+
90+### Meta Types
91+
92+* `DefaultMeta`: Core Django fields (e.g. `HTTP_HOST`, `REQUEST_METHOD`)
93+* `PlusMeta`: Common API headers (e.g. `HTTP_AUTHORIZATION`, `HTTP_X_APP_VERSION`)
94+* `Meta`: Union of both
95+
96+---
97+
98+## Exceptions
99+
100+* `InvalidClientDatetimeField`
101+* `InvalidServerDatetimeField`
102+* `NoUpdate`
103+
104+Raised when timestamps are missing, malformatted, or update should be skipped.
105+
106+---
107+
108+## Project Structure
109+
110+```
111+plusrequest/
112+├── request.py # PlusRequest class
113+├── timestamp_op.py # TimestampOp logic
114+├── meta.py # TypedDict for request.META
115+├── settings.py # Loads PlusRequestConf
116+├── types.py # Custom enums or aliases
117+```
118+
119+---
120+
121+## License
122+
123+MIT License
124+
125+```
126+
127+---
128+
129+Let me know if you want:
130+- Examples for writing tests with `PlusRequest`
131+- Sphinx or `mkdocs` setup
132+- DRF `APIView` integration patterns
133+```
+8-0django_plusrequest.egg-info/PKG-INFO
@@ -0,0 +1,8 @@
1+Metadata-Version: 2.4
2+Name: django-plusrequest
3+Version: 0.1.0
4+Summary: Middleware to add more functionality to request objects and improve type hints.
5+Project-URL: Homepage, https://github.com/huncholane/django-smartwatch
6+Requires-Python: >=3.8
7+Description-Content-Type: text/markdown
8+Requires-Dist: django
+17-0django_plusrequest.egg-info/SOURCES.txt
@@ -0,0 +1,17 @@
1+pyproject.toml
2+django_plusrequest.egg-info/PKG-INFO
3+django_plusrequest.egg-info/SOURCES.txt
4+django_plusrequest.egg-info/dependency_links.txt
5+django_plusrequest.egg-info/requires.txt
6+django_plusrequest.egg-info/top_level.txt
7+plusrequest/__init__.py
8+plusrequest/apps.py
9+plusrequest/exceptions.py
10+plusrequest/meta.py
11+plusrequest/middleware.py
12+plusrequest/request.py
13+plusrequest/settings.py
14+plusrequest/timestamp_op.py
15+plusrequest/types.py
16+plusrequest/views.py
17+plusrequest/migrations/__init__.py
+1-0django_plusrequest.egg-info/dependency_links.txt
@@ -0,0 +1 @@
1+
+1-0django_plusrequest.egg-info/requires.txt
@@ -0,0 +1 @@
1+django
+1-0django_plusrequest.egg-info/top_level.txt
@@ -0,0 +1 @@
1+plusrequest
+0-0plusrequest/__init__.py

No content changes (mode or rename only).

+6-0plusrequest/apps.py
@@ -0,0 +1,6 @@
1+from django.apps import AppConfig
2+
3+
4+class PlusrequestConfig(AppConfig):
5+ default_auto_field = 'django.db.models.BigAutoField'
6+ name = 'plusrequest'
+31-0plusrequest/exceptions.py
@@ -0,0 +1,31 @@
1+from typing import Any
2+from rest_framework import exceptions
3+
4+
5+class InvalidServerDatetimeField(exceptions.APIException):
6+ def __init__(self, field_name: str, val: Any, code: str | int):
7+ super().__init__(
8+ f"{field_name} is not a valid datetime. {val} is a {type(val)}", str(code)
9+ )
10+
11+
12+class InvalidClientDatetimeField(exceptions.APIException):
13+ def __init__(self, field_name: str, val: str, fmt: str, code: str | int):
14+ code = str(code)
15+ super().__init__(
16+ f"{field_name} header {val} val is unable to be parsed with {fmt}",
17+ code,
18+ )
19+
20+
21+class NoUpdate(exceptions.APIException):
22+ def __init__(self, request_method: str | None, code: str | int) -> None:
23+ code = str(code)
24+ if request_method in ["POST", "PUT"]:
25+ super().__init__(
26+ "Client has submitted older data than the server. Skipping update", code
27+ )
28+ elif request_method == "GET":
29+ super().__init__("Client is already up to date", code)
30+ else:
31+ super().__init__("No update", code)
+107-0plusrequest/meta.py
@@ -0,0 +1,107 @@
1+from typing import TypedDict
2+
3+
4+class DefaultMeta(TypedDict, total=False):
5+ """The default meta items in a django request."""
6+
7+ CONTENT_LENGTH: str
8+ """The length of the request body (as a string)."""
9+ CONTENT_TYPE: str
10+ """The MIME type of the request body."""
11+ HTTP_ACCEPT: str
12+ """Acceptable content types for the response."""
13+ HTTP_ACCEPT_ENCODING: str
14+ """Acceptable encodings for the response."""
15+ HTTP_ACCEPT_LANGUAGE: str
16+ """Acceptable languages for the response."""
17+ HTTP_HOST: str
18+ """The HTTP Host header sent by the client."""
19+ HTTP_REFERER: str
20+ """The referring page, if any."""
21+ HTTP_USER_AGENT: str
22+ """The client’s user-agent string."""
23+ QUERY_STRING: str
24+ """The query string, as a single (unparsed) string."""
25+ REMOTE_ADDR: str
26+ """The IP address of the client."""
27+ REMOTE_HOST: str
28+ """The hostname of the client."""
29+ REMOTE_USER: str
30+ """The user authenticated by the web server, if any."""
31+ REQUEST_METHOD: str
32+ """A string such as "GET" or "POST"."""
33+ SERVER_NAME: str
34+ """The hostname of the server."""
35+ SERVER_PORT: str
36+
37+
38+class DefaultHeaders(TypedDict, total=False):
39+ """Headers from the default meta"""
40+
41+ accept: str
42+ """Acceptable content types for the response."""
43+ accept_encoding: str
44+ """Acceptable encodings for the response."""
45+ HTTP_ACCEPT_LANGUAGE: str
46+ """Acceptable languages for the response."""
47+ HTTP_HOST: str
48+ """The HTTP Host header sent by the client."""
49+ HTTP_REFERER: str
50+ """The referring page, if any."""
51+ HTTP_USER_AGENT: str
52+ """The client’s user-agent string."""
53+
54+
55+class PlusMeta(TypedDict, total=False):
56+ """Optional headers commonly used in extended HTTP request metadata."""
57+
58+ HTTP_AUTHORIZATION: str | None
59+ """Standard header for bearer tokens, API keys, basic auth, etc."""
60+ HTTP_X_API_KEY: str | None
61+ """Custom API key used instead of Authorization."""
62+ HTTP_API_KEY: str | None
63+ """Alternate spelling, seen in some legacy systems."""
64+ HTTP_X_SESSION_ID: str | None
65+ """Session identifier, often used in mobile apps or sticky auth."""
66+ HTTP_SESSION: str | None
67+ """Alternate field for session ID."""
68+ HTTP_X_CLIENT_ID: str | None
69+ """Identifies the application or user making the request."""
70+ HTTP_X_CLIENT_SECRET: str | None
71+ """Used in OAuth-style flows to authenticate the client."""
72+ HTTP_X_APP_TOKEN: str | None
73+ """App-level token separate from user access."""
74+ HTTP_X_REFRESH_TOKEN: str | None
75+ """Used for token refresh flows in OAuth 2.0 systems."""
76+ HTTP_X_TOKEN: str | None
77+ """A token for api request"""
78+ HTTP_TOKEN: str | None
79+ """A token for api request"""
80+ HTTP_LASTUPDATED: str | None
81+ """Custom header indicating the client's last update timestamp (e.g., ISO 8601)."""
82+ HTTP_IF_NONE_MATCH: str | None
83+ """ETag validator for conditional requests (used for caching)."""
84+ HTTP_IF_MODIFIED_SINCE: str | None
85+ """Timestamp used to check if the resource has changed since the client's last version."""
86+ HTTP_X_REQUEST_ID: str | None
87+ """Unique identifier for tracing requests across services or logs."""
88+ HTTP_X_REAL_IP: str | None
89+ """Actual client IP address, typically added by a reverse proxy."""
90+ HTTP_X_FORWARDED_FOR: str | None
91+ """Comma-separated list of IPs forwarded through proxies (first is original client)."""
92+ HTTP_X_DEVICE_ID: str | None
93+ """Custom header identifying the client's device (mobile or desktop apps)."""
94+ HTTP_X_PLATFORM: str | None
95+ """Platform identifier such as 'ios', 'android', 'web', etc."""
96+ HTTP_X_APP_VERSION: str | None
97+ """Version string of the client app, useful for feature gating or debugging."""
98+ HTTP_ORIGIN: str | None
99+ """Origin of the request, used in CORS validation."""
100+ HTTP_DNT: str | None
101+ """Do Not Track header, indicating user's tracking preference ('1' or '0')."""
102+ HTTP_SEC_CH_UA: str | None
103+ """User-Agent Client Hint header, sent by modern browsers like Chrome."""
104+
105+
106+class Meta(DefaultMeta, PlusMeta):
107+ """A typed dict for the default meta items in django as well as some other common header fields."""
+23-0plusrequest/middleware.py
@@ -0,0 +1,23 @@
1+from rest_framework.request import Request
2+from plusrequest.request import PlusRequest
3+
4+
5+class PlusRequestMiddleware:
6+ def __init__(self, get_response):
7+ self.get_response = get_response
8+ # One-time configuration and initialization.
9+
10+ def __call__(self, request: Request):
11+ # Code to be executed for each request before
12+ # the view (and later middleware) are called.
13+
14+ # Add header device date functionality to request
15+ request = PlusRequest._promote_drf_to_plus_request(request)
16+
17+ # Ensure the member shortcut exists
18+ response = self.get_response(request)
19+
20+ # Code to be executed for each request/response after
21+ # the view is called.
22+
23+ return response
+0-0plusrequest/migrations/__init__.py

No content changes (mode or rename only).

+31-0plusrequest/request.py
@@ -0,0 +1,31 @@
1+from typing import (
2+ TYPE_CHECKING,
3+ cast,
4+)
5+from rest_framework.request import Request
6+
7+from plusrequest.meta import Meta
8+from plusrequest.timestamp_op import TimestampOp
9+
10+
11+class PlusRequest(Request):
12+ if TYPE_CHECKING:
13+ META: Meta
14+
15+ @property
16+ def top_builder(self):
17+ """A property that runs the `__call__` method for `TimestampOp` which initializes from kwargs first, then `PlusRequest` app settings.
18+
19+ ```python
20+ def view(request: PlusRequest):
21+ if request.top_builder(<configure>).client_is_newer:
22+ return Response("Client doesn't need anything")
23+ ...
24+ return Response(data)
25+ ```"""
26+ return TimestampOp(self)
27+
28+ @classmethod
29+ def _promote_drf_to_plus_request(cls, request: Request):
30+ request.__class__ = cls
31+ return cast(PlusRequest, request)
+32-0plusrequest/settings.py
@@ -0,0 +1,32 @@
1+from dataclasses import dataclass
2+from environs import env as e
3+from django.conf import settings
4+
5+from plusrequest.types import MissingAction
6+
7+
8+def env(type: type, key: str, default: str):
9+ return getattr(e, str(type))(f"PLUSREQUEST_{key}", default)
10+
11+
12+@dataclass()
13+class PlusRequestConf:
14+ """Dataclass for configuring PlusRequest"""
15+
16+ header_timestamp_field: str = env(str, "HEADER_TIMESTAMP_FIELD", "lastUpdated")
17+ """Field in headers to use for client timestamp"""
18+ body_timestamp_field: str = env(str, "BODY_TIMESTAMP_FIELD", "lastUpdated")
19+ """Field in request body to use for client timestamp"""
20+ instance_timestamp_field: str = env(str, "INSTANCE_TIMESTAMP_FIELD", "lastUpdated")
21+ """Field in model instance to use for server timestamp"""
22+ noupdate_code: str = env(str, "NOUPDATE_CODE", "418")
23+ """Status code for when the client is newer on a GET or client is older on a POST"""
24+ missing_action: MissingAction = env(str, "MISSING_ACTION", "noupdate")
25+ """What do do when the client does not provide a timestamp"""
26+ datetime_format: str = env(str, "DATETIME_FORMAT", "%Y-%m-%dT%H:%M:%S%z")
27+
28+
29+if hasattr(settings, "PLUSREQUEST_CONF"):
30+ conf: PlusRequestConf = settings.PLUSREQUEST_CONF
31+else:
32+ conf = PlusRequestConf()
+96-0plusrequest/timestamp_op.py
@@ -0,0 +1,96 @@
1+import datetime as dt
2+from typing import TYPE_CHECKING
3+from django.db import models
4+
5+from plusrequest.exceptions import (
6+ InvalidClientDatetimeField,
7+ InvalidServerDatetimeField,
8+ NoUpdate,
9+)
10+from plusrequest.settings import conf
11+from plusrequest.types import MissingAction
12+
13+if TYPE_CHECKING:
14+ from plusrequest.request import PlusRequest
15+
16+
17+class TimestampOp:
18+ """Builds a set of instructions on how to interpret client and server timestamps.
19+ Initialization will always raise an error if the server timestamp is missing.
20+ Errors for missing client timestamps can be configured."""
21+
22+ _missing_action: MissingAction
23+
24+ def __init__(self, parent: "PlusRequest"):
25+ self.parent = parent
26+
27+ def __call__(
28+ self,
29+ instance: models.Model | None = None,
30+ server_timestamp: dt.datetime | None = None,
31+ client_timestamp: dt.datetime | None = None,
32+ header_field: str | None = None,
33+ body_field: str | None = None,
34+ instance_field: str | None = None,
35+ missing_action: MissingAction | None = None,
36+ noupdate_code: str | None = None,
37+ datetime_format: str | None = None,
38+ ):
39+ self._header_field = header_field or conf.header_timestamp_field
40+ self._body_field = body_field or conf.body_timestamp_field
41+ self._instance_field = instance_field or conf.instance_timestamp_field
42+ self._noupdate_code = noupdate_code or conf.noupdate_code
43+ self._missing_action = missing_action or conf.missing_action
44+ self._datetime_format = datetime_format or conf.datetime_format
45+ self.client_timestamp = client_timestamp
46+
47+ if not server_timestamp and hasattr(instance, self._instance_field):
48+ self.server_timestamp = getattr(instance, self._instance_field)
49+ if not isinstance(server_timestamp, dt.datetime):
50+ raise InvalidServerDatetimeField(
51+ self._instance_field, server_timestamp, 500
52+ )
53+
54+ if not self.client_timestamp:
55+ header_str = self.parent.headers.get(self._header_field, None)
56+ body_str = self.parent.data.get(self._body_field, None)
57+ if header_str:
58+ try:
59+ self.client_timestamp = dt.datetime.strptime(
60+ header_str, self._datetime_format
61+ )
62+ except Exception:
63+ raise InvalidClientDatetimeField(
64+ self._header_field, header_str, self._datetime_format, 400
65+ )
66+ elif body_str:
67+ try:
68+ self.client_timestamp = dt.datetime.strptime(
69+ body_str, self._datetime_format
70+ )
71+ except Exception:
72+ raise InvalidClientDatetimeField(
73+ self._body_field, body_str, self._datetime_format, 400
74+ )
75+
76+ def raise_get(
77+ self, code: str | int | None = None, missing_action: MissingAction | None = None
78+ ):
79+ """Raises a drf exception `NoUpdate` which provides details that there is no need to give data back to the user."""
80+ code = code or self._noupdate_code
81+ missing_action = missing_action or self._missing_action
82+ if not self.client_timestamp and missing_action == "noupdate":
83+ raise NoUpdate(self.parent.method, code)
84+ if self.client_timestamp >= self.server_timestamp:
85+ raise NoUpdate(self.parent.method, code)
86+
87+ def raise_update(
88+ self, code: str | int | None = None, missing_action: MissingAction | None = None
89+ ):
90+ """Raises a drf exception `NoUpdate` which provides details that there is no need to update the server."""
91+ code = code or self._noupdate_code
92+ missing_action = missing_action or self._missing_action
93+ if not self.client_timestamp and missing_action == "noupdate":
94+ raise NoUpdate(self.parent.method, code)
95+ if self.client_timestamp < self.server_timestamp:
96+ raise NoUpdate(self.parent.method, code)
+4-0plusrequest/types.py
@@ -0,0 +1,4 @@
1+from typing import Literal
2+
3+
4+MissingAction = Literal["continue", "noupdate"]
+14-0pyproject.toml
@@ -0,0 +1,14 @@
1+[project]
2+name = "django-plusrequest"
3+version = "0.1.0"
4+description = "Middleware to add more functionality to request objects and improve type hints."
5+readme = "README.md"
6+requires-python = ">=3.8"
7+dependencies = ["django"]
8+
9+[project.urls]
10+Homepage = "https://github.com/huncholane/django-smartwatch"
11+
12+[build-system]
13+requires = ["setuptools"]
14+build-backend = "setuptools.build_meta"