diff --git a/restit/__init__.py b/restit/__init__.py index a10aa10..0955716 100644 --- a/restit/__init__.py +++ b/restit/__init__.py @@ -1,10 +1,12 @@ from .development_server import DevelopmentServer from .hyperlink import Hyperlink from .namespace import Namespace +from .path_parameter_decorator import path_parameter from .query_parameters_decorator import query_parameters from .request import Request from .request_body_decorator import request_body from .request_mapping import request_mapping +from .resource import Resource from .response import Response from .restit_app import RestitApp from .restit_test_app import RestitTestApp diff --git a/restit/hyperlink.py b/restit/hyperlink.py index 982fe6e..5a55541 100644 --- a/restit/hyperlink.py +++ b/restit/hyperlink.py @@ -1,7 +1,7 @@ from typing import Type -from restit import Request from restit.internal.resource_path import ResourcePath +from restit.request import Request from restit.resource import Resource diff --git a/restit/path_parameter_decorator.py b/restit/path_parameter_decorator.py new file mode 100644 index 0000000..966e438 --- /dev/null +++ b/restit/path_parameter_decorator.py @@ -0,0 +1,12 @@ +from typing import Type + +from restit.resource import Resource, PathParameter + + +# noinspection PyShadowingBuiltins +def path_parameter(name: str, type=str, doc: str = None): + def decorator(clazz: Type[Resource]): + clazz.add_path_parameter(PathParameter(name, type, doc)) + return clazz + + return decorator diff --git a/restit/resource.py b/restit/resource.py index 105e7fe..b6a6c12 100644 --- a/restit/resource.py +++ b/restit/resource.py @@ -1,11 +1,16 @@ +from collections import namedtuple, defaultdict from typing import Tuple, AnyStr, Dict, Union from werkzeug import Request -from werkzeug.exceptions import MethodNotAllowed +from werkzeug.exceptions import MethodNotAllowed, BadRequest from restit.internal.resource_path import ResourcePath from restit.response import Response +PathParameter = namedtuple("PathParameter", ["name", "type", "doc"]) + +_PATH_PARAMETER_MAPPING = defaultdict(list) + class Resource: __request_mapping__ = None @@ -13,6 +18,10 @@ class Resource: def __init__(self): self._resource_path = None + @classmethod + def add_path_parameter(cls, path_parameter: PathParameter): + _PATH_PARAMETER_MAPPING[cls].append(path_parameter) + def init(self): self._resource_path = ResourcePath(self.__request_mapping__) @@ -53,8 +62,32 @@ def head(self, request: Request) -> Response: def _handle_request(self, request_method: str, request: Request, path_params: Dict) -> Response: method = getattr(self, request_method.lower()) - return method(request, **path_params) + + passed_path_parameters = self._collect_and_convert_path_parameters(path_params) + + return method(request, **passed_path_parameters) + + def _collect_and_convert_path_parameters(self, path_params: dict): + for path_parameter in _PATH_PARAMETER_MAPPING[self.__class__]: + try: + path_parameter_value = path_params[path_parameter.name] + except KeyError: + raise Resource.PathParameterNotFoundException( + f"Unable to find {path_parameter} in incoming path parameters {path_params}" + ) + try: + path_params[path_parameter.name] = path_parameter.type(path_parameter_value) + except ValueError as error: + raise BadRequest( + f"Path parameter value '{path_parameter_value}' is not matching '{path_parameter}' " + f"({str(error)})" + ) + + return path_params def _get_match(self, url: str) -> Tuple[bool, Union[None, Dict[str, AnyStr]]]: assert self._resource_path return self._resource_path.get_match(url) + + class PathParameterNotFoundException(Exception): + pass diff --git a/restit/restit_test_app.py b/restit/restit_test_app.py index d9d525a..1e8e3d1 100644 --- a/restit/restit_test_app.py +++ b/restit/restit_test_app.py @@ -21,7 +21,6 @@ def __init__(self, restit_app: RestitApp): ) self._init() - def get(self, path: str, json: dict = None, data: dict = None, headers: dict = None) -> Response: return self._get_response_for_method(path, json, data, headers, "GET") @@ -47,7 +46,7 @@ def _get_response_for_method(self, path: str, json: dict, data: dict, headers: d def _get_response(self, wsgi_environment): request = Request(wsgi_environment) - resource, path_params = self._find_resource_for_url(request.get_path()) + resource, path_params = self._find_resource_for_url(request.get_extended_request_info().path) if self._raise_exceptions: return self._get_response_or_raise_not_found(path_params, request, resource) else: @@ -58,6 +57,7 @@ def _create_wsgi_environment( ): header = header or {} wsgi_environment = {} + parsed_path = urlparse(path) setup_testing_defaults(wsgi_environment) body_as_bytes = b"" if json is not None: @@ -66,10 +66,10 @@ def _create_wsgi_environment( body_as_bytes = "&".join([f"{key}={value}" for key, value in data.items()]) body_as_bytes = body_as_bytes.encode(encoding=get_default_encoding()) wsgi_environment["REQUEST_METHOD"] = method - wsgi_environment["PATH_INFO"] = path + wsgi_environment["PATH_INFO"] = parsed_path.path wsgi_environment["CONTENT_LENGTH"] = len(body_as_bytes) wsgi_environment["wsgi.input"] = BytesIO(body_as_bytes) - wsgi_environment["QUERY_STRING"] = urlparse(path).query + wsgi_environment["QUERY_STRING"] = parsed_path.query wsgi_environment["HTTP_ACCEPT"] = header.get("Accept", "*/*") wsgi_environment["HTTP_ACCEPT_ENCODING"] = header.get("Accept-Encoding", "gzip, deflate") wsgi_environment["CONTENT_TYPE"] = header.get("Content-Type", self._get_content_type(json, data)) diff --git a/test/restit/path_parameter_decorator_test.py b/test/restit/path_parameter_decorator_test.py new file mode 100644 index 0000000..365da6a --- /dev/null +++ b/test/restit/path_parameter_decorator_test.py @@ -0,0 +1,36 @@ +import unittest + +from restit import request_mapping, Request, Response, path_parameter, RestitApp, RestitTestApp +from restit.resource import Resource + + +@request_mapping("/path/:id1/and/:id2/and/:id3") +@path_parameter("id1", type=int, doc="First path parameter") +@path_parameter("id2", type=float, doc="Second path parameter") +@path_parameter("id3") +class Resource1(Resource): + def get(self, request: Request, **path_params) -> Response: + return Response(path_params) + + +class PathParameterTestCase(unittest.TestCase): + def setUp(self) -> None: + restit_app = RestitApp(resources=[ + Resource1() + ]) + self.restit_test_app = RestitTestApp(restit_app) + + def test_path_parameters(self): + response = self.restit_test_app.get("/path/1/and/10/and/20") + self.assertEqual(200, response.get_status_code()) + self.assertEqual({'id1': 1, 'id2': 10.0, 'id3': '20'}, response.json()) + + def test_conversion_exception(self): + response = self.restit_test_app.get("/path/1/and/hans/and/20") + self.assertEqual(400, response.get_status_code()) + self.assertEqual( + "400 Bad Request\n" + "

