Skip to content

Commit 1b2eddc

Browse files
committed
+ add documentation for change strategy
1 parent dff3c8d commit 1b2eddc

4 files changed

Lines changed: 359 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# DRF Issue #6855 Concept Location
2+
3+
## Problem Summary
4+
5+
The browsable API can crash while rendering an extra action page when the response data comes from a serializer whose `instance` is not a normal model object for the current view's permission class.
6+
7+
The failing path is in the browsable API renderer, not in view dispatch:
8+
9+
1. The extra action returns `Response(serializer.data)`.
10+
2. `serializer.data` is a `ReturnDict` or `ReturnList` that keeps a backlink to the serializer.
11+
3. `BrowsableAPIRenderer` inspects that serializer during HTML rendering.
12+
4. While building placeholder UI for other HTTP methods, it calls `view.check_object_permissions(request, instance)`.
13+
5. `instance` may be a non-model object from the serializer, which can be incompatible with the permission class and crash with `AttributeError`.
14+
15+
## Primary Source Locations
16+
17+
### `rest_framework/renderers.py`
18+
19+
This is the main issue location.
20+
21+
- `BrowsableAPIRenderer.get_context()`
22+
- Always asks for `put_form`, `post_form`, `delete_form`, and `options_form`.
23+
- This means a normal `GET` render also evaluates synthetic UI for other methods.
24+
25+
- `BrowsableAPIRenderer.get_rendered_html_form()`
26+
- Pulls `serializer = getattr(data, 'serializer', None)`.
27+
- Extracts `instance = getattr(serializer, 'instance', None)` when `many=False`.
28+
- Calls `show_form_for_method()` before the current `DELETE` / `OPTIONS` bailout.
29+
- This ordering is what exposes the bug.
30+
31+
- `BrowsableAPIRenderer.show_form_for_method()`
32+
- Calls `view.check_permissions(request)`.
33+
- If `obj is not None`, also calls `view.check_object_permissions(request, obj)`.
34+
- Only catches `APIException`, so an unexpected `AttributeError` from a permission class bubbles up and crashes rendering.
35+
36+
## Supporting Locations
37+
38+
### `rest_framework/utils/serializer_helpers.py`
39+
40+
- `ReturnDict`
41+
- `ReturnList`
42+
43+
These wrappers preserve `data.serializer`, which is why renderers can see the original serializer and its `.instance`.
44+
45+
### `rest_framework/views.py`
46+
47+
- `APIView.check_object_permissions()`
48+
49+
This loops through permission classes and directly passes the provided object to `has_object_permission()`. It assumes the caller supplied the correct domain object.
50+
51+
### `rest_framework/request.py`
52+
53+
- `override_method`
54+
55+
`get_rendered_html_form()` uses this context manager while probing alternate HTTP methods such as `OPTIONS` and `DELETE`.
56+
57+
## UI/Template Locations
58+
59+
### `rest_framework/templates/rest_framework/base.html`
60+
61+
- `options_form`
62+
- `delete_form`
63+
64+
These booleans control whether the browsable API shows the `OPTIONS` button and `DELETE` button/modal.
65+
66+
### `rest_framework/templates/rest_framework/admin.html`
67+
68+
- `delete_form`
69+
70+
The admin renderer also consumes the truthiness of `delete_form`.
71+
72+
## Existing Test Locations To Extend
73+
74+
### `tests/test_renderers.py`
75+
76+
Contains `BrowsableAPIRendererTests`, which is the closest existing unit/integration coverage for renderer behavior and extra actions.
77+
78+
### `tests/browsable_api/test_form_rendering.py`
79+
80+
Already has regression coverage around browsable API form generation and serializer shapes, including `many=True`.
81+
82+
### `tests/browsable_api/views.py`
83+
84+
Contains an example object-permission class that accesses nested attributes on the object and is useful as a pattern for reproducing this class of failure.
85+
86+
## Key Conceptual Insight
87+
88+
The renderer is mixing two different concepts:
89+
90+
- the object that was serialized for display
91+
- the object that should be used for permission checks when deciding whether to show action UI
92+
93+
For issue #6855, the immediate crash is triggered by the synthetic `OPTIONS` UI path, where no object-level edit form is actually rendered, so reusing `serializer.instance` is not appropriate.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# DRF Issue #6855 Impact Analysis
2+
3+
## Scope Of The Bug
4+
5+
The bug affects HTML rendering through `BrowsableAPIRenderer`. It does not affect normal JSON responses or the primary view execution path.
6+
7+
The issue appears when all of the following are true:
8+
9+
1. A browsable API page is rendered.
10+
2. The response data came from `serializer.data`, so the renderer can access `data.serializer`.
11+
3. The serializer has `many=False`, so `serializer.instance` is used.
12+
4. The serializer instance is not a safe object for the view's `has_object_permission()` logic.
13+
5. The renderer probes another method, especially `OPTIONS`, during page construction.
14+
15+
## Observable User Impact
16+
17+
- The browsable API page crashes instead of rendering successfully.
18+
- The failure happens after the view has already produced a valid response payload.
19+
- API clients using non-HTML renderers are unaffected, so the bug can be invisible in automated API tests unless browsable rendering is exercised.
20+
21+
## Why Extra Actions Are A Common Trigger
22+
23+
Extra actions are more likely to serialize data that is not the same object type returned by `get_object()`. Examples include:
24+
25+
- projection objects
26+
- service-layer result objects
27+
- denormalized DTO-like objects
28+
- serializer-backed views that do not call `get_object()`
29+
30+
That makes `serializer.instance` an unreliable proxy for object-permission checks during browsable API rendering.
31+
32+
## Method-Level Impact
33+
34+
### `OPTIONS`
35+
36+
This is the most important path for issue #6855.
37+
38+
- `OPTIONS` is typically present in `view.allowed_methods`.
39+
- `get_context()` asks for `options_form` on normal page render.
40+
- `get_rendered_html_form()` currently runs permission checks before its `OPTIONS` early return.
41+
- No real serializer-bound form is produced for `OPTIONS`, so the object-level permission check is not necessary for rendering the placeholder button.
42+
43+
### `DELETE`
44+
45+
This method shares the same code shape, but the impact is different.
46+
47+
- `DELETE` may or may not be allowed on the route.
48+
- `delete_form` is used to decide whether to show destructive UI.
49+
- Unlike `OPTIONS`, object-level permission checks may still be meaningful here because the button reflects an action against a resource.
50+
51+
This means a broad "skip object checks for both `DELETE` and `OPTIONS`" change would be riskier than a focused `OPTIONS` fix.
52+
53+
### `PUT` / `PATCH`
54+
55+
These are not the direct cause of the reported crash for a GET-only extra action because they usually fail the `allowed_methods` check earlier. They remain important regression surface because they rely on `instance` to prepopulate edit forms.
56+
57+
## Compatibility Risks
58+
59+
### Low-risk changes
60+
61+
- Preventing object-permission checks for the synthetic `OPTIONS` path.
62+
- Keeping existing permission behavior for edit and delete affordances.
63+
64+
### Medium-risk changes
65+
66+
- Replacing `serializer.instance` with `view.get_object()` inside the renderer.
67+
68+
This could:
69+
70+
- trigger additional database queries
71+
- fail on views that do not implement `get_object()`
72+
- introduce side effects in renderer code
73+
- blur the boundary between response rendering and view object retrieval
74+
75+
### Higher-risk changes
76+
77+
- Skipping object-permission checks for `DELETE`
78+
- Removing `delete_form` behavior entirely
79+
80+
Either could change which users see destructive UI and would need stronger review.
81+
82+
## Testing Impact
83+
84+
Regression coverage should include:
85+
86+
1. A browsable API request to an extra action that returns `serializer.data` for a non-model object.
87+
2. A permission class whose `has_object_permission()` would fail if handed that serializer instance.
88+
3. Confirmation that the HTML response renders successfully instead of crashing.
89+
4. Confirmation that existing delete/edit form behavior is not unintentionally broadened.
90+
91+
Useful secondary coverage:
92+
93+
- a direct renderer test for the `OPTIONS` path
94+
- confirmation that `many=True` behavior remains unchanged
95+
96+
## Documentation Impact
97+
98+
No public API documentation change is likely required if the fix is internal and behavior-preserving. A release note entry may still be appropriate because the change resolves a user-visible browsable API crash.
99+
100+
## Recommended Impact Conclusion
101+
102+
The smallest safe fix is to narrow the change to the synthetic `OPTIONS` form-generation path. That addresses the reported crash while minimizing risk to object-permission-sensitive UI such as delete and edit actions.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
## Quick Overview of Important Classes
2+
3+
Inside `rest_framework/`, common areas are:
4+
5+
`serializers.py`: converts Python/Django objects to API data and validates input.
6+
`views.py`, `generics.py`, `viewsets.py`: request handling abstractions.
7+
`routers.py`: URL routing for viewsets.
8+
`permissions.py`, `authentication.py`, `throttling.py`: access control and API policies.
9+
`renderers.py`, `parsers.py`: output/input formats like JSON and the browsable API.
10+
`response.py`, `request.py`: DRF’s request/response wrappers.
11+
12+
## Issue in Plain English
13+
14+
Issue #6855 is a Browsable API bug. The actual API endpoint works, but the HTML page crashes while DRF is trying to decide which buttons/forms to show.
15+
16+
The key flow is:
17+
18+
- A view action returns `Response(serializer.data)`.
19+
- In browsable mode, DRF looks at `data.serializer`.
20+
- It pulls `serializer.instance`.
21+
- It uses that instance for permission checks while deciding whether to show forms/buttons.
22+
- For some extra actions, that serializer.instance is not the real model object the permission logic expects.
23+
- Result: DRF calls object-permission logic on the wrong kind of object and crashes.
24+
25+
This is the critical code path:
26+
27+
```python
28+
# renderers.py
29+
def show_form_for_method(self, view, method, request, obj):
30+
"""
31+
Returns True if a form should be shown for this method.
32+
"""
33+
if method not in view.allowed_methods:
34+
return # Not a valid method
35+
36+
try:
37+
view.check_permissions(request)
38+
if obj is not None:
39+
view.check_object_permissions(request, obj)
40+
```
41+
42+
And this is where the renderer derives the object from the serializer and then asks for forms for several methods:
43+
44+
```python
45+
# renderers.py
46+
# See issue #2089 for refactoring this.
47+
serializer = getattr(data, 'serializer', None)
48+
if serializer and not getattr(serializer, 'many', False):
49+
instance = getattr(serializer, 'instance', None)
50+
if isinstance(instance, Page):
51+
instance = None
52+
else:
53+
instance = None
54+
55+
with override_method(view, request, method) as request:
56+
if not self.show_form_for_method(view, method, request, instance):
57+
return
58+
59+
if method in ('DELETE', 'OPTIONS'):
60+
return True # Don't actually need to return a form
61+
```
62+
63+
And the browsable page always probes multiple methods, even on a normal GET render:
64+
65+
```python
66+
# renderers.py
67+
'put_form': self.get_rendered_html_form(data, view, 'PUT', request),
68+
'post_form': self.get_rendered_html_form(data, view, 'POST', request),
69+
'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request),
70+
'options_form': self.get_rendered_html_form(data, view, 'OPTIONS', request),
71+
```
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# DRF Issue #6855 Source Code Strategy
2+
3+
## Goal
4+
5+
Fix the browsable API crash for extra actions that return serializer-backed data whose `serializer.instance` is not a valid object for the view's object-permission logic.
6+
7+
## Recommended Strategy
8+
9+
Implement a focused change in `rest_framework/renderers.py` so that the synthetic `OPTIONS` button path does not call `check_object_permissions()` on `serializer.instance`.
10+
11+
## Why This Strategy
12+
13+
It directly addresses the reported crash with the smallest behavioral change.
14+
15+
It also preserves current behavior for methods where object-level permission checks are still meaningful:
16+
17+
- `PUT`
18+
- `PATCH`
19+
- `DELETE`
20+
21+
## Proposed Code Change
22+
23+
### Primary change
24+
25+
Refactor `BrowsableAPIRenderer.get_rendered_html_form()` so that `OPTIONS` is handled before `show_form_for_method(view, method, request, instance)` is called.
26+
27+
Recommended behavior:
28+
29+
1. Enter `override_method(view, request, method)` as today.
30+
2. If `method == 'OPTIONS'`:
31+
- return early when the method is not allowed
32+
- run `view.check_permissions(request)`
33+
- do not run `view.check_object_permissions(request, instance)`
34+
- return `True` so the template can render the `OPTIONS` button
35+
3. For all other methods, keep the existing `show_form_for_method()` flow.
36+
4. Keep the existing `DELETE` truthy return after permission gating.
37+
38+
## Why Not Use `view.get_object()`
39+
40+
Avoid changing the renderer to retrieve a new object from the view.
41+
42+
Reasons:
43+
44+
- not every relevant view implements `get_object()`
45+
- extra actions may intentionally not be tied to the routed object
46+
- renderer-time object lookup can introduce extra queries and side effects
47+
- it is a larger semantic change than needed for this bug
48+
49+
## Why Not Skip Checks For Both `DELETE` And `OPTIONS`
50+
51+
That would be simpler mechanically, but it risks changing existing UI authorization behavior for delete actions.
52+
53+
`OPTIONS` is different because the renderer does not build an object-bound form for it. It only needs enough permission context to decide whether the method is generally accessible.
54+
55+
## Suggested Test Plan
56+
57+
### Regression test
58+
59+
Add a renderer-focused regression test in `tests/test_renderers.py` near `BrowsableAPIRendererTests`.
60+
61+
Suggested test shape:
62+
63+
1. Define a `ViewSet` with browsable API enabled.
64+
2. Add a detail extra action that returns `Response(serializer.data)`.
65+
3. Use a serializer whose `instance` is a custom object without the attributes expected by object permissions.
66+
4. Attach a permission class whose `has_object_permission()` would raise `AttributeError` if called with that custom object.
67+
5. Request the extra action with `HTTP_ACCEPT='text/html'`.
68+
6. Assert that the response renders successfully with status `200`.
69+
70+
### Guardrail test
71+
72+
Add a small test confirming that delete UI still respects the existing permission gate, or at minimum ensure no existing delete-related renderer test regresses when the new test is added.
73+
74+
### Optional unit test
75+
76+
If maintainers prefer tighter isolation, add a direct unit test around `get_rendered_html_form(..., method='OPTIONS', ...)` to assert the method returns truthy without consulting object permissions.
77+
78+
## Implementation Notes
79+
80+
- Keep the fix local to `BrowsableAPIRenderer`; avoid changing core permission APIs.
81+
- Do not broaden exception handling to swallow arbitrary `AttributeError`. That would hide real bugs instead of correcting the invalid permission-check input.
82+
- Preserve the current `many=True` behavior, where `instance` is already treated as `None`.
83+
84+
## Validation Steps
85+
86+
1. Run the new regression test.
87+
2. Run existing browsable API renderer tests.
88+
3. Verify no change in non-HTML renderer behavior.
89+
4. Manually confirm that an authenticated browsable API extra action page now renders instead of crashing.
90+
91+
## Fallback Option
92+
93+
If reviewers want a more centralized fix, a secondary approach is to teach `show_form_for_method()` to skip object-permission checks only for `OPTIONS`. That is still acceptable, but the `get_rendered_html_form()`-level change keeps the special case closest to the synthetic-form behavior that causes the bug.

0 commit comments

Comments
 (0)