Merge lp:~leonardr/lazr.restful/operation-sanity-check into lp:lazr.restful

Proposed by Leonard Richardson
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
Reviewer Review Type Date Requested Status
Abel Deuring (community) code Approve
Review via email: mp+54014@code.launchpad.net

Description of the change

This branch beefs up our post-web-service-definition sanity check, and the tests for it. Both the code and the tests have also been heavily refactored to avoid massive repetition.

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.

To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

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.

Revision history for this message
Leonard Richardson (leonardr) wrote :

s/IBar itself isn't published in 2.0/IBar itself isn't published until 2.0/

Revision history for this message
Abel Deuring (adeuring) wrote :

The code looks good. Thanks for these really useful additional consistency checks!

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/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)

Subscribers

People subscribed via source and target branches