Merge lp:~rharding/charmworld/bundle-metadata into lp:~juju-jitsu/charmworld/trunk
- bundle-metadata
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Aaron Bentley |
Approved revision: | 405 |
Merged at revision: | 404 |
Proposed branch: | lp:~rharding/charmworld/bundle-metadata |
Merge into: | lp:~juju-jitsu/charmworld/trunk |
Diff against target: |
340 lines (+191/-41) 3 files modified
charmworld/testing/factory.py (+43/-1) charmworld/views/api.py (+115/-38) charmworld/views/tests/test_api.py (+33/-2) |
To merge this branch: | bzr merge lp:~rharding/charmworld/bundle-metadata |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju Gui Bot | continuous-integration | Approve | |
Benji York (community) | Approve | ||
Review via email: mp+188905@code.launchpad.net |
Commit message
Implement the /file/xxxx api off of the bundle api space.
Description of the change
This branch implements the /file/xxxx api off of the bundle api space. This is required to pull README content. This duplicates some of the style of the api parsing from the Charm code with some changes to be a little simpler. Things are kept as lists until the last minute, etc.
The general idea is to get back to the idea of an api call with a /bundle/
The _charm_icon stuff is a start because we'll need to have the ability to load the bundle icon by default if the charm is not promulgated/has one much like we do with charm icon. It just won't have the category complication part.
makeBundle was updated so that you can force the basket record to be created as well. This was required to be able to load the test file object into gridfs to make sure we could fetch it via the api response.
QA:
pull the changes, if you've got the demo bundles loaded you should be able to hit:
http://
http://
and get the proper response.
Richard Harding (rharding) wrote : | # |
Benji York (benji) wrote : | # |
There are a few small things that should be addressed, but otherwise
this looks good.
It would be nice if the comment that includes "Bundle IDs can consist of
up to 4 parts." spelled out explicitly what those four parts are.
Line 96 of the diff: We should capitalize "id" unless we're also talking
about the "ego" and "super-ego". I also feel that we should capitalize
"URL" but that may be more of a personal preference, so I'll leave that
one up to you.
Line 98 of the diff: A "split point" is referenced, but I don't see one.
There is an identical comment earlier in the file; perhaps this is a
copy/paste bug.
The try/except around lines 110 through 119 of the diff seems a little
big and the except is for two unrelated exceptions. I think it would be
better to have less code in the try and handle each exception
independently.
Lines 130 and 162 of the diff: s/id/ID/
make_bundle_file could use a docstring.
Since test_extracting
setup/assert blocks it would be better to have it broken into three
separate tests (each with their own customized version of the comment at
the top of the test now).
Richard Harding (rharding) wrote : | # |
> There are a few small things that should be addressed, but otherwise
> this looks good.
>
> It would be nice if the comment that includes "Bundle IDs can consist of
> up to 4 parts." spelled out explicitly what those four parts are.
Updated the comment with the parts vs the sample urls. I agree that it's clearer
that way.
> Line 96 of the diff: We should capitalize "id" unless we're also talking
> about the "ego" and "super-ego". I also feel that we should capitalize
> "URL" but that may be more of a personal preference, so I'll leave that
> one up to you.
Updated, not a fan of caps URL, but it'll be more consistent.
> Line 98 of the diff: A "split point" is referenced, but I don't see one.
> There is an identical comment earlier in the file; perhaps this is a
> copy/paste bug.
Agreed, updated comment to note the position change vs the 'split point'.
> The try/except around lines 110 through 119 of the diff seems a little
> big and the except is for two unrelated exceptions. I think it would be
> better to have less code in the try and handle each exception
> independently.
Agree in a sense. I moved the try/except around the one line of code that's attempting to find a version number. Both exceptions come out of that line of code though, so left the two exceptions to be caught there. Added a comment on why each was included to help clarify that one can come from the int() call and the other from the [index] use.
> Lines 130 and 162 of the diff: s/id/ID/
>
> make_bundle_file could use a docstring.
Updated! Thanks, saw a lot of methods sans docstrings and trying to find balance. I'm usually a docstring all the things view myself.
> Since test_extracting
> setup/assert blocks it would be better to have it broken into three
> separate tests (each with their own customized version of the comment at
> the top of the test now).
Updated.
Juju Gui Bot (juju-gui-bot) wrote : | # |
FAILED: Autolanding.
More details in the following jenkins job:
http://
Executed test runs:
Juju Gui Bot (juju-gui-bot) wrote : | # |
FAILED: Autolanding.
No commit message was specified in the merge proposal. Hit 'Add commit message' on the merge proposal web page or follow the link below. You can approve the merge proposal yourself to rerun.
https:/
Juju Gui Bot (juju-gui-bot) wrote : | # |
FAILED: Autolanding.
More details in the following jenkins job:
http://
Executed test runs:
Juju Gui Bot (juju-gui-bot) wrote : | # |
FAILED: Autolanding.
More details in the following jenkins job:
http://
Executed test runs:
Juju Gui Bot (juju-gui-bot) wrote : | # |
FAILED: Autolanding.
More details in the following jenkins job:
http://
Executed test runs:
Juju Gui Bot (juju-gui-bot) : | # |
Preview Diff
1 | === modified file 'charmworld/testing/factory.py' | |||
2 | --- charmworld/testing/factory.py 2013-09-19 21:54:54 +0000 | |||
3 | +++ charmworld/testing/factory.py 2013-10-03 17:14:08 +0000 | |||
4 | @@ -11,8 +11,10 @@ | |||
5 | 11 | datetime, | 11 | datetime, |
6 | 12 | ) | 12 | ) |
7 | 13 | import hashlib | 13 | import hashlib |
8 | 14 | import magic | ||
9 | 14 | import os | 15 | import os |
10 | 15 | from random import randint | 16 | from random import randint |
11 | 17 | import re | ||
12 | 16 | import random | 18 | import random |
13 | 17 | import stat | 19 | import stat |
14 | 18 | import string | 20 | import string |
15 | @@ -34,7 +36,11 @@ | |||
16 | 34 | make_bundle_doc, | 36 | make_bundle_doc, |
17 | 35 | options_to_storage, | 37 | options_to_storage, |
18 | 36 | ) | 38 | ) |
20 | 37 | from charmworld.utils import write_locked | 39 | from charmworld.utils import ( |
21 | 40 | quote_yaml, | ||
22 | 41 | unquote_yaml, | ||
23 | 42 | write_locked, | ||
24 | 43 | ) | ||
25 | 38 | 44 | ||
26 | 39 | 45 | ||
27 | 40 | def random_string(length=None): | 46 | def random_string(length=None): |
28 | @@ -335,10 +341,22 @@ | |||
29 | 335 | 341 | ||
30 | 336 | 342 | ||
31 | 337 | def makeBundle(db, *args, **kwargs): | 343 | def makeBundle(db, *args, **kwargs): |
32 | 344 | with_basket = False | ||
33 | 345 | if 'with_basket' in kwargs: | ||
34 | 346 | with_basket = True | ||
35 | 347 | del kwargs['with_basket'] | ||
36 | 348 | |||
37 | 338 | bundle_data = get_bundle_data(*args, **kwargs) | 349 | bundle_data = get_bundle_data(*args, **kwargs) |
38 | 339 | bundle = Bundle(bundle_data) | 350 | bundle = Bundle(bundle_data) |
39 | 340 | bundle_data['_id'] = bundle.id | 351 | bundle_data['_id'] = bundle.id |
40 | 341 | db.bundles.insert(bundle_data) | 352 | db.bundles.insert(bundle_data) |
41 | 353 | |||
42 | 354 | if with_basket: | ||
43 | 355 | basket_data = { | ||
44 | 356 | '_id': re.sub('\/[^/]*$', '', bundle.id), | ||
45 | 357 | 'file_hashes': {} | ||
46 | 358 | } | ||
47 | 359 | db.baskets.save(basket_data) | ||
48 | 342 | return bundle, bundle_data | 360 | return bundle, bundle_data |
49 | 343 | 361 | ||
50 | 344 | 362 | ||
51 | @@ -352,6 +370,30 @@ | |||
52 | 352 | return charm_file | 370 | return charm_file |
53 | 353 | 371 | ||
54 | 354 | 372 | ||
55 | 373 | def make_bundle_file(db, bundle, path, content=None): | ||
56 | 374 | """Create a file in gridfs for a bundle. | ||
57 | 375 | |||
58 | 376 | Note that these are actually keyed off the basket the bundle is in and so | ||
59 | 377 | the supplied bundle must have a basket entry for the file to be created | ||
60 | 378 | successfully. | ||
61 | 379 | |||
62 | 380 | """ | ||
63 | 381 | fs = Bundle.getfs(db) | ||
64 | 382 | if content is None: | ||
65 | 383 | content = random_string(10).encode('utf-8') | ||
66 | 384 | _id = hashlib.sha256(content).hexdigest() | ||
67 | 385 | content_type = magic.from_buffer(content, mime=True) | ||
68 | 386 | |||
69 | 387 | fs.put(content, contentType=content_type, _id=_id) | ||
70 | 388 | |||
71 | 389 | # The list of files is actually stored on the basket object. | ||
72 | 390 | basket = db.baskets.find_one(bundle.basket_id) | ||
73 | 391 | hashes = unquote_yaml(basket.get('file_hashes', {})) | ||
74 | 392 | hashes[path] = _id | ||
75 | 393 | basket['file_hashes'] = quote_yaml(hashes) | ||
76 | 394 | db.baskets.save(basket) | ||
77 | 395 | |||
78 | 396 | |||
79 | 355 | def makeTestResult(db, name, branch_spec, status, provider, | 397 | def makeTestResult(db, name, branch_spec, status, provider, |
80 | 356 | branch_revision=None, timestamp=None, test_number=None): | 398 | branch_revision=None, timestamp=None, test_number=None): |
81 | 357 | """Create a test record. | 399 | """Create a test record. |
82 | 358 | 400 | ||
83 | === modified file 'charmworld/views/api.py' | |||
84 | --- charmworld/views/api.py 2013-10-02 15:33:27 +0000 | |||
85 | +++ charmworld/views/api.py 2013-10-03 17:14:08 +0000 | |||
86 | @@ -149,7 +149,7 @@ | |||
87 | 149 | 149 | ||
88 | 150 | @staticmethod | 150 | @staticmethod |
89 | 151 | def _get_api_id(charm): | 151 | def _get_api_id(charm): |
91 | 152 | """Return the API id for a Mongo-formatted charm.""" | 152 | """Return the API ID for a Mongo-formatted charm.""" |
92 | 153 | return re.sub('^cs:', '', charm.store_url) | 153 | return re.sub('^cs:', '', charm.store_url) |
93 | 154 | 154 | ||
94 | 155 | @staticmethod | 155 | @staticmethod |
95 | @@ -309,8 +309,77 @@ | |||
96 | 309 | return charm_id, trailing | 309 | return charm_id, trailing |
97 | 310 | 310 | ||
98 | 311 | @staticmethod | 311 | @staticmethod |
99 | 312 | def _parse_bundle_id(path): | ||
100 | 313 | """Extract the bundle API ID from a given URL path. | ||
101 | 314 | |||
102 | 315 | Bundle API IDs beginning with a '~' are considered to include an owner. | ||
103 | 316 | Bundle IDs can consist of up to 4 parts. | ||
104 | 317 | |||
105 | 318 | - owner (optional) | ||
106 | 319 | - basket (required) | ||
107 | 320 | - version (optional) | ||
108 | 321 | - bundle (required) | ||
109 | 322 | |||
110 | 323 | """ | ||
111 | 324 | if len(path) < 2: | ||
112 | 325 | raise ValueError('The bundle ID does not contain enough elements') | ||
113 | 326 | |||
114 | 327 | # Determine if the path includes an owner and adjust the backet index | ||
115 | 328 | # appropriately. | ||
116 | 329 | owner = None | ||
117 | 330 | if unquote(path[0])[0] == '~': | ||
118 | 331 | owner = path[0] | ||
119 | 332 | basket = path[1] | ||
120 | 333 | else: | ||
121 | 334 | basket = path[0] | ||
122 | 335 | |||
123 | 336 | # Now check if there's an optional revision section in there after the | ||
124 | 337 | # basket name. | ||
125 | 338 | version_index = 2 if owner else 1 | ||
126 | 339 | try: | ||
127 | 340 | version = int(path[version_index]) | ||
128 | 341 | except (IndexError, ValueError): | ||
129 | 342 | # If the url isn't long enough to have a version value, or the | ||
130 | 343 | # value in that slot is not an integer, then no version was | ||
131 | 344 | # provided. | ||
132 | 345 | version = None | ||
133 | 346 | |||
134 | 347 | if version: | ||
135 | 348 | if owner: | ||
136 | 349 | # URL includes an owner, a basket, and a version. | ||
137 | 350 | bundle = path[3] | ||
138 | 351 | else: | ||
139 | 352 | # URL does not include an owner and only has a basket, | ||
140 | 353 | # version, and bundle name. | ||
141 | 354 | bundle = path[2] | ||
142 | 355 | else: | ||
143 | 356 | if owner: | ||
144 | 357 | # URL includes an owner, a basket, and a bundle name. | ||
145 | 358 | bundle = path[2] | ||
146 | 359 | else: | ||
147 | 360 | # URL only includes a basket name and a bundle name. | ||
148 | 361 | bundle = path[1] | ||
149 | 362 | |||
150 | 363 | # So now we can pull out the parts that are the ID and the rest will | ||
151 | 364 | # be the trailing. | ||
152 | 365 | id_length = 2 | ||
153 | 366 | if owner: | ||
154 | 367 | id_length = id_length + 1 | ||
155 | 368 | if version: | ||
156 | 369 | id_length = id_length + 1 | ||
157 | 370 | parsedId = '/'.join(path[0:id_length]) | ||
158 | 371 | trailing = path[id_length:] | ||
159 | 372 | |||
160 | 373 | return parsedId, trailing, { | ||
161 | 374 | 'owner': owner, | ||
162 | 375 | 'basket': basket, | ||
163 | 376 | 'version': version, | ||
164 | 377 | 'bundle': bundle | ||
165 | 378 | } | ||
166 | 379 | |||
167 | 380 | @staticmethod | ||
168 | 312 | def _parse_charm_id(charm_id): | 381 | def _parse_charm_id(charm_id): |
170 | 313 | """Split a charm id into its component parts. | 382 | """Split a charm ID into its component parts. |
171 | 314 | 383 | ||
172 | 315 | Returns a tuple of (owner, series, name, revision). | 384 | Returns a tuple of (owner, series, name, revision). |
173 | 316 | """ | 385 | """ |
174 | @@ -358,6 +427,34 @@ | |||
175 | 358 | query, sort=[('store_data.revision', pymongo.DESCENDING)]) | 427 | query, sort=[('store_data.revision', pymongo.DESCENDING)]) |
176 | 359 | return charm_id, trailing, charm | 428 | return charm_id, trailing, charm |
177 | 360 | 429 | ||
178 | 430 | def _find_bundle(self, path): | ||
179 | 431 | try: | ||
180 | 432 | bundle_id, trailing, bundle_bits = self._parse_bundle_id(path) | ||
181 | 433 | except ValueError: | ||
182 | 434 | bundle = bundle_id = trailing = bundle_bits = None | ||
183 | 435 | else: | ||
184 | 436 | if bundle_bits['owner'] and bundle_bits['version']: | ||
185 | 437 | # We have all 4 parts needed, just use the ID. | ||
186 | 438 | query = {'_id': bundle_id} | ||
187 | 439 | elif bundle_bits['owner']: | ||
188 | 440 | # This URL includes the owner, basket name, and bundle name. | ||
189 | 441 | # The specified bundle may be promulgated or not. | ||
190 | 442 | owner = bundle_bits['owner'][1:] # Strip the tilde. | ||
191 | 443 | query = { | ||
192 | 444 | 'owner': owner, | ||
193 | 445 | 'basket_name': bundle_bits['basket'], | ||
194 | 446 | 'name': bundle_bits['bundle'] | ||
195 | 447 | } | ||
196 | 448 | else: | ||
197 | 449 | query = { | ||
198 | 450 | 'basket_name': bundle_bits['basket'], | ||
199 | 451 | 'name': bundle_bits['bundle'], | ||
200 | 452 | 'promulgated': True | ||
201 | 453 | } | ||
202 | 454 | |||
203 | 455 | bundle = Bundle.from_query(query, self.request.db) | ||
204 | 456 | return bundle_id, trailing, bundle | ||
205 | 457 | |||
206 | 361 | def charm(self, path=None): | 458 | def charm(self, path=None): |
207 | 362 | """Retrieve a charm according to its API ID (the path prefix).""" | 459 | """Retrieve a charm according to its API ID (the path prefix).""" |
208 | 363 | if path is None: | 460 | if path is None: |
209 | @@ -370,7 +467,7 @@ | |||
210 | 370 | if trailing is None: | 467 | if trailing is None: |
211 | 371 | return self._charm_details(charm_data) | 468 | return self._charm_details(charm_data) |
212 | 372 | elif trailing == self.ICON_TRAILING: | 469 | elif trailing == self.ICON_TRAILING: |
214 | 373 | return self._icon(charm) | 470 | return self._charm_icon(charm) |
215 | 374 | elif trailing.startswith('file/'): | 471 | elif trailing.startswith('file/'): |
216 | 375 | return self._charm_file(charm, trailing) | 472 | return self._charm_file(charm, trailing) |
217 | 376 | elif trailing == 'qa': | 473 | elif trailing == 'qa': |
218 | @@ -393,46 +490,26 @@ | |||
219 | 393 | status_code=200) | 490 | status_code=200) |
220 | 394 | 491 | ||
221 | 395 | def bundle(self, path=None): | 492 | def bundle(self, path=None): |
223 | 396 | """Retrieve a bundle based on id.""" | 493 | """Retrieve a bundle based on ID.""" |
224 | 397 | if path is None: | 494 | if path is None: |
225 | 398 | raise HTTPNotFound(self.request.path) | 495 | raise HTTPNotFound(self.request.path) |
226 | 496 | path = list(path) | ||
227 | 497 | bundle_id, trailing, bundle = self._find_bundle(path) | ||
228 | 498 | |||
229 | 399 | filename = None | 499 | filename = None |
255 | 400 | path = list(path) | 500 | |
231 | 401 | # If this is a request for the bundle's icon, we need to remove the | ||
232 | 402 | # file name from the path so the code below can use the remainder of | ||
233 | 403 | # the path to find the bundle. | ||
234 | 404 | if path[-1] == 'icon.svg': | ||
235 | 405 | filename = path.pop() | ||
236 | 406 | fullpath = '/'.join(path) | ||
237 | 407 | if len(path) == 4: | ||
238 | 408 | # We have an owner, basket name, bundle name, and revision, all | ||
239 | 409 | # those together are enough to build the bundle's ID. | ||
240 | 410 | query = {'_id': fullpath} | ||
241 | 411 | elif len(path) == 3: | ||
242 | 412 | # This URL includes the owner, basket name, and bundle name. The | ||
243 | 413 | # specified bundle may be promulgated or not. | ||
244 | 414 | owner = path[0][1:] # Strip the tilde. | ||
245 | 415 | query = {'owner': owner, 'basket_name': path[1], | ||
246 | 416 | 'name': path[2]} | ||
247 | 417 | elif len(path) == 2: | ||
248 | 418 | # Since there is no owner in the URL, this is a request for a | ||
249 | 419 | # promulgated bundle. | ||
250 | 420 | query = {'basket_name': path[0], 'name': path[1], | ||
251 | 421 | 'promulgated': True} | ||
252 | 422 | else: | ||
253 | 423 | raise HTTPNotFound(self.request.path) | ||
254 | 424 | bundle = Bundle.from_query(query, self.request.db) | ||
256 | 425 | if bundle is None: | 501 | if bundle is None: |
257 | 426 | status = 404 | 502 | status = 404 |
259 | 427 | result = {'type': 'no_such_bundle', 'bundle_id': fullpath} | 503 | result = {'type': 'no_such_bundle', 'bundle_id': '/'.join(path)} |
260 | 504 | return json_response(status, result) | ||
261 | 505 | if not trailing: | ||
262 | 506 | return json_response(200, bundle) | ||
263 | 507 | elif trailing and trailing[0] == self.ICON_TRAILING: | ||
264 | 508 | return self._bundle_file(bundle, filename) | ||
265 | 509 | elif trailing and trailing[0] == 'file': | ||
266 | 510 | return self._bundle_file(bundle, '/'.join(trailing[1:])) | ||
267 | 428 | else: | 511 | else: |
275 | 429 | # If the URL specified one of the bundle's files (as opposed to | 512 | raise HTTPNotFound('/'.join(path)) |
269 | 430 | # the bundle itself), return that file's contents. | ||
270 | 431 | if filename is not None: | ||
271 | 432 | return self._bundle_file(bundle, filename) | ||
272 | 433 | status = 200 | ||
273 | 434 | result = bundle | ||
274 | 435 | return json_response(status, result) | ||
276 | 436 | 513 | ||
277 | 437 | @staticmethod | 514 | @staticmethod |
278 | 438 | def _get_file_headers(md5sum, content_type=None): | 515 | def _get_file_headers(md5sum, content_type=None): |
279 | @@ -478,7 +555,7 @@ | |||
280 | 478 | headerlist=headerlist, | 555 | headerlist=headerlist, |
281 | 479 | status_code=200) | 556 | status_code=200) |
282 | 480 | 557 | ||
284 | 481 | def _icon(self, charm): | 558 | def _charm_icon(self, charm): |
285 | 482 | if (charm.files and | 559 | if (charm.files and |
286 | 483 | charm.files.get(quote_key('icon.svg')) and | 560 | charm.files.get(quote_key('icon.svg')) and |
287 | 484 | charm.promulgated): | 561 | charm.promulgated): |
288 | 485 | 562 | ||
289 | === modified file 'charmworld/views/tests/test_api.py' | |||
290 | --- charmworld/views/tests/test_api.py 2013-09-30 15:26:01 +0000 | |||
291 | +++ charmworld/views/tests/test_api.py 2013-10-03 17:14:08 +0000 | |||
292 | @@ -745,8 +745,8 @@ | |||
293 | 745 | 745 | ||
294 | 746 | def test_invalid_path(self): | 746 | def test_invalid_path(self): |
295 | 747 | self.makeBundle(name='bat') | 747 | self.makeBundle(name='bat') |
298 | 748 | with self.assertRaises(HTTPNotFound): | 748 | response = self.get_response('bundle', 'foo') |
299 | 749 | self.get_response('bundle', 'foo') | 749 | self.assertEqual(404, response.status_code) |
300 | 750 | 750 | ||
301 | 751 | def test_not_found(self): | 751 | def test_not_found(self): |
302 | 752 | self.makeBundle(name='bat') | 752 | self.makeBundle(name='bat') |
303 | @@ -802,6 +802,37 @@ | |||
304 | 802 | u'title': u'', | 802 | u'title': u'', |
305 | 803 | }) | 803 | }) |
306 | 804 | 804 | ||
307 | 805 | def test_extracting_bundle_id_with_trailing_full_id(self): | ||
308 | 806 | # If there are trailing characters after the bundle ID, | ||
309 | 807 | # they are returned in "trailing". | ||
310 | 808 | bundle_id, trailing, bits = self.api_class._parse_bundle_id( | ||
311 | 809 | '~bac/byobu/4/bat/file/README'.split('/')) | ||
312 | 810 | self.assertEqual('~bac/byobu/4/bat', bundle_id) | ||
313 | 811 | self.assertEqual(['file', 'README'], trailing) | ||
314 | 812 | |||
315 | 813 | def test_extracting_bundle_id_with_trailing_no_owner(self): | ||
316 | 814 | bundle_id, trailing, bits = self.api_class._parse_bundle_id( | ||
317 | 815 | 'byobu/4/bat/file/README'.split('/')) | ||
318 | 816 | self.assertEqual('byobu/4/bat', bundle_id) | ||
319 | 817 | self.assertEqual(['file', 'README'], trailing) | ||
320 | 818 | |||
321 | 819 | def test_extracting_bundle_id_with_trailing_no_owner_or_version(self): | ||
322 | 820 | bundle_id, trailing, bits = self.api_class._parse_bundle_id( | ||
323 | 821 | 'byobu/bat/file/README'.split('/')) | ||
324 | 822 | self.assertEqual('byobu/bat', bundle_id) | ||
325 | 823 | self.assertEqual(['file', 'README'], trailing) | ||
326 | 824 | |||
327 | 825 | def test_bundle_readme(self): | ||
328 | 826 | # Test that we can store a readme file and get it back out in a | ||
329 | 827 | # response with the url bundle/id/file/README or something like that. | ||
330 | 828 | bundle = self.makeBundle( | ||
331 | 829 | name='bat', owner='bac', | ||
332 | 830 | basket_with_rev='byobu/4', with_basket=True) | ||
333 | 831 | factory.make_bundle_file(self.db, bundle, u'README', 'Test Content') | ||
334 | 832 | response = self.get_response('bundle', '~bac/byobu/4/bat/file/README') | ||
335 | 833 | self.assertEqual('Test Content', response.body) | ||
336 | 834 | self.assertEqual(u'text/plain', response.content_type) | ||
337 | 835 | |||
338 | 805 | 836 | ||
339 | 806 | class TestAPI3Bundles(TestAPIBundles, API3Mixin): | 837 | class TestAPI3Bundles(TestAPIBundles, API3Mixin): |
340 | 807 | """Test API 3 bundle endpoint.""" | 838 | """Test API 3 bundle endpoint.""" |
The big goal was to get back the idea of the trailing. I kept it a [] vs a string so that it's easier to find the bits in it.
One thing I changed to make consistent was the custom json response if the bundle id was invalid. That matched the charm behavior vs just throwing the not found exception. We had agreed during API design that all failures should provide some sort of payload of helpful info if possible.
The _charm_icon stuff is a start because we'll need to have the ability to load the bundle icon by default if the charm is not promulgated/has one much like we do with charm icon. It just won't have the category complication part.
As is the old tests are passing and were very helpful. The next step is to get the readme into the test bundle and have tests against getting it back out again via the url /~owner/ basket/ bundle/ file/README and such.