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
1=== modified file 'charmworld/routes.py'
2--- charmworld/routes.py 2013-08-23 12:47:09 +0000
3+++ charmworld/routes.py 2013-08-29 18:36:30 +0000
4@@ -33,6 +33,8 @@
5 # Personal Charms
6 config.add_route("personal-collection", "/~{owner}")
7 config.add_route("personal-series", "/~{owner}/{series}")
8+ config.add_route("personal-charm-revision",
9+ "/~{owner}/{series}/{charm}-{revision}")
10 config.add_route("personal-charm", "/~{owner}/{series}/{charm}")
11 config.add_route("personal-charm-revision-json",
12 "/~{owner}/{series}/{charm}-{revision}/json")
13@@ -50,6 +52,8 @@
14 config.add_route("interface-collection-map", "/interface-charm-map")
15
16 # Personal Bundles
17+ config.add_route("personal-bundle-revision",
18+ "/~{owner}/bundles/{basket}/{rev}/{bundle}")
19 config.add_route("personal-bundle",
20 "/~{owner}/bundles/{basket}/{bundle}")
21 config.add_route("personal-bundle-json",
22@@ -57,7 +61,9 @@
23
24 # Official (promulgated) Bundles
25 config.add_route("official-bundle-json",
26- "/bundles/{basket}/{bundle}/json")
27+ "/bundles/{basket}/{rev}/{bundle}/json")
28+ config.add_route("official-bundle-revision",
29+ "/bundles/{basket}/{rev}/{bundle}")
30
31 # Tools
32 config.add_route("tools-core-review", "/tools/core-review-queue")
33@@ -105,4 +111,10 @@
34
35 # Monitoring support.
36 config.add_route('heartbeat', '/heartbeat')
37+
38+ # Unnamespaced promulgated charms.
39+ config.add_route("promulgated-charm-revision",
40+ "/{series}/{charm}-{revision}")
41+ config.add_route("promulgated-charm", "/{series}/{charm}")
42+
43 return config
44
45=== modified file 'charmworld/views/bundles.py'
46--- charmworld/views/bundles.py 2013-08-23 19:19:21 +0000
47+++ charmworld/views/bundles.py 2013-08-29 18:36:30 +0000
48@@ -100,6 +100,9 @@
49
50
51 @cached_view_config(
52+ route_name="personal-bundle-revision",
53+ renderer="charmworld:templates/bundle.pt")
54+@cached_view_config(
55 route_name="personal-bundle",
56 renderer="charmworld:templates/bundle.pt")
57 def personal_bundle(request):
58@@ -127,6 +130,14 @@
59
60
61 @cached_view_config(
62+ route_name="official-bundle-revision",
63+ renderer="charmworld:templates/bundle.pt")
64+def official_bundle(request):
65+ bundle = find_bundle(request, promulgated=True)
66+ return _bundle_view(request, found(bundle))
67+
68+
69+@cached_view_config(
70 route_name="official-bundle-json")
71 def official_bundle_json(request):
72 bundle = find_bundle(request, promulgated=True)
73
74=== modified file 'charmworld/views/charms.py'
75--- charmworld/views/charms.py 2013-08-22 16:23:51 +0000
76+++ charmworld/views/charms.py 2013-08-29 18:36:30 +0000
77@@ -185,8 +185,30 @@
78 json.dumps(charm, sort_keys=True, indent=2),
79 headerlist=[
80 ("Access-Control-Allow-Origin", "*"),
81- ("Access-Control-Allow-Headers", "X-Requested-With")],
82- content_type=u"application/json")
83+ ("Access-Control-Allow-Headers", "X-Requested-With"),
84+ ("Content-Type", "application/json")])
85+
86+
87+def _reconcile_revision(request):
88+ """Check and reconcile the revision munged with the charm name.
89+
90+ Charm names may contain dashes, but the charm store appends the
91+ charm revision revision with a dash. Routing rules may match a revision
92+ that is actually a part of the charm name. The juju-gui apache front-end
93+ will append -HEAD to versionless charm urls.
94+ """
95+ match = request.matchdict
96+ if 'revision' not in match or match['revision'].isdigit():
97+ # The charm name and revision look sane.
98+ return
99+ if match['revision'].upper() == 'HEAD':
100+ # Remove the false revision created by the juju-gui apache front-end.
101+ # Charmworld always shows head when no revision is specified.
102+ del match['revision']
103+ else:
104+ # The charm name was wrongly matched as a revision.
105+ match['charm'] = "%s-%s" % (match['charm'], match['revision'])
106+ del match['revision']
107
108
109 def found_charm_collection(db, query, sub_only, only_promulgated=False):
110@@ -275,7 +297,11 @@
111 @cached_view_config(
112 route_name="personal-charm",
113 renderer="charmworld:templates/charm.pt")
114+@cached_view_config(
115+ route_name="personal-charm-revision",
116+ renderer="charmworld:templates/charm.pt")
117 def personal_charm(request):
118+ _reconcile_revision(request)
119 return _charm_view(request, find_charm(request))
120
121
122@@ -284,18 +310,22 @@
123 @cached_view_config(
124 route_name="personal-charm-json")
125 def person_charm_json(request):
126- match = request.matchdict
127- if 'revision' in match and not match['revision'].isdigit():
128- match['charm'] = "%s-%s" % (
129- match['charm'], match['revision'])
130+ _reconcile_revision(request)
131 charm = Charm(find_charm(request))
132 return _json_charm(charm)
133
134
135 @cached_view_config(
136+ route_name="promulgated-charm-revision",
137+ renderer="charmworld:templates/charm.pt")
138+@cached_view_config(
139+ route_name="promulgated-charm",
140+ renderer="charmworld:templates/charm.pt")
141+@cached_view_config(
142 route_name="charm",
143 renderer="charmworld:templates/charm.pt")
144 def distro_charm(request):
145+ _reconcile_revision(request)
146 return _charm_view(request, find_charm(request, promulgated=True))
147
148
149@@ -306,10 +336,7 @@
150 route_name="charm-json",
151 )
152 def distro_charm_json(request):
153- match = request.matchdict
154- if 'revision' in match and not match['revision'].isdigit():
155- match['charm'] = "%s-%s" % (
156- match['charm'], match['revision'])
157+ _reconcile_revision(request)
158 charm = Charm(find_charm(request, promulgated=True))
159 return _json_charm(charm)
160
161
162=== modified file 'charmworld/views/tests/test_bundles.py'
163--- charmworld/views/tests/test_bundles.py 2013-08-27 14:39:13 +0000
164+++ charmworld/views/tests/test_bundles.py 2013-08-29 18:36:30 +0000
165@@ -250,6 +250,16 @@
166 lp_long_url = 'https://code.launchpad.net/{path}'.format(path=lp_path)
167 self.assertIn(lp_long_url, resp.body)
168
169+ def test_personal_bundle_route_with_revision(self):
170+ bundle, bundle_data = factory.makeBundle(
171+ self.db, name='gigis', owner='ladonna',
172+ basket_with_rev='basket/1', series='raring',
173+ )
174+ path = '/~ladonna/bundles/basket/1/gigis'
175+ response = self.app.get(path, status=200)
176+ self.assertIn(
177+ 'lp:~ladonna/charms/bundles/basket/bundle', response.body)
178+
179 def test_personal_bundle_route_404(self):
180 bundle, bundle_data = factory.makeBundle(
181 self.db, name='gigis', owner='ladonna',
182@@ -282,12 +292,22 @@
183 self.db, name='wiki', owner='charmers',
184 basket_with_rev='wiki-basket/1', series='raring', promulgated=True,
185 )
186- path = '/bundles/wiki-basket/wiki/json'
187+ path = '/bundles/wiki-basket/1/wiki/json'
188 resp = self.app.get(path, status=200)
189 self.assertIn(bundle.data['series'], resp.body)
190 self.assertIn(str(bundle.data['relations']), resp.body)
191 self.assertIn(str(bundle.data['services']), resp.body)
192
193+ def test_official_bundle_with_revision_route(self):
194+ bundle, bundle_data = factory.makeBundle(
195+ self.db, name='wiki', owner='charmers',
196+ basket_with_rev='wiki-basket/1', series='raring', promulgated=True,
197+ )
198+ path = '/bundles/wiki-basket/1/wiki'
199+ response = self.app.get(path, status=200)
200+ self.assertIn(
201+ 'lp:~charmers/charms/bundles/wiki-basket/bundle', response.body)
202+
203 def test_official_bundle_route_404(self):
204 bundle, bundle_data = factory.makeBundle(
205 self.db, name='gigis', owner='ladonna',
206
207=== modified file 'charmworld/views/tests/test_charms.py'
208--- charmworld/views/tests/test_charms.py 2013-08-22 16:23:51 +0000
209+++ charmworld/views/tests/test_charms.py 2013-08-29 18:36:30 +0000
210@@ -28,6 +28,7 @@
211 from charmworld.views.api import API3
212 from charmworld.views.charms import (
213 _charm_view,
214+ _reconcile_revision,
215 charm_collection,
216 config,
217 distro_charm,
218@@ -475,6 +476,49 @@
219 self.assertEqual(expect, response['icon_path'])
220
221
222+class CharmsViewHelpersTestCase(ViewTestBase):
223+
224+ def test_reconcile_revision_with_dashed_name(self):
225+ # The charm name is reconstructed when a revision is wrongly matched.
226+ request = self.getRequest()
227+ request.matchdict = {
228+ 'charm': 'charm',
229+ 'revision': 'name',
230+ }
231+ _reconcile_revision(request)
232+ self.assertEqual('charm-name', request.matchdict['charm'])
233+ self.assertNotIn('revision', request.matchdict)
234+
235+ def test_reconcile_revision_with_revision(self):
236+ # The charm name is reconstructed when a revision is wrongly matched.
237+ request = self.getRequest()
238+ request.matchdict = {
239+ 'charm': 'charm',
240+ 'revision': '1',
241+ }
242+ _reconcile_revision(request)
243+ self.assertEqual('charm', request.matchdict['charm'])
244+ self.assertEqual('1', request.matchdict['revision'])
245+
246+ def test_reconcile_revision_with_head_revision(self):
247+ # The HEAD revision is dropped.
248+ request = self.getRequest()
249+ request.matchdict = {
250+ 'charm': 'charm',
251+ 'revision': 'HEAD',
252+ }
253+ _reconcile_revision(request)
254+ self.assertEqual('charm', request.matchdict['charm'])
255+ self.assertNotIn('revision', request.matchdict)
256+ request.matchdict = {
257+ 'charm': 'charm',
258+ 'revision': 'head',
259+ }
260+ _reconcile_revision(request)
261+ self.assertEqual('charm', request.matchdict['charm'])
262+ self.assertNotIn('revision', request.matchdict)
263+
264+
265 class CharmsWebTestCase(WebTestBase):
266
267 def test_charm_collection_with_missing_data(self):
268@@ -487,6 +531,86 @@
269 resp = self.app.get('/charms/precise', status=200)
270 self.assertIn(two['short_url'], resp.body)
271
272+ def test_route_personal_charm_with_revision(self):
273+ # The route /~owner/series/charm-1 finds charms using owner,
274+ # series, charm name, and revision. The deploy instruction
275+ # does not include the revision.
276+ ignore, charm = factory.makeCharm(
277+ self.db, owner='owner', series='series', name='charm', files={})
278+ response = self.app.get('/~owner/series/charm-1', status=200)
279+ self.assertIn('juju deploy cs:~owner/series/charm', response.body)
280+
281+ def test_route_personal_charm_with_dash_and_revision(self):
282+ # /~owner/series/charm-name-1 route find the charm.
283+ # This is a corner-case where the revision and charm name are
284+ # ambigous because they are munged.
285+ ignore, charm = factory.makeCharm(
286+ self.db, owner='owner', series='series', name='charm-name',
287+ files={})
288+ response = self.app.get('/~owner/series/charm-name-1', status=200)
289+ self.assertIn('juju deploy cs:~owner/series/charm-name', response.body)
290+
291+ def test_route_personal_charm_with_head_revision(self):
292+ # the /~owner/series/charm-name-HEAD route finds the charm.
293+ # The juju-gui apache rewrite versionless charm urls to help the GUI.
294+ ignore, charm = factory.makeCharm(
295+ self.db, owner='owner', series='series', name='charm', files={})
296+ response = self.app.get('/~owner/series/charm-HEAD', status=200)
297+ self.assertIn('juju deploy cs:~owner/series/charm', response.body)
298+
299+ def test_route_personal_charm_without_revision(self):
300+ # The route /~owner/series/charm route finds the charm using owner
301+ # series, and charm name. Revision is ignored. The deploy instruction
302+ # matches the route.
303+ ignore, charm = factory.makeCharm(
304+ self.db, owner='owner', series='series', name='charm', files={})
305+ response = self.app.get('/~owner/series/charm', status=200)
306+ self.assertIn('juju deploy cs:~owner/series/charm', response.body)
307+
308+ def test_route_personal_charm_json_with_revision(self):
309+ # The route /~owner/series/charm-1/json finds charms using owner,
310+ # series, charm name, and revision. The address key does not contain
311+ # the revision.
312+ ignore, charm = factory.makeCharm(
313+ self.db, owner='owner', series='series', name='charm', files={})
314+ response = self.app.get('/~owner/series/charm-1/json', status=200)
315+ self.assertEqual('cs:~owner/series/charm', response.json['address'])
316+
317+ def test_route_personal_charm_json_without_revision(self):
318+ # The route /~owner/series/charm/json finds the charm using owner
319+ # series, and charm name. Revision is ignored. The address key matches
320+ # the route
321+ ignore, charm = factory.makeCharm(
322+ self.db, owner='owner', series='series', name='charm', files={})
323+ response = self.app.get('/~owner/series/charm/json', status=200)
324+ self.assertEqual('cs:~owner/series/charm', response.json['address'])
325+
326+ def test_route_promulgated_charm_with_revision(self):
327+ # The route /series/charm-1 route finds promulgated charms using
328+ # The series, charm name and version. The deploy instruction does not
329+ # include the version.
330+ ignore, charm = factory.makeCharm(
331+ self.db, promulgated=True, series='series', name='charm', files={})
332+ response = self.app.get('/series/charm-1', status=200)
333+ self.assertIn('juju deploy cs:series/charm', response.body)
334+
335+ def test_route_promulgated_charm_with_head_revision(self):
336+ # The route /series/charm-HEAD finds promulgated charms using
337+ # series, charm name, and head revision. The revision is not included
338+ # in the deploy instruction.
339+ ignore, charm = factory.makeCharm(
340+ self.db, promulgated=True, series='series', name='charm', files={})
341+ response = self.app.get('/series/charm-HEAD', status=200)
342+ self.assertIn('juju deploy cs:series/charm', response.body)
343+
344+ def test_route_promulgated_charm_without_revision(self):
345+ # The route /series/charm finds the promulgated charms using just
346+ # the series and charm name. The deploy instruction matches the route.
347+ ignore, charm = factory.makeCharm(
348+ self.db, promulgated=True, series='series', name='charm', files={})
349+ response = self.app.get('/series/charm', status=200)
350+ self.assertIn('juju deploy cs:series/charm', response.body)
351+
352
353 class TestQAForm(ViewTestBase):
354

Subscribers

People subscribed via source and target branches