Repository: ergo/pyramid_apispec

Issue:
Different views based on match_param predicates are not documented separately
When a route pattern includes placeholders that are used as predicates in a view's `match_param`, this means there can be multiple view functions with different documentation and unique URLs, however currently only one view's documentation is loaded and the path documented with APISpec includes placeholders the end user likely does not know about.

For example, the following route and views would currently only document as a single endpoint (`/foo/{id}/{view}`) when I think it should document two endpoints (`/foo/{id}/bar` and `/foo/{id}/baz`).

```python
config.add_route("foo", "/foo/{id}/{view}")

@view_config(route_name="foo", match_param="view=bar")
def foo_bar(request):
    """
    ---
    description: bar a foo
    """

@view_config(route_name="foo", match_param="view=baz")
def foo_baz(request):
    """
    ---
    description: baz a foo
    """
```

*Edit:* Grammar/clarity

Discussion:
smsearcy: Do I need to "sign" `AUTHORS.md`?

Patch:
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 8665adf..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-language: python
-sudo: false
-
-notifications:
-  on_success: change
-  on_failure: always
-
-matrix:
-  include:
-  - python: 3.5
-    env: TOXENV=py35
-  - python: 3.6
-    env: TOXENV=py36
-  - python: 3.7
-    env: TOXENV=py37
-  - python: 3.8
-    env: TOXENV=py38
-  - python: 3.9
-    env: TOXENV=py39
-  - python: nightly
-    env: TOXENV=py310
-  - python: 3.6
-    env: TOXENV=lint
-  allow_failures:
-  - python: nightly
-
-install:
-- travis_retry pip install -U setuptools pip tox
-
-script:
-- travis_retry tox
diff --git a/AUTHORS.md b/AUTHORS.md
index e12dc5d..0c02df7 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -34,3 +34,4 @@ Contributors:
 
 * Piotr Skamruk (jellonek) 2018-08-08
 * Daniel Debonzi (debonzi) 2019-10-21
+* Scott Searcy (smsearcy) 2021-09-02
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a70216d..6188a67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
    Unreleased section, uncommenting the header as necessary.
 -->
 
-<!-- ## Unreleased -->
+## Unreleased
 <!-- ### Changed -->
 <!-- ### Added -->
 <!-- ### Removed -->
