Merge lp:~free.ekanayaka/txaws/api-method into lp:txaws

Proposed by Free Ekanayaka
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
Reviewer Review Type Date Requested Status
Thomas Herve Approve
Free Ekanayaka (community) Abstain
Review via email: mp+72149@code.launchpad.net

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://archive.ubuntu.com/ubuntu//pool/universe/p/python-venusian/python-venusian_0.9-1_all.deb

To post a comment you must log in.
lp:~free.ekanayaka/txaws/api-method updated
93. By Free Ekanayaka

Move Registry to txaws.server.registry

Revision history for this message
Thomas Herve (therve) wrote :

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]
+ actions = method_class.actions or [name]
+ versions = method_class.versions or [None]

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!

review: Needs Fixing
lp:~free.ekanayaka/txaws/api-method updated
94. By Free Ekanayaka

Add registry module, skip tests if venusian is not there

Revision history for this message
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).

review: Approve
Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote :

Oops, auto-approved by mistake.

review: Abstain
lp:~free.ekanayaka/txaws/api-method updated
95. By Free Ekanayaka

Fix review points

Revision history for this message
Thomas Herve (therve) wrote :

Looks good, +1!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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)

Subscribers

People subscribed via source and target branches