Merge lp:~sinzui/charmworld/support-gui-charm-urls into lp:~juju-jitsu/charmworld/trunk

Proposed by Curtis Hovey
Status: Merged
Approved by: Curtis Hovey
Approved revision: 384
Merged at revision: 373
Proposed branch: lp:~sinzui/charmworld/support-gui-charm-urls
Merge into: lp:~juju-jitsu/charmworld/trunk
Diff against target: 353 lines (+206/-12)
5 files modified
charmworld/routes.py (+13/-1)
charmworld/views/bundles.py (+11/-0)
charmworld/views/charms.py (+37/-10)
charmworld/views/tests/test_bundles.py (+21/-1)
charmworld/views/tests/test_charms.py (+124/-0)
To merge this branch: bzr merge lp:~sinzui/charmworld/support-gui-charm-urls
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Benji York (community) Approve
Review via email: mp+182924@code.launchpad.net

Commit message

Support juju-gui URLs in charmworld.

Description of the change

Support juju-gui URLs in charmworld.

RULES

    Pre-implementation: gary, mark, abentley
    * Juju-gui wants to delegate requests for charm data when the client
      is not supported -- juju-gui supports a small set of JS-enabled
      browsers.
      * Users need to see charm (and bundle) details to learn what they
        do and how to use them. We expect users and sometimes bots to
        find these links and need access to discover juju or develop
        with it.
      * The gui or it's front-end server will forward the request to
        charmworld. Charmworld needs to understand the URL.
    * Charmworld supports unversioned user urls now. It must support:
      * -{rev} in any charm url.
      * /{series}/
    * ^ These urls do not conflict with current urls so moving the old urls
      can be postponed.

QA

    * Visit http://staging.jujucharms.com/precise/apache2-11
    * Visit http://staging.jujucharms.com/precise/apache2-HEAD
    * Visit http://staging.jujucharms.com/precise/apache2
    * Visit http://staging.jujucharms.com/~charmers/precise/apache2-11
    * Visit http://staging.jujucharms.com/~charmers/precise/apache2-HEAD
    * Visit http://staging.jujucharms.com/~charmers/precise/apache2
    * Visit http://staging.jujucharms.com/~gnuoy/precise/apache2-1
    * Visit http://staging.jujucharms.com/~gnuoy/precise/apache2-HEAD
    * Visit http://staging.jujucharms.com/~gnuoy/precise/apache2
    * Visit http://staging.jujucharms.com/~abentley/bundles/wiki-bundle/1/wiki
    * Visit http://staging.jujucharms.com/~abentley/bundles/wiki-bundle/1/wiki/json
    * Visit http://staging.jujucharms.com/~abentley/bundles/wiki-bundle/wiki

IMPLEMENTATION

I added routing tests for charms before I changed code. I discovered that
the charm json views were missing a content type. While content_type was
passed to the Response, it was clobbered by the headerlist. The fix was
to include the content-type in the header list.

I extracted a helper from a pattern I saw in several charm views.
_reconcile_revision() knows to correct the charm name when it is
mistaken for a revision. I added a rule to handle the HEAD case that
juju gui's apache adds.

The main difference between charmworld URLs and juju-gui URLs is the
revision and the lack of namespacing. Adding revision to personal branches
and bundles was trivial. Reviewed charms was tricky. Making the charms
the last rules ensures the current urls are not misconstrued to be charm
series. Since bundles is both a namespace and a series, there was no conflict.
I did add a revision to the official bundle json view to avoid conflicts with
the route I believe the GUI will use; this make the official json view
consistent with the person json view.

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

The branch looks good. The tests especially nice.

On lines 233, 255, and 262 you could use self.assertIn and get slightly
better diagnostics if the assertion ever fails.

Unpacking the comments in the routing tests would be nice. I can pretty
much understand them in the context of the branch, but I suspect someone
coming in cold would have to ruminate a bit to see what they are saying.
For example, test_route_personal_charm_with_revision's comment of

    # /~owner/series/charm-1 route find the charm.

Might be expanded to

    # A non-promulgated charm's route of the form /~owner/series/charm-1
    # specifies the charm with a charm store ID of
    # cs:~owner/series/charm.

review: Approve
383. By Curtis Hovey

Updated tests to better report the failing condition.

384. By Curtis Hovey

Updated comments.

Revision history for this message
Curtis Hovey (sinzui) wrote :