-<!-- ### Fixed -->
+### Fixed
+* Different views for the same route that use `match_param` predicates are 
+  documented as different endpoints [(#21)](https://github.com/ergo/pyramid_apispec/issues/21)
 
 ## [0.4.0] - 2020-10-21
 ### Fixed
diff --git a/pyramid_apispec/helpers.py b/pyramid_apispec/helpers.py
index db1b61c..e535099 100644
--- a/pyramid_apispec/helpers.py
+++ b/pyramid_apispec/helpers.py
@@ -47,6 +47,7 @@ Inspecting a route and its view::
 from __future__ import absolute_import
 
 from apispec.yaml_utils import load_operations_from_docstring, load_yaml_from_docstring
+from pyramid.predicates import MatchParamPredicate
 from pyramid.threadlocal import get_current_request
 
 # py 2/3 compat
@@ -132,6 +133,12 @@ def add_pyramid_paths(
 
         pattern = route["pattern"]
         pattern = reformat_pattern(pattern)
+        if maybe_view.get("match_param"):
+            # replace route patterns that are specified in the view's match_param argument,
+            # so that route URLs are unique and accurate
+            match_params = MatchParamPredicate(maybe_view["match_param"], None)
+            for key, value in match_params.reqs:
+                pattern = pattern.replace("{%s}" % key, value)
         spec.path(
             pattern, operations=get_operations(maybe_view, operations, autodoc=autodoc)
         )
diff --git a/setup.py b/setup.py
index 21f61e0..21e9e03 100644
--- a/setup.py
+++ b/setup.py
@@ -40,15 +40,18 @@ setup(
         "Programming Language :: Python :: 3.4",
         "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
     ],
     keywords="pyramid apispec marshmallow rest restful",
     packages=find_packages(exclude=["contrib", "docs", "tests"]),
     package_data={"pyramid_apispec": ["static/*.*"], "": ["LICENSE"]},
-    install_requires=["apispec[yaml]>=3.0.0"],
+    install_requires=["apispec[yaml]>=3.0.0", "pyramid"],
     setup_requires=["pytest-runner"],
     extras_require={
-        "dev": ["coverage", "pytest", "pyramid", "tox", "webtest", "wheel", "twine"],
-        "demo": ["marshmallow==3.8.0", "pyramid", "apispec", "webtest"],
+        "dev": ["coverage", "pytest", "tox", "webtest", "wheel", "twine"],
+        "demo": ["marshmallow==3.8.0", "apispec", "webtest"],
         "lint": ["black"],
     },
 )
diff --git a/tox.ini b/tox.ini
index 8bf92e5..6521874 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,13 +1,32 @@
 [tox]
-envlist = py34,py35,py36,py37,py38,py39,lint
+envlist = py36,py37,py38,py39,lint
+
 [testenv]
+extras = dev
+
+commands=
+    pytest {posargs}
+
+[testenv:apispec{3,4,5}]
+extras = dev
+deps=
+    apispec3: apispec>=3.0,<4.0
+    apispec4: apispec>=4.0,<5.0
+    apispec5: apispec>=5.0,<6.0
+
+commands=
+    pytest {posargs}
+
+[testenv:pyramid{1,2}]
+extras = dev
+deps=
+    pyramid1: pyramid<2.0
+    pyramid2: pyramid>=2.0,<3.0
+
 commands=
-    pip install -e .[dev]
     pytest {posargs}
 
 [testenv:lint]
-basepython = python3.6
 extras = dev, lint
 commands =
-    pip install -e .[dev]
-    black --check --verbose pyramid_apispec
+    black --check --verbose pyramid_apispec tests


Test patch:
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
new file mode 100644
index 0000000..1710da8
--- /dev/null
+++ b/.github/workflows/ci-tests.yml
@@ -0,0 +1,40 @@
+name: pyramid_apispec
+
+on: [push]
+
+jobs:
+  test:
+    runs-on: "ubuntu-latest"
+    strategy:
+      matrix:
+        python: ["3.6", "3.7", "3.8", "3.9"]
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python }}
+      - name: Install dependencies
+        run: |
+          python -m pip install -U pip
+          pip install tox
+      - name: Run tox
+        run: |
+          tox -e py
+
+  lint:
+    runs-on: "ubuntu-latest"
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: "3.x"
+      - name: Install dependencies
+        run: |
+          python -m pip install -U pip
+          pip install tox
+      - name: Lint with Black
+        run: |
+          tox -e lint
diff --git a/tests/tests.py b/tests/tests.py
index 5c71584..4f9af5a 100644
--- a/tests/tests.py
+++ b/tests/tests.py
@@ -3,13 +3,21 @@ import pytest
 from apispec import APISpec
 from pyramid.config import Configurator
 from pyramid.response import Response
-from pyramid.scripting import prepare
+from pyramid.testing import DummyRequest, testConfig
 
 from pyramid_apispec import __version__
 from pyramid_apispec.helpers import add_pyramid_paths
 
 from webtest import TestApp as WebTestApp  # Avoid pytest warning
 
