Repository: daleal/zum

Issue:
Support type declarations on the body attributes
Right now, everything that gets passed to the CLI gets interpreted as a string. It is **absolutely necessary** that support for typed body attributes is added.

Comments:

Patch:
diff --git a/zum/constants.py b/zum/constants.py
index a6d45ef..951f5db 100644
--- a/zum/constants.py
+++ b/zum/constants.py
@@ -20,3 +20,6 @@ HTTP_METHODS = [
     "patch",
     "trace",
 ]
+
+# Request body value types
+REQUEST_BODY_VALUE_TYPES = ["string", "integer", "float", "boolean", "null"]
diff --git a/zum/requests/core.py b/zum/requests/core.py
index fa01709..0326a9c 100644
--- a/zum/requests/core.py
+++ b/zum/requests/core.py
@@ -2,9 +2,9 @@
 Module for the core requests logic of zum.
 """
 
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List
 
-from zum.requests.errors import MissingEndpointParamsError
+from zum.requests.helpers import reduce_arguments
 from zum.requests.models import Request
 
 
@@ -18,23 +18,3 @@ def generate_request(raw_endpoint: Dict[str, Any], arguments: List[str]) -> Requ
     )
     body, _ = reduce_arguments(raw_endpoint.get("body"), remaining_arguments)
     return Request(raw_endpoint["route"], raw_endpoint["method"], params, body)
-
-
-def reduce_arguments(
-    keys: Optional[List[str]], arguments: List[Any]
-) -> Tuple[Dict[str, Any], List[Any]]:
-    """
-    Given a :keys array of strings, maps the first :keys.length elements
-    of the :arguments array to the :keys elements as keys, returning the
-    mapped elements and the rest of the :arguments array.
-    """
-    if keys is None:
-        return {}, arguments
-    if len(arguments) < len(keys):
-        raise MissingEndpointParamsError(
-            "Invalid amount of arguments passed to the command."
-        )
-    return (
-        {x[0]: x[1] for x in zip(keys, arguments)},
-        arguments[len(keys) :],
-    )
diff --git a/zum/requests/errors.py b/zum/requests/errors.py
index 17067fc..12b7366 100644
--- a/zum/requests/errors.py
+++ b/zum/requests/errors.py
@@ -10,3 +10,10 @@ class MissingEndpointParamsError(Exception):
 
 class InvalidEndpointDefinitionError(Exception):
     """An exception for when an endpoint is defined incorrectly."""
+
+
+class InvalidBodyParameterTypeError(Exception):
+    """
+    An exception for when a body parameter tries to be casted to a
+    type that doesn't match its intrinsec type.
+    """
diff --git a/zum/requests/helpers.py b/zum/requests/helpers.py
new file mode 100644
index 0000000..7370d0e
--- /dev/null
+++ b/zum/requests/helpers.py
@@ -0,0 +1,74 @@
+"""
+Module for the requests helpers of zum.
+"""
+
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+from zum.requests.errors import (
+    InvalidBodyParameterTypeError,
+    MissingEndpointParamsError,
+)
+from zum.requests.validations import validate_body_parameter_definition
+
+
+def reduce_arguments(
+    keys: Optional[List[str]], arguments: List[Any]
+) -> Tuple[Dict[str, Any], List[Any]]:
+    """
+    Given a :keys array of strings, maps the first :keys.length elements
+    of the :arguments array to the :keys elements as keys, returning the
+    mapped elements and the rest of the :arguments array.
+    """
+    if keys is None:
+        return {}, arguments
+    if len(arguments) < len(keys):
+        raise MissingEndpointParamsError(
+            "Invalid amount of arguments passed to the command."
+        )
+    casted_params = [cast_parameter(*x) for x in zip(keys, arguments)]
+    return (
+        # The next line is equivalent to `{**param for param in casted_params}`
+        {key: value for param in casted_params for key, value in param.items()},
+        arguments[len(keys) :],
+    )
+
+
+def cast_parameter(
+    definition: Union[str, Dict[str, str]], value: str
+) -> Dict[str, Any]:
+    """
+    Casts a value to its parameter definition and returns a dictionary with the
+    required key name and the value casted to its right type.
+    """
+    validate_body_parameter_definition(definition)
+    if isinstance(definition, str):
+        return {definition: value}
+    if "type" not in definition:
+        return {definition["name"]: value}
+    try:
+        return {definition["name"]: cast_value(value, definition["type"])}
+    except ValueError:
+        raise InvalidBodyParameterTypeError(  # pylint: disable=W0707
+            f"Parameter '{value}' can't be casted to '{definition['type']}'"
+        )
+
+
+def cast_value(value: str, casting_type: str) -> Any:
+    """Casts value depending on the casting type."""
+    if casting_type == "integer":
+        return int(value)
+    if casting_type == "float":
+        return float(value)
+    if casting_type == "boolean":
+        if value not in ["true", "false"]:
+            raise InvalidBodyParameterTypeError(
+                f"Booleans can't be '{value}', only 'true' or 'false'"
+            )
+        return value == "true"
+    if casting_type == "null":
+        if value != "null":
+            raise InvalidBodyParameterTypeError(
+                f"Null parameters can't be '{value}', only 'null'"
+            )
+        return None
+    return value  # String
diff --git a/zum/requests/validations.py b/zum/requests/validations.py
index e77a763..bef74f5 100644
--- a/zum/requests/validations.py
+++ b/zum/requests/validations.py
@@ -2,9 +2,9 @@
 Module for the requests validations of zum.
 """
 
