djangorestframework-services 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- djangorestframework_services-0.1.0.dist-info/METADATA +586 -0
- djangorestframework_services-0.1.0.dist-info/RECORD +66 -0
- djangorestframework_services-0.1.0.dist-info/WHEEL +4 -0
- rest_framework_services/__init__.py +70 -0
- rest_framework_services/_compat/__init__.py +11 -0
- rest_framework_services/_compat/arun_service.py +37 -0
- rest_framework_services/_compat/is_async.py +23 -0
- rest_framework_services/_compat/run_service.py +21 -0
- rest_framework_services/conf.py +24 -0
- rest_framework_services/exceptions/__init__.py +15 -0
- rest_framework_services/exceptions/service_error.py +20 -0
- rest_framework_services/exceptions/service_validation_error.py +26 -0
- rest_framework_services/management/__init__.py +0 -0
- rest_framework_services/management/commands/__init__.py +0 -0
- rest_framework_services/management/commands/startserviceapp.py +35 -0
- rest_framework_services/management/templates/service_app/__init__.py-tpl +0 -0
- rest_framework_services/management/templates/service_app/admin.py-tpl +1 -0
- rest_framework_services/management/templates/service_app/apps.py-tpl +6 -0
- rest_framework_services/management/templates/service_app/migrations/__init__.py-tpl +0 -0
- rest_framework_services/management/templates/service_app/models/__init__.py-tpl +0 -0
- rest_framework_services/management/templates/service_app/selectors/__init__.py-tpl +0 -0
- rest_framework_services/management/templates/service_app/serializers/__init__.py-tpl +0 -0
- rest_framework_services/management/templates/service_app/services/__init__.py-tpl +0 -0
- rest_framework_services/management/templates/service_app/tests/__init__.py-tpl +0 -0
- rest_framework_services/management/templates/service_app/urls.py-tpl +7 -0
- rest_framework_services/management/templates/service_app/utils/__init__.py-tpl +0 -0
- rest_framework_services/management/templates/service_app/validators/__init__.py-tpl +0 -0
- rest_framework_services/management/templates/service_app/views/__init__.py-tpl +0 -0
- rest_framework_services/mutations/__init__.py +23 -0
- rest_framework_services/mutations/acreate_from_input.py +43 -0
- rest_framework_services/mutations/apply_input.py +39 -0
- rest_framework_services/mutations/aupdate_from_input.py +52 -0
- rest_framework_services/mutations/create_from_input.py +48 -0
- rest_framework_services/mutations/update_from_input.py +57 -0
- rest_framework_services/mutations/utils.py +201 -0
- rest_framework_services/py.typed +0 -0
- rest_framework_services/selectors/__init__.py +9 -0
- rest_framework_services/selectors/async_selector.py +12 -0
- rest_framework_services/selectors/selector.py +22 -0
- rest_framework_services/selectors/utils.py +27 -0
- rest_framework_services/types/__init__.py +16 -0
- rest_framework_services/types/change_result.py +39 -0
- rest_framework_services/types/field_change.py +19 -0
- rest_framework_services/types/unset.py +34 -0
- rest_framework_services/views/__init__.py +24 -0
- rest_framework_services/views/mutation/__init__.py +11 -0
- rest_framework_services/views/mutation/mutation_flow_mixin.py +57 -0
- rest_framework_services/views/mutation/service_create_view.py +53 -0
- rest_framework_services/views/mutation/service_delete_view.py +46 -0
- rest_framework_services/views/mutation/service_update_view.py +55 -0
- rest_framework_services/views/mutation/utils.py +155 -0
- rest_framework_services/views/query/__init__.py +11 -0
- rest_framework_services/views/query/selector_list_view.py +52 -0
- rest_framework_services/views/query/selector_retrieve_view.py +61 -0
- rest_framework_services/views/utils.py +47 -0
- rest_framework_services/viewsets/__init__.py +25 -0
- rest_framework_services/viewsets/decorators/__init__.py +7 -0
- rest_framework_services/viewsets/decorators/service_action.py +72 -0
- rest_framework_services/viewsets/multi_serializer_mixin.py +39 -0
- rest_framework_services/viewsets/selector_list_mixin.py +50 -0
- rest_framework_services/viewsets/selector_retrieve_mixin.py +63 -0
- rest_framework_services/viewsets/selector_viewset.py +25 -0
- rest_framework_services/viewsets/service_create_mixin.py +53 -0
- rest_framework_services/viewsets/service_destroy_mixin.py +49 -0
- rest_framework_services/viewsets/service_update_mixin.py +56 -0
- rest_framework_services/viewsets/service_viewset.py +33 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: djangorestframework-services
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A service-oriented layer for Django REST Framework: precise, controllable side effects for mutating endpoints.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Artui/djangorestframework-services
|
|
6
|
+
Project-URL: Repository, https://github.com/Artui/djangorestframework-services
|
|
7
|
+
Project-URL: Issues, https://github.com/Artui/djangorestframework-services/issues
|
|
8
|
+
Author-email: Artur Veres <artur8118@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: django,djangorestframework,drf,service-layer,services
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 4.2
|
|
15
|
+
Classifier: Framework :: Django :: 5.0
|
|
16
|
+
Classifier: Framework :: Django :: 5.1
|
|
17
|
+
Classifier: Framework :: Django :: 5.2
|
|
18
|
+
Classifier: Framework :: Django :: 6.0
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Classifier: Programming Language :: Python
|
|
23
|
+
Classifier: Programming Language :: Python :: 3
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
29
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
30
|
+
Requires-Python: >=3.10
|
|
31
|
+
Requires-Dist: django>=4.2
|
|
32
|
+
Requires-Dist: djangorestframework-dataclasses>=1.3.1
|
|
33
|
+
Requires-Dist: djangorestframework>=3.14
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# djangorestframework-services
|
|
37
|
+
|
|
38
|
+
A service / selector layer for Django REST Framework.
|
|
39
|
+
|
|
40
|
+
DRF's default mode for mutating endpoints is "the serializer is the business
|
|
41
|
+
logic". That's fine for thin CRUD, but it falls apart the moment you need
|
|
42
|
+
to compose with an external system, fan out side effects, or write logic
|
|
43
|
+
that doesn't belong on a model. `djangorestframework-services` keeps DRF's
|
|
44
|
+
routing, validation, and serialization for what they're good at, and gives
|
|
45
|
+
you a precise, well-typed seam for the bits in the middle.
|
|
46
|
+
|
|
47
|
+
- **Services** — plain callables. The library does not define a `Service`
|
|
48
|
+
base class or prescribe a signature.
|
|
49
|
+
- **Selectors** — plain callables that override `get_queryset()` /
|
|
50
|
+
`get_object()`. Filter backends, pagination, and serialization stay
|
|
51
|
+
vanilla DRF.
|
|
52
|
+
- **Mutation helpers** — `create_from_input`, `update_from_input`,
|
|
53
|
+
`apply_input` (and async siblings) — DRF-style attribute application
|
|
54
|
+
with change tracking, no surprises.
|
|
55
|
+
- **Sync and async** services and selectors, transparently dispatched.
|
|
56
|
+
- **Atomic by default**, opt-out per view.
|
|
57
|
+
- **Framework-agnostic exceptions** — services don't import from DRF.
|
|
58
|
+
- **100% test coverage**, type-checked, Python 3.10–3.14, Django 4.2–6.0.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install djangorestframework-services
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
A `POST /authors/` endpoint that creates an `Author`. Input is validated
|
|
69
|
+
by a dataclass, the service is a plain function, and the response is
|
|
70
|
+
shaped by another dataclass — both halves rendered through
|
|
71
|
+
`DataclassSerializer` from
|
|
72
|
+
[`djangorestframework-dataclasses`](https://pypi.org/project/djangorestframework-dataclasses/),
|
|
73
|
+
which the library already depends on.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from dataclasses import dataclass
|
|
77
|
+
|
|
78
|
+
from rest_framework_dataclasses.serializers import DataclassSerializer
|
|
79
|
+
from rest_framework_services import ServiceCreateView, create_from_input
|
|
80
|
+
|
|
81
|
+
from myapp.models import Author
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# 1. Input — validated at the view boundary; the service receives a
|
|
85
|
+
# typed instance.
|
|
86
|
+
@dataclass
|
|
87
|
+
class CreateAuthorInput:
|
|
88
|
+
name: str
|
|
89
|
+
bio: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# 2. Output — a dataclass that shapes the JSON response. The service
|
|
93
|
+
# can return either the matching dataclass or a model instance with
|
|
94
|
+
# the same attribute names; DataclassSerializer reads via getattr.
|
|
95
|
+
@dataclass
|
|
96
|
+
class AuthorOutput:
|
|
97
|
+
id: int
|
|
98
|
+
name: str
|
|
99
|
+
bio: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AuthorOutputSerializer(DataclassSerializer):
|
|
103
|
+
class Meta:
|
|
104
|
+
dataclass = AuthorOutput
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# 3. Service — a plain callable. No DRF imports; raises framework-
|
|
108
|
+
# agnostic exceptions if it needs to.
|
|
109
|
+
def create_author(*, data: CreateAuthorInput) -> Author:
|
|
110
|
+
result = create_from_input(Author, data)
|
|
111
|
+
return result.instance
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# 4. View — wires it all together.
|
|
115
|
+
class CreateAuthorView(ServiceCreateView):
|
|
116
|
+
service = create_author
|
|
117
|
+
input_dataclass = CreateAuthorInput
|
|
118
|
+
output_serializer = AuthorOutputSerializer
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
# urls.py
|
|
123
|
+
from django.urls import path
|
|
124
|
+
from myapp.views import CreateAuthorView
|
|
125
|
+
|
|
126
|
+
urlpatterns = [path("authors/", CreateAuthorView.as_view())]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
POST `{"name": "Ada"}` → `201` with `{"id": 1, "name": "Ada", "bio": ""}`.
|
|
130
|
+
|
|
131
|
+
### Returning the output dataclass directly
|
|
132
|
+
|
|
133
|
+
If you want the service's return type to *be* the response shape — useful
|
|
134
|
+
when the API surface diverges from your model (computed fields, hidden
|
|
135
|
+
columns, denormalised joins) — have the service build and return the
|
|
136
|
+
output dataclass:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
def create_author(*, data: CreateAuthorInput) -> AuthorOutput:
|
|
140
|
+
author = Author.objects.create(name=data.name, bio=data.bio)
|
|
141
|
+
return AuthorOutput(id=author.id, name=author.name, bio=author.bio)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`AuthorOutputSerializer` renders it the same way; the view doesn't care.
|
|
145
|
+
|
|
146
|
+
### Alternative: `ModelSerializer`
|
|
147
|
+
|
|
148
|
+
When the response mirrors the model exactly, DRF's `ModelSerializer` is
|
|
149
|
+
the shorter path and gets the full DRF feature set (relations, nested
|
|
150
|
+
serializers, etc.):
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from rest_framework import serializers
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AuthorSerializer(serializers.ModelSerializer):
|
|
157
|
+
class Meta:
|
|
158
|
+
model = Author
|
|
159
|
+
fields = ("id", "name", "bio")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class CreateAuthorView(ServiceCreateView):
|
|
163
|
+
service = create_author
|
|
164
|
+
input_dataclass = CreateAuthorInput
|
|
165
|
+
output_serializer = AuthorSerializer
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Both patterns are first-class — the library doesn't care which kind of
|
|
169
|
+
DRF serializer you use for output. Pick `DataclassSerializer` when you
|
|
170
|
+
want the API contract to live alongside the service signature; pick
|
|
171
|
+
`ModelSerializer` when the response mirrors the model and you want
|
|
172
|
+
DRF's relational machinery.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Mental model
|
|
177
|
+
|
|
178
|
+
There are three building blocks:
|
|
179
|
+
|
|
180
|
+
| Block | What it is | Where it lives |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| **Service** | A plain callable that performs a mutation | Your code |
|
|
183
|
+
| **Selector** | A plain callable that returns data to read | Your code |
|
|
184
|
+
| **View / Viewset** | DRF view that wires a service or selector to an HTTP method | Provided by this library |
|
|
185
|
+
|
|
186
|
+
Views inspect the service / selector signature with `inspect.signature` and
|
|
187
|
+
pass only the arguments they declare from a known pool: `data`, `instance`,
|
|
188
|
+
`request`, `user`, `view`, plus extras from `get_service_kwargs()` /
|
|
189
|
+
`get_selector_kwargs()`. If a callable declares `**kwargs`, the entire pool
|
|
190
|
+
is forwarded.
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
def create_author(*, data, user): # the view passes only data + user
|
|
194
|
+
return Author.objects.create(name=data.name, created_by=user)
|
|
195
|
+
|
|
196
|
+
def list_authors(*, request): # request is in the pool
|
|
197
|
+
return Author.objects.filter(...)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Mutation helpers
|
|
203
|
+
|
|
204
|
+
The library doesn't run your services for you, but it does ship the helpers
|
|
205
|
+
that DRF's `serializer.save()` quietly performs — minus the surprises and
|
|
206
|
+
plus a typed change record.
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from rest_framework_services import update_from_input, UNSET
|
|
210
|
+
|
|
211
|
+
def update_author(*, instance, data):
|
|
212
|
+
result = update_from_input(instance, data, exclude_fields=["created_by"])
|
|
213
|
+
if result.get_field_change("email"):
|
|
214
|
+
send_email_changed_notice(instance)
|
|
215
|
+
return result.instance
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
What you get:
|
|
219
|
+
|
|
220
|
+
- **`apply_input(instance, data)`** — set attributes in memory, no save.
|
|
221
|
+
- **`create_from_input(Model, data)`** — build, save, optional M2M.
|
|
222
|
+
- **`update_from_input(instance, data)`** — diff in-memory state vs. input,
|
|
223
|
+
call `save(update_fields=[...])` with only the fields that actually
|
|
224
|
+
changed.
|
|
225
|
+
- **`acreate_from_input` / `aupdate_from_input`** — async equivalents
|
|
226
|
+
using Django 4.2+ `asave()` / `aset()`.
|
|
227
|
+
|
|
228
|
+
All of them accept:
|
|
229
|
+
|
|
230
|
+
- `data` — a dataclass, plain dict, or any object with `__dict__`.
|
|
231
|
+
- `field_map: dict[str, str]` — translate input keys to model attribute
|
|
232
|
+
names.
|
|
233
|
+
- `exclude_fields: list[str]` — fields to drop from the input before
|
|
234
|
+
applying.
|
|
235
|
+
- `m2m: dict[str, Any]` — many-to-many assignments applied post-save
|
|
236
|
+
(create/update only).
|
|
237
|
+
|
|
238
|
+
All of them return a `ChangeResult`:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
@dataclass(frozen=True)
|
|
242
|
+
class ChangeResult:
|
|
243
|
+
instance: Model
|
|
244
|
+
created: bool
|
|
245
|
+
changes: tuple[FieldChange, ...]
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def changed_fields(self) -> tuple[str, ...]: ...
|
|
249
|
+
def get_field_change(self, field_name: str) -> FieldChange | None: ...
|
|
250
|
+
def __bool__(self) -> bool: ... # True iff any change
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The `UNSET` sentinel distinguishes "field omitted from input" from "field
|
|
254
|
+
explicitly set to `None`" — critical for correct `PATCH` semantics.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Views
|
|
259
|
+
|
|
260
|
+
| Class | Method | Purpose |
|
|
261
|
+
|---|---|---|
|
|
262
|
+
| `ServiceCreateView` | `POST` | runs `service` to create |
|
|
263
|
+
| `ServiceUpdateView` | `PUT` / `PATCH` | runs `service` to update; instance from `get_object()` |
|
|
264
|
+
| `ServiceDeleteView` | `DELETE` | runs `service` to delete |
|
|
265
|
+
| `SelectorListView` | `GET` | uses `selector` (or `queryset`) for list |
|
|
266
|
+
| `SelectorRetrieveView` | `GET` | uses `selector` (or `queryset` + `lookup_field`) for retrieve |
|
|
267
|
+
|
|
268
|
+
Mutation views configure: `service`, `input_dataclass`, `output_serializer`,
|
|
269
|
+
`output_selector`, `atomic`, `success_status`. Selector views configure:
|
|
270
|
+
`selector` and DRF's standard `serializer_class`.
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
@dataclass
|
|
274
|
+
class UpdateAuthorInput:
|
|
275
|
+
name: str | None = None
|
|
276
|
+
bio: str | None = None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class UpdateAuthorView(ServiceUpdateView):
|
|
280
|
+
queryset = Author.objects.all()
|
|
281
|
+
service = update_author
|
|
282
|
+
input_dataclass = UpdateAuthorInput
|
|
283
|
+
output_serializer = AuthorOutputSerializer # DataclassSerializer
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
A `ModelSerializer` can be dropped in just as cleanly:
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
class UpdateAuthorView(ServiceUpdateView):
|
|
290
|
+
queryset = Author.objects.all()
|
|
291
|
+
service = update_author
|
|
292
|
+
input_dataclass = UpdateAuthorInput
|
|
293
|
+
output_serializer = AuthorSerializer # DRF ModelSerializer
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
When a service returns `None` and the view has an instance in scope (update
|
|
297
|
+
or delete), the in-memory instance is rendered — matching DRF's
|
|
298
|
+
`UpdateAPIView` shape without you having to wire it up.
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Viewsets
|
|
303
|
+
|
|
304
|
+
`ServiceViewSet` is a router-compatible viewset composed of per-action
|
|
305
|
+
mixins. Each action picks its own serializer; below the read side uses
|
|
306
|
+
two `DataclassSerializer` shapes (terse list, full detail) and the
|
|
307
|
+
mutations reuse the detail one:
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
from dataclasses import dataclass
|
|
311
|
+
|
|
312
|
+
from rest_framework_dataclasses.serializers import DataclassSerializer
|
|
313
|
+
from rest_framework_services import ServiceViewSet
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass
|
|
317
|
+
class AuthorListItem:
|
|
318
|
+
id: int
|
|
319
|
+
name: str
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass
|
|
323
|
+
class AuthorDetail:
|
|
324
|
+
id: int
|
|
325
|
+
name: str
|
|
326
|
+
bio: str
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class AuthorListItemSerializer(DataclassSerializer):
|
|
330
|
+
class Meta:
|
|
331
|
+
dataclass = AuthorListItem
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class AuthorDetailSerializer(DataclassSerializer):
|
|
335
|
+
class Meta:
|
|
336
|
+
dataclass = AuthorDetail
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class AuthorViewSet(ServiceViewSet):
|
|
340
|
+
queryset = Author.objects.all()
|
|
341
|
+
serializer_classes = {
|
|
342
|
+
"list": AuthorListItemSerializer,
|
|
343
|
+
"retrieve": AuthorDetailSerializer,
|
|
344
|
+
}
|
|
345
|
+
list_selector = list_authors
|
|
346
|
+
retrieve_selector = get_author
|
|
347
|
+
create_service = create_author
|
|
348
|
+
create_input_dataclass = CreateAuthorInput
|
|
349
|
+
create_output_serializer = AuthorDetailSerializer
|
|
350
|
+
update_service = update_author
|
|
351
|
+
update_input_dataclass = UpdateAuthorInput
|
|
352
|
+
update_output_serializer = AuthorDetailSerializer
|
|
353
|
+
destroy_service = delete_author
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Mix `ModelSerializer` and `DataclassSerializer` per action freely — the
|
|
357
|
+
viewset doesn't distinguish between them.
|
|
358
|
+
|
|
359
|
+
Register it with a router as usual:
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
router = DefaultRouter()
|
|
363
|
+
router.register("authors", AuthorViewSet, basename="author")
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Per-action mixins (`ServiceCreateMixin`, `ServiceUpdateMixin`,
|
|
367
|
+
`ServiceDestroyMixin`, `SelectorListMixin`, `SelectorRetrieveMixin`,
|
|
368
|
+
`MultiSerializerMixin`) are exported so you can compose only the actions
|
|
369
|
+
you need:
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
class AuthorReadOnly(SelectorListMixin, SelectorRetrieveMixin, GenericViewSet):
|
|
373
|
+
queryset = Author.objects.all()
|
|
374
|
+
serializer_class = AuthorDetailSerializer # or any DRF Serializer
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
`SelectorViewSet` is a pre-built read-only composition.
|
|
378
|
+
|
|
379
|
+
### `MultiSerializerMixin`
|
|
380
|
+
|
|
381
|
+
Per-action serializer dispatch via a single mapping:
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
serializer_classes = {
|
|
385
|
+
"list": ListSerializer,
|
|
386
|
+
"retrieve": DetailSerializer,
|
|
387
|
+
"my_custom_action": CustomSerializer,
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`get_serializer_class()` consults the map first, falls back to
|
|
392
|
+
`serializer_class`.
|
|
393
|
+
|
|
394
|
+
### `@service_action`
|
|
395
|
+
|
|
396
|
+
Custom viewset actions wrapped in the same plumbing as the standard
|
|
397
|
+
mutation flow:
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
from dataclasses import dataclass
|
|
401
|
+
|
|
402
|
+
from rest_framework_dataclasses.serializers import DataclassSerializer
|
|
403
|
+
from rest_framework_services import service_action
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@dataclass
|
|
407
|
+
class ApproveInput:
|
|
408
|
+
note: str = ""
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@dataclass
|
|
412
|
+
class InvoiceDetail:
|
|
413
|
+
id: int
|
|
414
|
+
customer: str
|
|
415
|
+
amount_cents: int
|
|
416
|
+
status: str
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class InvoiceDetailSerializer(DataclassSerializer):
|
|
420
|
+
class Meta:
|
|
421
|
+
dataclass = InvoiceDetail
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class InvoiceViewSet(ServiceViewSet):
|
|
425
|
+
@service_action(
|
|
426
|
+
detail=True,
|
|
427
|
+
methods=["post"],
|
|
428
|
+
service=approve_invoice,
|
|
429
|
+
input_dataclass=ApproveInput,
|
|
430
|
+
output_serializer=InvoiceDetailSerializer,
|
|
431
|
+
)
|
|
432
|
+
def approve(self, request, pk=None):
|
|
433
|
+
"""Approve an invoice."""
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
The decorated method body is *not* executed — the decorator supplies the
|
|
437
|
+
handler. The body is there so the action has a docstring, a name (used by
|
|
438
|
+
the router), and a place for `@action`-compatible metadata.
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## Errors
|
|
443
|
+
|
|
444
|
+
Services raise framework-agnostic exceptions. The view boundary translates
|
|
445
|
+
them to DRF responses.
|
|
446
|
+
|
|
447
|
+
```python
|
|
448
|
+
from rest_framework_services import ServiceError, ServiceValidationError
|
|
449
|
+
|
|
450
|
+
def withdraw(*, instance, data):
|
|
451
|
+
if data.amount > instance.balance:
|
|
452
|
+
raise ServiceValidationError({"amount": ["insufficient funds"]})
|
|
453
|
+
if instance.locked:
|
|
454
|
+
raise ServiceError("account is locked")
|
|
455
|
+
instance.balance -= data.amount
|
|
456
|
+
instance.save(update_fields=["balance"])
|
|
457
|
+
return instance
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
| Raised | Becomes | HTTP |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| `ServiceValidationError` | `rest_framework.exceptions.ValidationError` | 400 |
|
|
463
|
+
| `ServiceError` | `rest_framework.exceptions.APIException` | 422 |
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## Atomic transactions
|
|
468
|
+
|
|
469
|
+
Every service call is wrapped in `transaction.atomic()` by default. Opt out
|
|
470
|
+
on a view-by-view basis:
|
|
471
|
+
|
|
472
|
+
```python
|
|
473
|
+
class ImportView(ServiceCreateView):
|
|
474
|
+
service = run_import
|
|
475
|
+
atomic = False # the import service handles its own savepoints
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## Async services
|
|
481
|
+
|
|
482
|
+
Services and selectors can be `async def`. The dispatcher detects this via
|
|
483
|
+
`inspect.iscoroutinefunction` and runs them via `asgiref.sync.async_to_sync`
|
|
484
|
+
under sync views, or directly under async views. Atomic wrapping works for
|
|
485
|
+
both.
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
async def fetch_remote(*, request):
|
|
489
|
+
async with httpx.AsyncClient() as client:
|
|
490
|
+
return await client.get("https://...").json()
|
|
491
|
+
|
|
492
|
+
class FetchView(ServiceCreateView):
|
|
493
|
+
service = fetch_remote
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## `startserviceapp`
|
|
499
|
+
|
|
500
|
+
A management command that scaffolds a service-oriented Django app:
|
|
501
|
+
|
|
502
|
+
```bash
|
|
503
|
+
python manage.py startserviceapp billing
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Produces:
|
|
507
|
+
|
|
508
|
+
```
|
|
509
|
+
billing/
|
|
510
|
+
├── __init__.py
|
|
511
|
+
├── apps.py
|
|
512
|
+
├── admin.py
|
|
513
|
+
├── urls.py
|
|
514
|
+
├── models/__init__.py
|
|
515
|
+
├── views/__init__.py
|
|
516
|
+
├── services/__init__.py
|
|
517
|
+
├── selectors/__init__.py
|
|
518
|
+
├── validators/__init__.py
|
|
519
|
+
├── serializers/__init__.py
|
|
520
|
+
├── utils/__init__.py
|
|
521
|
+
├── migrations/__init__.py
|
|
522
|
+
└── tests/__init__.py
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Add `"rest_framework_services"` to `INSTALLED_APPS` to make the command
|
|
526
|
+
discoverable.
|
|
527
|
+
|
|
528
|
+
### A note on `validators/`
|
|
529
|
+
|
|
530
|
+
The `validators/` package is a stylistic convention, not a library
|
|
531
|
+
feature. The library doesn't import from it or look it up by name. It
|
|
532
|
+
exists to give business-level validation a home of its own — rules like
|
|
533
|
+
*"a draft invoice can only be sent if the customer has a verified email"*
|
|
534
|
+
or *"refunds beyond 30 days require manager approval"*. These belong
|
|
535
|
+
neither in the model nor in the serializer.
|
|
536
|
+
|
|
537
|
+
The split this layout suggests:
|
|
538
|
+
|
|
539
|
+
| Concern | Lives in |
|
|
540
|
+
|---|---|
|
|
541
|
+
| Type validation, required fields, format checks | DRF serializers (`serializers/`), including `DataclassSerializer` for service inputs |
|
|
542
|
+
| Business rules / cross-record invariants / external-state checks | Functions in `validators/`, called from services |
|
|
543
|
+
| Side effects, persistence, orchestration | `services/` |
|
|
544
|
+
|
|
545
|
+
Use it, ignore it, or rename it — the scaffolding is a starting point,
|
|
546
|
+
not a contract.
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
## Examples
|
|
551
|
+
|
|
552
|
+
A minimal but runnable example project lives in
|
|
553
|
+
[`examples/`](examples/). It demonstrates the create / list / retrieve /
|
|
554
|
+
update / destroy flows on a single resource and one custom action via
|
|
555
|
+
`@service_action`.
|
|
556
|
+
|
|
557
|
+
```bash
|
|
558
|
+
cd examples
|
|
559
|
+
python manage.py migrate
|
|
560
|
+
python manage.py runserver
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
## Compatibility
|
|
566
|
+
|
|
567
|
+
| Axis | Range |
|
|
568
|
+
|---|---|
|
|
569
|
+
| Python | 3.10 – 3.14 |
|
|
570
|
+
| Django | 4.2, 5.0, 5.1, 5.2, 6.0 |
|
|
571
|
+
| DRF | ≥ 3.14 |
|
|
572
|
+
|
|
573
|
+
CI runs the full Python × Django matrix with 100% coverage gating.
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Status
|
|
578
|
+
|
|
579
|
+
Pre-1.0. Public API is stable but may shift. See
|
|
580
|
+
[`CHANGELOG.md`](CHANGELOG.md) for the full release history.
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## License
|
|
585
|
+
|
|
586
|
+
MIT.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
rest_framework_services/__init__.py,sha256=OSbiDluOfNrvQjiuiWK2iaHzPjO6gjyiOYZphDWM26Q,2033
|
|
2
|
+
rest_framework_services/conf.py,sha256=thdxegDusmp8GxJebvat7hFalh34cECr_N_iy2CefHg,733
|
|
3
|
+
rest_framework_services/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
rest_framework_services/_compat/__init__.py,sha256=b6i4f5pzwl5eICY6h9WVRBOw0i2iM9uNb9nOd5Tt3u0,348
|
|
5
|
+
rest_framework_services/_compat/arun_service.py,sha256=FwfrJHT28rRxmExZKoQstHBijhXUFbOtHGlqHFd_c84,1188
|
|
6
|
+
rest_framework_services/_compat/is_async.py,sha256=tzmfIhR-pWyGuU0jjU0IbxIsCWnKGXkseZZID3Ruj1s,908
|
|
7
|
+
rest_framework_services/_compat/run_service.py,sha256=S5Ey8vOIzMMGCWKDB1qsGncM2mWMyMPQ3nYPQTcEtz4,491
|
|
8
|
+
rest_framework_services/exceptions/__init__.py,sha256=N5LXUOa2zwufcyJsF5U9wjTXhkuYKB-0MkWlYFraKrI,417
|
|
9
|
+
rest_framework_services/exceptions/service_error.py,sha256=GqNWnr1fjUoWEzUxpxYatp3mDSFXlvKr2NVhfI0QLGI,672
|
|
10
|
+
rest_framework_services/exceptions/service_validation_error.py,sha256=4DvcaIA6cUdPGFGcSn9g0NdrwU_Zwy9Z_0N7RSsl_Wk,922
|
|
11
|
+
rest_framework_services/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
rest_framework_services/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
rest_framework_services/management/commands/startserviceapp.py,sha256=1sfsZTwKC29OmOMAuVZBsDm8Dlw9-9lFvakXJAxjpeA,1400
|
|
14
|
+
rest_framework_services/management/templates/service_app/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
rest_framework_services/management/templates/service_app/admin.py-tpl,sha256=ImQU7Q0SJNTigjgz1o6mizJwFEepZle5JSCBJXheLMw,29
|
|
16
|
+
rest_framework_services/management/templates/service_app/apps.py-tpl,sha256=6VwM2ponNKuvJqE5ISV9sJ1idAta8va_tkHJzPTONIY,171
|
|
17
|
+
rest_framework_services/management/templates/service_app/urls.py-tpl,sha256=J5UH975fNjVwz7IhwRn3Bjft0cLkxPip05WxvPuAkfI,124
|
|
18
|
+
rest_framework_services/management/templates/service_app/migrations/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
rest_framework_services/management/templates/service_app/models/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
rest_framework_services/management/templates/service_app/selectors/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
rest_framework_services/management/templates/service_app/serializers/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
rest_framework_services/management/templates/service_app/services/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
rest_framework_services/management/templates/service_app/tests/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
rest_framework_services/management/templates/service_app/utils/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
+
rest_framework_services/management/templates/service_app/validators/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
rest_framework_services/management/templates/service_app/views/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
+
rest_framework_services/mutations/__init__.py,sha256=8l0ZIhYhUzQmOZyaMfb8vHbm407cFl4LkcQL1nNPxq8,940
|
|
28
|
+
rest_framework_services/mutations/acreate_from_input.py,sha256=vKFejgXG6p47yrNKcMHrTUq1ivNJJRpdDL-wKU3CIc0,1235
|
|
29
|
+
rest_framework_services/mutations/apply_input.py,sha256=tfvb-8ol8ddAAGaa16MgkBT2POacIX43aBbDYxLBI8A,1168
|
|
30
|
+
rest_framework_services/mutations/aupdate_from_input.py,sha256=1_SPMZjcerSDPkgLtIDq-HKrpdrAACxuIqzi9fbJIP4,1689
|
|
31
|
+
rest_framework_services/mutations/create_from_input.py,sha256=bd9Bm4r69HgJFRR6Z8SIsQzUxasx-o2_kBHIgBRUGTQ,1431
|
|
32
|
+
rest_framework_services/mutations/update_from_input.py,sha256=Urv_y0l_8YAKvpzQ8YKkaprbATKQdZdeaN2sg1ymURA,1891
|
|
33
|
+
rest_framework_services/mutations/utils.py,sha256=fgbetWLmffR0A0_lElihij6Vi15-KdQHXrK8DkeUsXg,6896
|
|
34
|
+
rest_framework_services/selectors/__init__.py,sha256=NBEYd6d1FklCw7cOywArfsY9WBluQxr-JdZbM42s_M8,255
|
|
35
|
+
rest_framework_services/selectors/async_selector.py,sha256=FjsgPtoFq1vsgBh1Q90vuNiIzj1t8AytWZIJthLJmXY,294
|
|
36
|
+
rest_framework_services/selectors/selector.py,sha256=nPali1_YtmSNB-kMH0Z9RyELEvn8wBXZKCLXTyqx0Dg,742
|
|
37
|
+
rest_framework_services/selectors/utils.py,sha256=jkScLQtG7Fhs2PdeOCYVIie8iz94PXfhgIXarxS_0oQ,777
|
|
38
|
+
rest_framework_services/types/__init__.py,sha256=qm2MDVSr2WOLT8-goYgJD-RYBGIjKfzAVfiP8k3-ghc,553
|
|
39
|
+
rest_framework_services/types/change_result.py,sha256=IXrXbvVYhzphp1kwN1G-bthfZy2Fa4ZWNUpD5z_ebRM,1229
|
|
40
|
+
rest_framework_services/types/field_change.py,sha256=mDR8OndlIE2zEWBFGqxFU6sN2t3tboRiERkmQyyo80s,393
|
|
41
|
+
rest_framework_services/types/unset.py,sha256=jXLfJPgZ0Lewgy5Jy6ylTTTe12VTE9Um4UwhtS5OoKo,790
|
|
42
|
+
rest_framework_services/views/__init__.py,sha256=GRg_h6Vn2qPDDgB00REY0yiSbVhj4YYDpocbKcCxltQ,586
|
|
43
|
+
rest_framework_services/views/utils.py,sha256=NKIAMy6LnOnSdiof_UBA0_XgMY79A3WNFZ_550oqkXU,1506
|
|
44
|
+
rest_framework_services/views/mutation/__init__.py,sha256=kSw67HTv6KaQTN-RTeUVZQ8tR07poCCpAgGuQQo1Phg,438
|
|
45
|
+
rest_framework_services/views/mutation/mutation_flow_mixin.py,sha256=MZO2Zabnb9k9ua15LV9eSRfQDTwyrBgSTtMzUvMlbwM,1954
|
|
46
|
+
rest_framework_services/views/mutation/service_create_view.py,sha256=zsVVzya3-iuPT2d5qumoLSqsCNBQ6jvKMixc42TLB5I,2196
|
|
47
|
+
rest_framework_services/views/mutation/service_delete_view.py,sha256=IB1-Bjp17Gd--F-gGHqwz_RvcKihRdNGJLL5FqlKK8U,1920
|
|
48
|
+
rest_framework_services/views/mutation/service_update_view.py,sha256=ZzkI5fgKnY7Ma5ZiETzS74G3mLtmhPANgC6vYDhcW3I,2237
|
|
49
|
+
rest_framework_services/views/mutation/utils.py,sha256=l9xxJnP_EP49Xup1upO5YljG3ePFI7f15WNl4I8FkS0,5602
|
|
50
|
+
rest_framework_services/views/query/__init__.py,sha256=ltbrUPeKXsMEGDNMzqcSTVM20BiXnYvu412vHTZ8aBc,331
|
|
51
|
+
rest_framework_services/views/query/selector_list_view.py,sha256=12oPsLCbWqBpvq6VwmFXvYeIsF76zsdMb15VKnTEYhk,1947
|
|
52
|
+
rest_framework_services/views/query/selector_retrieve_view.py,sha256=ts78GnFKOl8piQoyDHkrcS4Nznqeod2rtZPWHrT3DeQ,2194
|
|
53
|
+
rest_framework_services/viewsets/__init__.py,sha256=qa218RZoGkHZgJhp2u9-oSYbWBT6shljszqIeNv57BM,1086
|
|
54
|
+
rest_framework_services/viewsets/multi_serializer_mixin.py,sha256=FmT_rAvJlrb7OxVDJanCBzOlauXGoH5A7zxHb69odZ4,1297
|
|
55
|
+
rest_framework_services/viewsets/selector_list_mixin.py,sha256=UP_dUZqVfsR5UkZqdXoUSXGBoy236Xgrw_1faobqsYA,1790
|
|
56
|
+
rest_framework_services/viewsets/selector_retrieve_mixin.py,sha256=RQyLSijrIbJLn_cfjbdPKBcD0S0NCWdfqztEhVhFLnE,2336
|
|
57
|
+
rest_framework_services/viewsets/selector_viewset.py,sha256=RZ0-sQubEngOtpx0uYVb2mfdeWNOK3SDtLCPtiuL2mk,790
|
|
58
|
+
rest_framework_services/viewsets/service_create_mixin.py,sha256=9ZoFfRX4DXrOFW_bcrj8ulZT2qUcz3v7P_UNwz4o74s,2207
|
|
59
|
+
rest_framework_services/viewsets/service_destroy_mixin.py,sha256=MWG0Vl7Q9X3X0EIpQB3tmGl1HRPDq-unHHymAMZcEMI,1948
|
|
60
|
+
rest_framework_services/viewsets/service_update_mixin.py,sha256=1J91QyH1cPaAEXwEz96NS-UsTppvbLBhCzavDS5XmUs,2304
|
|
61
|
+
rest_framework_services/viewsets/service_viewset.py,sha256=NzVYi4BR2wec9n7vjaiF_sUEtVTiVEa_KgKwJaez7sw,1259
|
|
62
|
+
rest_framework_services/viewsets/decorators/__init__.py,sha256=t7M66kvrCUTsMsgkstSp_Zs4JZTBYzfVggeCn20pN30,150
|
|
63
|
+
rest_framework_services/viewsets/decorators/service_action.py,sha256=0QwA45N_iU59Q85mbbYVSmWb-PN6GMSO4FNuZnGtRjE,2606
|
|
64
|
+
djangorestframework_services-0.1.0.dist-info/METADATA,sha256=IjXsIOwx-gnq_Nkt4yAn24p9PQ6OCYliH2aPTCU5dpA,17438
|
|
65
|
+
djangorestframework_services-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
66
|
+
djangorestframework_services-0.1.0.dist-info/RECORD,,
|