Repository: falconry/falcon

Issue:
Routes with trailing slash do not work under default API Options
Hello,

It seems when using the current default for api.req_options.strip_url_path_trailing_slash which is False, adding a slash at the end of a route doesn't work.

I think I found out what the problem is.

Here is the code I tried:
```python
import falcon

class StaticResource(object):

    def on_get(self, _: falcon.Request, resp: falcon.Response) -> None:
        resp.status = falcon.HTTP_200
        resp.content_type = 'text/plain'
        resp.body = 'static'

def create_app() -> falcon.API:
    api = falcon.API()
    api.req_options.strip_url_path_trailing_slash = False
    api.add_route('/static_route/', StaticResource())
    api.add_route('/static_route2', StaticResource())
    print(api._router._finder_src)
    return api
```

The finder_src compiled function is :
```python
def find(path, return_values, patterns, converters, params):
    path_len = len(path)
    if path_len > 0:
        if path[0] == 'static_route':
            if path_len == 1:
                return return_values[0]
            return None
        if path[0] == 'static_route2':
            if path_len == 1:
                return return_values[1]
            return None
        return None
    return None
```
We see that the code generated for both routes is the same.
The path passed to this function when calling uri: "http://127.0.0.1:8000/static_route/" is : ['static_route', ''] which has a length of 2, leading the function to return None and therefore an HTTPError 404.
When calling uri: "http://127.0.0.1:8000/static_route", the function returns the correct route and leads to HTTP 200.

Therefore using the default option leads to an impossibility to have routes ending with a trailing slash and even worse, when using routes ending with a trailing slash, the route without it works...


Discussion:
Hi :wave:,

Thanks for using Falcon. The large amount of time and effort needed to
maintain the project and develop new features is not sustainable without
the generous financial support of community members like you.

Please consider helping us secure the future of the Falcon framework with a
one-time or recurring donation.

Thank you for your support!

Hi @laurent-chriqui, thanks for submitting a bug report, we really appreciate when it includes code!

I am not sure I understand what you expect. Note that, `req_options.strip_url_path_trailing_slash` controls how the `Request` segments are determined, 
not how routing works, they are independent ideas.

Consider a route `add_route('/home/', HomeResc())` and `add_route('/home/{param}, Dashboard())`.

How would you route a request to `/home/data` vs `/home/` with `strip_url_path_trailing_slash=False`? Should `/home/` go to `HomeResc` or should it go `Dashboard`? I think there is an argument it should go to `Dashboard` with `param=""` and that is what it does. Here is a quick example:

```python
import falcon
import falcon.testing
import falcon.routing


class TestResource:
    def on_get(self, req, resp, **kwargs):
        resp.media = {'params': kwargs}


api = falcon.API()
api.add_route('/home/', TestResource())
api.add_route('/home/{id}', TestResource())

client = falcon.testing.TestClient(api)

api.req_options.strip_url_path_trailing_slash = False
assert client.simulate_get('/home').json['params'] == {}
assert client.simulate_get('/home/').json['params'] == {'id': ''}
assert client.simulate_get('/home/data').json['params'] == {'id': 'data'}
```

This changes slightly with `strip_url_path_trailing_slash=True`:

```python
# ... same as above

api.req_options.strip_url_path_trailing_slash = True
assert client.simulate_get('/home').json['params'] == {}
assert client.simulate_get('/home/').json['params'] == {}.  # Note, we don't get `id`
assert client.simulate_get('/home/data').json['params'] == {'id': 'data'}
```

This might point to us needing to `raise` when there is a trailing slash passed to `add_route` since it is ultimately meaningless.

Morale of the story `strip_url_path_trailing_slash` is about the `Request` handling not about the routers behavior.

