Merge lp:~cjwatson/lazr.restful/scopes into lp:lazr.restful

Proposed by Colin Watson
Status: Merged
Merged at revision: 305
Proposed branch: lp:~cjwatson/lazr.restful/scopes
Merge into: lp:lazr.restful
Diff against target: 619 lines (+377/-9)
8 files modified
NEWS.rst (+9/-0)
src/lazr/restful/declarations.py (+62/-5)
src/lazr/restful/docs/webservice-declarations.rst (+205/-2)
src/lazr/restful/interfaces/_rest.py (+20/-0)
src/lazr/restful/tales.py (+7/-1)
src/lazr/restful/testing/webservice.py (+15/-0)
src/lazr/restful/tests/test_declarations.py (+32/-0)
src/lazr/restful/tests/test_webservice.py (+27/-1)
To merge this branch: bzr merge lp:~cjwatson/lazr.restful/scopes
Reviewer Review Type Date Requested Status
William Grant code Approve
Ioana Lasc (community) Approve
Cristian Gonzalez (community) Approve
Review via email: mp+409735@code.launchpad.net

Commit message

Add a new @scoped decorator.

Description of the change

This allows applications to tag methods with scope names and issue authentication tokens constrained to only be able to call methods with particular scopes. Scoped requests cannot currently use attributes, accessors, or mutators; this may change in future.

Launchpad will use this in conjunction with its new `AccessToken` table.

To post a comment you must log in.
Revision history for this message
Cristian Gonzalez (cristiangsp) wrote :

Looks good. Also nice to see the documentation included. Good job!

review: Approve
Revision history for this message
Ioana Lasc (ilasc) wrote :

LGTM!

