Repository: Algebra8/pyopenapi3

Issue:
Make parse_name_and_type_from_fmt_str return reference to components
`pyopenapi3.utils.parse_name_and_type_from_fmt_str` should be able to accept the following format: `{name:Component}`.

Currently, it will only consider data types:

```
# In parse_name_and_type_from_fmt_str
yield arg_name, getattr(pyopenapi3.data_types, _type_name)
```
Take the following example:

```
@open_bldr.path
class PetsWithId:

    path = "/pets/{pet_id:PetId}"


@open_bldr.component.parameter
class PetId:

    name = "pet_id"
    description = "Pet's Unique Identifier"
    in_field = "path"
    schema = create_schema(String, pattern="^[a-zA-Z0-9-]+$")
    required = True
```

Here, `PetId` is a parameter component, and should be referenced. In this scenario `parse_name_and_type_from_fmt_str` should yield `'pet_id', create_reference("PetId")`


Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index 8b5025d..45bfdbc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ __pycache__/
 pyopenapi3.egg-info/
 dist/
 build/
+.vscode/
diff --git a/src/pyopenapi3/builders.py b/src/pyopenapi3/builders.py
index 2fce8e4..c393181 100644
--- a/src/pyopenapi3/builders.py
+++ b/src/pyopenapi3/builders.py
@@ -3,6 +3,7 @@ from typing import get_type_hints, Union, List, Any, Dict, Optional
 from collections import deque
 import re
 
