Repository: Dorthu/openapi3

Issue:
Testing doesn't work
```
$ PYTHONPATH=. python3 -mpytest -sxv tests/

Test session starts (platform: linux, Python 3.9.9, pytest 6.0.2, pytest-sugar 0.9.4)
cachedir: .pytest_cache
rootdir: /src/openapi3
plugins: asyncio-0.14.0, xdist-2.2.0, gc-0.0.1, sugar-0.9.4, cov-2.10.1, anyio-3.3.0.1, forked-1.3.0, mock-1.10.4
collecting ... 

――――――――――――――――――――――――――――――――――― ERROR at setup of test_createPet ――――――――――――――――――――――――――――――――――――
ScopeMismatch: You tried to access the 'function' scoped fixture 'unused_tcp_port_factory' with a 'session' scoped request object, involved factories
tests/fastapi_test.py:30:  def server(event_loop, config)
tests/fastapi_test.py:17:  def config(unused_tcp_port_factory)
../../usr/lib/python3/dist-packages/pytest_asyncio/plugin.py:225:  def unused_tcp_port_factory()
                                                                                            4% ▍         
======================================== short test summary info ========================================
FAILED tests/fastapi_test.py::test_createPet
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

Results (0.22s):
       1 error
```

Comments:

Patch:
diff --git a/openapi3/openapi.py b/openapi3/openapi.py
index 5b79b14..0b2c753 100644
--- a/openapi3/openapi.py
+++ b/openapi3/openapi.py
@@ -101,16 +101,27 @@ class OpenAPI(ObjectBase):
         node = self
 
         for part in path:
+            part = part.replace('~1','/').replace('~0','~')
             if isinstance(node, Map):
-                if part not in node:  # pylint: disable=unsupported-membership-test
+                try:
+                    node = node[part]
+                except KeyError:
                     err_msg = "Invalid path {} in Reference".format(path)
                     raise ReferenceResolutionError(err_msg)
-                node = node.get(part)
             else:
-                if not hasattr(node, part):
+                try:
+                    ipart = int(part)
+                except ValueError:
+                    pass
+                else:
+                    if ipart>=0 and ipart<len(node):
+                        node = node[ipart]
+                        continue
+                try:
+                    node = getattr(node, part)
+                except AttributeError:
                     err_msg = "Invalid path {} in Reference".format(path)
                     raise ReferenceResolutionError(err_msg)
-                node = getattr(node, part)
 
         return node
 
diff --git a/setup.py b/setup.py
index 16d07b3..e5955e9 100755
--- a/setup.py
+++ b/setup.py
@@ -22,5 +22,7 @@ setup(
     packages=["openapi3"],
     license="BSD 3-Clause License",
     install_requires=["PyYaml", "requests"],
-    tests_require=["pytest", "pytest-asyncio", "uvloop", "hypercorn", "pydantic", "fastapi"],
+    extras_require={
+        "test": ["pytest", "pytest-asyncio==0.16", "uvloop", "hypercorn", "pydantic", "fastapi"],
+    },
 )


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 5a10c1b..b4e83cf 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -178,3 +178,11 @@ def schema_without_properties():
     Provides a spec that includes a schema with no properties defined
     """
     yield _get_parsed_yaml("schema-without-properties.yaml")
+
+
+@pytest.fixture
+def rfc_6901():
+    """
+    Provides a spec that includes RFC 6901 escape codes in ref paths
+    """
+    yield _get_parsed_yaml("rfc_6901.yaml")
diff --git a/tests/fixtures/rfc_6901.yaml b/tests/fixtures/rfc_6901.yaml
new file mode 100644
index 0000000..3b85030
--- /dev/null
+++ b/tests/fixtures/rfc_6901.yaml
@@ -0,0 +1,79 @@
+# this schema has refs whose paths include the escaped `~` and `/` characters
+# (escaped as ~0 and ~1 respectively).  This also purposefully includes the ~01
+# escape sequence to ensure parsing ends in `~1` and not `/`
+openapi: "3.1.0"
+info:
+  version: 1.0.0
+  title: RFC 6901 Test
+paths:
+  /ref-test:
+    parameters:
+      - $ref: '#/paths/~1parameters-holder/parameters/1'
+    get:
+      operationId: refTestGet
+      responses:
+        '200':
+          description: Test
+          content:
+            application/json:
+              schema:
+                description: |
+                  References all other fields in components/schemas to ensure all references
+                  are tested.
+                type: object
+                properties:
+                  one:
+                    $ref: '#/components/schemas/test~1one'
+                  two:
+                    $ref: '#/components/schemas/test~0two'
+                  three:
+                    $ref: '#/components/schemas/test~01three'
+                  four:
+                    $ref: '#/components/schemas/01/properties/example'
+  /parameters-holder:
+    parameters:
+      - name: example
+        in: query
+        schema:
+          type: int
+      - name: example2
+        in: query
+        schema:
+          type: int
+    get:
+      operationId: parametersHolderGet
+      responses:
+        '200':
+          description: Placeholder
+          content:
+            application/json:
+              schema:
+                type: object
+components:
+  schemas:
+    test/one:
+      description: |
+        Tests that refs can reference paths with a `/` character; this should be
+        escaped as `#/components/schemas/test~1one`
+      type: string
+    test~two:
+      description: |
+        Tests that refs can reference paths with a `~` character; this should be
+        escaped as `#/components/schemas/test~0two`
+      type: int
+    test~1three:
+      description: |
+        Tests that refs can reference paths with a ~1 sequence in them; this should
+        be escaped as `#/components/schemas/test~01three`
+      type: array
+      items:
+        type: string
+    '01':
+      description: |
+        Tests that paths parsed using integer-like segments are handled correctly.
+        This will be referenced as `#/components/schemas/0/properties/example`
+      type: object
+      properties:
+        example:
+          type: string
+          example: it worked
diff --git a/tests/ref_test.py b/tests/ref_test.py
index f7999be..3a15f5f 100644
--- a/tests/ref_test.py
+++ b/tests/ref_test.py
@@ -84,7 +84,7 @@ def test_ref_allof_handling(with_ref_allof):
     spec = OpenAPI(with_ref_allof)
     referenced_schema = spec.components.schemas['Example']
 
-    # this should have only one property; the allOf from 
+    # this should have only one property; the allOf from
     # paths['/allof-example']get.responses['200'].content['application/json'].schema
     # should not modify the component
     assert len(referenced_schema.properties) == 1, \
@@ -92,3 +92,26 @@ def test_ref_allof_handling(with_ref_allof):
                    len(referenced_schema.properties),
                    ", ".join(referenced_schema.properties.keys()),
             )
+
+def test_ref_6901_refs(rfc_6901):
+    """
+    Tests that RFC 6901 escape codes, such as ~0 and ~1, are pared correctly
+    """
+    spec = OpenAPI(rfc_6901, validate=True)
+    assert len(spec.errors()) == 0, spec.errors()
+
+    # spec parsed, make sure our refs got the right values
+    path = spec.paths['/ref-test']
+    response = path.get.responses['200'].content['application/json'].schema
+
+    assert response.properties['one'].type == 'string'
+    assert response.properties['two'].type == 'int'
+    assert response.properties['three'].type == 'array'
+
+    # ensure the integer path components parsed as expected too
+    assert response.properties['four'].type == 'string'
+    assert response.properties['four'].example == 'it worked'
+
+    # ensure integer path parsing does work as expected
+    assert len(path.parameters) == 1
+    assert path.parameters[0].name == 'example2'