Merge lp:~cjwatson/lazr.restful/range-factory into lp:lazr.restful

Proposed by Colin Watson on 2018-10-02
Status: Needs review
Proposed branch: lp:~cjwatson/lazr.restful/range-factory
Merge into: lp:lazr.restful
Diff against target: 415 lines (+146/-16)
9 files modified
setup.py (+1/-1)
src/lazr/restful/_operation.py (+10/-1)
src/lazr/restful/_resource.py (+16/-3)
src/lazr/restful/declarations.py (+11/-5)
src/lazr/restful/docs/webservice-declarations.txt (+47/-2)
src/lazr/restful/docs/webservice.txt (+51/-3)
src/lazr/restful/fields.py (+2/-1)
src/lazr/restful/interfaces/_fields.py (+5/-0)
src/lazr/restful/interfaces/_rest.py (+3/-0)
To merge this branch: bzr merge lp:~cjwatson/lazr.restful/range-factory
Reviewer Review Type Date Requested Status
LAZR Developers 2018-10-02 Pending
Review via email: mp+355966@code.launchpad.net

Commit message

Allow declaring a custom range factory for use with returned collections.

Description of the change

Integrating this into Launchpad's StormRangeFactory may still require a bit more care, and I'd like to have at least one use case there that definitely works before landing this, but I might as well get this up for review in the meantime.

To post a comment you must log in.

Unmerged revisions

212. By Colin Watson on 2018-10-01

Get ScopedCollection.range_factory_class from the corresponding field.

211. By Colin Watson on 2018-10-01

Merge trunk.

210. By Colin Watson on 2018-09-28

Merge trunk.

209. By Colin Watson on 2015-04-09