+
+@pytest.fixture
+def config():
+    """Pyramid test `Configurator` harness."""
+    with testConfig(request=DummyRequest()) as config:
+        yield config
+
+
 @pytest.fixture()
 def spec():
     return APISpec(
@@ -30,36 +38,38 @@ def test_version():
 
 
 class TestViewHelpers(object):
-    def test_path_from_view(self, spec):
+    def test_path_from_view(self, spec, config):
         def hi_request(request):
             return Response("Hi")
 
-        with Configurator() as config:
-            config.add_route("hi", "/hi")
-            config.add_view(hi_request, route_name="hi")
-            config.make_wsgi_app()
-        with prepare(config.registry):
-            add_pyramid_paths(
-                spec,
-                "hi",
-                operations={
-                    "get": {
-                        "parameters": [],
-                        "responses": {
-                            "200": {"description": "Test description", "schema": "file"}
-                        },
-                    }
-                },
-            )
+        config.add_route("hi", "/hi")
+        config.add_view(hi_request, route_name="hi")
+        add_pyramid_paths(
+            spec,
+            "hi",
+            operations={
+                "get": {
+                    "parameters": [],
+                    "responses": {
+                        "200": {"description": "Test description", "schema": "file"}
+                    },
+                }
+            },
+        )
         assert "/hi" in spec._paths
         assert "get" in spec._paths["/hi"]
         expected = {
             "parameters": [],
-            "responses": {"200": {"description": "Test description", "schema": "file"}},
+            "responses": {
+                "200": {
+                    "description": "Test description",
+                    "schema": {"$ref": "#/definitions/file"},
+                }
+            },
         }
         assert spec._paths["/hi"]["get"] == expected
 
-    def test_path_from_method_view(self, spec):
+    def test_path_from_method_view(self, spec, config):
         # Class Based Views
         class HelloApi(object):
             """Greeting API.
@@ -90,24 +100,19 @@ class TestViewHelpers(object):
                 """
                 return "hi"
 
-        with Configurator() as config:
-            config.add_route("hi", "/hi")
-            # normally this would be added via @view_config decorator
-            config.add_view(HelloApi, attr="get", route_name="hi", request_method="get")
-            config.add_view(
-                HelloApi, attr="post", route_name="hi", request_method="post"
-            )
-            config.add_view(
-                HelloApi,
-                attr="mixed",
-                route_name="hi",
-                request_method=["put", "delete"],
-                xhr=True,
-            )
-            config.make_wsgi_app()
+        config.add_route("hi", "/hi")
+        # normally this would be added via @view_config decorator
+        config.add_view(HelloApi, attr="get", route_name="hi", request_method="get")
+        config.add_view(HelloApi, attr="post", route_name="hi", request_method="post")
+        config.add_view(
+            HelloApi,
+            attr="mixed",
+            route_name="hi",
+            request_method=["put", "delete"],
+            xhr=True,
+        )
 
-        with prepare(config.registry):
-            add_pyramid_paths(spec, "hi")
+        add_pyramid_paths(spec, "hi")
 
         expected = {
             "description": "get a greeting",
@@ -118,16 +123,15 @@ class TestViewHelpers(object):
         assert spec._paths["/hi"]["x-extension"] == "global metadata"
         assert "mixed" in spec._paths["/hi"]["put"]["description"]
 
-    def test_autodoc_on(self, spec):
+    def test_autodoc_on(self, spec, config):
         def hi_request(request):
             return Response("Hi")
 
-        with Configurator() as config:
-            config.add_route("hi", "/hi")
-            config.add_view(hi_request, route_name="hi")
-            config.make_wsgi_app()
-        with prepare(config.registry):
-            add_pyramid_paths(spec, "hi")
+        config.add_route("hi", "/hi")
+        config.add_view(hi_request, route_name="hi")
+        config.make_wsgi_app()
+        add_pyramid_paths(spec, "hi")
+
         assert "/hi" in spec._paths
         assert "get" in spec._paths["/hi"]
         assert "head" in spec._paths["/hi"]
@@ -139,66 +143,95 @@ class TestViewHelpers(object):
         expected = {"responses": {}}
         assert spec._paths["/hi"]["get"] == expected
 
-    def test_autodoc_on_method(self, spec):
+    def test_autodoc_on_method(self, spec, config):
         def hi_request(request):
             return Response("Hi")
 
-        with Configurator() as config:
-            config.add_route("hi", "/hi")
-            config.add_view(hi_request, route_name="hi", request_method="GET")
-            config.make_wsgi_app()
-        with prepare(config.registry):
-            add_pyramid_paths(spec, "hi")
+        config.add_route("hi", "/hi")
+        config.add_view(hi_request, route_name="hi", request_method="GET")
+        add_pyramid_paths(spec, "hi")
+
         assert "/hi" in spec._paths
         assert "get" in spec._paths["/hi"]
         assert list(spec._paths["/hi"].keys()) == ["get"]
         expected = {"responses": {}}
         assert spec._paths["/hi"]["get"] == expected
 
-    def test_autodoc_off_empty(self, spec):
+    def test_autodoc_off_empty(self, spec, config):
         def hi_request(request):
             return Response("Hi")
 
-        with Configurator() as config:
-            config.add_route("hi", "/hi")
-            config.add_view(hi_request, route_name="hi")
-            config.make_wsgi_app()
-        with prepare(config.registry):
-            add_pyramid_paths(spec, "hi", autodoc=False)
+        config.add_route("hi", "/hi")
+        config.add_view(hi_request, route_name="hi")
+        add_pyramid_paths(spec, "hi", autodoc=False)
+
         assert "/hi" in spec._paths
         assert not spec._paths["/hi"].keys()
 
-    def test_path_with_multiple_methods(self, spec):
+    def test_path_with_multiple_methods(self, spec, config):
         def hi_request(request):
             return Response("Hi")
 
-        with Configurator() as config:
-            config.add_route("hi", "/hi")
-            config.add_view(hi_request, route_name="hi", request_method=["get", "post"])
-            config.make_wsgi_app()
-
-        with prepare(config.registry):
-            add_pyramid_paths(
-                spec,
-                "hi",
-                operations=dict(
-                    get={
-                        "description": "get a greeting",
-                        "responses": {"200": "..params.."},
-                    },
-                    post={
-                        "description": "post a greeting",
-                        "responses": {"200": "..params.."},
-                    },
-                ),
-            )
+        config.add_route("hi", "/hi")
+        config.add_view(hi_request, route_name="hi", request_method=["get", "post"])
+        add_pyramid_paths(
+            spec,
+            "hi",
+            operations=dict(
+                get={
+                    "description": "get a greeting",
+                    "responses": {"200": "..params.."},
+                },
+                post={
+                    "description": "post a greeting",
+                    "responses": {"200": "..params.."},
+                },
+            ),
+        )
 
         get_op = spec._paths["/hi"]["get"]
         post_op = spec._paths["/hi"]["post"]
         assert get_op["description"] == "get a greeting"
         assert post_op["description"] == "post a greeting"
 
-    def test_integration_with_docstring_introspection(self, spec):
+    def test_path_with_match_param(self, spec, config):
+        # this is not intended as an example of how to do i18n
+        def hello(request):
+            """Greet in English.
+
+            ---
+            description: English greeting
+            responses:
+                200:
+                    description: Success
+
+            """
+            return Response("Hello")
+
+        def hola(request):
+            """Greet in Spanish.
+
+            ---
+            description: Spanish greeting
+            responses:
+                200:
+                    description: Success
+
+            """
+            return Response("Hola")
+
+        config.add_route("greeting", "/greet/{language}")
+        config.add_view(hello, route_name="greeting", match_param="language=en")
+        config.add_view(hola, route_name="greeting", match_param="language=es")
+        config.make_wsgi_app()
+        add_pyramid_paths(spec, "greeting")
+
+        assert "/greet/en" in spec._paths
+        assert spec._paths["/greet/en"]["get"]["description"] == "English greeting"
+        assert "/greet/es" in spec._paths
+        assert spec._paths["/greet/es"]["get"]["description"] == "Spanish greeting"
+
+    def test_integration_with_docstring_introspection(self, spec, config):
         def hello():
             """A greeting endpoint.
 
@@ -227,13 +260,10 @@ class TestViewHelpers(object):
             """
             return "hi"
 
-        with Configurator() as config:
-            config.add_route("hello", "/hello")
-            config.add_view(hello, route_name="hello")
-            config.make_wsgi_app()
+        config.add_route("hello", "/hello")
+        config.add_view(hello, route_name="hello")
 
-        with prepare(config.registry):
-            add_pyramid_paths(spec, "hello")
+        add_pyramid_paths(spec, "hello")
 
         get_op = spec._paths["/hello"]["get"]
         post_op = spec._paths["/hello"]["post"]
@@ -243,46 +273,37 @@ class TestViewHelpers(object):
         assert "foo" not in spec._paths["/hello"]
         assert extension == "value"
 
-    def test_path_is_translated_to_swagger_template(self, spec):
+    def test_path_is_translated_to_swagger_template(self, spec, config):
         def get_pet(pet_id):
             return "representation of pet {pet_id}".format(pet_id=pet_id)
 
-        with Configurator() as config:
-            config.add_route("pet", "/pet/{pet_id}")
-            config.add_view(get_pet, route_name="pet")
-            config.make_wsgi_app()
+        config.add_route("pet", "/pet/{pet_id}")
+        config.add_view(get_pet, route_name="pet")
 
-        with prepare(config.registry):
-            add_pyramid_paths(spec, "pet")
+        add_pyramid_paths(spec, "pet")
         assert "/pet/{pet_id}" in spec._paths
 
-    def test_api_prefix(self, spec):
+    def test_api_prefix(self, spec, config):
         def api_routes(config):
             config.add_route("pet", "/pet/{pet_id}")
 
         def get_pet(pet_id):
             return "representation of pet {pet_id}".format(pet_id=pet_id)
 
-        with Configurator() as config:
-            config.include(api_routes, route_prefix="/api/v1/")
-            config.add_view(get_pet, route_name="pet")
-            config.make_wsgi_app()
+        config.include(api_routes, route_prefix="/api/v1/")
+        config.add_view(get_pet, route_name="pet")
 
-        with prepare(config.registry):
-            add_pyramid_paths(spec, "pet")
+        add_pyramid_paths(spec, "pet")
         assert "/api/v1/pet/{pet_id}" in spec._paths
 
-    def test_routes_with_regex(self, spec):
+    def test_routes_with_regex(self, spec, config):
         def get_pet(pet_id):
             return ""
 
-        with Configurator() as config:
-            config.add_route("pet", "/pet/{pet_id:\d+}")
-            config.add_view(get_pet, route_name="pet")
-            config.make_wsgi_app()
+        config.add_route("pet", r"/pet/{pet_id:\d+}")
+        config.add_view(get_pet, route_name="pet")
 
-        with prepare(config.registry):
-            add_pyramid_paths(spec, "pet")
+        add_pyramid_paths(spec, "pet")
         assert "/pet/{pet_id}" in spec._paths
 
 
@@ -313,15 +334,15 @@ class TestExplorer(object):
 
         with Configurator() as config:
             config.add_route("openapi_spec", "/api/{version}/openapi.json")
-            config.add_view(spec_view, route_name="openapi_spec", renderer='json')
+            config.add_view(spec_view, route_name="openapi_spec", renderer="json")
 
             config.include("pyramid_apispec.views")
             config.pyramid_apispec_add_explorer(
                 spec_route_name="openapi_spec",
-                explorer_route_path='/api/{version}/api-explorer'
+                explorer_route_path="/api/{version}/api-explorer",
             )
             app = config.make_wsgi_app()
 
             testapp = WebTestApp(app)
-            testapp.get('/api/v1/openapi.json')
-            testapp.get('/api/v1/api-explorer')
+            testapp.get("/api/v1/openapi.json")
+            testapp.get("/api/v1/api-explorer")