Skip to content

Commit 0a8201d

Browse files
committed
add allowed_methods to @expose
1 parent d551047 commit 0a8201d

File tree

4 files changed

+131
-0
lines changed

4 files changed

+131
-0
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# -*- coding: utf-8 -*-
2+
import json
3+
import pytest
4+
import tg
5+
6+
from tests.base import TestWSGIController, make_app
7+
from tg.controllers import TGController
8+
from tg.decorators import expose
9+
10+
11+
class AllowedMethodsController(TGController):
12+
@expose(allowed_methods=["POST"])
13+
def only_post(self):
14+
return "posted"
15+
16+
@expose(allowed_methods=["GET"])
17+
def only_get(self):
18+
return "readable"
19+
20+
21+
class TestExposeAllowedMethods(TestWSGIController):
22+
def setup_method(self):
23+
TestWSGIController.setup_method(self)
24+
self.app = make_app(AllowedMethodsController)
25+
26+
def test_disallowed_method_returns_405(self):
27+
response = self.app.get("/only_post", status=405)
28+
assert response.status_code == 405
29+
assert response.headers["Allow"] == "POST"
30+
31+
def test_allowed_method_succeeds(self):
32+
response = self.app.post("/only_post")
33+
assert response.text == "posted"
34+
35+
def test_get_request_allows_head_and_lists_header(self):
36+
response = self.app.post("/only_get", status=405)
37+
assert response.headers["Allow"] == "GET, HEAD"
38+
head_response = self.app.head("/only_get", status=200)
39+
assert head_response.status_code == 200
40+
41+
42+
class StackedMethodsController(TGController):
43+
@expose()
44+
@expose(allowed_methods=["POST"])
45+
@expose("json", allowed_methods=["DELETE"])
46+
def stacked(self):
47+
return getattr(tg.request, "method", "UNKNOWN").lower()
48+
49+
50+
class TestStackedExposeAllowedMethods(TestWSGIController):
51+
def setup_method(self):
52+
TestWSGIController.setup_method(self)
53+
self.app = make_app(StackedMethodsController)
54+
55+
def test_methods_from_multiple_expose_are_merged(self):
56+
post_response = self.app.post("/stacked")
57+
assert "post" in post_response.text
58+
delete_response = self.app.delete("/stacked")
59+
assert "delete" in delete_response.text
60+
self.app.get("/stacked", status=405)
61+
62+
63+
class ParentAllowedMethodsController(TGController):
64+
@expose("json", allowed_methods=["GET"])
65+
def endpoint(self):
66+
return dict(message="parent")
67+
68+
69+
class ChildAllowedMethodsController(ParentAllowedMethodsController):
70+
@expose(inherit=True, allowed_methods=["POST"])
71+
def endpoint(self):
72+
return dict(message="child")
73+
74+
75+
class TestInheritedExposeAllowedMethods(TestWSGIController):
76+
def setup_method(self):
77+
TestWSGIController.setup_method(self)
78+
self.app = make_app(ChildAllowedMethodsController)
79+
80+
def test_inherited_methods_are_merged(self):
81+
get_data = json.loads(self.app.get("/endpoint").text)
82+
assert get_data["message"] == "child"
83+
self.app.head("/endpoint", status=200)
84+
post_data = json.loads(self.app.post("/endpoint").text)
85+
assert post_data["message"] == "child"
86+
self.app.put("/endpoint", status=405)

tg/controllers/decoratedcontroller.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import tg
1515
from tg.configuration.utils import TGConfigError
16+
from tg.exceptions import HTTPMethodNotAllowed
1617
from tg.flash import flash
1718
from tg.predicates import NotAuthorizedError, not_anonymous
1819
from tg.render import render as tg_render
@@ -87,6 +88,9 @@ def _call(self, action, params, remainder=None, context=None):
8788
if not resp_headers.get("Content-Type"):
8889
resp_headers.pop("Content-Type", None)
8990

91+
# Enforce limited HTTP Methods in case they were registered
92+
self._enforce_allowed_methods(action.decoration, context.request)
93+
9094
if remainder:
9195
remainder = tuple(map(urllib.request.url2pathname, remainder or []))
9296
else:
@@ -149,6 +153,18 @@ def _call(self, action, params, remainder=None, context=None):
149153

150154
return response["response"]
151155

156+
@staticmethod
157+
def _enforce_allowed_methods(decoration, request):
158+
if not decoration or not decoration._allowed_methods:
159+
return
160+
161+
method = request.method.upper()
162+
if method in decoration._allowed_methods:
163+
return
164+
165+
allow_header = ", ".join(sorted(decoration._allowed_methods))
166+
raise HTTPMethodNotAllowed(headers=dict(Allow=allow_header))
167+
152168
@classmethod
153169
def _perform_validate(cls, controller, params, context):
154170
"""Run validation for the controller with the given parameters.

tg/decorators/decoration.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def __init__(self, controller):
3939
self.hooks = dict(
4040
before_validate=[], before_call=[], before_render=[], after_render=[]
4141
)
42+
self._allowed_methods = None
4243

4344
def __repr__(self): # pragma: no cover
4445
return "<Decoration %s for %r>" % (id(self), self.controller)
@@ -101,6 +102,10 @@ def merge(self, deco):
101102
# Inherit al validators registered on parent.
102103
self.validations = deco.validations + self.validations
103104

105+
if deco._allowed_methods:
106+
self._allowed_methods = self._allowed_methods or set()
107+
self._allowed_methods.update(deco._allowed_methods)
108+
104109
def register_template_engine(
105110
self, content_type, engine, template, exclude_names, render_params
106111
):
@@ -291,3 +296,21 @@ def _register_controller_wrapper(self, wrapper):
291296

292297
def _register_validation(self, validation):
293298
self.validations.insert(0, validation)
299+
300+
def _register_allowed_methods(self, methods):
301+
if methods is None:
302+
return
303+
304+
if isinstance(methods, str):
305+
methods = [methods]
306+
307+
normalized_methods = {
308+
method.strip().upper() for method in methods if method and method.strip()
309+
}
310+
if not normalized_methods:
311+
return
312+
313+
self._allowed_methods = self._allowed_methods or set()
314+
self._allowed_methods.update(normalized_methods)
315+
if "GET" in normalized_methods:
316+
self._allowed_methods.add("HEAD")

tg/decorators/decorators.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ class expose(object):
105105
class. This will let the exposed method expose the same template
106106
as the overridden method template and keep the same hooks and
107107
validation that the parent method had.
108+
allowed_methods
109+
Restrict the HTTP verbs that can reach the controller. Requests using
110+
other methods raise :class:`tg.exceptions.HTTPMethodNotAllowed`.
108111
109112
The expose decorator registers a number of attributes on the
110113
decorated function, but does not actually wrap the function the way
@@ -166,6 +169,7 @@ def __init__(
166169
custom_format=None,
167170
render_params=None,
168171
inherit=False,
172+
allowed_methods=None,
169173
):
170174
self.engine = None
171175
self.template = template
@@ -175,12 +179,14 @@ def __init__(
175179
self.render_params = render_params
176180

177181
self.inherit = inherit
182+
self.allowed_methods = allowed_methods
178183
self._func = None
179184

180185
def __call__(self, func):
181186
self._func = func
182187
deco = Decoration.get_decoration(func)
183188
deco._register_exposition(self, self.inherit)
189+
deco._register_allowed_methods(self.allowed_methods)
184190
return func
185191

186192
def _resolve_options(self):

0 commit comments

Comments
 (0)