Allow declaring a custom range factory for use with returned collections.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'setup.py'
2--- setup.py 2018-02-21 14:46:13 +0000
3+++ setup.py 2018-10-02 06:38:17 +0000
4@@ -56,7 +56,7 @@
5 'docutils>=0.3.7',
6 'epydoc', # used by wadl generation
7 'grokcore.component==1.6',
8- 'lazr.batchnavigator>=1.2.0-dev',
9+ 'lazr.batchnavigator>=1.2.3',
10 'lazr.delegates>=2.0.3',
11 'lazr.enum',
12 'lazr.lifecycle',
13
14=== modified file 'src/lazr/restful/_operation.py'
15--- src/lazr/restful/_operation.py 2018-04-23 12:42:00 +0000
16+++ src/lazr/restful/_operation.py 2018-10-02 06:38:17 +0000
17@@ -118,7 +118,9 @@
18 if self.total_size_only:
19 result = self.get_total_size(result)
20 else:
21- result = self.batch(result, self.request) + '}'
22+ result = self.batch(
23+ result, self.request,
24+ range_factory=self.get_range_factory(result)) + '}'
25 else:
26 # Serialize the result to JSON. Any embedded entries will be
27 # automatically serialized.
28@@ -163,6 +165,13 @@
29 # Any other objects (eg. Entries) are not batched.
30 return False
31
32+ def get_range_factory(self, result):
33+ """The custom range factory to use for this response, if any."""
34+ if (ICollectionField.providedBy(self.return_type) and
35+ self.return_type.range_factory_class is not None):
36+ return self.return_type.range_factory_class(result)
37+ return None
38+
39 def validate(self):
40 """Validate incoming arguments against the operation schema.
41
42
43=== modified file 'src/lazr/restful/_resource.py'
44--- src/lazr/restful/_resource.py 2018-10-01 08:55:53 +0000
45+++ src/lazr/restful/_resource.py 2018-10-02 06:38:17 +0000
46@@ -611,7 +611,7 @@
47 return simplejson.dumps(len(entries))
48
49
50- def batch(self, entries, request):
51+ def batch(self, entries, request, range_factory=None):
52 """Prepare a batch from a (possibly huge) list of entries.
53
54 :return: a JSON string representing a hash:
55@@ -635,7 +635,8 @@
56 """
57 if not hasattr(entries, '__len__'):
58 entries = IFiniteSequence(entries)
59- navigator = WebServiceBatchNavigator(entries, request)
60+ navigator = WebServiceBatchNavigator(
61+ entries, request, range_factory=range_factory)
62
63 view_permission = getUtility(IWebServiceConfiguration).view_permission
64 batch = { 'start' : navigator.batch.start }
65@@ -1817,7 +1818,12 @@
66 entries = self.collection.find()
67 if request is None:
68 request = self.request
69- result = super(CollectionResource, self).batch(entries, request)
70+ if self.collection.range_factory_class is None:
71+ range_factory = None
72+ else:
73+ range_factory = self.collection.range_factory_class(entries)
74+ result = super(CollectionResource, self).batch(
75+ entries, request, range_factory=range_factory)
76 result += (
77 ', "resource_type_link" : ' + simplejson.dumps(self.type_url))
78 return result
79@@ -2089,6 +2095,8 @@
80 class Collection:
81 """A collection of entries."""
82
83+ range_factory_class = None
84+
85 def __init__(self, context, request):
86 """Associate the entry with some database model object."""
87 self.context = context
88@@ -2124,6 +2132,11 @@
89 return getGlobalSiteManager().adapters.lookup(
90 (model_schema, request_interface), IEntry).schema
91
92+ @property
93+ def range_factory_class(self):
94+ """See `ICollection`."""
95+ return self.relationship.range_factory_class
96+
97 def find(self):
98 """See `ICollection`."""
99 return self.collection
100
101=== modified file 'src/lazr/restful/declarations.py'
102--- src/lazr/restful/declarations.py 2018-09-28 15:46:34 +0000
103+++ src/lazr/restful/declarations.py 2018-10-02 06:38:17 +0000
104@@ -354,7 +354,7 @@
105 return field
106
107
108-def export_as_webservice_collection(entry_schema):
109+def export_as_webservice_collection(entry_schema, range_factory_class=None):
110 """Mark the interface as exported on the web service as a collection.
111
112 :raises TypeError: if the interface doesn't have a method decorated with
113@@ -369,7 +369,8 @@
114 # check it.
115 tags = _get_interface_tags()
116 tags[LAZR_WEBSERVICE_EXPORTED] = dict(
117- type=COLLECTION_TYPE, collection_entry_schema=entry_schema)
118+ type=COLLECTION_TYPE, collection_entry_schema=entry_schema,
119+ collection_range_factory_class=range_factory_class)
120
121 def mark_collection(interface):
122 """Class advisor that tags the interface once it is created."""
123@@ -846,15 +847,18 @@
124 """Specify that the exported operation returns a collection.
125
126 The decorator takes one required argument, "schema", an interface that's
127- been exported as an entry.
128+ been exported as an entry, and one optional argument,
129+ "range_factory_class", which specifies a custom class implementing
130+ IRangeFactory to be used to adapt results for this collection.
131 """
132- def __init__(self, schema):
133+ def __init__(self, schema, range_factory_class=None):
134 _check_called_from_interface_def('%s()' % self.__class__.__name__)
135 if not IInterface.providedBy(schema):
136 raise TypeError('Collection value type %s does not provide '
137 'IInterface.' % schema)
138 self.return_type = CollectionField(
139- value_type=Reference(schema=schema))
140+ value_type=Reference(schema=schema),
141+ range_factory_class=range_factory_class)
142
143 def annotate_method(self, method, annotations):
144 annotations['return_type'] = self.return_type
145@@ -1355,8 +1359,10 @@
146 "version '%s'." % (interface.__name__, version))
147 method_name, params = default_content_by_version[version]
148 entry_schema = tag['collection_entry_schema']
149+ range_factory_class = tag['collection_range_factory_class']
150 class_dict = {
151 'entry_schema' : CollectionEntrySchema(entry_schema),
152+ 'range_factory_class': range_factory_class,
153 'method_name': method_name,
154 'params': params,
155 '__doc__': interface.__doc__,
156
157=== modified file 'src/lazr/restful/docs/webservice-declarations.txt'
158--- src/lazr/restful/docs/webservice-declarations.txt 2018-09-28 15:41:05 +0000
159+++ src/lazr/restful/docs/webservice-declarations.txt 2018-10-02 06:38:17 +0000
160@@ -215,6 +215,7 @@
161 >>> print_export_tag(IBookSet)
162 collection_default_content: {None: ('getAllBooks', {})}
163 collection_entry_schema: <InterfaceClass __builtin__.IBook>
164+ collection_range_factory_class: None
165 type: 'collection'
166
167 >>> print_export_tag(ICheckedOutBookSet)
168@@ -222,6 +223,7 @@
169 {'title': '',
170 'user': <class '...REQUEST_USER'>})}
171 collection_entry_schema: <InterfaceClass __builtin__.IBook>
172+ collection_range_factory_class: None
173 type: 'collection'
174
175 The entry schema for a collection must be provided and must be an
176@@ -231,7 +233,7 @@
177 ... export_as_webservice_collection()
178 Traceback (most recent call last):
179 ...
180- TypeError: export_as_webservice_collection() takes exactly 1
181+ TypeError: export_as_webservice_collection() takes at least 1
182 argument (0 given)
183
184 >>> class InvalidEntrySchema(Interface):
185@@ -355,12 +357,25 @@
186 a single entry; @operation_returns_collection_of is used when the
187 operation returns a collection of entries.
188
189+ >>> from lazr.batchnavigator import ListRangeFactory
190 >>> from lazr.restful.declarations import (
191 ... export_operation_as, export_factory_operation,
192 ... export_read_operation, operation_parameters,
193 ... operation_returns_entry, operation_returns_collection_of,
194 ... rename_parameters_as)
195 >>> from lazr.restful.interface import copy_field
196+
197+ >>> class RecordingRangeFactory(ListRangeFactory):
198+ ... calls = []
199+ ...
200+ ... def getEndpointMemos(self, batch):
201+ ... self.calls.append('getEndpointMemos')
202+ ... return ListRangeFactory.getEndpointMemos(self, batch)
203+ ...
204+ ... def getSlice(self, size, memo, forwards):
205+ ... self.calls.append(('getSlice', size, memo, forwards))
206+ ... return ListRangeFactory.getSlice(self, size, memo, forwards)
207+
208 >>> class IBookSetOnSteroids(IBookSet):
209 ... """IBookSet supporting some methods."""
210 ... export_as_webservice_collection(IBook)
211@@ -375,6 +390,17 @@
212 ...
213 ... @operation_parameters(
214 ... text=copy_field(IBook['title'], title=u'Text to search for.'))
215+ ... @operation_returns_collection_of(
216+ ... IBook, range_factory_class=RecordingRangeFactory)
217+ ... @export_read_operation()
218+ ... def searchBookTitlesCustomRange(text):
219+ ... """Return list of books whose titles contain 'text'.
220+ ...
221+ ... This method uses a custom range factory.
222+ ... """
223+ ...
224+ ... @operation_parameters(
225+ ... text=copy_field(IBook['title'], title=u'Text to search for.'))
226 ... @operation_returns_entry(IBook)
227 ... @export_read_operation()
228 ... def bestMatch(text):
229@@ -442,6 +468,22 @@
230 params: {'text': <...TextLine...>}
231 return_type: <lazr.restful.fields.CollectionField object...>
232 type: 'read_operation'
233+ >>> print IBookSetOnSteroids['searchBookTitles'].getTaggedValue(
234+ ... 'lazr.restful.exported')['return_type'].range_factory_class
235+ None
236+
237+The 'searchBookTitlesCustomRange' method is just the same, except that it
238+uses a custom range factory.
239+
240+ >>> print_export_tag(IBookSetOnSteroids['searchBookTitlesCustomRange'])
241+ as: 'searchBookTitlesCustomRange'
242+ call_with: {}
243+ params: {'text': <...TextLine...>}
244+ return_type: <lazr.restful.fields.CollectionField object...>
245+ type: 'read_operation'
246+ >>> print IBookSetOnSteroids['searchBookTitlesCustomRange'].getTaggedValue(
247+ ... 'lazr.restful.exported')['return_type'].range_factory_class
248+ <class 'RecordingRangeFactory'>
249
250 The 'bestMatch' method returns an entry.
251
252@@ -1187,6 +1229,9 @@
253 ... def searchBookTitles(self, text):
254 ... return self.result
255 ...
256+ ... def searchBookTitlesCustomRange(self, text):
257+ ... return self.result
258+ ...
259 ... def new(self, author, base_price, title):
260 ... return Book(author, title, base_price, "unknown")
261
262@@ -2584,7 +2629,7 @@
263
264 >>> print debug_proxy(ProxyFactory(collection_adapter))
265 zope.security._proxy._Proxy (using zope.security.checker.Checker)
266- public: entry_schema, find
267+ public: entry_schema, find, range_factory_class
268
269 >>> print debug_proxy(ProxyFactory(read_method_adapter))
270 zope.security._proxy._Proxy (using zope.security.checker.Checker)
271
272=== modified file 'src/lazr/restful/docs/webservice.txt'
273--- src/lazr/restful/docs/webservice.txt 2018-10-01 08:55:53 +0000
274+++ src/lazr/restful/docs/webservice.txt 2018-10-02 06:38:17 +0000
275@@ -609,13 +609,27 @@
276 ... singular="recipe", plural="recipes",
277 ... publish_web_link=True))
278
279+ >>> from lazr.batchnavigator import ListRangeFactory
280+ >>> class RecordingRangeFactory(ListRangeFactory):
281+ ... calls = []
282+ ...
283+ ... def getEndpointMemos(self, batch):
284+ ... self.calls.append('getEndpointMemos')
285+ ... return ListRangeFactory.getEndpointMemos(self, batch)
286+ ...
287+ ... def getSlice(self, size, memo, forwards):
288+ ... self.calls.append(('getSlice', size, memo, forwards))
289+ ... return ListRangeFactory.getSlice(self, size, memo, forwards)
290+
291 >>> from lazr.restful.fields import ReferenceChoice
292 >>> class ICookbookEntry(IEntry):
293 ... name = TextLine(title=u"Name", required=True)
294 ... cuisine = TextLine(title=u"Cuisine", required=False, default=None)
295 ... author = ReferenceChoice(
296 ... schema=IAuthor, vocabulary=AuthorVocabulary())
297- ... recipes = CollectionField(value_type=Reference(schema=IRecipe))
298+ ... recipes = CollectionField(
299+ ... value_type=Reference(schema=IRecipe),
300+ ... range_factory_class=RecordingRangeFactory)
301 ... comments = CollectionField(value_type=Reference(schema=IComment))
302 ... cover = Bytes(0, 5000, title=u"An image of the cookbook's cover.")
303 ... taggedValue(
304@@ -785,10 +799,12 @@
305 ... """
306 ...
307 ... entry_schema = ICookbookEntry
308+ ... range_factory_class = RecordingRangeFactory
309 ...
310 ... def find(self):
311- ... """Find all the cookbooks."""
312- ... return self.context.getAll()
313+ ... """Find all the cookbooks."""
314+ ... return self.context.getAll()
315+
316 >>> sm.registerAdapter(CookbookCollection,
317 ... (ICookbookSet, IWebServiceClientRequest),
318 ... provided=ICollection)
319@@ -1190,6 +1206,8 @@
320 But if we ask for a page size of two, we can see how pagination
321 works. Here's page one, with two cookbooks on it.
322
323+ >>> RecordingRangeFactory.calls = []
324+
325 >>> request = create_web_service_request(
326 ... '/beta/cookbooks', environ={'QUERY_STRING' : 'ws.size=2'})
327 >>> collection = request.traverse(app)
328@@ -1222,6 +1240,36 @@
329 >>> len(representation['entries'])
330 1
331
332+Serving this collection uses the custom range factory.
333+
334+ >>> 'getEndpointMemos' in RecordingRangeFactory.calls
335+ True
336+
337+Similarly, serving a scoped collection uses the custom range factory, if one
338+is defined.
339+
340+ >>> RecordingRangeFactory.calls = []
341+
342+ >>> request = create_web_service_request(
343+ ... '/beta/cookbooks/The Joy of Cooking/recipes',
344+ ... environ={'QUERY_STRING': 'ws.size=1'})
345+ >>> collection = request.traverse(app)
346+ >>> representation = load_json(collection())
347+
348+ >>> sorted(representation.keys())
349+ [u'entries', u'next_collection_link', u'resource_type_link',
350+ u'start', u'total_size']
351+ >>> representation['next_collection_link']
352+ u'http://api.cookbooks.dev/beta/cookbooks/The%20Joy%20of%20Cooking/recipes?ws.size=1&memo=1&ws.start=1'
353+ >>> len(representation['entries'])
354+ 1
355+ >>> representation['total_size']
356+ 2
357+
358+ >>> 'getEndpointMemos' in RecordingRangeFactory.calls
359+ True
360+
361+
362 Custom operations
363 =================
364
365
366=== modified file 'src/lazr/restful/fields.py'
367--- src/lazr/restful/fields.py 2016-02-17 01:07:21 +0000
368+++ src/lazr/restful/fields.py 2018-10-02 06:38:17 +0000
369@@ -25,7 +25,7 @@
370 # has a _type of list, and we don't want to have to implement list
371 # semantics for this class.
372
373- def __init__(self, *args, **kwargs):
374+ def __init__(self, range_factory_class=None, *args, **kwargs):
375 """A generic collection field.
376
377 The readonly property defaults to True since these fields are usually
378@@ -34,6 +34,7 @@
379 """
380 kwargs.setdefault('readonly', True)
381 super(CollectionField, self).__init__(*args, **kwargs)
382+ self.range_factory_class = range_factory_class
383
384
385 @implementer(IReference)
386
387=== modified file 'src/lazr/restful/interfaces/_fields.py'
388--- src/lazr/restful/interfaces/_fields.py 2011-01-19 22:29:32 +0000
389+++ src/lazr/restful/interfaces/_fields.py 2018-10-02 06:38:17 +0000
390@@ -34,6 +34,11 @@
391 All iterables satisfy this collection field.
392 """
393
394+ range_factory_class = Attribute(
395+ "A class implementing IRangeFactory to be used to adapt results "
396+ "for this collection field, or None.")
397+
398+
399 class IReference(IObject):
400 """A reference to an object providing a particular schema.
401
402
403=== modified file 'src/lazr/restful/interfaces/_rest.py'
404--- src/lazr/restful/interfaces/_rest.py 2011-03-31 01:13:59 +0000
405+++ src/lazr/restful/interfaces/_rest.py 2018-10-02 06:38:17 +0000
406@@ -224,6 +224,9 @@
407 """A collection, driven by an ICollectionResource."""
408
409 entry_schema = Attribute("The schema for this collection's entries.")
410+ range_factory_class = Attribute(
411+ "A class implementing IRangeFactory to be used to adapt results "
412+ "for this collection, or None.")
413
414 def find():
415 """Retrieve all entries in the collection under the given scope.

Subscribers

People subscribed via source and target branches