Merge lp:~leonardr/lazr.restful/generate-multiversion-operations into lp:lazr.restful
- generate-multiversion-operations
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Brad Crittenden (community) | code | Approve | |
Review via email: mp+18032@code.launchpad.net |
Commit message
Description of the change
Leonard Richardson (leonardr) wrote : | # |
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/
> --- src/lazr/
+++ src/lazr/
> + # It's possible that call_with, operation_
> + # operation_returns_* weren't used.
> + annotations.
> + annotations.
> + annotations.
> +
> + # 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.
>
> + # 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_
> class export_
> """Decorator specifying the name to export the method as."""
Preview Diff
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): |
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 IWebServiceConf iguration 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 IWebServiceRequ estVersion. You need to check against a specific versioned request interface. This caused several miscellaneous test failures.