Repository: aio-libs/aiohttp

Issue:
subapp url route without '/' not working
## Long story short
I'm trying to use the aiohttp subapp router to split my application into smaller bits but the expected routes are not as pretty as expected 

I have the main application that runs 
`app.router.add_subapp('/api/v1', apiv1_app)`
and the subapp that runs
`apiv1_app.router.add_get('/', api_info)`

## Expected behaviour
using '/api/v1' url should call api_info

## Actual behaviour
doesn't call api_info only but using '/api/v1/' is calling it... sofar so good (makes sense because thats what i had written there ) but using
`api_app.router.add_get('', api_info)` (the get without '/') results in `ValueError: path should be started with /`



Discussion:
Thank you very much for report.
I'll fix the bug soon.

BTW I have a feeling that subapps documentation is not perfect -- at least I did it very quickly.
Maybe you might propose something?
Pull Request for docs would be awesome!

I can do that... it was a bit try and errorish ;D


Resolving patch:
diff --git a/aiohttp/web_urldispatcher.py b/aiohttp/web_urldispatcher.py
--- a/aiohttp/web_urldispatcher.py
+++ b/aiohttp/web_urldispatcher.py
@@ -76,6 +76,9 @@ def add_prefix(self, prefix):
     def get_info(self):
         """Return a dict with additional info useful for introspection"""
 
+    def freeze(self):
+        pass
+
 
 class AbstractRoute(abc.ABC):
 
@@ -286,14 +289,18 @@ class PlainResource(Resource):
 
     def __init__(self, path, *, name=None):
         super().__init__(name=name)
-        assert path.startswith('/')
+        assert not path or path.startswith('/')
         self._path = path
 
+    def freeze(self):
+        if not self._path:
+            self._path = '/'
+
     def add_prefix(self, prefix):
         assert prefix.startswith('/')
-        assert prefix.endswith('/')
+        assert not prefix.endswith('/')
         assert len(prefix) > 1
-        self._path = prefix + self._path[1:]
+        self._path = prefix + self._path
 
     def _match(self, path):
         # string comparison is about 10 times faster than regexp matching
@@ -329,10 +336,10 @@ def __init__(self, pattern, formatter, *, name=None):
 
     def add_prefix(self, prefix):
         assert prefix.startswith('/')
-        assert prefix.endswith('/')
+        assert not prefix.endswith('/')
         assert len(prefix) > 1
-        self._pattern = re.compile(re.escape(prefix)+self._pattern.pattern[2:])
-        self._formatter = prefix + self._formatter[1:]
+        self._pattern = re.compile(re.escape(prefix)+self._pattern.pattern)
+        self._formatter = prefix + self._formatter
 
     def _match(self, path):
         match = self._pattern.fullmatch(path)
@@ -363,18 +370,16 @@ def __repr__(self):
 class PrefixResource(AbstractResource):
 
     def __init__(self, prefix, *, name=None):
-        assert prefix.startswith('/'), prefix
-        assert prefix.endswith('/'), prefix
+        assert not prefix or prefix.startswith('/'), prefix
+        assert prefix in ('', '/') or not prefix.endswith('/'), prefix
         super().__init__(name=name)
         self._prefix = quote(prefix, safe='/')
-        self._prefix_len = len(self._prefix)
 
     def add_prefix(self, prefix):
         assert prefix.startswith('/')
-        assert prefix.endswith('/')
+        assert not prefix.endswith('/')
         assert len(prefix) > 1
-        self._prefix = prefix + self._prefix[1:]
-        self._prefix_len = len(self._prefix)
+        self._prefix = prefix + self._prefix
 
 
 class StaticResource(PrefixResource):
@@ -415,6 +420,7 @@ def url_for(self, *, filename):
             filename = str(filename)
         while filename.startswith('/'):
             filename = filename[1:]
+        filename = '/' + filename
         url = self._prefix + quote(filename, safe='/')
         return URL(url)
 
@@ -440,7 +446,7 @@ def resolve(self, request):
         if method not in allowed_methods:
             return None, allowed_methods
 
-        match_dict = {'filename': unquote(path[self._prefix_len:])}
+        match_dict = {'filename': unquote(path[len(self._prefix)+1:])}
         return (UrlMappingMatchInfo(match_dict, self._routes[method]),
                 allowed_methods)
         yield  # pragma: no cover
@@ -775,8 +781,8 @@ def _reg_resource(self, resource):
         self._resources.append(resource)
 
     def add_resource(self, path, *, name=None):
-        if not path.startswith('/'):
-            raise ValueError("path should be started with /")
+        if path and not path.startswith('/'):
+            raise ValueError("path should be started with / or be empty")
         if not ('{' in path or '}' in path or self.ROUTE_RE.search(path)):
             resource = PlainResource(quote(path, safe='/'), name=name)
             self._reg_resource(resource)