-from typing import Any, Dict
+from typing import Any, Dict, Union
 
-from zum.constants import HTTP_METHODS
+from zum.constants import HTTP_METHODS, REQUEST_BODY_VALUE_TYPES
 from zum.requests.errors import InvalidEndpointDefinitionError
 
 
@@ -42,3 +42,20 @@ def validate_raw_endpoint_method(raw_endpoint: Dict[str, Any]) -> None:
         raise InvalidEndpointDefinitionError(
             "Invalid 'method' value for the endpoint (not a valid HTTP method)"
         )
+
+
+def validate_body_parameter_definition(definition: Union[str, Dict[str, str]]) -> None:
+    """Validates a body parameter definition."""
+    if not isinstance(definition, str) and not isinstance(definition, dict):
+        raise InvalidEndpointDefinitionError(
+            "Each endpoint body parameter should be a string or an object"
+        )
+    if isinstance(definition, dict):
+        if "name" not in definition:
+            raise InvalidEndpointDefinitionError(
+                "A name is required for every endpoint body parameter"
+            )
+        if "type" in definition and definition["type"] not in REQUEST_BODY_VALUE_TYPES:
+            raise InvalidEndpointDefinitionError(
+                f"Invalid type definition for body parameter '{definition['name']}'"
+            )


Test patch:
diff --git a/tests/requests/test_core.py b/tests/requests/test_core.py
index 6725196..7701f65 100644
--- a/tests/requests/test_core.py
+++ b/tests/requests/test_core.py
@@ -1,47 +1,9 @@
 import pytest
 
-from zum.requests.core import generate_request, reduce_arguments
-from zum.requests.errors import MissingEndpointParamsError
+from zum.requests.core import generate_request
 from zum.requests.models import Request
 
 
