Repository: falconry/falcon

Issue:
Add default file name in the StaticRoute 
Consider adding the ability to specify a default filename for a static route. 

I'm not sure if it would be best to send the file or raise an `HTTPMovedPermanently` (or a `HTTPPermanentRedirect`). 
Probably for performance reasons the redirect would be best (but in this case maybe `default_filename` should become `default_location`)




Discussion:
Thinking back at my use case, serving a single page application html file, the two alternatives are not the same, since by using a redirect the original url would be lost, and with it the location on the spa.



There should be an overrideable default `index.html` that is served if the root path to the app is served.  This is how most static content hosting works.  No redirect logic necessary.  
I'll try to implement it. 
I was thinking about leaving the default route optional though.

Resolving patch:
diff --git a/falcon/api.py b/falcon/api.py
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -371,7 +371,7 @@ class can use suffixed responders to distinguish requests
         self._router.add_route(uri_template, method_map, resource, *args,
                                **kwargs)
 
-    def add_static_route(self, prefix, directory, downloadable=False):
+    def add_static_route(self, prefix, directory, downloadable=False, fallback_filename=None):
         """Add a route to a directory of static files.
 
         Static routes provide a way to serve files directly. This
@@ -383,6 +383,8 @@ def add_static_route(self, prefix, directory, downloadable=False):
             Serving files directly from the web server,
             rather than through the Python app, will always be more efficient,
             and therefore should be preferred in production deployments.
+            For security reasons, the directory and the fallback_filename (if provided)
+            should be read only for the account running the application.
 
         Static routes are matched in LIFO order. Therefore, if the same
         prefix is used for two routes, the second one will override the
@@ -410,12 +412,16 @@ def add_static_route(self, prefix, directory, downloadable=False):
             downloadable (bool): Set to ``True`` to include a
                 Content-Disposition header in the response. The "filename"
                 directive is simply set to the name of the requested file.
+            fallback_filename (str): Fallback filename used when the requested file
+                is not found. Can be a relative path inside the prefix folder or any valid
+                absolute path.
 
         """
 
         self._static_routes.insert(
             0,
-            routing.StaticRoute(prefix, directory, downloadable=downloadable)
+            routing.StaticRoute(prefix, directory, downloadable=downloadable,
+                                fallback_filename=fallback_filename)
         )
 
     def add_sink(self, sink, prefix=r'/'):
diff --git a/falcon/routing/static.py b/falcon/routing/static.py
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -24,6 +24,9 @@ class StaticRoute(object):
         downloadable (bool): Set to ``True`` to include a
             Content-Disposition header in the response. The "filename"
             directive is simply set to the name of the requested file.
