Merge lp:~allenap/maas/api-uri-bug-1059645 into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 1324
Proposed branch: lp:~allenap/maas/api-uri-bug-1059645
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 332 lines (+163/-16)
6 files modified
src/maasserver/api.py (+20/-4)
src/maasserver/apidoc.py (+3/-8)
src/maasserver/tests/test_api.py (+55/-0)
src/maasserver/tests/test_apidoc.py (+4/-4)
src/maasserver/utils/__init__.py (+18/-0)
src/maasserver/utils/tests/test_utils.py (+63/-0)
To merge this branch: bzr merge lp:~allenap/maas/api-uri-bug-1059645
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Review via email: mp+132527@code.launchpad.net

This proposal supersedes a proposal from 2012-10-30.

Commit message

In the API description, make handler URIs absolute with respect to the request.

Previously, DEFAULT_MAAS_URL was always used.

Description of the change

I had to put a lot more work in to satisfy the review comments from jtv and jam, so I'm putting this back up for review.

The biggest change is that describe_handler now sets only a "path" field with the URI template; the describe() API function has to manufacture the "uri" field. I originally was using Django's HttpRequest.build_absolute_uri for this, but the additional tests I added showed that it was returning unexpected (and, imo, fairly useless) things, so I had to create a version that works.

These tests also highlighted that AnonFilesHandler was missing a resource_uri() method.

To post a comment you must log in.
Revision history for this message
John A Meinel (jameinel) wrote : Posted in a previous version of this proposal

This tests if the 'scheme' matches appropriately. Would you also want to test a nested case? (so rather than foobar.com/MAAS/1.0/api something like foo.com/bar/MAAS/1.0/api).

Otherwise it seems good to me.

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote : Posted in a previous version of this proposal

The comment “Make all API URLs absolute” in api.py seems a bit terse. Please leave a note so that some future visitor knows not to let this regress.

In test_handler_uris_are_absolute you make conditional assertions inside nested loops. That has its drawbacks: a failure stops the entire test, and you're not verifying that you're actually exercising the assertions. Better to gather your results and your expectations, and verify equality in one go.

Finally, in test_apidoc.py, why does the URL lack protocol and hostname?

Jeroen

review: Approve
Revision history for this message
Raphaël Badin (rvb) wrote :

Looks good to me.

[0]

272 + def make_request(
273 + self, host="example.com", port=80, script_name="", is_secure=False):

