Merge lp:~free.ekanayaka/txaws/api-method into lp:txaws
- api-method
- Merge into trunk
Status: | Merged | ||||
---|---|---|---|---|---|
Merge reported by: | Free Ekanayaka | ||||
Merged at revision: | not available | ||||
Proposed branch: | lp:~free.ekanayaka/txaws/api-method | ||||
Merge into: | lp:txaws | ||||
Diff against target: |
478 lines (+316/-16) 6 files modified
txaws/server/method.py (+45/-0) txaws/server/registry.py (+50/-0) txaws/server/resource.py (+38/-10) txaws/server/tests/test_method.py (+18/-0) txaws/server/tests/test_registry.py (+97/-0) txaws/server/tests/test_resource.py (+68/-6) |
||||
To merge this branch: | bzr merge lp:~free.ekanayaka/txaws/api-method | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Thomas Herve | Approve | ||
Free Ekanayaka (community) | Abstain | ||
Review via email: mp+72149@code.launchpad.net |
Commit message
Description of the change
This branch adds a Method abstraction for encapsulating the logic that handles specific actions and versions. It also adds a Registry for registering the methods of a QueryAPI. The former QueryAPI.actions attribute is still supported but deprecated in favor of Registry.
You will need the python-venusian package from oneiric:
http://
- 93. By Free Ekanayaka
-
Move Registry to txaws.server.
registry
- 94. By Free Ekanayaka
-
Add registry module, skip tests if venusian is not there
Free Ekanayaka (free.ekanayaka) wrote : | # |
Thanks for the careful review.
[1] Fixed.
[2] Yeah, not even sure how to do that, it seems we don't have dependencies at all there? I'd make that another (possibly trivial) branch.
[3] Fixed.
[4] Not really, the only reason for backward compatibility is to not break Landscape and Clouddeck code when this is merged and the integration branches that I'm preparing are not yet merged as well. Also I don't want to break other people's code, though I believe we're the only ones using this stuff. This logic can be eventually dropped.
[5] Fixed.
[6] I was thinking to API changes, like you change the name of an action but you still want it to be handled by the same method class, or you have to different actions but with very similar behavior and you want to have a single method for them (say it's simpler or easier to maintain then two different methods with a base class). We don't have a use case in Landscape for now, so I can drop it if you prefer.
[7] There are several options to have the method available to newer versions. One could just specify None as version, or you can have a base class for your methods (something that in practice you're going to need anyway) and list all your default versions there, and override them for some particular methods. That's what I do in the integration branches that I'm preparing. Also, yes, you can make versions a property for greater control. In general, I think the supported versions is really an information that should be attached to methods, for fine grained control (when you need it, otherwise the patterns above work fine).
Free Ekanayaka (free.ekanayaka) wrote : | # |
Oops, auto-approved by mistake.
- 95. By Free Ekanayaka
-
Fix review points
Preview Diff
1 | === added file 'txaws/server/method.py' |
2 | --- txaws/server/method.py 1970-01-01 00:00:00 +0000 |
3 | +++ txaws/server/method.py 2011-08-22 14:16:29 +0000 |
4 | @@ -0,0 +1,45 @@ |
5 | +def method(method_class): |
6 | + """Decorator to use to mark an API method. |
7 | + |
8 | + When invoking L{Registry.scan} the classes marked with this decorator |
9 | + will be added to the registry. |
10 | + |
11 | + @param method_class: The L{Method} class to register. |
12 | + """ |
13 | + |
14 | + def callback(scanner, name, method_class): |
15 | + if method_class.actions is not None: |
16 | + actions = method_class.actions |
17 | + else: |
18 | + actions = [name] |
19 | + |
20 | + if method_class.versions is not None: |
21 | + versions = method_class.versions |
22 | + else: |
23 | + versions = [None] |
24 | + |
25 | + for action in actions: |
26 | + for version in versions: |
27 | + scanner.registry.add(method_class, |
28 | + action=action, |
29 | + version=version) |
30 | + |
31 | + from venusian import attach |
32 | + attach(method_class, callback) |
33 | + return method_class |
34 | + |
35 | + |
36 | +class Method(object): |
37 | + """Handle a single HTTP request to an API resource. |
38 | + |
39 | + @cvar actions: List of actions that the Method can handle, if C{None} |
40 | + the class name will be used as only supported action. |
41 | + @cvar versions: List of versions that the Method can handle, if C{None} |
42 | + all versions will be supported. |
43 | + """ |
44 | + actions = None |
45 | + versions = None |
46 | + |
47 | + def invoke(self, call): |
48 | + """Invoke this method for executing the given C{call}.""" |
49 | + raise NotImplemented("Sub-classes have to implement the invoke method") |
50 | |
51 | === added file 'txaws/server/registry.py' |
52 | --- txaws/server/registry.py 1970-01-01 00:00:00 +0000 |
53 | +++ txaws/server/registry.py 2011-08-22 14:16:29 +0000 |
54 | @@ -0,0 +1,50 @@ |
55 | +from txaws.server.exception import APIError |
56 | + |
57 | + |
58 | +class Registry(object): |
59 | + """Register API L{Method}s. for handling specific actions and versions""" |
60 | + |
61 | + def __init__(self): |
62 | + self._by_action = {} |
63 | + |
64 | + def add(self, method_class, action, version): |
65 | + """Add a method class to the regitry. |
66 | + |
67 | + @param method_class: The method class to add |
68 | + @param action: The action that the method class can handle |
69 | + @param version: The version that the method class can handle |
70 | + """ |
71 | + by_version = self._by_action.setdefault(action, {}) |
72 | + if version in by_version: |
73 | + raise RuntimeError("A method was already registered for action" |
74 | + " %s in version %s" % (action, version)) |
75 | + by_version[version] = method_class |
76 | + |
77 | + def check(self, action, version): |
78 | + """Check if the given action is supported in the given version. |
79 | + |
80 | + @raises APIError: If there's no method class registered for handling |
81 | + the given action or version. |
82 | + """ |
83 | + if action not in self._by_action: |
84 | + raise APIError(400, "InvalidAction", "The action %s is not valid " |
85 | + "for this web service." % action) |
86 | + by_version = self._by_action[action] |
87 | + if None not in by_version: |
88 | + # There's no catch-all method, let's try the version-specific one |
89 | + if version not in by_version: |
90 | + raise APIError(400, "InvalidVersion", "Invalid API version.") |
91 | + |
92 | + def get(self, action, version): |
93 | + """Get the method class handing the given action and version.""" |
94 | + by_version = self._by_action[action] |
95 | + if version in by_version: |
96 | + return by_version[version] |
97 | + else: |
98 | + return by_version[None] |
99 | + |
100 | + def scan(self, module): |
101 | + """Scan the given module object for L{Method}s and register them.""" |
102 | + from venusian import Scanner |
103 | + scanner = Scanner(registry=self) |
104 | + scanner.scan(module) |
105 | |
106 | === modified file 'txaws/server/resource.py' |
107 | --- txaws/server/resource.py 2011-05-20 07:57:00 +0000 |
108 | +++ txaws/server/resource.py 2011-08-22 14:16:29 +0000 |
109 | @@ -19,6 +19,8 @@ |
110 | class QueryAPI(Resource): |
111 | """Base class for EC2-like query APIs. |
112 | |
113 | + @param registry: The L{Registry} to use to look up L{Method}s for handling |
114 | + the API requests. |
115 | @param path: Optionally, the actual resource path the clients are using |
116 | when sending HTTP requests to this API, to take into account when |
117 | validating the signature. This can differ from the one in the HTTP |
118 | @@ -29,8 +31,6 @@ |
119 | |
120 | The following class variables must be defined by sub-classes: |
121 | |
122 | - @ivar actions: The actions that the API supports. The 'Action' field of |
123 | - the request must contain one of these. |
124 | @ivar signature_versions: A list of allowed values for 'SignatureVersion'. |
125 | @cvar content_type: The content type to set the 'Content-Type' header to. |
126 | """ |
127 | @@ -48,9 +48,19 @@ |
128 | Unicode("Signature"), |
129 | Integer("SignatureVersion", optional=True, default=2)) |
130 | |
131 | - def __init__(self, path=None): |
132 | + def __init__(self, registry=None, path=None): |
133 | Resource.__init__(self) |
134 | self.path = path |
135 | + self.registry = registry |
136 | + |
137 | + def get_method(self, call, *args, **kwargs): |
138 | + """Return the L{Method} instance to invoke for the given L{Call}. |
139 | + |
140 | + @param args: Positional arguments to pass to the method constructor. |
141 | + @param kwargs: Keyword arguments to pass to the method constructor. |
142 | + """ |
143 | + method_class = self.registry.get(call.action, call.version) |
144 | + return method_class(*args, **kwargs) |
145 | |
146 | def get_principal(self, access_key): |
147 | """Return a principal object by access key. |
148 | @@ -108,6 +118,16 @@ |
149 | """ |
150 | raise NotImplementedError("Must be implemented by subclass.") |
151 | |
152 | + def dump_result(self, result): |
153 | + """Serialize the result of the method invokation. |
154 | + |
155 | + @param result: The L{Method} result to serialize. |
156 | + """ |
157 | + return result |
158 | + |
159 | + def authorize(self, method, call): |
160 | + """Authorize to invoke the given L{Method} with the given L{Call}.""" |
161 | + |
162 | def execute(self, call): |
163 | """Execute an API L{Call}. |
164 | |
165 | @@ -118,7 +138,10 @@ |
166 | @raises: An L{APIError} in case the execution fails, sporting an error |
167 | message the HTTP status code to return. |
168 | """ |
169 | - raise NotImplementedError() |
170 | + method = self.get_method(call) |
171 | + deferred = maybeDeferred(self.authorize, method, call) |
172 | + deferred.addCallback(lambda _: method.invoke(call)) |
173 | + return deferred.addCallback(self.dump_result) |
174 | |
175 | def get_utc_time(self): |
176 | """Return a C{datetime} object with the current time in UTC.""" |
177 | @@ -142,7 +165,7 @@ |
178 | params = dict((k, v[-1]) for k, v in request.args.iteritems()) |
179 | args, rest = self.schema.extract(params) |
180 | |
181 | - self._validate_generic_parameters(args, self.get_utc_time()) |
182 | + self._validate_generic_parameters(args) |
183 | |
184 | def create_call(principal): |
185 | self._validate_principal(principal, args) |
186 | @@ -157,11 +180,10 @@ |
187 | deferred.addCallback(create_call) |
188 | return deferred |
189 | |
190 | - def _validate_generic_parameters(self, args, utc_now): |
191 | + def _validate_generic_parameters(self, args): |
192 | """Validate the generic request parameters. |
193 | |
194 | @param args: Parsed schema arguments. |
195 | - @param utc_now: The current UTC time in datetime format. |
196 | @raises APIError: In the following cases: |
197 | - Action is not included in C{self.actions} |
198 | - SignatureVersion is not included in C{self.signature_versions} |
199 | @@ -169,9 +191,15 @@ |
200 | - Expires is before the current time |
201 | - Timestamp is older than 15 minutes. |
202 | """ |
203 | - if not args.Action in self.actions: |
204 | - raise APIError(400, "InvalidAction", "The action %s is not valid " |
205 | - "for this web service." % args.Action) |
206 | + utc_now = self.get_utc_time() |
207 | + |
208 | + if getattr(self, "actions", None) is not None: |
209 | + # Check the deprecated 'actions' attribute |
210 | + if not args.Action in self.actions: |
211 | + raise APIError(400, "InvalidAction", "The action %s is not " |
212 | + "valid for this web service." % args.Action) |
213 | + else: |
214 | + self.registry.check(args.Action, args.Version) |
215 | |
216 | if not args.SignatureVersion in self.signature_versions: |
217 | raise APIError(403, "InvalidSignature", "SignatureVersion '%s' " |
218 | |
219 | === added file 'txaws/server/tests/test_method.py' |
220 | --- txaws/server/tests/test_method.py 1970-01-01 00:00:00 +0000 |
221 | +++ txaws/server/tests/test_method.py 2011-08-22 14:16:29 +0000 |
222 | @@ -0,0 +1,18 @@ |
223 | +from twisted.trial.unittest import TestCase |
224 | + |
225 | +from txaws.server.method import Method |
226 | + |
227 | + |
228 | +class MethodTest(TestCase): |
229 | + |
230 | + def setUp(self): |
231 | + super(MethodTest, self).setUp() |
232 | + self.method = Method() |
233 | + |
234 | + def test_defaults(self): |
235 | + """ |
236 | + By default a L{Method} applies to all API versions and handles a |
237 | + single action matching its class name. |
238 | + """ |
239 | + self.assertIdentical(None, self.method.actions) |
240 | + self.assertIdentical(None, self.method.versions) |
241 | |
242 | === added file 'txaws/server/tests/test_registry.py' |
243 | --- txaws/server/tests/test_registry.py 1970-01-01 00:00:00 +0000 |
244 | +++ txaws/server/tests/test_registry.py 2011-08-22 14:16:29 +0000 |
245 | @@ -0,0 +1,97 @@ |
246 | +from twisted.trial.unittest import TestCase |
247 | + |
248 | +from txaws.server.method import Method |
249 | +from txaws.server.registry import Registry |
250 | +from txaws.server.exception import APIError |
251 | + |
252 | +try: |
253 | + import venusian |
254 | +except ImportError: |
255 | + method = lambda function: function |
256 | + has_venusian = False |
257 | +else: |
258 | + from txaws.server.method import method |
259 | + has_venusian = True |
260 | + |
261 | + |
262 | +@method |
263 | +class TestMethod(Method): |
264 | + pass |
265 | + |
266 | + |
267 | +class RegistryTest(TestCase): |
268 | + |
269 | + def setUp(self): |
270 | + super(RegistryTest, self).setUp() |
271 | + self.registry = Registry() |
272 | + |
273 | + def test_add(self): |
274 | + """ |
275 | + L{MehtodRegistry.add} registers a method class for the given action |
276 | + and version. |
277 | + """ |
278 | + self.registry.add(TestMethod, "test", "1.0") |
279 | + self.registry.add(TestMethod, "test", "2.0") |
280 | + self.registry.check("test", "1.0") |
281 | + self.registry.check("test", "2.0") |
282 | + self.assertIdentical(TestMethod, self.registry.get("test", "1.0")) |
283 | + self.assertIdentical(TestMethod, self.registry.get("test", "2.0")) |
284 | + |
285 | + def test_add_duplicate_method(self): |
286 | + """ |
287 | + L{MehtodRegistry.add} fails if a method class for the given action |
288 | + and version was already registered. |
289 | + """ |
290 | + |
291 | + class TestMethod2(Method): |
292 | + pass |
293 | + |
294 | + self.registry.add(TestMethod, "test", "1.0") |
295 | + self.assertRaises(RuntimeError, self.registry.add, TestMethod2, |
296 | + "test", "1.0") |
297 | + |
298 | + def test_get(self): |
299 | + """ |
300 | + L{MehtodRegistry.get} returns the method class registered for the |
301 | + given action and version. |
302 | + """ |
303 | + |
304 | + class TestMethod2(Method): |
305 | + pass |
306 | + |
307 | + self.registry.add(TestMethod, "test", "1.0") |
308 | + self.registry.add(TestMethod, "test", "2.0") |
309 | + self.registry.add(TestMethod2, "test", "3.0") |
310 | + self.assertIdentical(TestMethod, self.registry.get("test", "1.0")) |
311 | + self.assertIdentical(TestMethod, self.registry.get("test", "2.0")) |
312 | + self.assertIdentical(TestMethod2, self.registry.get("test", "3.0")) |
313 | + |
314 | + def test_check_with_missing_action(self): |
315 | + """ |
316 | + L{MehtodRegistry.get} fails if the given action is not registered. |
317 | + """ |
318 | + error = self.assertRaises(APIError, self.registry.check, "boom", "1.0") |
319 | + self.assertEqual(400, error.status) |
320 | + self.assertEqual("InvalidAction", error.code) |
321 | + self.assertEqual("The action boom is not valid for this web service.", |
322 | + error.message) |
323 | + |
324 | + def test_check_with_missing_version(self): |
325 | + """ |
326 | + L{MehtodRegistry.get} fails if the given action is not registered. |
327 | + """ |
328 | + self.registry.add(TestMethod, "test", "1.0") |
329 | + error = self.assertRaises(APIError, self.registry.check, "test", "2.0") |
330 | + self.assertEqual(400, error.status) |
331 | + self.assertEqual("InvalidVersion", error.code) |
332 | + self.assertEqual("Invalid API version.", error.message) |
333 | + |
334 | + def test_scan(self): |
335 | + """ |
336 | + L{MehtodRegistry.scan} registers the L{Method}s decorated with L{api}. |
337 | + """ |
338 | + self.registry.scan(__import__(__name__)) |
339 | + self.assertIdentical(TestMethod, self.registry.get("TestMethod", None)) |
340 | + |
341 | + if not has_venusian: |
342 | + test_scan.skip = "venusian module not available" |
343 | |
344 | === modified file 'txaws/server/tests/test_resource.py' |
345 | --- txaws/server/tests/test_resource.py 2011-05-19 15:45:27 +0000 |
346 | +++ txaws/server/tests/test_resource.py 2011-08-22 14:16:29 +0000 |
347 | @@ -1,3 +1,4 @@ |
348 | +from json import dumps, loads |
349 | from pytz import UTC |
350 | from cStringIO import StringIO |
351 | from datetime import datetime |
352 | @@ -7,6 +8,8 @@ |
353 | from txaws.credentials import AWSCredentials |
354 | from txaws.service import AWSServiceEndpoint |
355 | from txaws.ec2.client import Query |
356 | +from txaws.server.method import Method |
357 | +from txaws.server.registry import Registry |
358 | from txaws.server.resource import QueryAPI |
359 | |
360 | |
361 | @@ -55,6 +58,12 @@ |
362 | return self.written.getvalue() |
363 | |
364 | |
365 | +class TestMethod(Method): |
366 | + |
367 | + def invoke(self, call): |
368 | + return "data" |
369 | + |
370 | + |
371 | class TestPrincipal(object): |
372 | |
373 | def __init__(self, creds): |
374 | @@ -71,7 +80,6 @@ |
375 | |
376 | class TestQueryAPI(QueryAPI): |
377 | |
378 | - actions = ["SomeAction"] |
379 | signature_versions = (1, 2) |
380 | content_type = "text/plain" |
381 | |
382 | @@ -79,9 +87,6 @@ |
383 | QueryAPI.__init__(self, *args, **kwargs) |
384 | self.principal = None |
385 | |
386 | - def execute(self, call): |
387 | - return "data" |
388 | - |
389 | def get_principal(self, access_key): |
390 | if self.principal and self.principal.access_key == access_key: |
391 | return self.principal |
392 | @@ -94,7 +99,9 @@ |
393 | |
394 | def setUp(self): |
395 | super(QueryAPITest, self).setUp() |
396 | - self.api = TestQueryAPI() |
397 | + self.registry = Registry() |
398 | + self.registry.add(TestMethod, action="SomeAction", version=None) |
399 | + self.api = TestQueryAPI(registry=self.registry) |
400 | |
401 | def test_handle(self): |
402 | """ |
403 | @@ -116,11 +123,46 @@ |
404 | self.api.principal = TestPrincipal(creds) |
405 | return self.api.handle(request).addCallback(check) |
406 | |
407 | + def test_handle_with_dump_result(self): |
408 | + """ |
409 | + L{QueryAPI.handle} serializes the action result with C{dump_result}. |
410 | + """ |
411 | + creds = AWSCredentials("access", "secret") |
412 | + endpoint = AWSServiceEndpoint("http://uri") |
413 | + query = Query(action="SomeAction", creds=creds, endpoint=endpoint) |
414 | + query.sign() |
415 | + request = FakeRequest(query.params, endpoint) |
416 | + |
417 | + def check(ignored): |
418 | + self.assertEqual("data", loads(request.response)) |
419 | + |
420 | + self.api.dump_result = dumps |
421 | + self.api.principal = TestPrincipal(creds) |
422 | + return self.api.handle(request).addCallback(check) |
423 | + |
424 | + def test_handle_with_deprecated_actions(self): |
425 | + """ |
426 | + L{QueryAPI.handle} supports the legacy 'actions' attribute. |
427 | + """ |
428 | + self.api.actions = ["SomeAction"] |
429 | + creds = AWSCredentials("access", "secret") |
430 | + endpoint = AWSServiceEndpoint("http://uri") |
431 | + query = Query(action="SomeAction", creds=creds, endpoint=endpoint) |
432 | + query.sign() |
433 | + request = FakeRequest(query.params, endpoint) |
434 | + |
435 | + def check(ignored): |
436 | + self.assertEqual("data", request.response) |
437 | + |
438 | + self.api.principal = TestPrincipal(creds) |
439 | + return self.api.handle(request).addCallback(check) |
440 | + |
441 | def test_handle_pass_params_to_call(self): |
442 | """ |
443 | L{QueryAPI.handle} creates a L{Call} object with the correct |
444 | parameters. |
445 | """ |
446 | + self.registry.add(TestMethod, "SomeAction", "1.2.3") |
447 | creds = AWSCredentials("access", "secret") |
448 | endpoint = AWSServiceEndpoint("http://uri") |
449 | query = Query(action="SomeAction", creds=creds, endpoint=endpoint, |
450 | @@ -250,7 +292,27 @@ |
451 | return self.api.handle(request).addCallback(check) |
452 | |
453 | def test_handle_with_unsupported_action(self): |
454 | - """Only actions listed in L{QueryAPI.actions} are supported.""" |
455 | + """Only actions registered in the L{Registry} are supported.""" |
456 | + creds = AWSCredentials("access", "secret") |
457 | + endpoint = AWSServiceEndpoint("http://uri") |
458 | + query = Query(action="FooBar", creds=creds, endpoint=endpoint) |
459 | + query.sign() |
460 | + request = FakeRequest(query.params, endpoint) |
461 | + |
462 | + def check(ignored): |
463 | + self.flushLoggedErrors() |
464 | + self.assertEqual("InvalidAction - The action FooBar is not valid" |
465 | + " for this web service.", request.response) |
466 | + self.assertEqual(400, request.code) |
467 | + |
468 | + return self.api.handle(request).addCallback(check) |
469 | + |
470 | + def test_handle_with_deprecated_actions_and_unsupported_action(self): |
471 | + """ |
472 | + If the deprecated L{QueryAPI.actions} attribute is set, it will be |
473 | + used for looking up supported actions. |
474 | + """ |
475 | + self.api.actions = ["SomeAction"] |
476 | creds = AWSCredentials("access", "secret") |
477 | endpoint = AWSServiceEndpoint("http://uri") |
478 | query = Query(action="FooBar", creds=creds, endpoint=endpoint) |
Nice branch!
[1] The tests should be skipped if venusian is not present.
[2] It may be worth modifying setup.py to specify the optional dependency. Although it seems we're behind already.
[3]
+ if hasattr(self, "actions"):
Please use getattr(self, "actions", None) is not None.
[4] I guess backward compatibility is offered because you had to override the execute method?
[5] class.actions or [name] class.versions or [None]
+ actions = method_
+ versions = method_
Please use explicit conditionals here.
[6] I'm not sure I see the value of Method.actions being a list. Do you have a use case in mind?
[7] Having the version on the method is nice, but it's not really convenient. I'd like to have maybe the version introduced, but then have the method automatically available to newer versions. I guess we could make versions a property and implement some logic, but I wanted your point of view.
Thanks!