@@ -830,8 +836,8 @@ def add_static(self, prefix, path, *, name=None, expect_handler=None,
         """
         # TODO: implement via PrefixedResource, not ResourceAdapter
         assert prefix.startswith('/')
-        if not prefix.endswith('/'):
-            prefix += '/'
+        if prefix.endswith('/'):
+            prefix = prefix[:-1]
         resource = StaticResource(prefix, path,
                                   name=name,
                                   expect_handler=expect_handler,
@@ -879,13 +885,19 @@ def add_delete(self, *args, **kwargs):
         return self.add_route(hdrs.METH_DELETE, *args, **kwargs)
 
     def add_subapp(self, prefix, subapp):
-        assert prefix.startswith('/')
-        if not prefix.endswith('/'):
-            prefix += '/'
         if subapp.frozen:
             raise RuntimeError("Cannod add frozen application")
+        if prefix.endswith('/'):
+            prefix = prefix[:-1]
+        if prefix in ('', '/'):
+            raise ValueError("Prefix cannot be empty")
         resource = PrefixedSubAppResource(prefix, subapp)
         self._reg_resource(resource)
         self._app._reg_subapp_signals(subapp)
         subapp.freeze()
         return resource
+
+    def freeze(self):
+        super().freeze()
+        for resource in self._resources:
+            resource.freeze()


Test patch:
diff --git a/tests/test_urldispatch.py b/tests/test_urldispatch.py
--- a/tests/test_urldispatch.py
+++ b/tests/test_urldispatch.py
@@ -418,19 +418,19 @@ def test_contains(router):
 def test_static_repr(router):
     router.add_static('/get', os.path.dirname(aiohttp.__file__),
                       name='name')
-    assert re.match(r"<StaticResource 'name' /get/", repr(router['name']))
+    assert re.match(r"<StaticResource 'name' /get", repr(router['name']))
 
 
 def test_static_adds_slash(router):
     route = router.add_static('/prefix',
                               os.path.dirname(aiohttp.__file__))
-    assert '/prefix/' == route._prefix
+    assert '/prefix' == route._prefix
 
 
-def test_static_dont_add_trailing_slash(router):
+def test_static_remove_trailing_slash(router):
     route = router.add_static('/prefix/',
                               os.path.dirname(aiohttp.__file__))
-    assert '/prefix/' == route._prefix
+    assert '/prefix' == route._prefix
 
 
 @asyncio.coroutine
@@ -805,7 +805,7 @@ def test_static_resource_get_info(router):
     directory = pathlib.Path(aiohttp.__file__).parent
     resource = router.add_static('/st', directory)
     assert resource.get_info() == {'directory': directory,
-                                   'prefix': '/st/'}
+                                   'prefix': '/st'}
 
 
 @asyncio.coroutine
@@ -910,7 +910,7 @@ def test_url_for_in_resource_route(router):
 def test_subapp_get_info(router, loop):
     subapp = web.Application(loop=loop)
     resource = router.add_subapp('/pre', subapp)
-    assert resource.get_info() == {'prefix': '/pre/', 'app': subapp}
+    assert resource.get_info() == {'prefix': '/pre', 'app': subapp}
 
 
 def test_subapp_url(router, loop):
@@ -931,7 +931,7 @@ def test_subapp_repr(router, loop):
     subapp = web.Application(loop=loop)
     resource = router.add_subapp('/pre', subapp)
     assert repr(resource).startswith(
-        '<PrefixedSubAppResource /pre/ -> <Application')
+        '<PrefixedSubAppResource /pre -> <Application')
 
 
 def test_subapp_len(router, loop):
@@ -989,3 +989,25 @@ def test_set_options_route(router):
 def test_dynamic_url_with_name_started_from_undescore(router):
     route = router.add_route('GET', '/get/{_name}', make_handler())
     assert URL('/get/John') == route.url_for(_name='John')
+
+
+def test_cannot_add_subapp_with_empty_prefix(router, loop):
+    subapp = web.Application(loop=loop)
+    with pytest.raises(ValueError):
+        router.add_subapp('', subapp)
+
+
+def test_cannot_add_subapp_with_slash_prefix(router, loop):
+    subapp = web.Application(loop=loop)
+    with pytest.raises(ValueError):
+        router.add_subapp('/', subapp)
+
+
+@asyncio.coroutine
+def test_convert_empty_path_to_slash_on_freezing(router):
+    handler = make_handler()
+    route = router.add_get('', handler)
+    resource = route.resource
+    assert resource.get_info() == {'path': ''}
+    router.freeze()
+    assert resource.get_info() == {'path': '/'}