Merge lp:~leonardr/lazr.restful/generate-multiversion-operations into lp:lazr.restful

Proposed by Leonard Richardson
Status: Merged
Merged at revision: not available
Proposed branch: lp:~leonardr/lazr.restful/generate-multiversion-operations
Merge into: lp:lazr.restful
Diff against target: 882 lines (+466/-111)
8 files modified
src/lazr/restful/declarations.py (+90/-42)
src/lazr/restful/directives/__init__.py (+4/-3)
src/lazr/restful/docs/webservice-declarations.txt (+99/-28)
src/lazr/restful/example/multiversion/resources.py (+33/-4)
src/lazr/restful/example/multiversion/root.py (+3/-3)
src/lazr/restful/example/multiversion/tests/introduction.txt (+87/-4)
src/lazr/restful/metazcml.py (+147/-25)
src/lazr/restful/tests/test_webservice.py (+3/-2)
To merge this branch: bzr merge lp:~leonardr/lazr.restful/generate-multiversion-operations
Reviewer Review Type Date Requested Status
Brad Crittenden (community) code Approve
Review via email: mp+18032@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

This branch adds version information to all generated named operations, and introduces two new declarations (@operation_for_version and @operation_removed_in_version) that let you publish the same underlying Python method differently in different versions of the web service.

Each web service version can apply its own set of annotations to a given method, possibly even making a drastic change like changing an operation from a read operation to a write operation. By default, annotations are inherited from earlier versions, and if a particular version does not modify a named operation at all, it inherits the previous version's implementation (or lack of an implementation).

Because the list of versions is not available at the time these annotations are being processed, I use None to designate the earliest version in the web service. All subsequent versions that I need to deal with are passed as arguments to @operation_for_version and @operation_removed_in_version, so I can just use those strings. If the developer passes versions into those annotations that turn out not to be in the IWebServiceConfiguration version list, an error will happen during the second stage of ZCML processing.

I add a new type of operation to OPERATION_TYPES, 'removed_operation'. When iterating over an interface definition, this makes it easy to notice an operation that was present in older versions but not in the latest version. Simply removing the 'type' annotation makes it look like the method was incompletely defined.

The core of the code is in metazcml#register_webservice_operations, which takes code that used to be run only once, and runs it once for every versioned set of annotations. Because register_webservice_operations does not have access to the IWebServiceVersion marker interfaces, it can't register the operations directly. Instead, it passes the version name into register_adapter_for_version(), which runs later, after the IWebServiceVersion interfaces have been created. This function looks up the IWebServiceVersion utility given the version name, and registers the named operation with that versioned marker interface, so that it will only be used for that version of the web service.

generate_operation_adapter() now takes a version number, and generates the appropriate adapter class for that version, using that version's annotations.

Adapter lookups for operations no longer work if you check against IWebServiceRequestVersion. You need to check against a specific versioned request interface. This caused several miscellaneous test failures.

Revision history for this message
Brad Crittenden (bac) wrote :

Hi Leonard,

The branch is really interesting and the tests are incredibly clear and well-written. I've only got two little issues.

--Brad

> === modified file 'src/lazr/restful/declarations.py'
> --- src/lazr/restful/declarations.py 2010-01-20 21:32:53 +0000
+++ src/lazr/restful/declarations.py 2010-01-25 20:02:11 +0000

> + # It's possible that call_with, operation_parameters, and/or
> + # operation_returns_* weren't used.
> + annotations.setdefault('call_with', {})
> + annotations.setdefault('params', {})
> + annotations.setdefault('return_type', None)
> +
> + # Make sure that all parameters exists and that we miss none.

typo: exist

> @@ -495,6 +521,10 @@
> # new dict is empty rather than copying the old annotations
> annotations.push(self.version, True)
>
> + # We need to set a special 'type' so that lazr.restful can
> + # easily distinguish a method that's not present in the latest
> + # version from a method that was incompletely annotated.
> + annotations['type'] = 'removed_operation'

Use REMOVED_OPERATION_TYPE?

