Merge lp:~leonardr/lazr.restful/operation-sanity-check into lp:lazr.restful
- operation-sanity-check
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 184 |
Proposed branch: | lp:~leonardr/lazr.restful/operation-sanity-check |
Merge into: | lp:lazr.restful |
Diff against target: |
346 lines (+234/-46) 2 files modified
src/lazr/restful/metazcml.py (+85/-18) src/lazr/restful/tests/test_declarations.py (+149/-28) |
To merge this branch: | bzr merge lp:~leonardr/lazr.restful/operation-sanity-check |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Abel Deuring (community) | code | Approve | |
Review via email: mp+54014@code.launchpad.net |
Commit message
Description of the change
This branch beefs up our post-web-
Previously, the sanity check only looked at fields that were References to unpublished entries. We had a wide variety of tests for what counts as an unpublished entry (never published, published and removed, published too late), but there are lots of places other than Reference fields where unpublished entries might show up:
1. A field might be a *collection* of unpublished entries.
2. A named operation might *return* an unpublished entry (or a collection of them).
3. A named operation might *accept* an unpublished entry (or a collection of them) as a parameter.
Leonard Richardson (leonardr) wrote : | # |
Leonard Richardson (leonardr) wrote : | # |
s/IBar itself isn't published in 2.0/IBar itself isn't published until 2.0/
Abel Deuring (adeuring) wrote : | # |
The code looks good. Thanks for these really useful additional consistency checks!
Preview Diff
1 | === modified file 'src/lazr/restful/metazcml.py' |
2 | --- src/lazr/restful/metazcml.py 2011-03-18 12:13:42 +0000 |
3 | +++ src/lazr/restful/metazcml.py 2011-03-18 15:37:24 +0000 |
4 | @@ -33,6 +33,7 @@ |
5 | |
6 | from lazr.restful.interfaces import ( |
7 | ICollection, |
8 | + ICollectionField, |
9 | IEntry, |
10 | IReference, |
11 | IResourceDELETEOperation, |
12 | @@ -358,26 +359,92 @@ |
13 | available_registrations.add(VersionedObject( |
14 | version_for_marker[version_marker], interface)) |
15 | |
16 | - # Check every Reference field in every IEntry interface, making |
17 | - # sure that it's a Reference to another IEntry published in that |
18 | - # version. |
19 | + # Check every Reference and CollectionField field in every IEntry |
20 | + # interface, making sure that they only reference entries that are |
21 | + # published in that version. |
22 | for version, interface in entries: |
23 | - if version is None: |
24 | - version = versions[0] |
25 | for name, field in interface.namesAndDescriptions(): |
26 | - if IReference.providedBy(field): |
27 | - referenced_interface = field.schema |
28 | - to_check = VersionedObject(version, referenced_interface) |
29 | - if to_check not in available_registrations: |
30 | - raise ValueError( |
31 | - "In version %(version)s, %(interface)s.%(field)s " |
32 | - "is a Reference to %(reference)s, but version " |
33 | - "%(version)s of the web service does not publish " |
34 | - "%(reference)s as an entry. (It may not be published " |
35 | - "at all.)" % dict( |
36 | - version=version, interface=interface.__name__, |
37 | - field=field.__name__, |
38 | - reference=referenced_interface.__name__)) |
39 | + referenced_interface, what = _extract_reference_type(field) |
40 | + if referenced_interface is not None: |
41 | + _assert_interface_registered_for_version( |
42 | + version, referenced_interface, available_registrations, |
43 | + ("%(interface)s.%(field)s is %(what)s" |
44 | + % dict(interface=interface.__name__, |
45 | + field=field.__name__, what=what))) |
46 | + |
47 | + # For every version, check the return value and arguments to every |
48 | + # named operation published in that version, making sure that |
49 | + # there are no references to IEntries not published in that |
50 | + # version. |
51 | + for version, method in operations: |
52 | + tags = method.getTaggedValue(LAZR_WEBSERVICE_EXPORTED) |
53 | + return_type = tags.get('return_type') |
54 | + referenced_interface, what = _extract_reference_type(return_type) |
55 | + if referenced_interface is not None: |
56 | + _assert_interface_registered_for_version( |
57 | + version, referenced_interface, available_registrations, |
58 | + "named operation %s returns %s" % (tags['as'], what)) |
59 | + |
60 | + if not 'params' in tags: |
61 | + continue |
62 | + |
63 | + for name, field in tags['params'].items(): |
64 | + referenced_interface, what = _extract_reference_type(field) |
65 | + if referenced_interface is not None: |
66 | + _assert_interface_registered_for_version( |
67 | + version, referenced_interface, available_registrations, |
68 | + "named operation %s accepts %s" % (tags['as'], what)) |
69 | + |
70 | +def _extract_reference_type(field): |
71 | + """Determine what kind of object the given field is a reference to. |
72 | + |
73 | + This is a helper method used by the sanity checker. |
74 | + |
75 | + :return: A 2-tuple (reference_type, human_readable): If |
76 | + `field` is a reference to a scalar entry, human_readable is |
77 | + the name of the interface. If `field` is a collection of |
78 | + entries, human_readable is the string "a collection of |
79 | + [interface name]". If `field` is not a reference to an |
80 | + entry, both values are None. |
81 | + """ |
82 | + if IReference.providedBy(field): |
83 | + schema = field.schema |
84 | + return (schema, schema.__name__) |
85 | + elif ICollectionField.providedBy(field): |
86 | + schema = field.value_type.schema |
87 | + return (schema, "a collection of %s" % schema.__name__) |
88 | + else: |
89 | + return (None, None) |
90 | + |
91 | + |
92 | +def _assert_interface_registered_for_version( |
93 | + version, interface, available_registrations, error_message_insert): |
94 | + """A helper method for the sanity checker. |
95 | + |
96 | + See if the given entry interface is published in the given version |
97 | + (as determined by the contents of `available_registrations`), and |
98 | + if it's not, raise an exception. |
99 | + |
100 | + :param version: The version in which `interface` should be published. |
101 | + :param interface: The interface that ought to be published. |
102 | + :param available_registrations: A set of (version, interface) pairs |
103 | + to check. |
104 | + :param error_message_insert: A snippet of text explaining the significance |
105 | + if `interface`, eg. "IMyInterface.myfield is IOughtToBePublished" or |
106 | + "named operation my_named_operation returns IOughtToBePublished". |
107 | + """ |
108 | + |
109 | + if version is None: |
110 | + version = getUtility(IWebServiceConfiguration).active_versions[0] |
111 | + |
112 | + to_check = VersionedObject(version, interface) |
113 | + if to_check not in available_registrations: |
114 | + raise ValueError( |
115 | + "In version %(version)s, %(problem)s, but version %(version)s " |
116 | + "of the web service does not publish %(reference)s as " |
117 | + "an entry. (It may not be published at all.)" % dict( |
118 | + version=version, problem=error_message_insert, |
119 | + reference=interface.__name__)) |
120 | |
121 | |
122 | def generate_and_register_webservice_operations( |
123 | |
124 | === modified file 'src/lazr/restful/tests/test_declarations.py' |
125 | --- src/lazr/restful/tests/test_declarations.py 2011-03-18 12:43:45 +0000 |
126 | +++ src/lazr/restful/tests/test_declarations.py 2011-03-18 15:37:24 +0000 |
127 | @@ -1,3 +1,7 @@ |
128 | +# Copyright 2008-2011 Canonical Ltd. All rights reserved. |
129 | + |
130 | +"""Unit tests for the conversion of interfaces into a web service.""" |
131 | + |
132 | from zope.configuration.config import ConfigurationExecutionError |
133 | from zope.component import ( |
134 | adapts, |
135 | @@ -37,8 +41,13 @@ |
136 | mutator_for, |
137 | operation_for_version, |
138 | operation_parameters, |
139 | - ) |
140 | -from lazr.restful.fields import Reference |
141 | + operation_returns_collection_of, |
142 | + operation_returns_entry, |
143 | + ) |
144 | +from lazr.restful.fields import ( |
145 | + CollectionField, |
146 | + Reference, |
147 | + ) |
148 | from lazr.restful.interfaces import ( |
149 | IEntry, |
150 | IResourceGETOperation, |
151 | @@ -725,11 +734,54 @@ |
152 | pass |
153 | |
154 | |
155 | -class IReferencesUnpublishedObject(Interface): |
156 | +class IReferencesNotPublished(Interface): |
157 | export_as_webservice_entry() |
158 | field = exported(Reference(schema=INotPublished)) |
159 | |
160 | |
161 | +class IReferencesCollectionOfNotPublished(Interface): |
162 | + export_as_webservice_entry() |
163 | + field = exported( |
164 | + CollectionField(value_type=Reference(schema=INotPublished))) |
165 | + |
166 | + |
167 | +class IOperationReturnsNotPublished(Interface): |
168 | + export_as_webservice_entry() |
169 | + |
170 | + @operation_returns_entry(INotPublished) |
171 | + @export_read_operation() |
172 | + def get_impossible_object(): |
173 | + pass |
174 | + |
175 | + |
176 | +class IOperationReturnsNotPublishedCollection(Interface): |
177 | + export_as_webservice_entry() |
178 | + |
179 | + @operation_returns_collection_of(INotPublished) |
180 | + @export_read_operation() |
181 | + def get_impossible_objects(): |
182 | + pass |
183 | + |
184 | + |
185 | +class IOperationAcceptsNotPublished(Interface): |
186 | + export_as_webservice_entry() |
187 | + |
188 | + @operation_parameters(arg=Reference(schema=INotPublished)) |
189 | + @export_write_operation() |
190 | + def use_impossible_object(arg): |
191 | + pass |
192 | + |
193 | + |
194 | +class IOperationAcceptsCollectionOfNotPublished(Interface): |
195 | + export_as_webservice_entry() |
196 | + |
197 | + @operation_parameters( |
198 | + arg=CollectionField(value_type=Reference(schema=INotPublished))) |
199 | + @export_write_operation() |
200 | + def use_impossible_objects(arg): |
201 | + pass |
202 | + |
203 | + |
204 | class IPublishedTooLate(Interface): |
205 | export_as_webservice_entry(as_of='2.0') |
206 | |
207 | @@ -761,41 +813,62 @@ |
208 | class TestSanityChecking(TestCaseWithWebServiceFixtures): |
209 | """Test lazr.restful's sanity checking upon web service registration.""" |
210 | |
211 | + def _test_fails_sanity_check( |
212 | + self, expect_failure_in_version, expect_failure_for_reason, |
213 | + expect_failure_due_to_interface, *classes): |
214 | + """Verify that the given interfaces can't become a web service. |
215 | + |
216 | + The given set of interfaces are expected to fail the sanity |
217 | + check because they include an annotation that makes some |
218 | + version of the web service reference an entry not defined in |
219 | + that version (or at all). |
220 | + |
221 | + :param expect_failure_in_version: Which version of the web |
222 | + service will fail the sanity check. |
223 | + :param expect_failure_for_reason: The reason that will be given |
224 | + for failing the sanity check. |
225 | + :param expect_failure_due_to_interface: The interface that will |
226 | + cause the failure due to not being published in |
227 | + `expect_failure_in_version`. |
228 | + :param classes: The interfaces to attempt to publish as a web |
229 | + service. `expect_failure_due_to_interface` will be added to |
230 | + this list, so there's no need to specify it again. |
231 | + """ |
232 | + exception = self.assertRaises( |
233 | + ConfigurationExecutionError, register_test_module, 'testmod', |
234 | + *(list(classes) + [expect_failure_due_to_interface])) |
235 | + expected_message = ( |
236 | + "In version %(version)s, %(reason)s, but version %(version)s " |
237 | + "of the web service does not publish %(interface)s as an entry. " |
238 | + "(It may not be published at all.)" % dict( |
239 | + version=expect_failure_in_version, |
240 | + reason=expect_failure_for_reason, |
241 | + interface=expect_failure_due_to_interface.__name__)) |
242 | + self.assertTrue(expected_message in str(exception)) |
243 | + |
244 | def test_reference_to_unpublished_object_fails(self): |
245 | - exception = self.assertRaises( |
246 | - ConfigurationExecutionError, register_test_module, 'testmod', |
247 | - INotPublished, IReferencesUnpublishedObject) |
248 | - self.assertTrue( |
249 | - ("In version 1.0, IReferencesUnpublishedObjectEntry_1_0.field " |
250 | - "is a Reference to INotPublished, but version 1.0 of the web " |
251 | - "service does not publish INotPublished as an entry. " |
252 | - "(It may not be published at all.)") in str(exception)) |
253 | + self._test_fails_sanity_check( |
254 | + '1.0', |
255 | + ("IReferencesNotPublishedEntry_1_0.field is INotPublished"), |
256 | + INotPublished, IReferencesNotPublished) |
257 | |
258 | def test_reference_to_object_published_later_fails(self): |
259 | - exception = self.assertRaises( |
260 | - ConfigurationExecutionError, register_test_module, 'testmod', |
261 | + self._test_fails_sanity_check( |
262 | + '1.0', |
263 | + ("IReferencesPublishedTooLateEntry_1_0.field is " |
264 | + "IPublishedTooLate"), |
265 | IPublishedTooLate, IReferencesPublishedTooLate) |
266 | - self.assertTrue( |
267 | - ("In version 1.0, IReferencesPublishedTooLateEntry_1_0.field is " |
268 | - "a Reference to IPublishedTooLate, but version 1.0 of the web " |
269 | - "service does not publish IPublishedTooLate as an entry. (It " |
270 | - "may not be published at all.)") in str(exception)) |
271 | |
272 | def test_reference_to_object_published_and_then_removed_fails(self): |
273 | # This setup is acceptable in version 1.0, but in version 2.0 |
274 | # IPublishedAndThenRemoved is gone. This puts |
275 | # IReferencesPublishedAndThenRemoved in violation of the |
276 | # sanity check in 2.0, even though it hasn't changed since 1.0. |
277 | - exception = self.assertRaises( |
278 | - ConfigurationExecutionError, register_test_module, |
279 | - 'testmod', IPublishedAndThenRemoved, |
280 | - IReferencesPublishedAndThenRemoved) |
281 | - self.assertTrue( |
282 | - ("In version 2.0, " |
283 | - "IReferencesPublishedAndThenRemovedEntry_2_0.field is a " |
284 | - "Reference to IPublishedAndThenRemoved, but version 2.0 of the " |
285 | - "web service does not publish IPublishedAndThenRemoved as an " |
286 | - "entry. (It may not be published at all.)")) |
287 | + self._test_fails_sanity_check( |
288 | + '2.0', |
289 | + ("IReferencesPublishedAndThenRemovedEntry_2_0.field is " |
290 | + "IPublishedAndThenRemoved"), |
291 | + IPublishedAndThenRemoved, IReferencesPublishedAndThenRemoved) |
292 | |
293 | def test_reference_to_object_published_earlier_succeeds(self): |
294 | # It's okay for an object defined in 2.0 to reference an |
295 | @@ -806,3 +879,51 @@ |
296 | module = register_test_module( |
297 | 'testmod', IPublishedEarly, IPublishedLate) |
298 | |
299 | + # At this point we've tested all the ways entries might not be |
300 | + # published in a given version: they might never be published, |
301 | + # they might be published later, or they might be published |
302 | + # earlier and then removed. |
303 | + |
304 | + # Now we need to test all the places an unpublished entry might |
305 | + # show up, not counting 'as the referent of a Reference field', |
306 | + # which we've already tested. |
307 | + |
308 | + def test_reference_to_collection_of_unpublished_objects_fails(self): |
309 | + # An entry may define a field that's a scoped collection of |
310 | + # unpublished entries. |
311 | + self._test_fails_sanity_check( |
312 | + '1.0', |
313 | + ("IReferencesCollectionOfNotPublishedEntry_1_0.field is a " |
314 | + "collection of INotPublished"), |
315 | + INotPublished, IReferencesCollectionOfNotPublished) |
316 | + |
317 | + def test_operation_returning_unpublished_object_fails(self): |
318 | + # An operation may return an unpublished entry. |
319 | + self._test_fails_sanity_check( |
320 | + '1.0', |
321 | + ("named operation get_impossible_object returns INotPublished"), |
322 | + INotPublished, IOperationReturnsNotPublished) |
323 | + |
324 | + def test_operation_returning_collection_of_unpublished_object_fails(self): |
325 | + # An operation may return a collection of unpublished entries. |
326 | + self._test_fails_sanity_check( |
327 | + '1.0', |
328 | + ("named operation get_impossible_objects returns a collection " |
329 | + "of INotPublished"), |
330 | + INotPublished, IOperationReturnsNotPublishedCollection) |
331 | + |
332 | + def test_operation_taking_unpublished_argument_fails(self): |
333 | + # An operation may take an unpublished entry as an argument. |
334 | + self._test_fails_sanity_check( |
335 | + '1.0', |
336 | + ("named operation use_impossible_object accepts INotPublished"), |
337 | + INotPublished, IOperationAcceptsNotPublished) |
338 | + |
339 | + def test_operation_taking_unpublished_collection_argument_fails(self): |
340 | + # An operation may take a collection of unpublished entries as |
341 | + # an argument. |
342 | + self._test_fails_sanity_check( |
343 | + '1.0', |
344 | + ("named operation use_impossible_objects accepts a collection " |
345 | + "of INotPublished"), |
346 | + INotPublished, IOperationAcceptsCollectionOfNotPublished) |
Some background: in the currently released version of lazr.restful, an interface that's got export_ as_webservice_ entry() is published in *every single version* of the web service. Since then, I've landed code that lets you publish entries themselves on the same terms as you publish their fields and named operations. For instance, you can publish IFoo in 1.0 and get rid of it in 2.0, or you can start publishing IBar in 2.0 and then give it a new human-readable name in 3.0.
The problem (and the reason I didn't do this to start with) is that this creates potential data integrity problems. What if IFoo references IBar in 1.0, but IBar itself isn't published in 2.0? The web service definition for 1.0 is incoherent. The job of the sanity checker is to go through the web service definition and make sure version X contains no references to entries not published in version X.