Repository: encode/starlette

Issue:
Endpoint path parameters type specification compatibility with OAS
When specifying a convertor type for a path parameter like so:
```
...
Route('/users/{user_id:int}', user, methods=["GET", "POST"])
...
```

The OAS schema generated using `SchemaGenerator` interprets the whole portion within `{}`  eg. `'user_id:int'` as the required path parameter variable name, which then requires the parameter name in OAS to be `user_id:int` instead of just `user_id`.

![user_idint](https://user-images.githubusercontent.com/26809407/70369654-50312280-18eb-11ea-9089-53743115394c.png)

I think the convertor is great in that a GET request to `/users/foo` will result in `404: NOT FOUND`. Of course, another option is to have a `try-except` clause to handle non-integer values within the method... but I was wondering what the community thinks about this and if this even should be supported by the `SchemaGenerator`.


Discussion:
Thanks for raising this.

As a first pass at resolving this we should just do the simple thing and strip any `:<specifier>` portion from the string. Would gratefully accept a pull request for that.

A second pass could be to *also* make sure that we're generating the correct type description in OpenAPI.

Resolving patch:
diff --git a/starlette/schemas.py b/starlette/schemas.py
--- a/starlette/schemas.py
+++ b/starlette/schemas.py
@@ -1,4 +1,5 @@
 import inspect
+import re
 import typing
 
 from starlette.requests import Request
@@ -49,10 +50,11 @@ def get_endpoints(
 
         for route in routes:
             if isinstance(route, Mount):
+                path = self._remove_converter(route.path)
                 routes = route.routes or []
                 sub_endpoints = [
                     EndpointInfo(
-                        path="".join((route.path, sub_endpoint.path)),
+                        path="".join((path, sub_endpoint.path)),
                         http_method=sub_endpoint.http_method,
                         func=sub_endpoint.func,
                     )
@@ -64,23 +66,32 @@ def get_endpoints(
                 continue
 
             elif inspect.isfunction(route.endpoint) or inspect.ismethod(route.endpoint):
+                path = self._remove_converter(route.path)
                 for method in route.methods or ["GET"]:
                     if method == "HEAD":
                         continue
                     endpoints_info.append(
-                        EndpointInfo(route.path, method.lower(), route.endpoint)
+                        EndpointInfo(path, method.lower(), route.endpoint)
                     )
             else:
+                path = self._remove_converter(route.path)
                 for method in ["get", "post", "put", "patch", "delete", "options"]:
                     if not hasattr(route.endpoint, method):
                         continue
                     func = getattr(route.endpoint, method)
-                    endpoints_info.append(
-                        EndpointInfo(route.path, method.lower(), func)
-                    )
+                    endpoints_info.append(EndpointInfo(path, method.lower(), func))
 
         return endpoints_info
 
+    def _remove_converter(self, path: str) -> str:
+        """
+        Remove the converter from the path.
+        For example, a route like this:
+            Route("/users/{id:int}", endpoint=get_user, methods=["GET"])
+        Should be represented as `/users/{id}` in the OpenAPI schema.
+        """
+        return re.sub(r":\w+}", "}", path)
+
     def parse_docstring(self, func_or_method: typing.Callable) -> dict:
         """
         Given a function, parse the docstring as YAML and return a dictionary of info.


Test patch:
diff --git a/tests/test_schemas.py b/tests/test_schemas.py
--- a/tests/test_schemas.py
+++ b/tests/test_schemas.py
@@ -13,6 +13,17 @@ def ws(session):
     pass  # pragma: no cover
 
 
+def get_user(request):
+    """
+    responses:
+        200:
+            description: A user.
+            examples:
+                {"username": "tom"}
+    """
+    pass  # pragma: no cover
+
+
 def list_users(request):
     """
     responses:
@@ -103,6 +114,7 @@ def schema(request):
 app = Starlette(
     routes=[
         WebSocketRoute("/ws", endpoint=ws),
+        Route("/users/{id:int}", endpoint=get_user, methods=["GET"]),
         Route("/users", endpoint=list_users, methods=["GET", "HEAD"]),
         Route("/users", endpoint=create_user, methods=["POST"]),
         Route("/orgs", endpoint=OrganisationsEndpoint),
@@ -168,6 +180,16 @@ def test_schema_generation():
                     }
                 },
             },
+            "/users/{id}": {
+                "get": {
+                    "responses": {
+                        200: {
+                            "description": "A user.",
+                            "examples": {"username": "tom"},
+                        }
+                    }
+                },
+            },
         },
     }
 
@@ -216,6 +238,13 @@ def test_schema_generation():
           description: A user.
           examples:
             username: tom
+  /users/{id}:
+    get:
+      responses:
+        200:
+          description: A user.
+          examples:
+            username: tom
 """