> class export_operation_as(_method_annotator):
> """Decorator specifying the name to export the method as."""

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/lazr/restful/declarations.py'
2--- src/lazr/restful/declarations.py 2010-01-20 21:32:53 +0000
3+++ src/lazr/restful/declarations.py 2010-01-25 20:02:11 +0000
4@@ -59,14 +59,16 @@
5 from lazr.restful.security import protect_schema
6 from lazr.restful.utils import (
7 camelcase_to_underscore_separated, get_current_browser_request,
8- VersionedDict)
9+ make_identifier_safe, VersionedDict)
10
11 LAZR_WEBSERVICE_EXPORTED = '%s.exported' % LAZR_WEBSERVICE_NS
12 COLLECTION_TYPE = 'collection'
13 ENTRY_TYPE = 'entry'
14 FIELD_TYPE = 'field'
15+REMOVED_OPERATION_TYPE = 'removed_operation'
16 OPERATION_TYPES = (
17- 'destructor', 'factory', 'read_operation', 'write_operation')
18+ 'destructor', 'factory', 'read_operation', 'write_operation',
19+ REMOVED_OPERATION_TYPE)
20
21 # Marker to specify that a parameter should contain the request user.
22 REQUEST_USER = object()
23@@ -312,6 +314,13 @@
24 # version.
25 annotations = VersionedDict()
26 annotations.push(None)
27+
28+ # The initial presumption is that an operation is not
29+ # published in the earliest version of the web service. An
30+ # @export_*_operation declaration will modify
31+ # annotations['type'] in place to signal that it is in
32+ # fact being published.
33+ annotations['type'] = REMOVED_OPERATION_TYPE
34 method.__dict__[LAZR_WEBSERVICE_EXPORTED] = annotations
35 self.annotate_method(method, annotations)
36 return method
37@@ -336,42 +345,59 @@
38 for name, method in interface.namesAndDescriptions(True):
39 if not IMethod.providedBy(method):
40 continue
41- annotations = method.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED)
42- if annotations is None:
43- continue
44- if annotations.get('type') is None:
45- continue
46- # Method is exported under its own name by default.
47- if 'as' not in annotations:
48- annotations['as'] = method.__name__
49-
50- # It's possible that call_with, operation_parameters, and/or
51- # operation_returns_* weren't used.
52- annotations.setdefault('call_with', {})
53- annotations.setdefault('params', {})
54- annotations.setdefault('return_type', None)
55-
56- # Make sure that all parameters exists and that we miss none.
57- info = method.getSignatureInfo()
58- defined_params = set(info['optional'])
59- defined_params.update(info['required'])
60- exported_params = set(annotations['params'])
61- exported_params.update(annotations['call_with'])
62- undefined_params = exported_params.difference(defined_params)
63- if undefined_params and info['kwargs'] is None:
64- raise TypeError(
65- 'method "%s" doesn\'t have the following exported '
66- 'parameters: %s.' % (
67- method.__name__, ", ".join(sorted(undefined_params))))
68- missing_params = set(
69- info['required']).difference(exported_params)
70- if missing_params:
71- raise TypeError(
72- 'method "%s" needs more parameters definitions to be '
73- 'exported: %s' % (
74- method.__name__, ", ".join(sorted(missing_params))))
75-
76- _update_default_and_required_params(annotations['params'], info)
77+ annotation_stack = method.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED)
78+ if annotation_stack is None:
79+ continue
80+ if annotation_stack.get('type') is None:
81+ continue
82+
83+ # Make sure that each version of the web service defines
84+ # a self-consistent view of this method.
85+ for version, annotations in annotation_stack.stack:
86+ if version is None:
87+ # Create a human-readable name for the earliest version
88+ # without trying to look it up.
89+ version = "(earliest version)"
90+
91+ if annotations['type'] == REMOVED_OPERATION_TYPE:
92+ # The method is published in other versions of the web
93+ # service, but not in this one. Don't try to validate this
94+ # version's annotations.
95+ continue
96+
97+ # Method is exported under its own name by default.
98+ if 'as' not in annotations:
99+ annotations['as'] = method.__name__
100+
101+ # It's possible that call_with, operation_parameters, and/or
102+ # operation_returns_* weren't used.
103+ annotations.setdefault('call_with', {})
104+ annotations.setdefault('params', {})
105+ annotations.setdefault('return_type', None)
106+
107+ # Make sure that all parameters exists and that we miss none.
108+ info = method.getSignatureInfo()
109+ defined_params = set(info['optional'])
110+ defined_params.update(info['required'])
111+ exported_params = set(annotations['params'])
112+ exported_params.update(annotations['call_with'])
113+ undefined_params = exported_params.difference(defined_params)
114+ if undefined_params and info['kwargs'] is None:
115+ raise TypeError(
116+ 'method "%s" doesn\'t have the following exported '
117+ 'parameters in version "%s": %s.' % (
118+ method.__name__, version,
119+ ", ".join(sorted(undefined_params))))
120+ missing_params = set(
121+ info['required']).difference(exported_params)
122+ if missing_params:
123+ raise TypeError(
124+ 'method "%s" is missing exported parameter definitions '
125+ 'in version "%s": %s' % (
126+ method.__name__, version,
127+ ", ".join(sorted(missing_params))))
128+
129+ _update_default_and_required_params(annotations['params'], info)
130
131
132 def _update_default_and_required_params(params, method_info):
133@@ -495,6 +521,10 @@
134 # new dict is empty rather than copying the old annotations
135 annotations.push(self.version, True)
136
137+ # We need to set a special 'type' so that lazr.restful can
138+ # easily distinguish a method that's not present in the latest
139+ # version from a method that was incompletely annotated.
140+ annotations['type'] = 'removed_operation'
141
142 class export_operation_as(_method_annotator):
143 """Decorator specifying the name to export the method as."""
144@@ -940,8 +970,13 @@
145 return u''
146
147
148-def generate_operation_adapter(method):
149- """Create an IResourceOperation adapter for the exported method."""
150+def generate_operation_adapter(method, version=None):
151+ """Create an IResourceOperation adapter for the exported method.
152+
153+ :param version: The name of the version for which to generate an
154+ operation adapter. None means to generate an adapter for the earliest
155+ version.
156+ """
157
158 if not IMethod.providedBy(method):
159 raise TypeError("%r doesn't provide IMethod." % method)
160@@ -949,6 +984,18 @@
161 if tag is None:
162 raise TypeError(
163 "'%s' isn't tagged for webservice export." % method.__name__)
164+ match = [annotations for version_name, annotations in tag.stack
165+ if version_name==version]
166+ if len(match) == 0:
167+ raise AssertionError("'%s' isn't tagged for export to web service "
168+ "version '%s'" % (method.__name__, version))
169+ tag = match[0]
170+ if version is None:
171+ # We need to incorporate the version into a Python class name,
172+ # but we won't find out the name of the earliest version until
173+ # runtime. Use a generic string that won't conflict with a
174+ # real version string.
175+ version = "__Earliest"
176
177 bases = (BaseResourceOperationAdapter, )
178 if tag['type'] == 'read_operation':
179@@ -969,7 +1016,8 @@
180 if return_type is None:
181 return_type = None
182
183- name = '%s_%s_%s' % (prefix, method.interface.__name__, tag['as'])
184+ name = '%s_%s_%s_%s' % (prefix, method.interface.__name__, tag['as'],
185+ version)
186 class_dict = {'params' : tuple(tag['params'].values()),
187 'return_type' : return_type,
188 '_export_info': tag,
189@@ -978,7 +1026,7 @@
190
191 if tag['type'] == 'write_operation':
192 class_dict['send_modification_event'] = True
193- factory = type(name, bases, class_dict)
194+ factory = type(make_identifier_safe(name), bases, class_dict)
195 classImplements(factory, provides)
196 protect_schema(factory, provides)
197
198
199=== modified file 'src/lazr/restful/directives/__init__.py'
200--- src/lazr/restful/directives/__init__.py 2010-01-12 15:06:15 +0000
201+++ src/lazr/restful/directives/__init__.py 2010-01-25 20:02:11 +0000
202@@ -98,13 +98,14 @@
203 sm.registerUtility(utility, IWebServiceConfiguration)
204
205 # Create and register marker interfaces for request objects.
206- for version in set(
207+ superclass = IWebServiceClientRequest
208+ for version in (
209 utility.active_versions + [utility.latest_version_uri_prefix]):
210 classname = ("IWebServiceClientRequestVersion" +
211 make_identifier_safe(version))
212- marker_interface = InterfaceClass(
213- classname, (IWebServiceClientRequest,), {})
214+ marker_interface = InterfaceClass(classname, (superclass,), {})
215 register_versioned_request_utility(marker_interface, version)
216+ superclass = marker_interface
217 return True
218
219
220
221=== modified file 'src/lazr/restful/docs/webservice-declarations.txt'
222--- src/lazr/restful/docs/webservice-declarations.txt 2010-01-20 21:24:53 +0000
223+++ src/lazr/restful/docs/webservice-declarations.txt 2010-01-25 20:02:11 +0000
224@@ -535,8 +535,8 @@
225 ... def a_method(param1, param2, param3, param4): pass
226 Traceback (most recent call last):
227 ...
228- TypeError: method "a_method" needs more parameters definitions to be
229- exported: param3, param4
230+ TypeError: method "a_method" is missing exported parameter definitions
231+ in version "(earliest version)": param3, param4
232
233 Defining a parameter not available on the method also results in an
234 error:
235@@ -549,8 +549,8 @@
236 ... def a_method(): pass
237 Traceback (most recent call last):
238 ...
239- TypeError: method "a_method" doesn't have the following exported
240- parameters: no_such_param.
241+ TypeError: method "a_method" doesn't have the following exported parameters
242+ in version "(earliest version)": no_such_param.
243
244 But that's not a problem if the exported method actually takes arbitrary
245 keyword parameters:
246@@ -1042,12 +1042,15 @@
247 >>> IResourceGETOperation.implementedBy(read_method_adapter_factory)
248 True
249
250-The defined adapter is named GET_<interface>_<exported_name> and uses
251-the ResourceOperation base class.
252+The defined adapter is named GET_<interface>_<exported_name>___Earliest
253+and uses the ResourceOperation base class. The "___Earliest" indicates
254+that the adapter will be used in the earliest version of the web
255+service, and any subsequent versions, until a newer implementation
256+supercedes it.
257
258 >>> from lazr.restful import ResourceOperation
259 >>> read_method_adapter_factory.__name__
260- 'GET_IBookSetOnSteroids_searchBooks'
261+ 'GET_IBookSetOnSteroids_searchBooks___Earliest'
262 >>> issubclass(read_method_adapter_factory, ResourceOperation)
263 True
264
265@@ -1108,10 +1111,10 @@
266 >>> IResourcePOSTOperation.implementedBy(write_method_adapter_factory)
267 True
268
269-The generated adapter class name is POST_<interface>_<operation>.
270+The generated adapter class name is POST_<interface>_<operation>___Earliest.
271
272 >>> print write_method_adapter_factory.__name__
273- POST_IBookOnSteroids_checkout
274+ POST_IBookOnSteroids_checkout___Earliest
275
276 The adapter's params property also contains the available parameters
277 (for which there are none in this case.)
278@@ -1156,10 +1159,11 @@
279 >>> factory_method_adapter.send_modification_event
280 False
281
282-The generated adapter class name is also POST_<interface>_<operation>.
283+The generated adapter class name is also
284+POST_<interface>_<operation>___Earliest.
285
286 >>> print write_method_adapter_factory.__name__
287- POST_IBookOnSteroids_checkout
288+ POST_IBookOnSteroids_checkout___Earliest
289
290 The adapter's params property also contains the available parameters.
291
292@@ -1230,10 +1234,11 @@
293 ... destructor_method_adapter_factory)
294 True
295
296-The generated adapter class name is DELETE_<interface>_<operation>.
297+The generated adapter class name is
298+DELETE_<interface>_<operation>___Earliest.
299
300 >>> print destructor_method_adapter_factory.__name__
301- DELETE_IBookOnSteroids_destroy
302+ DELETE_IBookOnSteroids_destroy___Earliest
303
304
305 === Destructor ===
306@@ -1513,7 +1518,7 @@
307 2.0, 1.0, and in an unnamed pre-1.0 version.
308
309 >>> from lazr.restful.declarations import operation_for_version
310- >>> class MultiVersionMethod(Interface):
311+ >>> class IMultiVersionMethod(Interface):
312 ... export_as_webservice_entry()
313 ...
314 ... @call_with(fixed='2.0 value')
315@@ -1532,14 +1537,79 @@
316 ... fixed=TextLine()
317 ... )
318 ... @export_read_operation()
319- ... def a_method(required, fixed='Fixed value'):
320+ ... def a_method(required, fixed):
321 ... """Method demonstrating multiversion publication."""
322
323+Here's a simple implementation of IMultiVersionMethod. It'll
324+illustrate how the different versions of the web service invoke
325+`a_method` with different hard-coded values for the `fixed` argument.
326+
327+ >>> class MultiVersionMethod():
328+ ... """Simple IMultiVersionMethod implementation."""
329+ ... implements(IMultiVersionMethod)
330+ ...
331+ ... def a_method(self, required, fixed='Fixed value'):
332+ ... return "Required value: %s. Fixed value: %s" % (
333+ ... required, fixed)
334+
335+By passing a version string into generate_operation_adapter(), we can
336+get different adapter classes for different versions of the web
337+service. We'll be invoking each version against the same data model
338+object. Here it is:
339+
340+ >>> data_object = MultiVersionMethod()
341+
342+Passing in None to generate_operation_adapter gets us the method as it
343+appears in the earliest version of the web service.
344+
345+ >>> method = IMultiVersionMethod['a_method']
346+ >>> adapter_earliest_factory = generate_operation_adapter(method, None)
347+ >>> print adapter_earliest_factory.__name__
348+ GET_IMultiVersionMethod_a_method___Earliest
349+
350+ >>> method_earliest = adapter_earliest_factory(data_object, request)
351+ >>> print method_earliest.call(required="foo")
352+ Required value: foo. Fixed value: pre-1.0 value
353+
354+Passing in '1.0' or '2.0' gets us the method as it appears in the
355+appropriate version of the web service. Note that the name of the
356+adapter factory changes to reflect the fact that the method's name in
357+1.0 is 'new_name', not 'a_method'.
358+
359+ >>> adapter_10_factory = generate_operation_adapter(method, '1.0')
360+ >>> print adapter_10_factory.__name__
361+ GET_IMultiVersionMethod_new_name_1_0
362+
363+ >>> method_10 = adapter_10_factory(data_object, request)
364+ >>> print method_10.call(required="bar")
365+ Required value: bar. Fixed value: 1.0 value
366+
367+ >>> adapter_20_factory = generate_operation_adapter(method, '2.0')
368+ >>> print adapter_20_factory.__name__
369+ GET_IMultiVersionMethod_new_name_2_0
370+
371+ >>> method_20 = adapter_20_factory(data_object, request)
372+ >>> print method_20.call(required="baz")
373+ Required value: baz. Fixed value: 2.0 value
374+
375+An error occurs when we try to generate an adapter for a version
376+that's not mentioned in the annotations.
377+
378+ >>> generate_operation_adapter(method, 'NoSuchVersion')
379+ Traceback (most recent call last):
380+ ...
381+ AssertionError: 'a_method' isn't tagged for export to web service
382+ version 'NoSuchVersion'
383+
384+Now that we've seen how lazr.restful uses the annotations to create
385+classes, let's take a closer look at how the 'a_method' method object
386+is annotated.
387+
388+ >>> dictionary = method.getTaggedValue('lazr.restful.exported')
389+
390 The tagged value containing the annotations looks like a dictionary,
391 but it's actually a stack of dictionaries named after the versions.
392
393- >>> dictionary = MultiVersionMethod['a_method'].getTaggedValue(
394- ... 'lazr.restful.exported')
395 >>> dictionary.dict_names
396 [None, '1.0', '2.0']
397
398@@ -1581,8 +1651,8 @@
399 'fixed' argument is fixed to the string 'pre-1.0 value'.
400
401 >>> ignored = dictionary.pop()
402- >>> print dictionary.get('as')
403- None
404+ >>> print dictionary['as']
405+ a_method
406 >>> print dictionary['params']['required'].__name__
407 required
408 >>> dictionary['call_with']
409@@ -1618,7 +1688,7 @@
410 >>> print version
411 2.0
412 >>> sorted(attrs.items())
413- []
414+ [('type', 'removed_operation')]
415
416 It is present in 1.0:
417
418@@ -1634,7 +1704,7 @@
419 been defined yet:
420
421 >>> print dictionary.pop()
422- (None, {})
423+ (None, {'type': 'removed_operation'})
424
425 The @operation_removed_in_version declaration can also be used to
426 reset a named operation's definition if you need to completely re-do
427@@ -1792,22 +1862,23 @@
428 IResourceOperation adapters named under the exported method names
429 are also available for IBookSetOnSteroids and IBookOnSteroids.
430
431- >>> from zope.component import getGlobalSiteManager
432+ >>> from zope.component import getGlobalSiteManager, getUtility
433 >>> adapter_registry = getGlobalSiteManager().adapters
434
435+ >>> request_interface = getUtility(IWebServiceVersion, name='beta')
436 >>> from lazr.restful.interfaces import IWebServiceClientRequest
437 >>> adapter_registry.lookup(
438- ... (IBookSetOnSteroids, IWebServiceClientRequest),
439+ ... (IBookSetOnSteroids, request_interface),
440 ... IResourceGETOperation, 'searchBooks')
441- <class '...GET_IBookSetOnSteroids_searchBooks'>
442+ <class '...GET_IBookSetOnSteroids_searchBooks___Earliest'>
443 >>> adapter_registry.lookup(
444- ... (IBookSetOnSteroids, IWebServiceClientRequest),
445+ ... (IBookSetOnSteroids, request_interface),
446 ... IResourcePOSTOperation, 'create_book')
447- <class '...POST_IBookSetOnSteroids_create_book'>
448+ <class '...POST_IBookSetOnSteroids_create_book___Earliest'>
449 >>> adapter_registry.lookup(
450- ... (IBookOnSteroids, IWebServiceClientRequest),
451+ ... (IBookOnSteroids, request_interface),
452 ... IResourcePOSTOperation, 'checkout')
453- <class '...POST_IBookOnSteroids_checkout'>
454+ <class '...POST_IBookOnSteroids_checkout___Earliest'>
455
456 There is also a 'index.html' view on the IWebServiceClientRequest
457 registered for the InvalidEmail exception.
458
459=== modified file 'src/lazr/restful/example/multiversion/resources.py'
460--- src/lazr/restful/example/multiversion/resources.py 2009-11-16 14:25:45 +0000
461+++ src/lazr/restful/example/multiversion/resources.py 2010-01-25 20:02:11 +0000
462@@ -5,16 +5,20 @@
463 'KeyValuePair',
464 'PairSet']
465
466+from zope.interface import implements
467 from zope.schema import Text
468 from zope.location.interfaces import ILocation
469
470 from lazr.restful.declarations import (
471 collection_default_content, export_as_webservice_collection,
472- export_as_webservice_entry, exported)
473+ export_as_webservice_entry, export_operation_as,
474+ export_read_operation, exported, operation_for_version,
475+ operation_parameters, operation_removed_in_version)
476
477-# We don't need separate implementations of these classes, so borrow
478-# the implementations from the WSGI example.
479-from lazr.restful.example.wsgi.resources import PairSet, KeyValuePair
480+# Our implementations of these classes can be based on the
481+# implementations from the WSGI example.
482+from lazr.restful.example.wsgi.resources import (
483+ PairSet as BasicPairSet, KeyValuePair as BasicKeyValuePair)
484
485 # Our interfaces _will_ diverge from the WSGI example interfaces, so
486 # define them separately.
487@@ -33,3 +37,28 @@
488
489 def get(request, name):
490 """Retrieve a key-value pair by its key."""
491+
492+ # This operation is not published in trunk.
493+ @operation_removed_in_version('trunk')
494+ # In 3.0, it's published as 'by_value'
495+ @export_operation_as('by_value')
496+ @operation_for_version('3.0')
497+ # In 1.0 and 2.0, it's published as 'byValue'
498+ @export_operation_as('byValue')
499+ @operation_parameters(value=Text())
500+ @export_read_operation()
501+ @operation_for_version('1.0')
502+ # This operation is not published in versions earlier than 1.0.
503+ def find_for_value(value):
504+ """Find key-value pairs that have the given value."""
505+
506+
507+class PairSet(BasicPairSet):
508+ implements(IPairSet)
509+
510+ def find_for_value(self, value):
511+ return [pair for pair in self.pairs if value == pair.value]
512+
513+
514+class KeyValuePair(BasicKeyValuePair):
515+ implements(IKeyValuePair)
516
517=== modified file 'src/lazr/restful/example/multiversion/root.py'
518--- src/lazr/restful/example/multiversion/root.py 2009-11-16 14:25:45 +0000
519+++ src/lazr/restful/example/multiversion/root.py 2010-01-25 20:02:11 +0000
520@@ -34,7 +34,7 @@
521
522 class WebServiceConfiguration(BaseWSGIWebServiceConfiguration):
523 code_revision = '1'
524- active_versions = ['beta', '1.0', '2.0']
525+ active_versions = ['beta', '1.0', '2.0', '3.0']
526 latest_version_uri_prefix = 'trunk'
527 use_https = False
528 view_permission = 'zope.Public'
529@@ -45,8 +45,8 @@
530 def _build_top_level_objects(self):
531 pairset = PairSet()
532 pairset.pairs = [
533- KeyValuePair(self, "foo", "bar"),
534- KeyValuePair(self, "1", "2")
535+ KeyValuePair(pairset, "foo", "bar"),
536+ KeyValuePair(pairset, "1", "2")
537 ]
538 collections = dict(pairs=(IKeyValuePair, pairset))
539 return collections, {}
540
541=== modified file 'src/lazr/restful/example/multiversion/tests/introduction.txt'
542--- src/lazr/restful/example/multiversion/tests/introduction.txt 2009-11-16 14:25:45 +0000
543+++ src/lazr/restful/example/multiversion/tests/introduction.txt 2010-01-25 20:02:11 +0000
544@@ -12,10 +12,10 @@
545 >>> from lazr.restful.testing.webservice import WebServiceCaller
546 >>> webservice = WebServiceCaller(domain='multiversion.dev')
547
548-The multiversion web service serves three named versions of the same
549-web service: "beta", "1.0", and "2.0". Once you make a request to the
550-service root of a particular version, the web service only serves you
551-links within that version.
552+The multiversion web service serves four named versions of the same
553+web service: "beta", "1.0", "2.0", and "3.0". Once you make a request
554+to the service root of a particular version, the web service only
555+serves you links within that version.
556
557 >>> top_level_response = webservice.get(
558 ... "/", api_version="beta").jsonBody()
559@@ -32,6 +32,11 @@
560 >>> print top_level_response['key_value_pairs_collection_link']
561 http://multiversion.dev/2.0/pairs
562
563+ >>> top_level_response = webservice.get(
564+ ... "/", api_version="3.0").jsonBody()
565+ >>> print top_level_response['key_value_pairs_collection_link']
566+ http://multiversion.dev/3.0/pairs
567+
568 Like all web services, the multiversion service also serves a
569 development version which tracks the current state of the web service,
570 including all changes that have not yet been folded into a named
571@@ -62,3 +67,81 @@
572 >>> print webservice.get('/', api_version="no_such_version")
573 HTTP/1.1 404 Not Found
574 ...
575+
576+Collections and entries
577+=======================
578+
579+The web service presents a single collection of key-value pairs.
580+
581+ >>> body = webservice.get('/pairs').jsonBody()
582+ >>> for entry in body['entries']:
583+ ... print entry['self_link'], entry['key'], entry['value']
584+ http://multiversion.dev/3.0/pairs/foo foo bar
585+ http://multiversion.dev/3.0/pairs/1 1 2
586+
587+ >>> body = webservice.get('/pairs/foo').jsonBody()
588+ >>> print body['key'], body['value']
589+ foo bar
590+
591+Named operations
592+================
593+
594+The collection of key-value pairs defines a named operation for
595+finding pairs, given a value. This operation is present in some
596+versions of the web service but not others. In some versions it's
597+called "byValue"; in others, it's called "by_value".
598+
599+ >>> def show_value(version, op):
600+ ... url = '/pairs?ws.op=%s&value=bar' % op
601+ ... body = webservice.get(url, api_version=version).jsonBody()
602+ ... return body[0]['key']
603+
604+The named operation is not published at all in the 'beta' version of
605+the web service.
606+
607+ >>> print show_value("beta", 'byValue')
608+ Traceback (most recent call last):
609+ ...
610+ ValueError: No such operation: byValue
611+
612+ >>> print show_value("beta", 'by_value')
613+ Traceback (most recent call last):
614+ ...
615+ ValueError: No such operation: by_value
616+
617+In the '1.0' and '2.0' versions, the named operation is published as
618+'byValue'. 'by_value' does not work.
619+
620+ >>> print show_value("1.0", 'byValue')
621+ foo
622+
623+ >>> print show_value("2.0", 'byValue')
624+ foo
625+ >>> print show_value("2.0", 'by_value')
626+ Traceback (most recent call last):
627+ ...
628+ ValueError: No such operation: by_value
629+
630+In the '3.0' version, the named operation is published as
631+'by_value'. 'byValue' does not work.
632+
633+ >>> print show_value("3.0", "by_value")
634+ foo
635+
636+ >>> print show_value("3.0", 'byValue')
637+ Traceback (most recent call last):
638+ ...
639+ ValueError: No such operation: byValue
640+
641+In the 'trunk' version, the named operation has been removed. Neither
642+'byValue' nor 'by_value' work.
643+
644+ >>> print show_value("trunk", 'byValue')
645+ Traceback (most recent call last):
646+ ...
647+ ValueError: No such operation: byValue
648+
649+ >>> print show_value("trunk", 'by_value')
650+ Traceback (most recent call last):
651+ ...
652+ ValueError: No such operation: by_value
653
654=== modified file 'src/lazr/restful/metazcml.py'
655--- src/lazr/restful/metazcml.py 2010-01-06 20:15:10 +0000
656+++ src/lazr/restful/metazcml.py 2010-01-25 20:02:11 +0000
657@@ -8,6 +8,7 @@
658
659 import inspect
660
661+from zope.component import getUtility
662 from zope.component.zcml import handler
663 from zope.configuration.fields import GlobalObject
664 from zope.interface import Interface
665@@ -15,14 +16,15 @@
666
667
668 from lazr.restful.declarations import (
669- LAZR_WEBSERVICE_EXPORTED, OPERATION_TYPES, generate_collection_adapter,
670- generate_entry_adapter, generate_entry_interface,
671- generate_operation_adapter)
672+ LAZR_WEBSERVICE_EXPORTED, OPERATION_TYPES, REMOVED_OPERATION_TYPE,
673+ generate_collection_adapter, generate_entry_adapter,
674+ generate_entry_interface, generate_operation_adapter)
675 from lazr.restful.error import WebServiceExceptionView
676
677 from lazr.restful.interfaces import (
678 ICollection, IEntry, IResourceDELETEOperation, IResourceGETOperation,
679- IResourcePOSTOperation, IWebServiceClientRequest)
680+ IResourceOperation, IResourcePOSTOperation, IWebServiceClientRequest,
681+ IWebServiceConfiguration, IWebServiceVersion)
682
683
684 class IRegisterDirective(Interface):
685@@ -32,6 +34,41 @@
686 title=u'Module which will be inspected for webservice declarations')
687
688
689+def register_adapter_for_version(factory, interface, version_name,
690+ provides, name, info):
691+ """A version-aware wrapper for the registerAdapter operation.
692+
693+ During web service generation we often need to register an adapter
694+ for a particular version of the web service. The way to do this is
695+ to register a multi-adapter using the interface being adapted to,
696+ plus the marker interface for the web service version.
697+
698+ These marker interfaces are not available when the web service is
699+ being generated, but the version strings are available. So methods
700+ like register_webservice_operations use this function as a handler
701+ for the second stage of ZCML processing.
702+
703+ This function simply looks up the appropriate marker interface and
704+ calls Zope's handler('registerAdapter').
705+ """
706+ if version_name is None:
707+ # When we were processing annotations we didn't know the name
708+ # of the earliest supported version. We know this now.
709+ utility = getUtility(IWebServiceConfiguration)
710+ if len(utility.active_versions) > 0:
711+ version_name = utility.active_versions[0]
712+ else:
713+ # This service only publishes a 'development' version.
714+ version_name = utility.latest_version_uri_prefix
715+ # Make sure the given version string has an
716+ # IWebServiceVersion utility registered for it, and is not
717+ # just a random string.
718+ marker = getUtility(IWebServiceVersion, name=version_name)
719+
720+ handler('registerAdapter', factory, (interface, marker),
721+ provides, name, info)
722+
723+
724 def find_exported_interfaces(module):
725 """Find all the interfaces in a module marked for export.
726
727@@ -95,32 +132,117 @@
728
729
730 def register_webservice_operations(context, interface):
731- """Create and register adapters for all exported methods."""
732+ """Create and register adapters for all exported methods.
733
734+ Different versions of the web service may publish the same
735+ operation differently or under different names.
736+ """
737 for name, method in interface.namesAndDescriptions(True):
738 tag = method.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED)
739 if tag is None or tag['type'] not in OPERATION_TYPES:
740+ # This method is not published as a named operation.
741 continue
742- name = tag['as']
743- if tag['type'] == 'read_operation':
744- provides = IResourceGETOperation
745- elif tag['type'] in ['factory', 'write_operation']:
746- provides = IResourcePOSTOperation
747- elif tag['type'] in ['destructor']:
748- provides = IResourceDELETEOperation
749- name = ''
750- else:
751- raise AssertionError('Unknown operation type: %s' % tag['type'])
752- factory = generate_operation_adapter(method)
753- context.action(
754- discriminator=(
755- 'adapter', (interface, IWebServiceClientRequest),
756- provides, tag['as']),
757- callable=handler,
758- args=('registerAdapter',
759- factory, (interface, IWebServiceClientRequest), provides,
760- name, context.info),
761- )
762+
763+ operation_name = None
764+ # If an operation's name does not change between version n and
765+ # version n+1, we want lookups for requests that come in for
766+ # version n+1 to find the registered adapter for version n. This
767+ # happens automatically. But if the operation name changes
768+ # (because the operation is now published under a new name, or
769+ # because the operation has been removed), we need to register a
770+ # masking adapter: something that will stop version n+1's lookups
771+ # from finding the adapter registered for version n. To this end
772+ # we keep track of what the operation looked like in the previous
773+ # version.
774+ previous_operation_name = None
775+ previous_operation_provides = None
776+ for version, tag in tag.stack:
777+ if tag['type'] == REMOVED_OPERATION_TYPE:
778+ # This operation is not present in this version.
779+ # We'll represent this by setting the operation_name
780+ # to None. If the operation was not present in the
781+ # previous version either (or there is no previous
782+ # version), previous_operation_name will also be None
783+ # and nothing will happen. If the operation was
784+ # present in the previous version,
785+ # previous_operation_name will not be None, and the
786+ # code that handles name changes will install a
787+ # masking adapter.
788+ operation_name = None
789+ operation_provides = None
790+ factory = None
791+ else:
792+ if tag['type'] == 'read_operation':
793+ operation_provides = IResourceGETOperation
794+ elif tag['type']in ['factory', 'write_operation']:
795+ operation_provides = IResourcePOSTOperation
796+ elif tag['type'] in ['destructor']:
797+ operation_provides = IResourceDELETEOperation
798+ else:
799+ # We know it's not REMOVED_OPERATION_TYPE, because
800+ # that case is handled above.
801+ raise AssertionError(
802+ 'Unknown operation type: %s' % tag['type'])
803+ operation_name = tag.get('as')
804+ if tag['type'] in ['destructor']:
805+ operation_name = ''
806+ factory = generate_operation_adapter(method, version)
807+
808+ # Operations are looked up by name. If the operation's
809+ # name has changed from the previous version to this
810+ # version, or if the operation was removed in this
811+ # version, we need to block lookups of the previous name
812+ # from working.
813+ check = (previous_operation_name, previous_operation_provides,
814+ operation_name, operation_provides, version, factory)
815+ if (operation_name != previous_operation_name
816+ and previous_operation_name is not None):
817+ _add_versioned_adapter_action(
818+ context, interface, previous_operation_provides,
819+ previous_operation_name, version,
820+ _mask_adapter_registration)
821+
822+ # If the operation exists in this version (ie. its name is
823+ # not None), register it using this version's name.
824+ if operation_name is not None:
825+ _add_versioned_adapter_action(
826+ context, interface, operation_provides, operation_name,
827+ version, factory)
828+ previous_operation_name = operation_name
829+ previous_operation_provides = operation_provides
830+
831+
832+def _mask_adapter_registration(*args):
833+ """A factory function that stops an adapter lookup from succeeding.
834+
835+ This function is registered when it's necessary to explicitly stop
836+ some part of web service version n from being visible to version n+1.
837+ """
838+ return None
839+
840+
841+def _add_versioned_adapter_action(
842+ context, interface, provides, name, version, factory):
843+ """Helper function to register a versioned operation factory.
844+
845+ :param context: The context on which to add a registration action.
846+ :param interface: The IEntry subclass that publishes the named
847+ operation.
848+ :param provides: The IResourceOperation subclass provided by the
849+ factory.
850+ :param name: The name of the named operation in this version.
851+ :param version: The version of the web service that publishes this
852+ named operation.
853+ :param factory: The object that handles invocations of the named
854+ operation.
855+ """
856+ context.action(
857+ discriminator=(
858+ 'webservice versioned adapter',
859+ (interface, IWebServiceClientRequest), provides, name, version),
860+ callable=register_adapter_for_version,
861+ args=(factory, interface, version, provides, name, context.info),
862+ )
863
864
865 def register_exception_view(context, exception):
866
867=== modified file 'src/lazr/restful/tests/test_webservice.py'
868--- src/lazr/restful/tests/test_webservice.py 2010-01-14 17:08:20 +0000
869+++ src/lazr/restful/tests/test_webservice.py 2010-01-25 20:02:11 +0000
870@@ -53,9 +53,10 @@
871 :return: the factory (autogenerated class) that implements the operation
872 on the webservice.
873 """
874+ request_interface = getUtility(IWebServiceVersion, name='trunk')
875 return getGlobalSiteManager().adapters.lookup(
876- (model_interface, IWebServiceClientRequest),
877- IResourceGETOperation, name=name)
878+ (model_interface, request_interface),
879+ IResourceGETOperation, name=name)
880
881
882 class IGenericEntry(Interface):

Subscribers

People subscribed via source and target branches