I am going to mark this as needing documentation [here](https://falcon.readthedocs.io/en/stable/api/api.html#falcon.RequestOptions.strip_url_path_trailing_slash). If the other maintainers think there are code level changes for this... or if I am patently wrong (that happens far more often than I like!) please let me know!

**Bonus**... if you would like it to be different [you can change it](https://falcon.readthedocs.io/en/stable/api/routing.html#routing-custom)! 🎉 

**Bonus 2**... If you are actually trying to serve statics, you might want to look at [`add_static_route`](https://falcon.readthedocs.io/en/stable/api/api.html#falcon.API.add_static_route). If it doesn't meet your needs, let us know, we might be able to add features!

**Bonus 3**.... Thanks again for submitting this!

Hi,

Thank you for taking this issue into consideration and for exposing the reasoning behind this behavior.

It would seem that changing the option `req_options.strip_url_path_trailing_slash` would not prevent the developper to define a route ending with a forward slash. I understand the tricky part about the routes such as `'/home/{id}'` but as you pointed it out, the problem remains when the option is set to True.

In my opinion, if a route ending with a forward slash is explicitly defined (`'/home/'`, it should override a route such as `'/home/{id}'` and it is up to the developper to not have colliding routes, or to decide that if the parameter is null, it should point to a different ressource and define it using `'/home/'`.

It would seem to me that going through the hassle of defining a custom router to achieve this is a little overkill...

Again, thank you very much for looking into it and I look forward to hearing your opinion on what I've just written.
> `'/home/'`, it should override a route such as `'/home/{id}'`

That isn't the expected behavior from my perspective. `/home/{id}` is _more_ specific than `/home/` not less so overriding doesn't seem to be a good option.

Regardless, it seems to me you want the forward-slash character to have special meaning for the router, but it doesn't. The `/` character is just for delineating route parts and we [strip the trailing slash off](https://github.com/falconry/falcon/blob/master/falcon/routing/compiled.py#L152) in the default `CompiledRouter`. note the `.strip` and `.split` calls, that is all we are doing to get the parts.

_Routing_ compilation is independent from the _Request_ parsing and `req_options.strip_url_path_trailing_slash`  is about the `Request`.

Maybe we could not do that `.strip` call if this flag is set to `False`, but I don't think we should conflate those two. In my opinion, it would be _more preferable_ to render a warning that trailing `/` is a no-op. However, the _most preferable_ (in my opinion) is to leave it the way it is.  

Maybe we could add this to the `CompiledRouterOptions` ([here](https://github.com/falconry/falcon/blob/master/falcon/routing/compiled.py#L668)) allowing support for trailing slashes? If you wanted to take a stab at that and submit a PR, we could take a look at that implementation.

Thanks again, happy to continue dialoging and thanks for using Falcon! 

If you find Falcon useful, consider helping the project but submitting a PR for any of the open issues we have lots of `good first issue` items that can be picked up or consider financial support!
You are right that the documentation should be updated if things are left the way they are because the doc leads us to believe that we have the possibility to define routes ending with a trailing slash ([here](https://falcon.readthedocs.io/en/stable/user/faq.html#how-does-falcon-handle-a-trailing-slash-in-the-request-path))

> For example, with this option enabled, adding a route for '/foo/bar' implicitly adds a route for '/foo/bar/'. In other words, requests coming in for either path will be sent to the same resource.


Hello, we experienced this issue as well. We were on version 1.4.1 and one of the routes was defined with a / in the end. It used to work with and without the slash when queried. However, after upgrading, querying with a / returns a 404. 
@vishalrp You can easily bring back the 1.4.1 behaviour by setting [`strip_url_path_trailing_slash`](https://falcon.readthedocs.io/en/stable/api/api.html#falcon.RequestOptions.strip_url_path_trailing_slash) to `True` in your application's [request options](https://falcon.readthedocs.io/en/stable/api/api.html#falcon.RequestOptions).

It is a much bigger issue when the new default behaviour is desired, i.e., one wants to differentiate between the two routes, with and without slash, e.g., in a case described by the [`strip_url_path_trailing_slash`](https://falcon.readthedocs.io/en/stable/api/api.html#falcon.RequestOptions.strip_url_path_trailing_slash) docs:
> However, this behavior can be problematic in certain cases, such as when working with authentication schemes that employ URL-based signatures.

As also demonstrated by @laurent-chriqui , we now simply fail on this premise:
```python
>>> import falcon
>>> import falcon.testing
>>> class Responder:
...     def on_get(self, req, resp):
...         pass
... 
>>> app = falcon.API()
>>> app.add_route('/my/uri/example', Responder())
>>> 
>>> app.add_route('/my/uri/example/', Responder())
>>> client = falcon.testing.TestClient(app)
>>> client.simulate_get('/my/uri/example').status_code
200
>>> client.simulate_get('/my/uri/example/').status_code
404
```
To summarize, I am convinced this is bug, and a fairly serious one for users that do need to tell whether there was a trailing slash or not. Others can define routes without trailing slashes, and/or enable [`strip_url_path_trailing_slash`](https://falcon.readthedocs.io/en/stable/api/api.html#falcon.RequestOptions.strip_url_path_trailing_slash) as needed.
Added to the excellent analysis by @laurent-chriqui , we seem to always strip trailing slashes inside [`CompiledRouter.add_route`](https://falcon.readthedocs.io/en/stable/api/routing.html#falcon.routing.CompiledRouter.add_route):
```python
        path = uri_template.strip('/').split('/')
```

When fixing this, have the special `/` case in mind (there cannot be a route without a trailing slash for `/`) though.

Also watch out for conflicts between `/home/` and `/home/{id}`, as also discussed by @nZac .
OTOH I hope the current logic should just work for this `/home/` and `/home/{id}`, as it does for `/home/kitchen` and `/home/{id}`, except there would be an empty string in lieu of `kitchen`:
```
>>> class Responder:
...     def on_get(self, req, resp, **kwargs):
...         resp.media = {'kwargs': kwargs}
... 
>>> app = falcon.API()
>>> app.add_route('/home/kitchen', Responder())
>>> app.add_route('/home/{id}', Responder())
>>> client = falcon.testing.TestClient(app)
>>> client.simulate_get('/home/kitchen').json
{'kwargs': {}}
>>> client.simulate_get('/home/file001').json
{'kwargs': {'id': 'file001'}}
```

Resolving patch:
diff --git a/falcon/app.py b/falcon/app.py
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -456,6 +456,13 @@ def add_route(self, uri_template, resource, **kwargs):
 
                 (See also: :meth:`~.App.add_sink`)
 
+                Warning:
+                    If :attr:`~falcon.RequestOptions.strip_url_path_trailing_slash`
+                    is enabled, `uri_template` should be provided without a
+                    trailing slash.
+
+                    (See also: :ref:`trailing_slash_in_path`)
+
             resource (instance): Object which represents a REST
                 resource. Falcon will pass GET requests to ``on_get()``,
                 PUT requests to ``on_put()``, etc. If any HTTP methods are not
diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py
--- a/falcon/routing/compiled.py
+++ b/falcon/routing/compiled.py
@@ -198,7 +198,7 @@ class can use suffixed responders to distinguish requests
         if re.search(r'\s', _FIELD_PATTERN.sub('{FIELD}', uri_template)):
             raise ValueError('URI templates may not include whitespace.')
 
-        path = uri_template.strip('/').split('/')
+        path = uri_template.lstrip('/').split('/')
 
         used_names = set()
         for segment in path:


Test patch:
diff --git a/tests/test_httperror.py b/tests/test_httperror.py
--- a/tests/test_httperror.py
+++ b/tests/test_httperror.py
@@ -605,7 +605,7 @@ def test_405_without_body_with_extra_headers(self, client):
 
     def test_405_without_body_with_extra_headers_double_check(self, client):
         client.app.add_route(
-            '/405/', MethodNotAllowedResourceWithHeadersWithAccept()
+            '/405', MethodNotAllowedResourceWithHeadersWithAccept()
         )
 
         response = client.simulate_request(path='/405')
diff --git a/tests/test_uri_templates.py b/tests/test_uri_templates.py
--- a/tests/test_uri_templates.py
+++ b/tests/test_uri_templates.py
@@ -82,6 +82,18 @@ def on_get(self, req, resp, file_id, ext):
         self.called = True
 
 
+class KitchenSinkResource:
+    def __init__(self):
+        self.call_count = 0
+        self.kwargs = None
+        self.uri_template = None
+
+    def on_get(self, req, resp, **kwargs):
+        self.call_count += 1
+        self.kwargs = kwargs
+        self.uri_template = req.uri_template
+
+
 class ResourceWithSuffixRoutes:
     def __init__(self):
         self.get_called = False
@@ -349,21 +361,22 @@ def convert(self, fragment):
 def test_single_trailing_slash(client):
     resource1 = IDResource()
     client.app.add_route('/1/{id}/', resource1)
-    result = client.simulate_get('/1/123')
+    assert client.simulate_get('/1/123').status_code == 404
+    result = client.simulate_get('/1/123/')
     assert result.status == falcon.HTTP_200
     assert resource1.called
     assert resource1.id == '123'
-    assert resource1.req.path == '/1/123'
+    assert resource1.req.path == '/1/123/'
 
     resource2 = IDResource()
     client.app.add_route('/2/{id}/', resource2)
-    result = client.simulate_get('/2/123/')
+    result = client.simulate_get('/2/123')
     assert result.status == falcon.HTTP_404
     assert not resource2.called
     assert resource2.id is None
 
     resource3 = IDResource()
-    client.app.add_route('/3/{id}/', resource3)
+    client.app.add_route('/3/{id}', resource3)
     client.app.req_options.strip_url_path_trailing_slash = True
     result = client.simulate_get('/3/123/')
     assert result.status == falcon.HTTP_200
@@ -475,6 +488,34 @@ def test_adding_suffix_routes(client):
     assert resource_with_suffix_routes.collection_id == 'foo321'
 
 
+@pytest.mark.parametrize('reverse', [True, False])
+def test_with_and_without_trailing_slash(client, reverse):
+    routes = [
+        ('/kitchen', KitchenSinkResource()),
+        ('/kitchen/', KitchenSinkResource()),
+        ('/kitchen/{item}', KitchenSinkResource()),
+        ('/kitchen/{item}/', KitchenSinkResource()),
+        ('/kitchen/sink', KitchenSinkResource()),
+        ('/kitchen/sink/', KitchenSinkResource()),
+    ]
+    if reverse:
+        routes.reverse()
+
+    for route in routes:
+        client.app.add_route(*route)
+
+    for uri_template, resource in routes:
+        item = None
+        if '{item}' in uri_template:
+            item = 'kettle' if uri_template.endswith('/') else 'teapot'
+        resp = client.simulate_get(uri_template.replace('{item}', item or ''))
+
+        assert resp.status_code == 200
+        assert resource.call_count == 1
+        assert resource.kwargs.get('item') == item
+        assert resource.uri_template == uri_template
+
+
 def test_custom_error_on_suffix_route_not_found(client):
     resource_with_suffix_routes = ResourceWithSuffixRoutes()
     with pytest.raises(SuffixedMethodNotFoundError):