+from pyopenapi3.data_types import Component
 from pyopenapi3.utils import (
     build_mediatype_schema_from_content,
     create_schema,
@@ -12,7 +13,7 @@ from pyopenapi3.utils import (
 )
 from pyopenapi3.objects import (
     Response,
-    RequestBody,
+    RequestBody
 )
 from pyopenapi3.schemas import (
     RequestBodyObject,
@@ -212,6 +213,12 @@ class OperationBuilder:
         )
 
 
+# Issue-75: Save any user defined Component schema so that it can
+# be validated and potentially referenced by `PathItemBuilder`.
+# See call to `parse_name_and_type_from_fmt_str` in `PathItemBuilder`.
+_allowed_types: Dict[str, Component] = {}
+
+
 class PathItemBuilder:
 
     def __init__(self):
@@ -248,7 +255,9 @@ class PathItemBuilder:
             parameters += getattr(cls, 'parameters')
         # The given path may also hold params, e.g. "/users/{id:Int64}"
         path = cls.path
-        for name, _type in parse_name_and_type_from_fmt_str(path):
+        for name, _type in parse_name_and_type_from_fmt_str(
+            path, allowed_types=_allowed_types
+        ):
             parameters.append(
                 ParamBuilder('path').build_param(
                     name=name, schema=_type, required=True
@@ -269,8 +278,14 @@ class ParamBuilder:
 
     _field_keys = set(ParameterObject.__fields__.keys())
     _field_keys.add('schema')
+    _allowable_in_fields = {'path', 'query', 'header', 'cookie'}
 
     def __init__(self, __in):
+        if __in not in self._allowable_in_fields:
+            raise ValueError(
+                f"{__in} is not an acceptable `in-field`. "
+                f"Choices are {list(self._allowable_in_fields)}"
+            )
         self.__in = __in
 
     def __call__(self, **kwargs):
@@ -420,7 +435,6 @@ class ComponentBuilder:
         self._fields_used = set()
 
         # Parameter builds
-        # TODO parameters building for Comps
         self.parameter = self._parameters
         self._parameter_builds = {}
 
@@ -484,14 +498,18 @@ class ComponentBuilder:
         # Flush functions that were used to build this ObjectSchema.
         self._field_builds = {}
 
-        injected_comp_cls = inject_component(cls)
-        return injected_comp_cls
+        return inject_component(cls)
 
     def _parameters(self, cls=None, /, *, as_dict=None):
         if cls is not None:
             self._parameter_builds[cls.__name__] = \
                 ParamBuilder.build_param_from_cls(cls)
-            return cls
+
+            injected_comp_cls = inject_component(cls)
+            # Allow `cls` to be a valid reference in formatted `path`
+            # on `PathItemBuilder`
+            _allowed_types[cls.__name__] = injected_comp_cls
+            return injected_comp_cls
         if as_dict is None:
             raise ValueError(
                 "When not using the Components' parameter builder as a "
@@ -548,7 +566,7 @@ class ComponentBuilder:
             return func
 
         # Note, there is no good reason for why we can't just dump
-        # the functions and any attrs (i.e. {} or kwargs), onto a
+        # the functions and any attrs (i.e. {} or kwargs), into a
         # dict hosted by ComponentsBuilder, and flushing the dict
         # after using it (note this is important, or else fields on
         # older components will be used for the current iteration).
diff --git a/src/pyopenapi3/utils.py b/src/pyopenapi3/utils.py
index 74f61fd..ae92335 100644
--- a/src/pyopenapi3/utils.py
+++ b/src/pyopenapi3/utils.py
@@ -28,6 +28,7 @@ from .data_types import (
 # _from_fmt_str`
 import pyopenapi3.data_types
 from .schemas import (
+    Schema,
     StringDTSchema, ByteDTSchema, BinaryDTSchema, DateDTSchema,
     DateTimeDTSchema, PasswordDTSchema, IntegerDTSchema, Int32DTSchema,
     Int64DTSchema, NumberDTSchema, FloatDTSchema, DoubleDTSchema,
@@ -104,12 +105,31 @@ def format_description(s: Optional[str]) -> Optional[str]:
 
 
 def parse_name_and_type_from_fmt_str(
-        formatted_str) -> Generator[Tuple[str, Type[Field]], None, None]:
+        formatted_str: str,
+        allowed_types: Optional[Dict[str, Component]] = None
+) -> Generator[Tuple[str, Union[Type[Field], str]], None, None]:
     """
-    Parse a formatted string and return the names
-    of the args and their types.
+    Parse a formatted string and return the names of the args
+    and their types. Will raise a ValueError if the type is not
+    a pyopenapi3 `Field` or an already defined Component Parameter
+    type.
 
-    E.g. "/user/{id:int}" -> ("id", "int")
+    In the case that the type represents a `Field`, then its
+    type will be returned, respectively. Otherwise, if it is an
+    already defined Component Parameter, then the name of the
+    class that defines the parameter will be returned.
+
+    .. code:: none
+        # E.g. 1
+
+        "/user/{id:String}" -> ("id", pyopenapi3.data_types.String)
+
+        # E.g. 2
+
+        @open_bldr.component.parameter
+        class PetId: ...
+
+        "/pets/{pet:PetId}" -> ("pet", "PetId")
 
     If the string is not formatted, then will return (None, None).
     """
@@ -117,11 +137,19 @@ def parse_name_and_type_from_fmt_str(
         if arg_name is not None:
             try:
                 assert _type_name is not None
-                yield arg_name, getattr(pyopenapi3.data_types, _type_name)
+                _type = (
+                    allowed_types[_type_name] if allowed_types is not None
+                    and _type_name in allowed_types
+                    else getattr(pyopenapi3.data_types, _type_name)
+                )
+                yield arg_name, _type
             except AttributeError:
                 raise ValueError(
                     "A non-`Field` or `OpenApiObject` type was found. "
-                    f"Can't use `{_type_name}` as a type in {formatted_str}."
+                    f"Can't use `{_type_name}` as a type in {formatted_str}. "
+                    f"Must be a stringified pyopenapi3 `data_type`, such "
+                    f"as `pyopenapi3.data_types.String`, or a reference to a "
+                    f"Component."
                 ) from None
 
 
@@ -143,6 +171,10 @@ def create_schema(
         __type: Type[OpenApiObject],
         **kwargs: Any
 ) -> Union[SchemaObject, ReferenceObject]:
+    if isinstance(__type, Schema):
+        # Case where a Schema is already defined, don't need
+        # to recreate it.
+        return __type
     if issubclass(__type, Component):
         return convert_objects_to_schema(__type)
     elif issubclass(__type, Primitive):


Test patch:
diff --git a/tests/examples/component.py b/tests/examples/component.py
index d05ee3e..e74bea9 100644
--- a/tests/examples/component.py
+++ b/tests/examples/component.py
@@ -105,7 +105,7 @@ object_lvl_test = {
     "Pet": {
       "required": [
         "name",
-        "animal_type"
+        "animal_type",
       ],
       "type": "object",
       "properties": {
diff --git a/tests/examples/path.py b/tests/examples/path.py
index 5333a38..e23760b 100644
--- a/tests/examples/path.py
+++ b/tests/examples/path.py
@@ -20,3 +20,14 @@ path = {
     }
   }
 }
+
+path_with_parameter = {
+  'parameters': [
+    {
+      'name': 'pet_id',
+      'in': 'path',
+      'required': True,
+      'schema': {'$ref': '#/components/schemas/PetID'}
+    }
+  ]
+}
diff --git a/tests/test_builders.py b/tests/test_builders.py
index 8b7c385..b8c24b3 100644
--- a/tests/test_builders.py
+++ b/tests/test_builders.py
@@ -1,8 +1,11 @@
+import pytest
+
+from pyopenapi3 import OpenApiBuilder, create_schema
 from pyopenapi3.builders import (
     InfoBuilder,
     ServerBuilder,
     PathsBuilder,
-    ComponentBuilder
+    ComponentBuilder,
 )
 from pyopenapi3.objects import (
     Response,
@@ -150,6 +153,71 @@ def test_path_success():
     assert path_bldr.build['/pets'].dict() == path_examples.path['/pets']
 
 
+@pytest.fixture
+def _allowed_types():
+    """Tear down for global `pyopenapi3.builders._allowed_types`."""
+
+    # Clean up global `_allowed_types`; any test that indirectly
+    # makes use of `pyopenapi3.builders._allowed_types` but can be
+    # directly affected, such as in `test_paths_path__break`, should
+    # use this fixture. This is because any allowed type should be
+    # available for the entire running process.
+    import pyopenapi3
+    pyopenapi3.builders._allowed_types = {}
+    yield
+
+
+def test_path_with_path_parameter():
+    open_bldr = OpenApiBuilder()
+
+    @open_bldr.component.parameter
+    class PetID:
+
+        name = "pet_id"
+        description = "Pet's Unique identifier"
+        in_field = "path"
+        schema = create_schema(String, pattern="^[a-zA-Z0-9-]+$")
+        required = True
+
+    @open_bldr.path
+    class Path:
+
+        path = "/pets/{pet_id:PetID}"
+
+    p = open_bldr.path.build["/pets/{pet_id}"]
+
+    assert p.dict() == path_examples.path_with_parameter
+
+
+def test_paths_path__break(_allowed_types):
+    path_bldr = PathsBuilder()
+
+    with pytest.raises(ValueError):
+        @path_bldr
+        class Path:
+
+            # Should break because `PetID` is not a component parameter
+            # schema and is not a `pyopenapi3.data_types.Field` Type.
+            path = "/pets/{pet_id:PetID}"
+
+
+def test_path_parameter_in_field__fail():
+    comp = ComponentBuilder()
+
+    with pytest.raises(ValueError):
+
+        @comp.parameter
+        class PetID:
+
+            name = "pet_id"
+            description = "Pet's Unique identifier"
+            # Should fail with anything not in `ParamBuilder
+            # ._allowable_in_fields`.
+            in_field = "notpath"
+            schema = create_schema(String, pattern="^[a-zA-Z0-9-]+$")
+            required = True
+
+
 def test_components_builder():
     comp = ComponentBuilder()