Merge lp:~benji/charmworld/update-bundle-api into lp:~juju-jitsu/charmworld/trunk
- update-bundle-api
- Merge into trunk
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 |
Related bugs: |
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
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
Charmworld Lander (charmworld-lander) wrote : | # |
No commit message specified.
Charmworld Lander (charmworld-lander) wrote : | # |
No commit message specified.
Preview Diff
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``. |
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.