Weird formatting here.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py 2012-10-30 10:58:09 +0000
+++ src/maasserver/api.py 2012-11-02 09:26:19 +0000
@@ -164,6 +164,7 @@
164from maasserver.server_address import get_maas_facing_server_address164from maasserver.server_address import get_maas_facing_server_address
165from maasserver.utils import (165from maasserver.utils import (
166 absolute_reverse,166 absolute_reverse,
167 build_absolute_uri,
167 map_enum,168 map_enum,
168 strip_domain,169 strip_domain,
169 )170 )
@@ -1021,6 +1022,10 @@
10211022
1022 get = operation(idempotent=True, exported_as='get')(get_file)1023 get = operation(idempotent=True, exported_as='get')(get_file)
10231024
1025 @classmethod
1026 def resource_uri(cls, *args, **kwargs):
1027 return ('files_handler', [])
1028
10241029
1025class FilesHandler(OperationsHandler):1030class FilesHandler(OperationsHandler):
1026 """File management operations."""1031 """File management operations."""
@@ -1935,12 +1940,23 @@
1935 Returns a JSON object describing the whole MAAS API.1940 Returns a JSON object describing the whole MAAS API.
1936 """1941 """
1937 from maasserver import urls_api as urlconf1942 from maasserver import urls_api as urlconf
1943 resources = [
1944 describe_resource(resource)
1945 for resource in find_api_resources(urlconf)
1946 ]
1947 # Make all URIs absolute. Clients - maas-cli in particular - expect that
1948 # all handler URIs are absolute, not just paths. The handler URIs returned
1949 # by describe_resource() are relative paths.
1950 absolute = partial(build_absolute_uri, request)
1951 for resource in resources:
1952 for handler_type in "anon", "auth":
1953 handler = resource[handler_type]
1954 if handler is not None:
1955 handler["uri"] = absolute(handler["path"])
1956 # Package it all up.
1938 description = {1957 description = {
1939 "doc": "MAAS API",1958 "doc": "MAAS API",
1940 "resources": [1959 "resources": resources,
1941 describe_resource(resource)
1942 for resource in find_api_resources(urlconf)
1943 ],
1944 }1960 }
1945 # For backward compatibility, add "handlers" as an alias for all not-None1961 # For backward compatibility, add "handlers" as an alias for all not-None
1946 # anon and auth handlers in "resources".1962 # anon and auth handlers in "resources".
19471963
=== modified file 'src/maasserver/apidoc.py'
--- src/maasserver/apidoc.py 2012-10-06 22:42:45 +0000
+++ src/maasserver/apidoc.py 2012-11-02 09:26:19 +0000
@@ -19,9 +19,7 @@
1919
20from inspect import getdoc20from inspect import getdoc
21from itertools import izip_longest21from itertools import izip_longest
22from urlparse import urljoin
2322
24from django.conf import settings
25from django.core.urlresolvers import (23from django.core.urlresolvers import (
26 get_resolver,24 get_resolver,
27 RegexURLPattern,25 RegexURLPattern,
@@ -137,11 +135,8 @@
137 if isinstance(handler, BaseHandler):135 if isinstance(handler, BaseHandler):
138 handler = type(handler)136 handler = type(handler)
139137
140 uri_template = generate_doc(handler).resource_uri_template138 path = generate_doc(handler).resource_uri_template
141 if uri_template is None:139 path = "" if path is None else path
142 uri_template = settings.DEFAULT_MAAS_URL
143 else:
144 uri_template = urljoin(settings.DEFAULT_MAAS_URL, uri_template)
145140
146 resource_uri = getattr(handler, "resource_uri", lambda: ())141 resource_uri = getattr(handler, "resource_uri", lambda: ())
147 view_name, uri_params, uri_kw = merge(resource_uri(), (None, (), {}))142 view_name, uri_params, uri_kw = merge(resource_uri(), (None, (), {}))
@@ -154,7 +149,7 @@
154 "doc": getdoc(handler),149 "doc": getdoc(handler),
155 "name": handler.__name__,150 "name": handler.__name__,
156 "params": uri_params,151 "params": uri_params,
157 "uri": uri_template,152 "path": path,
158 }153 }
159154
160155
161156
=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py 2012-10-30 10:58:09 +0000
+++ src/maasserver/tests/test_api.py 2012-11-02 09:26:19 +0000
@@ -27,10 +27,12 @@
27import httplib27import httplib
28from itertools import izip28from itertools import izip
29import json29import json
30from operator import itemgetter
30import os31import os
31import random32import random
32import shutil33import shutil
33import sys34import sys
35from urlparse import urlparse
3436
35from apiclient.maas_client import MAASClient37from apiclient.maas_client import MAASClient
36from celery.app import app_or_default38from celery.app import app_or_default
@@ -38,9 +40,11 @@
38from django.contrib.auth.models import AnonymousUser40from django.contrib.auth.models import AnonymousUser
39from django.core.urlresolvers import reverse41from django.core.urlresolvers import reverse
40from django.http import QueryDict42from django.http import QueryDict
43from django.test.client import RequestFactory
41from fixtures import Fixture44from fixtures import Fixture
42from maasserver import api45from maasserver import api
43from maasserver.api import (46from maasserver.api import (
47 describe,
44 DISPLAYED_NODEGROUP_FIELDS,48 DISPLAYED_NODEGROUP_FIELDS,
45 extract_constraints,49 extract_constraints,
46 extract_oauth_key,50 extract_oauth_key,
@@ -130,10 +134,15 @@
130from provisioningserver.pxe import tftppath134from provisioningserver.pxe import tftppath
131from provisioningserver.testing.boot_images import make_boot_image_params135from provisioningserver.testing.boot_images import make_boot_image_params
132from testresources import FixtureResource136from testresources import FixtureResource
137from testscenarios import multiply_scenarios
133from testtools.matchers import (138from testtools.matchers import (
139 AfterPreprocessing,
134 AllMatch,140 AllMatch,
135 Contains,141 Contains,
136 Equals,142 Equals,
143 Is,
144 MatchesAll,
145 MatchesAny,
137 MatchesListwise,146 MatchesListwise,
138 MatchesStructure,147 MatchesStructure,
139 StartsWith,148 StartsWith,
@@ -4349,3 +4358,49 @@
4349 self.assertSetEqual(4358 self.assertSetEqual(
4350 {"doc", "handlers", "resources"}, set(description))4359 {"doc", "handlers", "resources"}, set(description))
4351 self.assertIsInstance(description["handlers"], list)4360 self.assertIsInstance(description["handlers"], list)
4361
4362
4363class TestDescribeAbsoluteURIs(AnonAPITestCase):
4364 """Tests for the `describe` view's URI manipulation."""
4365
4366 scenarios_schemes = (
4367 ("http", dict(scheme="http")),
4368 ("https", dict(scheme="https")),
4369 )
4370
4371 scenarios_paths = (
4372 ("script-at-root", dict(script_name="", path_info="")),
4373 ("script-below-root-1", dict(script_name="/foo/bar", path_info="")),
4374 ("script-below-root-2", dict(script_name="/foo", path_info="/bar")),
4375 )
4376
4377 scenarios = multiply_scenarios(
4378 scenarios_schemes, scenarios_paths)
4379
4380 def test_handler_uris_are_absolute(self):
4381 server = factory.make_name("server").lower()
4382 extra = {
4383 "PATH_INFO": self.path_info,
4384 "SCRIPT_NAME": self.script_name,
4385 "SERVER_NAME": server,
4386 "wsgi.url_scheme": self.scheme,
4387 }
4388 request = RequestFactory().get(
4389 "/%s/describe" % factory.make_name("path"), **extra)
4390 response = describe(request)
4391 self.assertEqual(httplib.OK, response.status_code, response.content)
4392 description = json.loads(response.content)
4393 expected_uri = AfterPreprocessing(
4394 urlparse, MatchesStructure(
4395 scheme=Equals(self.scheme), hostname=Equals(server),
4396 # The path is always the script name followed by "api/"
4397 # because all API calls are within the "api" tree.
4398 path=StartsWith(self.script_name + "/api/")))
4399 expected_handler = MatchesAny(
4400 Is(None), AfterPreprocessing(itemgetter("uri"), expected_uri))
4401 expected_resource = MatchesAll(
4402 AfterPreprocessing(itemgetter("anon"), expected_handler),
4403 AfterPreprocessing(itemgetter("auth"), expected_handler))
4404 resources = description["resources"]
4405 self.assertNotEqual([], resources)
4406 self.assertThat(resources, AllMatch(expected_resource))
43524407
=== modified file 'src/maasserver/tests/test_apidoc.py'
--- src/maasserver/tests/test_apidoc.py 2012-10-08 10:23:16 +0000
+++ src/maasserver/tests/test_apidoc.py 2012-11-02 09:26:19 +0000
@@ -203,7 +203,7 @@
203 observed = describe_handler(ExampleHandler)203 observed = describe_handler(ExampleHandler)
204 # The description contains several entries.204 # The description contains several entries.
205 self.assertSetEqual(205 self.assertSetEqual(
206 {"actions", "doc", "name", "params", "uri"},206 {"actions", "doc", "name", "params", "path"},
207 set(observed))207 set(observed))
208 self.assertEqual(ExampleHandler.__doc__, observed["doc"])208 self.assertEqual(ExampleHandler.__doc__, observed["doc"])
209 self.assertEqual(ExampleHandler.__name__, observed["name"])209 self.assertEqual(ExampleHandler.__name__, observed["name"])
@@ -231,11 +231,11 @@
231 }231 }
232 self.assertSetEqual(expected_actions, observed_actions)232 self.assertSetEqual(expected_actions, observed_actions)
233 self.assertSetEqual({"system_id"}, set(description["params"]))233 self.assertSetEqual({"system_id"}, set(description["params"]))
234 # The URI is a URI Template <http://tools.ietf.org/html/rfc6570>, the234 # The path is a URI Template <http://tools.ietf.org/html/rfc6570>, the
235 # components of which correspond to the parameters declared.235 # components of which correspond to the parameters declared.
236 self.assertEqual(236 self.assertEqual(
237 "http://example.com/api/1.0/nodes/{system_id}/",237 "/api/1.0/nodes/{system_id}/",
238 description["uri"])238 description["path"])
239239
240 def test_describe_resource_anonymous_resource(self):240 def test_describe_resource_anonymous_resource(self):
241 # When the resource does not require authentication, any configured241 # When the resource does not require authentication, any configured
242242
=== modified file 'src/maasserver/utils/__init__.py'
--- src/maasserver/utils/__init__.py 2012-10-25 12:17:28 +0000
+++ src/maasserver/utils/__init__.py 2012-11-02 09:26:19 +0000
@@ -12,6 +12,7 @@
12__metaclass__ = type12__metaclass__ = type
13__all__ = [13__all__ = [
14 'absolute_reverse',14 'absolute_reverse',
15 'build_absolute_uri',
15 'get_db_state',16 'get_db_state',
16 'ignore_unused',17 'ignore_unused',
17 'map_enum',18 'map_enum',
@@ -85,6 +86,23 @@
85 return url86 return url
8687
8788
89def build_absolute_uri(request, path):
90 """Given a full app-relative path, returns an absolute URI.
91
92 The path ordinarily starts with a forward-slash... imagine that you've
93 magically chroot'ed to your Django application; the path is the absolute
94 path to the view within that environment.
95
96 The URI returned uses the request to figure out how to make an absolute
97 URL. This means that the URI returned will use the same IP address or
98 alias that the request came in on.
99 """
100 script_name = request.META["SCRIPT_NAME"]
101 return "%s://%s%s%s" % (
102 "https" if request.is_secure() else "http",
103 request.get_host(), script_name, path)
104
105
88def strip_domain(hostname):106def strip_domain(hostname):
89 """Return `hostname` with the domain part removed."""107 """Return `hostname` with the domain part removed."""
90 return hostname.split('.', 1)[0]108 return hostname.split('.', 1)[0]
91109
=== modified file 'src/maasserver/utils/tests/test_utils.py'
--- src/maasserver/utils/tests/test_utils.py 2012-10-25 12:17:28 +0000
+++ src/maasserver/utils/tests/test_utils.py 2012-11-02 09:26:19 +0000
@@ -16,11 +16,13 @@
1616
17from django.conf import settings17from django.conf import settings
18from django.core.urlresolvers import reverse18from django.core.urlresolvers import reverse
19from django.http import HttpRequest
19from maasserver.enum import NODE_STATUS_CHOICES20from maasserver.enum import NODE_STATUS_CHOICES
20from maasserver.testing.factory import factory21from maasserver.testing.factory import factory
21from maasserver.testing.testcase import TestCase as DjangoTestCase22from maasserver.testing.testcase import TestCase as DjangoTestCase
22from maasserver.utils import (23from maasserver.utils import (
23 absolute_reverse,24 absolute_reverse,
25 build_absolute_uri,
24 get_db_state,26 get_db_state,
25 map_enum,27 map_enum,
26 strip_domain,28 strip_domain,
@@ -107,6 +109,67 @@
107 self.assertEqual(status, get_db_state(node, 'status'))109 self.assertEqual(status, get_db_state(node, 'status'))
108110
109111
112class TestBuildAbsoluteURI(TestCase):
113 """Tests for `build_absolute_uri`."""
114
115 def make_request(self, host="example.com", port=80, script_name="",
116 is_secure=False):
117 """Return a :class:`HttpRequest` with the given parameters."""
118 request = HttpRequest()
119 request.META["SERVER_NAME"] = host
120 request.META["SERVER_PORT"] = port
121 request.META["SCRIPT_NAME"] = script_name
122 request.is_secure = lambda: is_secure
123 return request
124
125 def test_simple(self):
126 request = self.make_request()
127 self.assertEqual(
128 "http://example.com/fred",
129 build_absolute_uri(request, "/fred"))
130
131 def test_different_port(self):
132 request = self.make_request(port=1234)
133 self.assertEqual(
134 "http://example.com:1234/fred",
135 build_absolute_uri(request, "/fred"))
136
137 def test_script_name_is_prefixed(self):
138 # The script name is always prefixed to the given path.
139 request = self.make_request(script_name="/foo/bar")
140 self.assertEqual(
141 "http://example.com/foo/bar/fred",
142 build_absolute_uri(request, "/fred"))
143
144 def test_secure(self):
145 request = self.make_request(port=443, is_secure=True)
146 self.assertEqual(
147 "https://example.com/fred",
148 build_absolute_uri(request, "/fred"))
149
150 def test_different_port_and_secure(self):
151 request = self.make_request(port=9443, is_secure=True)
152 self.assertEqual(
153 "https://example.com:9443/fred",
154 build_absolute_uri(request, "/fred"))
155
156 def test_no_leading_forward_slash(self):
157 # No attempt is made to ensure that the given path is separated from
158 # the to-be-prefixed path.
159 request = self.make_request(script_name="/foo")
160 self.assertEqual(
161 "http://example.com/foobar",
162 build_absolute_uri(request, "bar"))
163
164 def test_preserve_two_leading_slashes(self):
165 # Whilst this shouldn't ordinarily happen, two leading slashes in the
166 # path should be preserved, and not treated specially.
167 request = self.make_request(script_name="//foo")
168 self.assertEqual(
169 "http://example.com//foo/fred",
170 build_absolute_uri(request, "/fred"))
171
172
110class TestStripDomain(TestCase):173class TestStripDomain(TestCase):
111174
112 def test_strip_domain(self):175 def test_strip_domain(self):