+        fallback_filename (str): Fallback filename used when the requested file
+                is not found. Can be a relative path inside the prefix folder or any valid
+                absolute path.
     """
 
     # NOTE(kgriffs): Don't allow control characters and reserved chars
@@ -33,13 +36,20 @@ class StaticRoute(object):
     # minimizes how much can be included in the payload.
     _MAX_NON_PREFIXED_LEN = 512
 
-    def __init__(self, prefix, directory, downloadable=False):
+    def __init__(self, prefix, directory, downloadable=False, fallback_filename=None):
         if not prefix.startswith('/'):
             raise ValueError("prefix must start with '/'")
 
         if not os.path.isabs(directory):
             raise ValueError('directory must be an absolute path')
 
+        if fallback_filename is None:
+            self._fallback_filename = None
+        else:
+            self._fallback_filename = os.path.join(directory, fallback_filename)
+            if not os.path.isfile(self._fallback_filename):
+                raise ValueError('fallback_filename is not a file')
+
         # NOTE(kgriffs): Ensure it ends with a path separator to ensure
         # we only match on the complete segment. Don't raise an error
         # because most people won't expect to have to append a slash.
@@ -52,7 +62,9 @@ def __init__(self, prefix, directory, downloadable=False):
 
     def match(self, path):
         """Check whether the given path matches this route."""
-        return path.startswith(self._prefix)
+        if self._fallback_filename is None:
+            return path.startswith(self._prefix)
+        return path.startswith(self._prefix) or path == self._prefix[:-1]
 
     def __call__(self, req, resp):
         """Resource responder for this route."""
@@ -61,7 +73,8 @@ def __call__(self, req, resp):
 
         # NOTE(kgriffs): Check surrounding whitespace and strip trailing
         # periods, which are illegal on windows
-        if (not without_prefix or
+        # NOTE(CaselIT): An empty filename is allowed when fallback_filename is provided
+        if (not (without_prefix or self._fallback_filename is not None) or
                 without_prefix.strip().rstrip('.') != without_prefix or
                 self._DISALLOWED_CHARS_PATTERN.search(without_prefix) or
                 '\\' in without_prefix or
@@ -86,7 +99,12 @@ def __call__(self, req, resp):
         try:
             resp.stream = io.open(file_path, 'rb')
         except IOError:
-            raise falcon.HTTPNotFound()
+            if self._fallback_filename is None:
+                raise falcon.HTTPNotFound()
+            try:
+                resp.stream = io.open(self._fallback_filename, 'rb')
+            except IOError:
+                raise falcon.HTTPNotFound()
 
         suffix = os.path.splitext(file_path)[1]
         resp.content_type = resp.options.static_media_types.get(


Test patch:
diff --git a/tests/test_static.py b/tests/test_static.py
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 
 import io
+import os
 
 import pytest
 
@@ -106,6 +107,21 @@ def test_invalid_args(prefix, directory, client):
         client.app.add_static_route(prefix, directory)
 
 
+@pytest.mark.parametrize('default', [
+    'not-existing-file',
+    # directories
+    '.',
+    '/tmp',
+])
+def test_invalid_args_fallback_filename(client, default):
+    prefix, directory = '/static', '/var/www/statics'
+    with pytest.raises(ValueError, match='fallback_filename'):
+        StaticRoute(prefix, directory, fallback_filename=default)
+
+    with pytest.raises(ValueError, match='fallback_filename'):
+        client.app.add_static_route(prefix, directory, fallback_filename=default)
+
+
 @pytest.mark.parametrize('uri_prefix, uri_path, expected_path, mtype', [
     ('/static/', '/css/test.css', '/css/test.css', 'text/css'),
     ('/static', '/css/test.css', '/css/test.css', 'text/css'),
@@ -199,3 +215,88 @@ def test_downloadable_not_found(client):
 
     response = client.simulate_request(path='/downloads/thing.zip')
     assert response.status == falcon.HTTP_404
+
+
+@pytest.mark.parametrize('uri, default, expected', [
+    ('', 'default', 'default'),
+    ('other', 'default', 'default'),
+    ('index2', 'index', 'index2'),
+    ('absolute', '/foo/bar/index', '/foo/bar/index'),
+])
+def test_fallback_filename(uri, default, expected, monkeypatch):
+
+    def mockOpen(path, mode):
+        if default in path:
+            return path
+        raise IOError()
+
+    monkeypatch.setattr(io, 'open', mockOpen)
+    monkeypatch.setattr('os.path.isfile', lambda file: default in file)
+
+    sr = StaticRoute('/static', '/var/www/statics', fallback_filename=default)
+
+    req_path = '/static/' + uri
+
+    req = Request(testing.create_environ(
+        host='test.com',
+        path=req_path,
+        app='statics'
+    ))
+    resp = Response()
+    sr(req, resp)
+
+    assert sr.match(req.path)
+    assert resp.stream == os.path.join('/var/www/statics', expected)
+
+
+@pytest.mark.parametrize('strip_slash', [True, False])
+@pytest.mark.parametrize('path, fallback, static_exp, assert_axp', [
+    ('/index', 'index.html', 'index', 'index'),
+    ('', 'index.html', 'index.html', None),
+    ('/', 'index.html', 'index.html', None),
+    ('/other', 'index.html', 'index.html', None),
+    ('/other', 'index.raise', None, None)
+])
+def test_e2e_fallback_filename(client, monkeypatch, strip_slash, path, fallback,
+                               static_exp, assert_axp):
+
+    def mockOpen(path, mode):
+        if 'index' in path and 'raise' not in path:
+            return [path.encode('utf-8')]
+        raise IOError()
+
+    monkeypatch.setattr(io, 'open', mockOpen)
+    monkeypatch.setattr('os.path.isfile', lambda file: 'index' in file)
+
+    client.app.req_options.strip_url_path_trailing_slash = strip_slash
+    client.app.add_static_route('/static', '/opt/somesite/static',
+                                fallback_filename=fallback)
+    client.app.add_static_route('/assets/', '/opt/somesite/assets')
+
+    def test(prefix, directory, expected):
+        response = client.simulate_request(path=prefix + path)
+        if expected is None:
+            assert response.status == falcon.HTTP_404
+        else:
+            assert response.status == falcon.HTTP_200
+            assert response.text == directory + expected
+
+    test('/static', '/opt/somesite/static/', static_exp)
+    test('/assets', '/opt/somesite/assets/', assert_axp)
+
+
+@pytest.mark.parametrize('default, path, expected', [
+    (None, '/static', False),
+    (None, '/static/', True),
+    (None, '/staticfoo', False),
+    (None, '/static/foo', True),
+    ('index2', '/static', True),
+    ('index2', '/static/', True),
+    ('index2', '/staticfoo', False),
+    ('index2', '/static/foo', True),
+])
+def test_match(default, path, expected, monkeypatch):
+    monkeypatch.setattr('os.path.isfile', lambda file: True)
+    sr = StaticRoute('/static', '/var/www/statics', fallback_filename=default)
+
+    assert sr.match(path) == expected