Bad Request

\n" + "

Path parameter value 'hans' is not matching 'PathParameter(name='id2', type=, " + "doc='Second path parameter')' (could not convert string to float: 'hans')

\n", response.text + ) diff --git a/test/restit/query_parameter_decorator_test.py b/test/restit/query_parameter_decorator_test.py index b010c7e..c36e93b 100644 --- a/test/restit/query_parameter_decorator_test.py +++ b/test/restit/query_parameter_decorator_test.py @@ -1,13 +1,14 @@ -import requests +import unittest + from marshmallow import Schema, fields from werkzeug import Request from werkzeug.exceptions import BadRequest +from restit import RestitApp, RestitTestApp from restit.query_parameters_decorator import query_parameters from restit.request_mapping import request_mapping from restit.resource import Resource from restit.response import Response -from test.base_test_server_test_case import BaseTestServerTestCase class QueryParameterSchema(Schema): @@ -29,23 +30,22 @@ def get(self, request: Request) -> Response: return Response(request.query_parameters) -class QueryParameterDecoratorTestCase(BaseTestServerTestCase): - @classmethod - def setUpClass(cls) -> None: - BaseTestServerTestCase.resources = [ +class QueryParameterDecoratorTestCase(unittest.TestCase): + def setUp(self) -> None: + restit_app = RestitApp(resources=[ QueryParametersResource(), CustomErrorClassResource() - ] - BaseTestServerTestCase.setUpClass() + ]) + self.restit_test_app = RestitTestApp(restit_app) def test_query_parameter(self): - response = requests.get(f"http://127.0.0.1:{self.port}/queryparams?param1=1¶m2=huhu") - self.assertEqual(200, response.status_code) + response = self.restit_test_app.get("/queryparams?param1=1¶m2=huhu") + self.assertEqual(200, response.get_status_code()) self.assertEqual({'param1': 1, 'param2': 'huhu'}, response.json()) def test_validation_error_gives_422_status(self): - response = requests.get(f"http://127.0.0.1:{self.port}/queryparams?param1=1&") - self.assertEqual(422, response.status_code) + response = self.restit_test_app.get(f"/queryparams?param1=1&") + self.assertEqual(422, response.get_status_code()) self.assertEqual( "422 Unprocessable Entity\n" "

Unprocessable Entity

\n" @@ -54,8 +54,8 @@ def test_validation_error_gives_422_status(self): ) def test_validation_error_custom_error_class(self): - response = requests.get(f"http://127.0.0.1:{self.port}/custom-error-class?param1=1&") - self.assertEqual(400, response.status_code) + response = self.restit_test_app.get(f"/custom-error-class?param1=1&") + self.assertEqual(400, response.get_status_code()) self.assertEqual( "400 Bad Request\n" "

Bad Request

\n" diff --git a/test/restit/test_restit_test_app.py b/test/restit/test_restit_test_app.py index 8c0139d..60b04ef 100644 --- a/test/restit/test_restit_test_app.py +++ b/test/restit/test_restit_test_app.py @@ -33,7 +33,6 @@ class NoMethodsResource(Resource): @request_mapping("/miau/:id") class ResourceWithPathParams(Resource): - def get(self, request: Request, **path_params) -> Response: return Response(path_params)