Merge lp:~benji/charmworld/update-bundle-api into lp:~juju-jitsu/charmworld/trunk

Proposed by Benji York
Status: Merged
Approved by: Benji York
Approved revision: 352
Merged at revision: 353
Proposed branch: lp:~benji/charmworld/update-bundle-api
Merge into: lp:~juju-jitsu/charmworld/trunk
Diff against target: 445 lines (+225/-69)
6 files modified
charmworld/models.py (+37/-6)
charmworld/testing/factory.py (+3/-5)
charmworld/tests/test_models.py (+71/-25)
charmworld/views/api.py (+25/-12)
charmworld/views/tests/test_api.py (+81/-21)
docs/api.rst (+8/-0)
To merge this branch: bzr merge lp:~benji/charmworld/update-bundle-api
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Approve
Review via email: mp+180384@code.launchpad.net

Commit message

Add metadata to the bundle API endpoint

Description of the change

Add metadata to the bundle API endpoint

To post a comment you must log in.
Revision history for this message
Aaron Bentley (abentley) wrote :

This looks fine.

If we're generalizing the way we retrieve charms in from_query, I'd prefer to accept a collection, rather than a database. (i.e. 'db.bundles', not 'db'.) That supports temporary collections, like we use in exodus migrations.

I expect we will eventually want to operate at a higher level, e.g. BundleSource.get_bundle(name, basket, revision, promulgated=True). At that point, the code will ensure the queries are not overbroad.

You're changing the contract of json_response, so that it no longer will support lists, strings, or other non-dict values. I'd prefer you didn't do that, but if you do, please change the docs so they don't say just "json-serializable value".

At 188, 'overbroad' is misspelled, and at 197, "convenient".

Thanks for spelling out the precise JSON that the API will return. I'm not sure we need all of it, but it's pre-existing, so that's okay.

It would be nice to update the API docs, but we can do that in another branch.

None of these are blockers, but please address json_response one way or the other.

review: Approve
Revision history for this message
Benji York (benji) wrote :

On Thu, Aug 15, 2013 at 2:28 PM, Aaron Bentley <email address hidden> wrote:
> Review: Approve

Thanks for the review.

> This looks fine.
>
> If we're generalizing the way we retrieve charms in from_query, I'd
> prefer to accept a collection, rather than a database. (i.e.
> 'db.bundles', not 'db'.) That supports temporary collections, like we
> use in exodus migrations.

The reason I took "db" as the parameter is that I wanted to hide the
knowledge of which collection holds the bundles in the Bundles class
instead of having it in all the places that load bundles. However I
wasn't aware of the other use case for specifying the collection.

After thinking it over and trying a couple of different things, I added
an optional "collection" parameter to from_query so any special-purpose
code that needs to operate over a particular collection can do so while
most code won't have to know where the bundles are stored. If we don't
end up liking this approach we can change it later.

> You're changing the contract of json_response, so that it no longer
> will support lists, strings, or other non-dict values. I'd prefer you
> didn't do that, but if you do, please change the docs so they don't
> say just "json-serializable value".

That's a good catch. I have changed json_response so that if an object
instance is passed in, it is "cast" to dict() and serialized to JSON.

> At 188, 'overbroad' is misspelled, and at 197, "convenient".

Thanks. I am not the best speller.

> It would be nice to update the API docs, but we can do that in another branch.

I didn't even know we had API docs. :) I added the beginnings of bundle
documentation, but we could use more. It would be nice if the docs were
tested so we will know if the code and docs ever get out of sync. Maybe
we can do that down the road.
--
Benji York

Revision history for this message
Charmworld Lander (charmworld-lander) wrote :

No commit message specified.

Revision history for this message
Charmworld Lander (charmworld-lander) wrote :