-class TestReduceArguments:
-    def setup_method(self):
-        self.keys = ["first", "second", "third"]
-        self.short_args = [1, 2]
-        self.perfect_args = [1, 2, 3]
-        self.long_args = [1, 2, 3, 4, 5]
-        self.output = {
-            "perfect": {
-                "processed": {"first": 1, "second": 2, "third": 3},
-                "remaining": [],
-            },
-            "long": {
-                "processed": {"first": 1, "second": 2, "third": 3},
-                "remaining": [4, 5],
-            },
-        }
-
-    def test_empty_keys(self):
-        processed, remaining = reduce_arguments(None, self.perfect_args)
-        assert processed == {}
-        assert remaining == self.perfect_args
-
-    def test_short_args(self):
-        with pytest.raises(MissingEndpointParamsError):
-            processed, remaining = reduce_arguments(self.keys, self.short_args)
-
-    def test_perfect_args(self):
-        processed, remaining = reduce_arguments(self.keys, self.perfect_args)
-        assert processed == self.output["perfect"]["processed"]
-        assert remaining == self.output["perfect"]["remaining"]
-
-    def test_long_args(self):
-        processed, remaining = reduce_arguments(self.keys, self.long_args)
-        assert processed == self.output["long"]["processed"]
-        assert remaining == self.output["long"]["remaining"]
-
-
 class TestGenerateRequest:
     def setup_method(self):
         self.raw_endpoint = {
diff --git a/tests/requests/test_helpers.py b/tests/requests/test_helpers.py
new file mode 100644
index 0000000..14de2e7
--- /dev/null
+++ b/tests/requests/test_helpers.py
@@ -0,0 +1,118 @@
+import pytest
+
+from zum.requests.errors import (
+    InvalidBodyParameterTypeError,
+    MissingEndpointParamsError,
+)
+from zum.requests.helpers import cast_parameter, cast_value, reduce_arguments
+
+
+class TestReduceArguments:
+    def setup_method(self):
+        self.keys = ["first", "second", "third"]
+        self.short_args = [1, 2]
+        self.perfect_args = [1, 2, 3]
+        self.long_args = [1, 2, 3, 4, 5]
+        self.output = {
+            "perfect": {
+                "processed": {"first": 1, "second": 2, "third": 3},
+                "remaining": [],
+            },
+            "long": {
+                "processed": {"first": 1, "second": 2, "third": 3},
+                "remaining": [4, 5],
+            },
+        }
+
+    def test_empty_keys(self):
+        processed, remaining = reduce_arguments(None, self.perfect_args)
+        assert processed == {}
+        assert remaining == self.perfect_args
+
+    def test_short_args(self):
+        with pytest.raises(MissingEndpointParamsError):
+            processed, remaining = reduce_arguments(self.keys, self.short_args)
+
+    def test_perfect_args(self):
+        processed, remaining = reduce_arguments(self.keys, self.perfect_args)
+        assert processed == self.output["perfect"]["processed"]
+        assert remaining == self.output["perfect"]["remaining"]
+
+    def test_long_args(self):
+        processed, remaining = reduce_arguments(self.keys, self.long_args)
+        assert processed == self.output["long"]["processed"]
+        assert remaining == self.output["long"]["remaining"]
+
+
+class TestCastParameter:
+    def setup_method(self):
+        self.string = {"input": ["key", "value"], "output": {"key": "value"}}
+        self.no_type = {"input": [{"name": "key"}, "value"], "output": {"key": "value"}}
+        self.casted = {
+            "input": [{"name": "key", "type": "integer"}, "69"],
+            "output": {"key": 69},
+        }
+        self.invalid_casting = [{"name": "key", "type": "integer"}, "invalid"]
+
+    def test_string_definition_casting(self):
+        output = cast_parameter(*self.string["input"])
+        assert output == self.string["output"]
+
+    def test_no_type_definition_casting(self):
+        output = cast_parameter(*self.no_type["input"])
+        assert output == self.no_type["output"]
+
+    def test_casted_definition_casting(self):
+        output = cast_parameter(*self.casted["input"])
+        assert output == self.casted["output"]
+
+    def test_invalid_casting(self):
+        with pytest.raises(InvalidBodyParameterTypeError) as excinfo:
+            cast_parameter(*self.invalid_casting)
+        assert "can't be casted" in str(excinfo.value)
+
+
+class TestCastValue:
+    def setup_method(self):
+        self.integer = {"input": ["420", "integer"], "output": 420}
+        self.float = {"input": ["6.9", "float"], "output": 6.9}
+        self.invalid_boolean = ["invalid", "boolean"]
+        self.true = {"input": ["true", "boolean"], "output": True}
+        self.false = {"input": ["false", "boolean"], "output": False}
+        self.invalid_null = ["invalid", "null"]
+        self.null = {"input": ["null", "null"], "output": None}
+        self.string = {"input": ["valid", "string"], "output": "valid"}
+
+    def test_integer(self):
+        output = cast_value(*self.integer["input"])
+        assert output == self.integer["output"]
+
+    def test_float(self):
+        output = cast_value(*self.float["input"])
+        assert output == self.float["output"]
+
+    def test_invalid_boolean(self):
+        with pytest.raises(InvalidBodyParameterTypeError) as excinfo:
+            cast_value(*self.invalid_boolean)
+        assert "only 'true' or 'false'" in str(excinfo.value)
+
+    def test_true_boolean(self):
+        output = cast_value(*self.true["input"])
+        assert output == self.true["output"]
+
+    def test_false_boolean(self):
+        output = cast_value(*self.false["input"])
+        assert output == self.false["output"]
+
+    def test_invalid_null(self):
+        with pytest.raises(InvalidBodyParameterTypeError) as excinfo:
+            cast_value(*self.invalid_null)
+        assert "only 'null'" in str(excinfo.value)
+
+    def test_null(self):
+        output = cast_value(*self.null["input"])
+        assert output == self.null["output"]
+
+    def test_string(self):
+        output = cast_value(*self.string["input"])
+        assert output == self.string["output"]
diff --git a/tests/requests/test_valdiations.py b/tests/requests/test_valdiations.py
index 7f2ea9d..108d8ec 100644
--- a/tests/requests/test_valdiations.py
+++ b/tests/requests/test_valdiations.py
@@ -2,6 +2,7 @@ import pytest
 
 from zum.requests.errors import InvalidEndpointDefinitionError
 from zum.requests.validations import (
+    validate_body_parameter_definition,
     validate_raw_endpoint,
     validate_raw_endpoint_method,
     validate_raw_endpoint_route,
@@ -73,3 +74,30 @@ class TestRawEndpointMethodValidation:
 
     def test_uppercased_valid_method(self):
         validate_raw_endpoint_method(self.uppercased_valid)
+
+
+class TestBodyParameterDefinitionValidation:
+    def setup_method(self):
+        self.invalid_parameter = ["asdf"]
+        self.no_name = {"type": "integer"}
+        self.invalid_type = {"name": "test", "type": "invalid"}
+        self.valid = ["valid", {"name": "valid"}, {"name": "valid", "type": "integer"}]
+
+    def test_invalid_parameter(self):
+        with pytest.raises(InvalidEndpointDefinitionError) as excinfo:
+            validate_body_parameter_definition(self.invalid_parameter)
+        assert "should be a string or an object" in str(excinfo.value)
+
+    def test_no_name(self):
+        with pytest.raises(InvalidEndpointDefinitionError) as excinfo:
+            validate_body_parameter_definition(self.no_name)
+        assert "A name is required for every endpoint" in str(excinfo.value)
+
+    def test_invalid_type(self):
+        with pytest.raises(InvalidEndpointDefinitionError) as excinfo:
+            validate_body_parameter_definition(self.invalid_type)
+        assert "Invalid type definition" in str(excinfo.value)
+
+    def test_valid_parameter_definitions(self):
+        for parameter in self.valid:
+            validate_body_parameter_definition(parameter)