review: Approve
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'NEWS.rst'
2--- NEWS.rst 2021-09-13 15:23:15 +0000
3+++ NEWS.rst 2021-10-06 10:26:55 +0000
4@@ -2,6 +2,15 @@
5 NEWS for lazr.restful
6 =====================
7
8+1.1.0
9+=====
10+
11+- Add a new ``@scoped`` decorator to ``lazr.restful.declarations``, allowing
12+ applications to tag methods with scope names and issue authentication
13+ tokens constrained to only be able to call methods with particular scopes.
14+ Scoped requests cannot currently use attributes, accessors, or mutators;
15+ this may change in future.
16+
17 1.0.4 (2021-09-13)
18 ==================
19
20
21=== modified file 'src/lazr/restful/declarations.py'
22--- src/lazr/restful/declarations.py 2021-02-16 16:51:35 +0000
23+++ src/lazr/restful/declarations.py 2021-10-06 10:26:55 +0000
24@@ -40,6 +40,7 @@
25 'operation_returns_entry',
26 'operation_returns_collection_of',
27 'rename_parameters_as',
28+ 'scoped',
29 'webservice_error',
30 ]
31
32@@ -1059,6 +1060,28 @@
33 annotations['cache_for'] = self.duration
34
35
36+class scoped(_method_annotator):
37+ """Decorator assigning scopes to a method.
38+
39+ This may be used to grant authentication tokens that are only valid for
40+ certain webservice operations.
41+
42+ The decorator takes a collection of scope names as positional arguments.
43+ """
44+
45+ def __init__(self, *scopes):
46+ for scope in scopes:
47+ if not isinstance(scope, six.string_types):
48+ raise TypeError(
49+ 'Scope should be a string type, not %s' %
50+ scope.__class__.__name__)
51+ self.scopes = scopes
52+
53+ def annotate_method(self, method, annotations):
54+ """See `_method_annotator`."""
55+ annotations['scopes'] = list(self.scopes)
56+
57+
58 class export_read_operation(_export_operation):
59 """Decorator marking a method for export as a read operation."""
60 type = 'read_operation'
61@@ -1315,7 +1338,7 @@
62 orig_name, 'context', accessor,
63 accessor_annotations, orig_iface)
64 else:
65- prop = Passthrough(orig_name, 'context', orig_iface)
66+ prop = _ScopeChecker(orig_name, 'context', orig_iface)
67
68 adapter_dict[tags['as']] = prop
69
70@@ -1359,11 +1382,40 @@
71 return params
72
73
74+def _check_request(context, required_scopes):
75+ """Check whether the current request may call a particular method.
76+
77+ See `IWebServiceConfiguration.checkRequest`.
78+ """
79+ check_request = getattr(
80+ getUtility(IWebServiceConfiguration), 'checkRequest', None)
81+ if check_request is not None:
82+ check_request(context, required_scopes)
83+
84+
85+class _ScopeChecker(Passthrough):
86+ """Check scopes before allowing access to properties."""
87+
88+ def __get__(self, inst, cls=None):
89+ context = getattr(inst, self.contextvar)
90+ if self.adaptation is not None:
91+ context = self.adaptation(context)
92+ _check_request(context, None)
93+ return super(_ScopeChecker, self).__get__(inst, cls=cls)
94+
95+ def __set__(self, inst, value):
96+ context = getattr(inst, self.contextvar)
97+ if self.adaptation is not None:
98+ context = self.adaptation(context)
99+ _check_request(context, None)
100+ return super(_ScopeChecker, self).__set__(inst, value)
101+
102+
103 class _AccessorWrapper:
104 """A wrapper class for properties with accessors.
105
106 We define this separately from PropertyWithAccessor and
107- PropertyWithAccessorAndMutator to avoid multple inheritance issues.
108+ PropertyWithAccessorAndMutator to avoid multiple inheritance issues.
109 """
110
111 def __get__(self, obj, *args):
112@@ -1373,6 +1425,7 @@
113 context = getattr(obj, self.contextvar)
114 if self.adaptation is not None:
115 context = self.adaptation(context)
116+ _check_request(context, None)
117 # Error checking code in accessor_for() guarantees that there
118 # is one and only one non-fixed parameter for the accessor
119 # method.
120@@ -1383,7 +1436,7 @@
121 """A wrapper class for properties with mutators.
122
123 We define this separately from PropertyWithMutator and
124- PropertyWithAccessorAndMutator to avoid multple inheritance issues.
125+ PropertyWithAccessorAndMutator to avoid multiple inheritance issues.
126 """
127
128 def __set__(self, obj, new_value):
129@@ -1393,13 +1446,14 @@
130 context = getattr(obj, self.contextvar)
131 if self.adaptation is not None:
132 context = self.adaptation(context)
133+ _check_request(context, None)
134 # Error checking code in mutator_for() guarantees that there
135 # is one and only one non-fixed parameter for the mutator
136 # method.
137 getattr(context, self.mutator)(new_value, **params)
138
139
140-class PropertyWithAccessor(_AccessorWrapper, Passthrough):
141+class PropertyWithAccessor(_AccessorWrapper, _ScopeChecker):
142 """A property with a accessor method."""
143
144 def __init__(self, name, context, accessor, accessor_annotations,
145@@ -1409,7 +1463,7 @@
146 self.accessor_annotations = accessor_annotations
147
148
149-class PropertyWithMutator(_MutatorWrapper, Passthrough):
150+class PropertyWithMutator(_MutatorWrapper, _ScopeChecker):
151 """A property with a mutator method."""
152
153 def __init__(self, name, context, mutator, mutator_annotations,
154@@ -1542,6 +1596,7 @@
155 'Cache-control', 'max-age=%i'
156 % self._export_info['cache_for'])
157
158+ _check_request(self.context, self._export_info.get('scopes', []))
159 result = self._getMethod()(**params)
160 return self.encodeResult(result)
161
162@@ -1613,6 +1668,7 @@
163 raise AssertionError('Unknown method export type: %s' % operation_type)
164
165 return_type = match['return_type']
166+ scopes = match.get('scopes') or []
167
168 name = _versioned_class_name(
169 '%s_%s_%s' % (prefix, method.interface.__name__, match['as']),
170@@ -1620,6 +1676,7 @@
171 class_dict = {
172 'params': tuple(match['params'].values()),
173 'return_type': return_type,
174+ 'scopes': tuple(scopes),
175 '_orig_iface': method.interface,
176 '_export_info': match,
177 '_method_name': method.__name__,
178
179=== modified file 'src/lazr/restful/docs/webservice-declarations.rst'
180--- src/lazr/restful/docs/webservice-declarations.rst 2021-02-16 16:51:35 +0000
181+++ src/lazr/restful/docs/webservice-declarations.rst 2021-10-06 10:26:55 +0000
182@@ -836,15 +836,32 @@
183 utilities providing basic information about the web service. This one
184 is just a dummy.
185
186- >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration
187 >>> from zope.component import provideUtility
188+ >>> from zope.security.interfaces import Unauthorized
189 >>> from lazr.restful.interfaces import IWebServiceConfiguration
190+ >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration
191 >>> class MyWebServiceConfiguration(TestWebServiceConfiguration):
192 ... active_versions = ["beta", "1.0", "2.0", "3.0"]
193 ... last_version_with_mutator_named_operations = "1.0"
194 ... first_version_with_total_size_link = "2.0"
195 ... code_revision = "1.0b"
196 ... default_batch_size = 50
197+ ... _scopes = None
198+ ...
199+ ... def checkRequest(self, obj, required_scopes):
200+ ... if self._scopes is not None:
201+ ... if not required_scopes:
202+ ... raise Unauthorized(
203+ ... 'Current authentication only allows calling '
204+ ... 'scoped methods.')
205+ ... elif not any(
206+ ... scope in required_scopes
207+ ... for scope in self._scopes):
208+ ... raise Unauthorized(
209+ ... 'Current authentication does not allow calling '
210+ ... 'this method (one of these scopes is required: '
211+ ... '%s).' % ', '.join(
212+ ... "'%s'" % scope for scope in required_scopes))
213 >>> provideUtility(MyWebServiceConfiguration(), IWebServiceConfiguration)
214
215 We must also set up the ability to create versioned requests. This web
216@@ -1540,6 +1557,192 @@
217 TypeError: A field can only have one mutator method for version
218 (earliest version); set_value_2 makes two.
219
220+Scopes
221+------
222+
223+A method can be tagged with a list of scope names. If the user has
224+authenticated in such a way as to limit their access to particular scopes
225+(indicated by `IWebServiceConfiguration.checkRequest()`), then they can only
226+call methods that declare at least one of the corresponding scopes.
227+
228+ >>> from lazr.restful.declarations import scoped
229+ >>> from zope.component import getUtility
230+
231+ >>> @exported_as_webservice_entry()
232+ ... class IScopedEntry(Interface):
233+ ...
234+ ... value = exported(TextLine(readonly=False))
235+ ...
236+ ... @scoped('read')
237+ ... @export_read_operation()
238+ ... def get_info():
239+ ... pass
240+ ...
241+ ... @scoped('update')
242+ ... @export_write_operation()
243+ ... def do_update():
244+ ... pass
245+ ...
246+ ... @scoped('read', 'update')
247+ ... @export_write_operation()
248+ ... def multiple_scopes():
249+ ... pass
250+ ...
251+ ... @export_write_operation()
252+ ... def unscoped():
253+ ... pass
254+
255+ >>> @implementer(IScopedEntry)
256+ ... class ScopedEntry(object):
257+ ...
258+ ... value = 'initial'
259+ ...
260+ ... def get_info(self):
261+ ... print('get_info called')
262+ ...
263+ ... def do_update(self):
264+ ... print('do_update called')
265+ ...
266+ ... def multiple_scopes(self):
267+ ... print('multiple_scopes called')
268+ ...
269+ ... def unscoped(self):
270+ ... print('unscoped called')
271+
272+ >>> [(version, scoped_entry_interface)] = generate_entry_interfaces(
273+ ... IScopedEntry, [], 'beta')
274+ >>> scoped_entry_adapter_factory = generate_entry_adapters(
275+ ... IScopedEntry, [], [(version, scoped_entry_interface)])[0].object
276+
277+ >>> get_info_method_adapter_factory = generate_operation_adapter(
278+ ... IScopedEntry['get_info'])
279+ >>> IResourceGETOperation.implementedBy(get_info_method_adapter_factory)
280+ True
281+ >>> do_update_method_adapter_factory = generate_operation_adapter(
282+ ... IScopedEntry['do_update'])
283+ >>> IResourcePOSTOperation.implementedBy(do_update_method_adapter_factory)
284+ True
285+ >>> multiple_scopes_method_adapter_factory = generate_operation_adapter(
286+ ... IScopedEntry['multiple_scopes'])
287+ >>> IResourcePOSTOperation.implementedBy(
288+ ... multiple_scopes_method_adapter_factory)
289+ True
290+ >>> unscoped_method_adapter_factory = generate_operation_adapter(
291+ ... IScopedEntry['unscoped'])
292+ >>> IResourcePOSTOperation.implementedBy(unscoped_method_adapter_factory)
293+ True
294+
295+ >>> obj = ScopedEntry()
296+ >>> request = FakeRequest(version='beta')
297+ >>> scoped_entry_adapter = scoped_entry_adapter_factory(obj, request)
298+ >>> get_info_method_adapter = (
299+ ... get_info_method_adapter_factory(obj, request))
300+ >>> do_update_method_adapter = (
301+ ... do_update_method_adapter_factory(obj, request))
302+ >>> multiple_scopes_method_adapter = (
303+ ... multiple_scopes_method_adapter_factory(obj, request))
304+ >>> unscoped_method_adapter = (
305+ ... unscoped_method_adapter_factory(obj, request))
306+
307+A user with unscoped authentication can call any method, and get or set
308+attributes.
309+
310+ >>> _ = get_info_method_adapter.call()
311+ get_info called
312+ >>> _ = do_update_method_adapter.call()
313+ do_update called
314+ >>> _ = multiple_scopes_method_adapter.call()
315+ multiple_scopes called
316+ >>> _ = unscoped_method_adapter.call()
317+ unscoped called
318+ >>> print(scoped_entry_adapter.value)
319+ initial
320+ >>> scoped_entry_adapter.value = 'set by unscoped user'
321+
322+A user with both scopes can call any method tagged with either scope, but
323+can neither get nor set attributes.
324+
325+ >>> config = getUtility(IWebServiceConfiguration)
326+ >>> config._scopes = ['read', 'update']
327+ >>> _ = get_info_method_adapter.call()
328+ get_info called
329+ >>> _ = do_update_method_adapter.call()
330+ do_update called
331+ >>> _ = multiple_scopes_method_adapter.call()
332+ multiple_scopes called
333+ >>> _ = unscoped_method_adapter.call()
334+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
335+ Traceback (most recent call last):
336+ ...
337+ zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
338+ >>> print(scoped_entry_adapter.value)
339+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
340+ Traceback (most recent call last):
341+ ...
342+ zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
343+ >>> scoped_entry_adapter.value = 'set by scoped user'
344+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
345+ Traceback (most recent call last):
346+ ...
347+ zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
348+
349+A user with one scope can only call the methods tagged with that scope, and
350+can neither get nor set attributes.
351+
352+ >>> config._scopes = ['read']
353+ >>> _ = get_info_method_adapter.call()
354+ get_info called
355+ >>> _ = do_update_method_adapter.call()
356+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
357+ Traceback (most recent call last):
358+ ...
359+ zope.security.interfaces.Unauthorized: Current authentication does not allow calling this method (one of these scopes is required: 'update').
360+ >>> _ = multiple_scopes_method_adapter.call()
361+ multiple_scopes called
362+ >>> _ = unscoped_method_adapter.call()
363+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
364+ Traceback (most recent call last):
365+ ...
366+ zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
367+ >>> print(scoped_entry_adapter.value)
368+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
369+ Traceback (most recent call last):
370+ ...
371+ zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
372+ >>> scoped_entry_adapter.value = 'set by scoped user'
373+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
374+ Traceback (most recent call last):
375+ ...
376+ zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
377+
378+ >>> config._scopes = ['update']
379+ >>> _ = get_info_method_adapter.call()
380+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
381+ Traceback (most recent call last):
382+ ...
383+ zope.security.interfaces.Unauthorized: Current authentication does not allow calling this method (one of these scopes is required: 'read').
384+ >>> _ = do_update_method_adapter.call()
385+ do_update called
386+ >>> _ = multiple_scopes_method_adapter.call()
387+ multiple_scopes called
388+ >>> _ = unscoped_method_adapter.call()
389+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
390+ Traceback (most recent call last):
391+ ...
392+ zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
393+ >>> print(scoped_entry_adapter.value)
394+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
395+ Traceback (most recent call last):
396+ ...
397+ zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
398+ >>> scoped_entry_adapter.value = 'set by scoped user'
399+ ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
400+ Traceback (most recent call last):
401+ ...
402+ zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
403+
404+ >>> config._scopes = None
405+
406 Read-only fields
407 ----------------
408
409@@ -2593,7 +2796,7 @@
410 IResourceOperation adapters named under the exported method names
411 are also available for IBookSetOnSteroids and IBookOnSteroids.
412
413- >>> from zope.component import getGlobalSiteManager, getUtility
414+ >>> from zope.component import getGlobalSiteManager
415 >>> adapter_registry = getGlobalSiteManager().adapters
416
417 >>> from lazr.restful.interfaces import IWebServiceClientRequest
418
419=== modified file 'src/lazr/restful/interfaces/_rest.py'
420--- src/lazr/restful/interfaces/_rest.py 2020-02-04 11:52:59 +0000
421+++ src/lazr/restful/interfaces/_rest.py 2021-10-06 10:26:55 +0000
422@@ -652,6 +652,26 @@
423 value will be fed back into your code.
424 """
425
426+ def checkRequest(context, required_scopes):
427+ """Check whether the current request may call a particular method.
428+
429+ Authenticated users may be limited to certain scopes, in which case
430+ they will only be able to use methods with corresponding `@scoped`
431+ decorators. This method is called to check whether a call to a
432+ method on `context` tagged with `required_scopes` should be allowed.
433+ The return value is ignored; it only matters whether this raises a
434+ `zope.security.interfaces.Unauthorized` exception.
435+
436+ For compatibility, if this method is unimplemented, it is treated as
437+ if it did not raise an exception.
438+
439+ :param context: The context object.
440+ :param required_scopes: A list of scope names for this method, or
441+ None if the method is unscoped.
442+ :raises zope.security.interfaces.Unauthorized: if the call should
443+ not be allowed.
444+ """
445+
446
447 class IUnmarshallingDoesntNeedValue(Interface):
448 """A marker interface for unmarshallers that work without values.
449
450=== modified file 'src/lazr/restful/tales.py'
451--- src/lazr/restful/tales.py 2020-07-22 23:22:26 +0000
452+++ src/lazr/restful/tales.py 2021-10-06 10:26:55 +0000
453@@ -804,7 +804,13 @@
454 @property
455 def doc(self):
456 """Human-readable documentation for this operation."""
457- return generate_wadl_doc(self.operation.__doc__)
458+ docstring = self.operation.__doc__
459+ # Hack scope information into the docstring for now.
460+ scopes = getattr(self.operation, 'scopes', None)
461+ if scopes:
462+ docstring += '\n\nScopes: %s\n' % (
463+ ', '.join("``%s``" % scope for scope in scopes))
464+ return generate_wadl_doc(docstring)
465
466 @property
467 def has_return_type(self):
468
469=== modified file 'src/lazr/restful/testing/webservice.py'
470--- src/lazr/restful/testing/webservice.py 2021-05-20 20:44:54 +0000
471+++ src/lazr/restful/testing/webservice.py 2021-10-06 10:26:55 +0000
472@@ -47,6 +47,7 @@
473 from zope.proxy import ProxyBase
474 from zope.schema import TextLine
475 from zope.security.checker import ProxyFactory
476+from zope.security.interfaces import Unauthorized
477 from zope.testing.cleanup import CleanUp
478 from zope.traversing.browser.interfaces import IAbsoluteURL
479
480@@ -565,6 +566,7 @@
481 active_versions = ['1.0', '2.0']
482 hostname = "webservice_test"
483 last_version_with_mutator_named_operations = None
484+ _scopes = None
485
486 def createRequest(self, body_instream, environ):
487 request = Request(body_instream, environ)
488@@ -573,6 +575,19 @@
489 tag_request_with_version_name(request, '2.0')
490 return request
491
492+ def checkRequest(self, obj, required_scopes):
493+ if self._scopes is not None:
494+ if not required_scopes:
495+ raise Unauthorized(
496+ 'Current authentication only allows calling '
497+ 'scoped methods.')
498+ elif not any(scope in required_scopes for scope in self._scopes):
499+ raise Unauthorized(
500+ 'Current authentication does not allow calling '
501+ 'this method (one of these scopes is required: '
502+ '%s).'
503+ % ', '.join("'%s'" % scope for scope in required_scopes))
504+
505
506 class IWebServiceTestRequest10(IWebServiceClientRequest):
507 """A marker interface for requests to the '1.0' web service."""
508
509=== modified file 'src/lazr/restful/tests/test_declarations.py'
510--- src/lazr/restful/tests/test_declarations.py 2020-07-22 23:22:26 +0000
511+++ src/lazr/restful/tests/test_declarations.py 2021-10-06 10:26:55 +0000
512@@ -28,6 +28,7 @@
513 MultiChecker,
514 ProxyFactory,
515 )
516+from zope.security.interfaces import Unauthorized
517 from zope.security.management import (
518 endInteraction,
519 newInteraction,
520@@ -339,6 +340,37 @@
521 self.assertEqual(
522 'product', EntryAdapterUtility(adapter.__class__).singular_type)
523
524+ def test_accessor_for_with_scopes(self):
525+ # Users with scopes cannot use accessors.
526+ self.product._branches = [
527+ Branch('A branch'), Branch('Another branch')]
528+ register_test_module('testmod', IBranch, IProduct, IHasBranches)
529+ config = getUtility(IWebServiceConfiguration)
530+ config._scopes = ['scope']
531+ self.addCleanup(setattr, config, '_scopes', None)
532+ adapter = getMultiAdapter(
533+ (self.product, self.one_zero_request), IEntry)
534+ exception = self.assertRaises(
535+ Unauthorized, getattr, adapter, 'branches')
536+ self.assertEqual(
537+ 'Current authentication only allows calling scoped methods.',
538+ str(exception))
539+
540+ def test_mutator_for_with_scopes(self):
541+ # Users with scopes cannot use mutators.
542+ self.product._dev_branch = Branch('A product branch')
543+ register_test_module('testmod', IBranch, IProduct, IHasBranches)
544+ config = getUtility(IWebServiceConfiguration)
545+ config._scopes = ['scope']
546+ self.addCleanup(setattr, config, '_scopes', None)
547+ adapter = getMultiAdapter(
548+ (self.product, self.two_zero_request), IEntry)
549+ exception = self.assertRaises(
550+ Unauthorized, setattr, adapter, 'development_branch_20', None)
551+ self.assertEqual(
552+ 'Current authentication only allows calling scoped methods.',
553+ str(exception))
554+
555
556 class TestExportAsWebserviceEntry(testtools.TestCase):
557 """Tests for export_as_webservice_entry."""
558
559=== modified file 'src/lazr/restful/tests/test_webservice.py'
560--- src/lazr/restful/tests/test_webservice.py 2021-01-21 00:36:11 +0000
561+++ src/lazr/restful/tests/test_webservice.py 2021-10-06 10:26:55 +0000
562@@ -61,9 +61,11 @@
563 ResourceGETOperation,
564 )
565 from lazr.restful.declarations import (
566+ export_read_operation,
567 exported,
568 exported_as_webservice_entry,
569 LAZR_WEBSERVICE_NAME,
570+ scoped,
571 )
572 from lazr.restful.testing.webservice import (
573 create_web_service_request,
574@@ -667,13 +669,20 @@
575 class WadlAPITestCase(WebServiceTestCase):
576 """Test the docstring generation."""
577
578+ @exported_as_webservice_entry()
579+ class IScopedEntry(Interface):
580+ @scoped('test-scope')
581+ @export_read_operation()
582+ def test():
583+ """A method with a scope."""
584+
585 # This one is used to test when docstrings are missing.
586 @exported_as_webservice_entry()
587 class IUndocumentedEntry(Interface):
588 a_field = exported(TextLine())
589
590 testmodule_objects = [
591- IGenericEntry, IGenericCollection, IUndocumentedEntry]
592+ IGenericEntry, IGenericCollection, IScopedEntry, IUndocumentedEntry]
593
594 def test_wadl_field_type(self):
595 """Test the generated XSD field types for various fields."""
596@@ -747,6 +756,23 @@
597 self.assertTrue(len(doclines) > 3,
598 'Missing the parameter table: %s' % "\n".join(doclines))
599
600+ def test_wadl_operation_with_scopes_doc(self):
601+ """Test the wadl:doc generated for an operation adapter."""
602+ operation = get_operation_factory(self.IScopedEntry, 'test')
603+ doclines = test_tales(
604+ 'operation/wadl_operation:doc', operation=operation).splitlines()
605+ # Only compare the first three lines and the last one.
606+ # we dont care about the formatting of the parameters table.
607+ self.assertEqual([
608+ '<wadl:doc xmlns="http://www.w3.org/1999/xhtml">',
609+ '<p>A method with a scope.</p>',
610+ '<p>Scopes: <tt class="rst-docutils literal"><span class="pre">'
611+ 'test-scope</span></tt></p>',
612+ ], doclines[0:3])
613+ self.assertEqual('</wadl:doc>', doclines[-1])
614+ self.assertTrue(len(doclines) > 3,
615+ 'Missing the parameter table: %s' % "\n".join(doclines))
616+
617
618 class DuplicateNameTestCase(WebServiceTestCase):
619 """Test AssertionError when two resources expose the same name.

Subscribers

People subscribed via source and target branches