Self-approving changes made per review.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'charmworld/routes.py'
--- charmworld/routes.py 2013-08-23 12:47:09 +0000
+++ charmworld/routes.py 2013-08-29 18:36:30 +0000
@@ -33,6 +33,8 @@
33 # Personal Charms33 # Personal Charms
34 config.add_route("personal-collection", "/~{owner}")34 config.add_route("personal-collection", "/~{owner}")
35 config.add_route("personal-series", "/~{owner}/{series}")35 config.add_route("personal-series", "/~{owner}/{series}")
36 config.add_route("personal-charm-revision",
37 "/~{owner}/{series}/{charm}-{revision}")
36 config.add_route("personal-charm", "/~{owner}/{series}/{charm}")38 config.add_route("personal-charm", "/~{owner}/{series}/{charm}")
37 config.add_route("personal-charm-revision-json",39 config.add_route("personal-charm-revision-json",
38 "/~{owner}/{series}/{charm}-{revision}/json")40 "/~{owner}/{series}/{charm}-{revision}/json")
@@ -50,6 +52,8 @@
50 config.add_route("interface-collection-map", "/interface-charm-map")52 config.add_route("interface-collection-map", "/interface-charm-map")
5153
52 # Personal Bundles54 # Personal Bundles
55 config.add_route("personal-bundle-revision",
56 "/~{owner}/bundles/{basket}/{rev}/{bundle}")
53 config.add_route("personal-bundle",57 config.add_route("personal-bundle",
54 "/~{owner}/bundles/{basket}/{bundle}")58 "/~{owner}/bundles/{basket}/{bundle}")
55 config.add_route("personal-bundle-json",59 config.add_route("personal-bundle-json",
@@ -57,7 +61,9 @@
5761
58 # Official (promulgated) Bundles62 # Official (promulgated) Bundles
59 config.add_route("official-bundle-json",63 config.add_route("official-bundle-json",
60 "/bundles/{basket}/{bundle}/json")64 "/bundles/{basket}/{rev}/{bundle}/json")
65 config.add_route("official-bundle-revision",
66 "/bundles/{basket}/{rev}/{bundle}")
6167
62 # Tools68 # Tools
63 config.add_route("tools-core-review", "/tools/core-review-queue")69 config.add_route("tools-core-review", "/tools/core-review-queue")
@@ -105,4 +111,10 @@
105111
106 # Monitoring support.112 # Monitoring support.
107 config.add_route('heartbeat', '/heartbeat')113 config.add_route('heartbeat', '/heartbeat')
114
115 # Unnamespaced promulgated charms.
116 config.add_route("promulgated-charm-revision",
117 "/{series}/{charm}-{revision}")
118 config.add_route("promulgated-charm", "/{series}/{charm}")
119
108 return config120 return config
109121
=== modified file 'charmworld/views/bundles.py'
--- charmworld/views/bundles.py 2013-08-23 19:19:21 +0000
+++ charmworld/views/bundles.py 2013-08-29 18:36:30 +0000
@@ -100,6 +100,9 @@
100100
101101
102@cached_view_config(102@cached_view_config(
103 route_name="personal-bundle-revision",
104 renderer="charmworld:templates/bundle.pt")
105@cached_view_config(
103 route_name="personal-bundle",106 route_name="personal-bundle",
104 renderer="charmworld:templates/bundle.pt")107 renderer="charmworld:templates/bundle.pt")
105def personal_bundle(request):108def personal_bundle(request):
@@ -127,6 +130,14 @@
127130
128131
129@cached_view_config(132@cached_view_config(
133 route_name="official-bundle-revision",
134 renderer="charmworld:templates/bundle.pt")
135def official_bundle(request):
136 bundle = find_bundle(request, promulgated=True)
137 return _bundle_view(request, found(bundle))
138
139
140@cached_view_config(
130 route_name="official-bundle-json")141 route_name="official-bundle-json")
131def official_bundle_json(request):142def official_bundle_json(request):
132 bundle = find_bundle(request, promulgated=True)143 bundle = find_bundle(request, promulgated=True)
133144
=== modified file 'charmworld/views/charms.py'
--- charmworld/views/charms.py 2013-08-22 16:23:51 +0000
+++ charmworld/views/charms.py 2013-08-29 18:36:30 +0000
@@ -185,8 +185,30 @@
185 json.dumps(charm, sort_keys=True, indent=2),185 json.dumps(charm, sort_keys=True, indent=2),
186 headerlist=[186 headerlist=[
187 ("Access-Control-Allow-Origin", "*"),187 ("Access-Control-Allow-Origin", "*"),
188 ("Access-Control-Allow-Headers", "X-Requested-With")],188 ("Access-Control-Allow-Headers", "X-Requested-With"),
189 content_type=u"application/json")189 ("Content-Type", "application/json")])
190
191
192def _reconcile_revision(request):
193 """Check and reconcile the revision munged with the charm name.
194
195 Charm names may contain dashes, but the charm store appends the
196 charm revision revision with a dash. Routing rules may match a revision
197 that is actually a part of the charm name. The juju-gui apache front-end
198 will append -HEAD to versionless charm urls.
199 """
200 match = request.matchdict
201 if 'revision' not in match or match['revision'].isdigit():
202 # The charm name and revision look sane.
203 return
204 if match['revision'].upper() == 'HEAD':
205 # Remove the false revision created by the juju-gui apache front-end.
206 # Charmworld always shows head when no revision is specified.
207 del match['revision']
208 else:
209 # The charm name was wrongly matched as a revision.
210 match['charm'] = "%s-%s" % (match['charm'], match['revision'])
211 del match['revision']
190212
191213
192def found_charm_collection(db, query, sub_only, only_promulgated=False):214def found_charm_collection(db, query, sub_only, only_promulgated=False):
@@ -275,7 +297,11 @@
275@cached_view_config(297@cached_view_config(
276 route_name="personal-charm",298 route_name="personal-charm",
277 renderer="charmworld:templates/charm.pt")299 renderer="charmworld:templates/charm.pt")
300@cached_view_config(
301 route_name="personal-charm-revision",
302 renderer="charmworld:templates/charm.pt")
278def personal_charm(request):303def personal_charm(request):
304 _reconcile_revision(request)
279 return _charm_view(request, find_charm(request))305 return _charm_view(request, find_charm(request))
280306
281307
@@ -284,18 +310,22 @@
284@cached_view_config(310@cached_view_config(
285 route_name="personal-charm-json")311 route_name="personal-charm-json")
286def person_charm_json(request):312def person_charm_json(request):
287 match = request.matchdict313 _reconcile_revision(request)
288 if 'revision' in match and not match['revision'].isdigit():
289 match['charm'] = "%s-%s" % (
290 match['charm'], match['revision'])
291 charm = Charm(find_charm(request))314 charm = Charm(find_charm(request))
292 return _json_charm(charm)315 return _json_charm(charm)
293316
294317
295@cached_view_config(318@cached_view_config(
319 route_name="promulgated-charm-revision",
320 renderer="charmworld:templates/charm.pt")
321@cached_view_config(
322 route_name="promulgated-charm",
323 renderer="charmworld:templates/charm.pt")
324@cached_view_config(
296 route_name="charm",325 route_name="charm",
297 renderer="charmworld:templates/charm.pt")326 renderer="charmworld:templates/charm.pt")
298def distro_charm(request):327def distro_charm(request):
328 _reconcile_revision(request)
299 return _charm_view(request, find_charm(request, promulgated=True))329 return _charm_view(request, find_charm(request, promulgated=True))
300330
301331
@@ -306,10 +336,7 @@
306 route_name="charm-json",336 route_name="charm-json",
307)337)
308def distro_charm_json(request):338def distro_charm_json(request):
309 match = request.matchdict339 _reconcile_revision(request)
310 if 'revision' in match and not match['revision'].isdigit():
311 match['charm'] = "%s-%s" % (
312 match['charm'], match['revision'])
313 charm = Charm(find_charm(request, promulgated=True))340 charm = Charm(find_charm(request, promulgated=True))
314 return _json_charm(charm)341 return _json_charm(charm)
315342
316343
=== modified file 'charmworld/views/tests/test_bundles.py'
--- charmworld/views/tests/test_bundles.py 2013-08-27 14:39:13 +0000
+++ charmworld/views/tests/test_bundles.py 2013-08-29 18:36:30 +0000
@@ -250,6 +250,16 @@
250 lp_long_url = 'https://code.launchpad.net/{path}'.format(path=lp_path)250 lp_long_url = 'https://code.launchpad.net/{path}'.format(path=lp_path)
251 self.assertIn(lp_long_url, resp.body)251 self.assertIn(lp_long_url, resp.body)
252252
253 def test_personal_bundle_route_with_revision(self):
254 bundle, bundle_data = factory.makeBundle(
255 self.db, name='gigis', owner='ladonna',
256 basket_with_rev='basket/1', series='raring',
257 )
258 path = '/~ladonna/bundles/basket/1/gigis'
259 response = self.app.get(path, status=200)
260 self.assertIn(
261 'lp:~ladonna/charms/bundles/basket/bundle', response.body)
262
253 def test_personal_bundle_route_404(self):263 def test_personal_bundle_route_404(self):
254 bundle, bundle_data = factory.makeBundle(264 bundle, bundle_data = factory.makeBundle(
255 self.db, name='gigis', owner='ladonna',265 self.db, name='gigis', owner='ladonna',
@@ -282,12 +292,22 @@
282 self.db, name='wiki', owner='charmers',292 self.db, name='wiki', owner='charmers',
283 basket_with_rev='wiki-basket/1', series='raring', promulgated=True,293 basket_with_rev='wiki-basket/1', series='raring', promulgated=True,
284 )294 )
285 path = '/bundles/wiki-basket/wiki/json'295 path = '/bundles/wiki-basket/1/wiki/json'
286 resp = self.app.get(path, status=200)296 resp = self.app.get(path, status=200)
287 self.assertIn(bundle.data['series'], resp.body)297 self.assertIn(bundle.data['series'], resp.body)
288 self.assertIn(str(bundle.data['relations']), resp.body)298 self.assertIn(str(bundle.data['relations']), resp.body)
289 self.assertIn(str(bundle.data['services']), resp.body)299 self.assertIn(str(bundle.data['services']), resp.body)
290300
301 def test_official_bundle_with_revision_route(self):
302 bundle, bundle_data = factory.makeBundle(
303 self.db, name='wiki', owner='charmers',
304 basket_with_rev='wiki-basket/1', series='raring', promulgated=True,
305 )
306 path = '/bundles/wiki-basket/1/wiki'
307 response = self.app.get(path, status=200)
308 self.assertIn(
309 'lp:~charmers/charms/bundles/wiki-basket/bundle', response.body)
310
291 def test_official_bundle_route_404(self):311 def test_official_bundle_route_404(self):
292 bundle, bundle_data = factory.makeBundle(312 bundle, bundle_data = factory.makeBundle(
293 self.db, name='gigis', owner='ladonna',313 self.db, name='gigis', owner='ladonna',
294314
=== modified file 'charmworld/views/tests/test_charms.py'
--- charmworld/views/tests/test_charms.py 2013-08-22 16:23:51 +0000
+++ charmworld/views/tests/test_charms.py 2013-08-29 18:36:30 +0000
@@ -28,6 +28,7 @@
28from charmworld.views.api import API328from charmworld.views.api import API3
29from charmworld.views.charms import (29from charmworld.views.charms import (
30 _charm_view,30 _charm_view,
31 _reconcile_revision,
31 charm_collection,32 charm_collection,
32 config,33 config,
33 distro_charm,34 distro_charm,
@@ -475,6 +476,49 @@
475 self.assertEqual(expect, response['icon_path'])476 self.assertEqual(expect, response['icon_path'])
476477
477478
479class CharmsViewHelpersTestCase(ViewTestBase):
480
481 def test_reconcile_revision_with_dashed_name(self):
482 # The charm name is reconstructed when a revision is wrongly matched.
483 request = self.getRequest()
484 request.matchdict = {
485 'charm': 'charm',
486 'revision': 'name',
487 }
488 _reconcile_revision(request)
489 self.assertEqual('charm-name', request.matchdict['charm'])
490 self.assertNotIn('revision', request.matchdict)
491
492 def test_reconcile_revision_with_revision(self):
493 # The charm name is reconstructed when a revision is wrongly matched.
494 request = self.getRequest()
495 request.matchdict = {
496 'charm': 'charm',
497 'revision': '1',
498 }
499 _reconcile_revision(request)
500 self.assertEqual('charm', request.matchdict['charm'])
501 self.assertEqual('1', request.matchdict['revision'])
502
503 def test_reconcile_revision_with_head_revision(self):
504 # The HEAD revision is dropped.
505 request = self.getRequest()
506 request.matchdict = {
507 'charm': 'charm',
508 'revision': 'HEAD',
509 }
510 _reconcile_revision(request)
511 self.assertEqual('charm', request.matchdict['charm'])
512 self.assertNotIn('revision', request.matchdict)
513 request.matchdict = {
514 'charm': 'charm',
515 'revision': 'head',
516 }
517 _reconcile_revision(request)
518 self.assertEqual('charm', request.matchdict['charm'])
519 self.assertNotIn('revision', request.matchdict)
520
521
478class CharmsWebTestCase(WebTestBase):522class CharmsWebTestCase(WebTestBase):
479523
480 def test_charm_collection_with_missing_data(self):524 def test_charm_collection_with_missing_data(self):
@@ -487,6 +531,86 @@
487 resp = self.app.get('/charms/precise', status=200)531 resp = self.app.get('/charms/precise', status=200)
488 self.assertIn(two['short_url'], resp.body)532 self.assertIn(two['short_url'], resp.body)
489533
534 def test_route_personal_charm_with_revision(self):
535 # The route /~owner/series/charm-1 finds charms using owner,
536 # series, charm name, and revision. The deploy instruction
537 # does not include the revision.
538 ignore, charm = factory.makeCharm(
539 self.db, owner='owner', series='series', name='charm', files={})
540 response = self.app.get('/~owner/series/charm-1', status=200)
541 self.assertIn('juju deploy cs:~owner/series/charm', response.body)
542
543 def test_route_personal_charm_with_dash_and_revision(self):
544 # /~owner/series/charm-name-1 route find the charm.
545 # This is a corner-case where the revision and charm name are
546 # ambigous because they are munged.
547 ignore, charm = factory.makeCharm(
548 self.db, owner='owner', series='series', name='charm-name',
549 files={})
550 response = self.app.get('/~owner/series/charm-name-1', status=200)
551 self.assertIn('juju deploy cs:~owner/series/charm-name', response.body)
552
553 def test_route_personal_charm_with_head_revision(self):
554 # the /~owner/series/charm-name-HEAD route finds the charm.
555 # The juju-gui apache rewrite versionless charm urls to help the GUI.
556 ignore, charm = factory.makeCharm(
557 self.db, owner='owner', series='series', name='charm', files={})
558 response = self.app.get('/~owner/series/charm-HEAD', status=200)
559 self.assertIn('juju deploy cs:~owner/series/charm', response.body)
560
561 def test_route_personal_charm_without_revision(self):
562 # The route /~owner/series/charm route finds the charm using owner
563 # series, and charm name. Revision is ignored. The deploy instruction
564 # matches the route.
565 ignore, charm = factory.makeCharm(
566 self.db, owner='owner', series='series', name='charm', files={})
567 response = self.app.get('/~owner/series/charm', status=200)
568 self.assertIn('juju deploy cs:~owner/series/charm', response.body)
569
570 def test_route_personal_charm_json_with_revision(self):
571 # The route /~owner/series/charm-1/json finds charms using owner,
572 # series, charm name, and revision. The address key does not contain
573 # the revision.
574 ignore, charm = factory.makeCharm(
575 self.db, owner='owner', series='series', name='charm', files={})
576 response = self.app.get('/~owner/series/charm-1/json', status=200)
577 self.assertEqual('cs:~owner/series/charm', response.json['address'])
578
579 def test_route_personal_charm_json_without_revision(self):
580 # The route /~owner/series/charm/json finds the charm using owner
581 # series, and charm name. Revision is ignored. The address key matches
582 # the route
583 ignore, charm = factory.makeCharm(
584 self.db, owner='owner', series='series', name='charm', files={})
585 response = self.app.get('/~owner/series/charm/json', status=200)
586 self.assertEqual('cs:~owner/series/charm', response.json['address'])
587
588 def test_route_promulgated_charm_with_revision(self):
589 # The route /series/charm-1 route finds promulgated charms using
590 # The series, charm name and version. The deploy instruction does not
591 # include the version.
592 ignore, charm = factory.makeCharm(
593 self.db, promulgated=True, series='series', name='charm', files={})
594 response = self.app.get('/series/charm-1', status=200)
595 self.assertIn('juju deploy cs:series/charm', response.body)
596
597 def test_route_promulgated_charm_with_head_revision(self):
598 # The route /series/charm-HEAD finds promulgated charms using
599 # series, charm name, and head revision. The revision is not included
600 # in the deploy instruction.
601 ignore, charm = factory.makeCharm(
602 self.db, promulgated=True, series='series', name='charm', files={})
603 response = self.app.get('/series/charm-HEAD', status=200)
604 self.assertIn('juju deploy cs:series/charm', response.body)
605
606 def test_route_promulgated_charm_without_revision(self):
607 # The route /series/charm finds the promulgated charms using just
608 # the series and charm name. The deploy instruction matches the route.
609 ignore, charm = factory.makeCharm(
610 self.db, promulgated=True, series='series', name='charm', files={})
611 response = self.app.get('/series/charm', status=200)
612 self.assertIn('juju deploy cs:series/charm', response.body)
613
490614
491class TestQAForm(ViewTestBase):615class TestQAForm(ViewTestBase):
492616

Subscribers

People subscribed via source and target branches