No commit message specified.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'charmworld/models.py'
2--- charmworld/models.py 2013-08-15 19:57:31 +0000
3+++ charmworld/models.py 2013-08-16 14:42:35 +0000
4@@ -1259,7 +1259,6 @@
5 'owner': '',
6 'basket': None,
7 'name': '',
8- 'inherits': None,
9 'title': '',
10 'description': '',
11 'data': dict(),
12@@ -1268,16 +1267,37 @@
13 }
14
15 def __init__(self, data):
16+ assert 'inherits' not in data, (
17+ 'inheriting from other bundles is not supported, the bundle '
18+ 'must be preprocessed to incorporate any inheritance')
19 self._raw_representation = data
20 self._representation = dict(self._COMMON_REPRESENTATION)
21 self._representation.update(data)
22
23+ @classmethod
24+ def from_query(cls, query, db=None, collection=None):
25+ """Given a query and a database or collection, find a bundle.
26+
27+ If the query doesn't match any bundles, None is returned. If the
28+ query matches more than one bundle, an exception is raised.
29+ """
30+ assert db is not None or collection is not None, (
31+ 'either "db" or "collection" must be provided')
32+ assert db is None or collection is None, (
33+ 'only one of "db" or "collection" can be provided')
34+ if collection is None:
35+ collection = db.bundles
36+ cursor = collection.find(query, limit=2)
37+ if cursor.count() > 1:
38+ raise ValueError('more than one matching bundle')
39+ elif cursor.count() == 0:
40+ # No bundles match the query.
41+ return None
42+ return cls(cursor.next())
43+
44 def __eq__(self, other):
45- if (isinstance(other, self.__class__)
46- and self.permanent_url == other.permanent_url):
47- # These two bundles are the same.
48- return True
49- return False
50+ return (isinstance(other, self.__class__)
51+ and self.permanent_url == other.permanent_url)
52
53 def __getattr__(self, name):
54 if name in self._COMMON_REPRESENTATION:
55@@ -1308,6 +1328,17 @@
56 """The permanent url for the bundle."""
57 return "jc:{}".format(self.id)
58
59+ def __iter__(self):
60+ """Iterate over the keys and values of this bundle.
61+
62+ Among other uses, this makes it easy to get a dictionary
63+ representation of a bundle by "casting" with dict(bundle).
64+ """
65+ yield 'id', self.id
66+ yield 'permanent_url', self.permanent_url
67+ for key in self._COMMON_REPRESENTATION:
68+ yield key, self.__getattr__(key)
69+
70
71 def acquire_session_secret(database):
72 """Return a secret to use for AuthTkt sessions.
73
74=== modified file 'charmworld/testing/factory.py'
75--- charmworld/testing/factory.py 2013-08-14 15:12:04 +0000
76+++ charmworld/testing/factory.py 2013-08-16 14:42:35 +0000
77@@ -310,10 +310,9 @@
78 return record_id, charm
79
80
81-def get_bundle_data(name=None, owner=None, basket=None, inherits=None,
82- series='precise', title='', description='',
83- services=dict(), relations=dict(), promulgated=False,
84- branch_deleted=False):
85+def get_bundle_data(name=None, owner=None, basket=None, series='precise',
86+ title='', description='', services=dict(),
87+ relations=dict(), promulgated=False, branch_deleted=False):
88 if name is None:
89 name = random_string(10)
90 if owner is None:
91@@ -327,7 +326,6 @@
92 branch_deleted=branch_deleted,
93 data=data,
94 description=description,
95- inherits=inherits,
96 name=name,
97 owner=owner,
98 promulgated=promulgated,
99
100=== modified file 'charmworld/tests/test_models.py'
101--- charmworld/tests/test_models.py 2013-08-15 19:57:31 +0000
102+++ charmworld/tests/test_models.py 2013-08-16 14:42:35 +0000
103@@ -1603,9 +1603,24 @@
104 self.assertTrue(FauxElasticSearchClient.index_bundles_called)
105
106
107-class BundleTestCase(TestCase):
108+class BundleTestCase(MongoTestBase):
109 """Unit tests for the Bundle model that wraps bundle representations."""
110
111+ bundle_data = {
112+ 'owner': 'sinzui',
113+ 'basket': 'mysql',
114+ 'name': 'tiny',
115+ 'title': 'Tiny bundle',
116+ 'description': 'My Tiny Bundle',
117+ 'data': {
118+ 'services': dict(apache=1),
119+ 'relations': dict(a=1),
120+ 'series': 'series',
121+ },
122+ 'promulgated': True,
123+ 'branch_deleted': True,
124+ }
125+
126 def test_init_with_empty_representation(self):
127 bundle_data = {}
128 bundle = Bundle(bundle_data)
129@@ -1617,7 +1632,6 @@
130 'owner': 'sinzui',
131 'basket': 'mysql',
132 'name': 'tiny',
133- 'inherits': 'main-mysql',
134 'title': 'Tiny bundle',
135 'description': 'My Tiny Bundle',
136 'data': {
137@@ -1638,32 +1652,24 @@
138 bundle.not_a_real_attribute
139
140 def test_is_equal(self):
141- bundle1 = Bundle(
142- {'name': 'tiny',
143- 'owner': 'bac',
144- 'basket': 'apache/1',
145- 'inherits': 'big-apache',
146- })
147- bundle2 = Bundle(
148- {'name': 'tiny',
149- 'owner': 'bac',
150- 'basket': 'apache/1',
151- 'inherits': 'moderately-big-apache',
152- 'branch_deleted': True,
153- })
154+ # If two bundles have the same permanent_url, they are considered
155+ # equal, even if their data differ.
156+ common = {
157+ 'name': 'tiny',
158+ 'owner': 'bac',
159+ 'basket': 'apache/1',
160+ }
161+ bundle1 = Bundle(dict(common, title='Tiny Apache'))
162+ bundle2 = Bundle(dict(common, title='Itty Bitty Apache'))
163 self.assertEqual(bundle1, bundle2)
164
165 def test_not_equal(self):
166- bundle1 = Bundle(
167- {'name': 'tiny',
168- 'owner': 'bac',
169- 'basket': 'apache/4',
170- })
171- bundle2 = Bundle(
172- {'name': 'tiny',
173- 'owner': 'abc',
174- 'basket': 'apache/4',
175- })
176+ common = {
177+ 'name': 'tiny',
178+ 'basket': 'apache/4',
179+ }
180+ bundle1 = Bundle(dict(common, owner='bac'))
181+ bundle2 = Bundle(dict(common, owner='abc'))
182 self.assertNotEqual(bundle1, bundle2)
183
184 def test_id(self):
185@@ -1693,6 +1699,46 @@
186 self.assertEqual(
187 "/bundle/~bac/apache/6/tiny", bundle.short_url)
188
189+ def test_loading_a_bundle_from_the_database(self):
190+ # A bundle can be loaded from the database with a query.
191+ self.db.bundles.save(self.bundle_data)
192+ bundle = Bundle.from_query({'owner': 'sinzui'}, self.db)
193+ self.assertIsNotNone(bundle)
194+ self.assertEqual(bundle.basket, 'mysql')
195+ self.assertEqual(bundle.name, 'tiny')
196+
197+ def test_loading_a_bundle_from_a_given_collection(self):
198+ # A bundle can be loaded from a specific collection.
199+ collection = self.db.create_collection(factory.random_string(10))
200+ collection.save(self.bundle_data)
201+ bundle = Bundle.from_query({'owner': 'sinzui'}, collection=collection)
202+ self.assertIsNotNone(bundle)
203+ self.assertEqual(bundle.basket, 'mysql')
204+ self.assertEqual(bundle.name, 'tiny')
205+
206+ def test_loading_a_nonexistent_bundle_returns_none(self):
207+ # If a query does not match any bundles, None is returned.
208+ bundle = Bundle.from_query({'owner': 'nunyas'}, self.db)
209+ self.assertIsNone(bundle)
210+
211+ def test_overbroad_query_raises_error(self):
212+ # If a query matches multiple bundles, an exception is raised.
213+ self.db.bundles.save({})
214+ self.db.bundles.save({})
215+ with self.assertRaises(Exception):
216+ Bundle.from_query({}, self.db)
217+
218+ def test_bundles_are_iterable(self):
219+ # When a bundle is iterated over it generates a series of key, value
220+ # pairs. This is convenient for generating a dictionary from a
221+ # bundle (e.g., dict(bundle)).
222+ bundle = Bundle({'name': 'bundle-name'})
223+ self.assertIn(('name', 'bundle-name'), list(bundle))
224+ # The important properties are also represented in the output.
225+ bundle_data = dict(bundle)
226+ self.assertIn('id', bundle_data)
227+ self.assertIn('permanent_url', bundle_data)
228+
229
230 class TestSlurpFiles(MongoTestBase):
231
232
233=== modified file 'charmworld/views/api.py'
234--- charmworld/views/api.py 2013-08-15 17:54:48 +0000
235+++ charmworld/views/api.py 2013-08-16 14:42:35 +0000
236@@ -39,22 +39,33 @@
237 )
238
239
240-def json_response(status, params, headers=[]):
241+def json_response(status, value, headers=[]):
242 """Return a JSON API response.
243
244 :param status: The HTTP status code to use.
245- :param params: A json-serializable value to use as the body, or None for
246+ :param value: A json-serializable value to use as the body, or None for
247 no body.
248 """
249- if params is None:
250+ # If the value is an object, then it must be representable as a mapping.
251+ if value is None:
252 body = ''
253 else:
254- body = json.dumps(params, sort_keys=True, indent=2)
255+ while True:
256+ try:
257+ body = json.dumps(value, sort_keys=True, indent=2)
258+ except TypeError:
259+ # If the serialization wasn't possible, and the value wasn't
260+ # already a dictionary, convert it to one and try again.
261+ if not isinstance(value, dict):
262+ value = dict(value)
263+ continue
264+ break
265+
266 return Response(body,
267 headerlist=[
268 ('Content-Type', 'application/json'),
269- ("Access-Control-Allow-Origin", "*"),
270- ("Access-Control-Allow-Headers", "X-Requested-With"),
271+ ('Access-Control-Allow-Origin', '*'),
272+ ('Access-Control-Allow-Headers', 'X-Requested-With'),
273 ] + headers,
274 status_code=status)
275
276@@ -363,12 +374,14 @@
277 query = {'basket': basket_id, 'name': path[2], 'promulgated': True}
278 else:
279 raise HTTPNotFound(self.request.path)
280- bundle_data = self.request.db.bundles.find_one(query)
281- if bundle_data is None:
282- return json_response(
283- 404, {'type': 'no_such_bundle', 'bundle_id': fullpath})
284- bundle = Bundle(bundle_data)
285- return {bundle.name: bundle.data}
286+ bundle = Bundle.from_query(query, self.request.db)
287+ if bundle is None:
288+ status = 404
289+ result = {'type': 'no_such_bundle', 'bundle_id': fullpath}
290+ else:
291+ status = 200
292+ result = bundle
293+ return json_response(status, result)
294
295 @staticmethod
296 def _get_file_headers(md5sum, content_type=None):
297
298=== modified file 'charmworld/views/tests/test_api.py'
299--- charmworld/views/tests/test_api.py 2013-08-15 17:54:48 +0000
300+++ charmworld/views/tests/test_api.py 2013-08-16 14:42:35 +0000
301@@ -58,27 +58,72 @@
302
303 class TestJSONResponse(ViewTestBase):
304
305- def test_json_response(self):
306- """json_response produces expected response."""
307- error = json_response(200, {'type': 'not_an_error'})
308- self.assertEqual(200, error.status_code)
309- self.assertEqual({'type': 'not_an_error'}, error.json_body)
310- self.assertEqual('application/json', error.content_type)
311- self.assertIn(("Access-Control-Allow-Origin", "*"), error.headerlist)
312- self.assertIn(
313- ("Access-Control-Allow-Headers", "X-Requested-With"),
314- error.headerlist)
315+ def test_json_response_status(self):
316+ # The status passed in will be used to generate the HTTP response.
317+ response = json_response(200, None)
318+ self.assertEqual(200, response.status_code)
319+
320+ def test_json_response_body(self):
321+ # The value passed in will be serialized to JSON and returned as the
322+ # body of the response.
323+ response = json_response(200, {'type': 'not_an_error'})
324+ self.assertEqual({'type': 'not_an_error'}, response.json_body)
325+ self.assertEqual('application/json', response.content_type)
326+
327+ def test_content_type(self):
328+ # The content type is set as JSON.
329+ response = json_response(200, {})
330+ self.assertEqual('application/json', response.content_type)
331+
332+ def test_cross_origin_headers(self):
333+ # Access control headers are set that allow cross origin resource
334+ # sharing (CORS).
335+ response = json_response(200, {})
336+ self.assertIn(
337+ ('Access-Control-Allow-Origin', '*'),
338+ response.headerlist)
339+ self.assertIn(
340+ ('Access-Control-Allow-Headers', 'X-Requested-With'),
341+ response.headerlist)
342
343 def test_json_response_none(self):
344- response = json_response(201, None)
345- self.assertEqual(201, response.status_code)
346+ # If given None as the body value, an empty body is generated.
347+ response = json_response(200, None)
348 self.assertEqual(0, response.content_length)
349+ self.assertEqual('', response.body)
350+ # Despite the empty body, the JSON content type is set.
351 self.assertEqual('application/json', response.content_type)
352- self.assertIn(("Access-Control-Allow-Origin", "*"),
353- response.headerlist)
354- self.assertIn(
355- ("Access-Control-Allow-Headers", "X-Requested-With"),
356- response.headerlist)
357+
358+ def test_serializing_mappings(self):
359+ response = json_response(200, {'a': 1, 'b': 2})
360+ self.assertEqual(response.json_body, {'a': 1, 'b': 2})
361+
362+ def test_serializing_lists(self):
363+ response = json_response(200, [1, 2, 3])
364+ self.assertEqual(response.json_body, [1, 2, 3])
365+
366+ def test_serializing_strings(self):
367+ response = json_response(200, 'test')
368+ self.assertEqual(response.json_body, 'test')
369+
370+ def test_serializing_ints(self):
371+ response = json_response(200, 42)
372+ self.assertEqual(response.json_body, 42)
373+
374+ def test_serializing_objects(self):
375+ # We want to be able to pass in objects as a value and have them
376+ # serialized to JSON, so any value that is an instance will be "cast"
377+ # to a dict() in order to retrieve the values to be seriallized. The
378+ # easiest way to make an object behave correctly in that scenario is
379+ # to give it an __iter__ method that is a generator yielding key,
380+ # value pairs.
381+ class Model:
382+ def __iter__(self):
383+ yield ('first', 1)
384+ yield ('second', 2)
385+
386+ response = json_response(200, Model())
387+ self.assertEqual(response.json_body, {'first': 1, 'second': 2})
388
389
390 class APITestBase(ViewTestBase):
391@@ -651,7 +696,6 @@
392 name=bundle.name,
393 owner=bundle.owner,
394 basket=bundle.basket,
395- inherits=None,
396 title='',
397 data=dict(services=dict(),
398 relations=dict(),
399@@ -706,11 +750,27 @@
400 self.assertEqual(200, response.status_code)
401
402 def test_results_match(self):
403- bundle = self.makeBundle(name='bat', owner='bac', basket='byobu/4')
404+ self.makeBundle(name='bat', owner='bac', basket='byobu/4')
405 response = self.get_response('bundle', '~bac/byobu/4/bat')
406 self.assertEqual(200, response.status_code)
407- json = dict(response.json)
408- self.assertEqual(json['bat'], bundle.data)
409+ self.assertEqual(
410+ response.json,
411+ {
412+ u'basket': u'byobu/4',
413+ u'branch_deleted': False,
414+ u'data': {
415+ u'series': u'precise',
416+ u'services': {},
417+ u'relations': {},
418+ },
419+ u'description': u'',
420+ u'id': u'~bac/byobu/4/bat',
421+ u'name': u'bat',
422+ u'owner': u'bac',
423+ u'permanent_url': u'jc:~bac/byobu/4/bat',
424+ u'promulgated': False,
425+ u'title': u'',
426+ })
427
428
429 class TestAPI3Bundles(TestAPIBundles, API3Mixin):
430
431=== modified file 'docs/api.rst'
432--- docs/api.rst 2013-07-17 14:38:00 +0000
433+++ docs/api.rst 2013-08-16 14:42:35 +0000
434@@ -340,3 +340,11 @@
435 }
436 }
437 }
438+
439+
440+Retrieving a bundle
441+===================
442+A bundle can be retrieved by invoking GET on ``bundle/$bundle-id``,
443+where the bundle-id is appended to the path without escaping. So if the
444+bundle-id is ``apache2/with-squid``, the path is
445+``bundle/apache2/with-squid``, not ``bundle/apache2%2fwith-squid``.

Subscribers

People subscribed via source and target branches