def test_normalize_absolute_roa_uri_abs(self): '''This test case ensures an absolute uri is correctly transformed to a relative uri by removing protocol and hostname.''' roa_api = "https://api.fantastico.com" self.assertEqual("", roa_helper.normalize_absolute_roa_uri(roa_api))
def test_normalize_absolute_roa_uri_withpath(self): '''This test case ensures an absolute uri which also contains a path is transformed correctly to a relative uri by removing protocol and hostname.''' roa_api = "https://api.fantastico.com/api/sandboxed" self.assertEqual("/api/sandboxed", roa_helper.normalize_absolute_roa_uri(roa_api))
def test_normalize_absolute_roa_uri_relative(self): '''This test case ensure relative uris are not changed during normalization procedure.''' roa_api = "/api" self.assertEqual(roa_api, roa_helper.normalize_absolute_roa_uri(roa_api))
class RoaController(BaseController): '''This class provides dynamic routes for ROA registered resources. All CRUD operations are supported out of the box. In addition error handling is automatically provided by this controller.''' SETTINGS_FACADE = SettingsFacade() ROA_API = roa_helper.normalize_absolute_roa_uri( SETTINGS_FACADE.get("roa_api")) BASE_URL = r"%s/(?P<version>\d{1,}\.\d{1,})(?P<resource_url>/[^/]*?)" % ROA_API BASE_LATEST_URL = "%s/latest(?P<resource_url>/[^/]*?)" % ROA_API OFFSET_DEFAULT = 0 LIMIT_DEFAULT = 100 def __init__(self, settings_facade, resources_registry_cls=ResourcesRegistry, model_facade_cls=ModelFacade, conn_manager=mvc, json_serializer_cls=ResourceJsonSerializer, query_parser_cls=QueryParser): super(RoaController, self).__init__(settings_facade) self._resources_registry = resources_registry_cls() self._model_facade_cls = model_facade_cls self._conn_manager = conn_manager self._json_serializer_cls = json_serializer_cls self._query_parser_cls = query_parser_cls doc_base = "%sfeatures/roa/errors/" % self._settings_facade.get( "doc_base") self._errors_url = doc_base + "error_%s.html" self._roa_api = self._settings_facade.get("roa_api") def _parse_filter(self, filter_expr, model): '''This method parse a string filter expression and builds a compatible ModelFilter.''' if not filter_expr: return None query_parser = self._query_parser_cls() return query_parser.parse_filter(filter_expr, model) def _parse_sort(self, sort_expr, model): '''This method parse a string sort expression and builds a compatible ModelSort.''' if not sort_expr: return None query_parser = self._query_parser_cls() return query_parser.parse_sort(sort_expr, model) def _build_error_response(self, http_code, error_code, error_description, error_details): '''This method builds an error response compliant with :doc:`/features/roa/rest_responses` specification.''' error = { "error_code": error_code, "error_description": error_description, "error_details": error_details } response = Response(text=json.dumps(error), status_code=http_code, content_type="application/json") self._add_cors_headers(response) return response def _handle_resource_notfound(self, version, url): '''This method build a resource not found response which is sent to the client. You can find more information about error responses format on :doc:`/features/roa/rest_responses`''' error_code = 10000 return self._build_error_response( http_code=404, error_code=error_code, error_description="Resource %s version %s does not exist." % (url, version), error_details=self._errors_url % error_code) def _handle_resource_item_notfound(self, version, url, resource_id): '''This method build a resource not found response which is sent to the client. You can find more information about error responses format on :doc:`/features/roa/rest_responses`. In the error description it also contains resource id.''' error_code = 10040 return self._build_error_response(http_code=404, error_code=error_code, error_description="Resource %s version %s id %s does not exist." % \ (url, version, resource_id), error_details=self._errors_url % error_code) def _handle_resource_invalid(self, version, url, ex): '''This method builds a resource invalid response which is sent to the client.''' error_code = 10010 return self._build_error_response(http_code=ex.http_code, error_code=error_code, error_description="Resource %s version %s is invalid: %s" % \ (url, version, str(ex)), error_details=self._errors_url % error_code) def _handle_resource_nobody(self, version, url): '''This method builds a resource nobody given response which is sent to the client.''' error_code = 10020 return self._build_error_response(http_code=400, error_code=error_code, error_description="Resource %s version %s can not be created: no body given." % \ (url, version), error_details=self._errors_url % error_code) def _handle_resource_dberror(self, version, url, dbex): '''This method builds a resource dberror response which is sent to the client.''' error_code = 10030 return self._build_error_response(http_code=400, error_code=error_code, error_description="Resource %s version %s db exception: %s." % \ (url, version, str(dbex)), error_details=self._errors_url % error_code) def _get_current_connection(self, request): '''This method returns the current db connection for this request.''' return self._conn_manager.CONN_MANAGER.get_connection( request.request_id) def _trim_resource_url(self, resource_url): '''This method removes traling slash from resource url.''' if resource_url[-1] == "/": resource_url = resource_url[:-1] if resource_url[0] != "/": resource_url = "/%s" % resource_url return resource_url def _add_cors_headers(self, response): '''This method add cors headers to a given respones.''' response.headers["Access-Control-Allow-Origin"] = "*" response.headers[ "Access-Control-Allow-Methods"] = "OPTIONS,GET,POST,PUT,DELETE" @Controller(url=BASE_URL + "$", method="GET") def get_collection(self, request, version, resource_url): '''This method provides the route for accessing a resource collection. :doc:`/features/roa/rest_standard` for collections are enabled by this method. The typical response format is presented below: .. code-block:: javascript var response = {"items": [ // resources represented as json objects. ], "totalItems": 100} If a resource is not found or the resource version does not exist the following response is returned: .. code-block:: javascript {"error_code": 10000, "error_description": "Resource %s version %s does not exist.", "error_details": "http://rcosnita.github.io/fantastico/html/features/roa/errors/error_10000.html"} ''' if version != "latest": version = float(version) params = CollectionParams(request, RoaController.OFFSET_DEFAULT, RoaController.LIMIT_DEFAULT) resource = self._resources_registry.find_by_url(resource_url, version) if not resource: return self._handle_resource_notfound(version, resource_url) self._inject_security_context(request, resource.model) access_token = self.validate_security_context(request, "read") json_serializer = self._json_serializer_cls(resource) filter_expr = self._parse_filter(params.filter_expr, resource.model) if resource.user_dependent: if filter_expr: filter_expr = ModelFilterAnd( filter_expr, ModelFilter(resource.model.user_id, access_token.user_id, ModelFilter.EQ)) else: filter_expr = ModelFilter(resource.model.user_id, access_token.user_id, ModelFilter.EQ) sort_expr = self._parse_sort(params.order_expr, resource.model) model_facade = self._model_facade_cls( resource.model, self._get_current_connection(request)) models = model_facade.get_records_paged(start_record=params.offset, end_record=params.offset + params.limit, filter_expr=filter_expr, sort_expr=sort_expr) items = [ json_serializer.serialize(model, params.fields) for model in models ] if resource.validator: resource.validator().format_collection(items, request) models_count = model_facade.count_records(filter_expr=filter_expr) body = {"items": items, "totalItems": models_count} response = Response(text=json.dumps(body), content_type="application/json", status_code=200) self._add_cors_headers(response) return response @Controller(url=BASE_LATEST_URL + "$", method="GET") def get_collection_latest(self, request, resource_url): '''This method retrieves a resource collection using the latest version of the api.''' return self.get_collection(request, "latest", self._trim_resource_url(resource_url)) @Controller(url=[BASE_URL + "$", BASE_URL + "/(?P<resource_id>.*?)$"], method="OPTIONS") # pylint: disable=W0613 @CorsEnabled() def handle_resource_options(self, request, version, resource_url, **kwargs): '''This method enables support for http ajax CORS requests. This is mandatory if we want to host apis on different domains than project host.''' pass @Controller(url=[ BASE_LATEST_URL + "$", BASE_LATEST_URL + "/(?P<resource_id>.*?)$" ], method="OPTIONS") def handle_resource_options_latest(self, request, resource_url, **kwargs): '''This method handles OPTIONS http requests for ROA api latest versions.''' return self.handle_resource_options( request, "latest", self._trim_resource_url(resource_url), **kwargs) def _validate_resource(self, resource, request, request_body, existing_resource_id=None): '''This method is used to validate the resource. If the resource validation fails an error response is sent. Otherwise the newly validated model is returned.''' if not request_body: return self._handle_resource_nobody(resource.version, resource.url) model = self._json_serializer_cls(resource).deserialize( request_body.decode()) action = "create" if existing_resource_id: action = "update" self._inject_security_context(request, model) self.validate_security_context(request, action) if not resource.validator: return model try: resource.validator().validate(model, request, existing_resource_id) except FantasticoRoaError as ex: return self._handle_resource_invalid(resource.version, resource.url, ex) return model @Controller(url=BASE_URL + "/(?P<resource_id>.*?)$", method="GET") def get_item(self, request, version, resource_url, resource_id): '''This method provides the API for retrieving a single item from a collection. The item is uniquely identified by resource_id. Below you can find a success response example: .. code-block:: html GET - /api/1.0/simple-resources/1 HTTP/1.1 200 OK Content-Type: application/json Content-Length: ... { "id": 1, "name": "Test resource", "description": "Simple description" } Of course there are cases when exceptions might occur. Below, you can find a list of error response retrieved from get_item API: * **10000** - Whenever we try to retrieve a resource with unknown type. (Not registered to ROA). * **10030** - Whenever we try to retrieve a resource and an unexpected database exception occurs. * **10040** - Whenever we try to retrieve a resource which does not exist. ''' if version != "latest": version = float(version) fields = request.params.get("fields") resource = self._resources_registry.find_by_url(resource_url, version) if not resource: return self._handle_resource_notfound(version, resource_url) self._inject_security_context(request, resource.model) access_token = self.validate_security_context(request, "read") model_facade = self._model_facade_cls( resource.model, self._get_current_connection(request)) try: model = model_facade.find_by_pk( {model_facade.model_pk_cols[0]: resource_id}) if not self._is_model_owned_by(model, access_token, resource): model = None except FantasticoDbError as dbex: return self._handle_resource_dberror(version, resource_url, dbex) if not model: return self._handle_resource_item_notfound(version, resource_url, resource_id) json_serializer = self._json_serializer_cls(resource) resource_body = json_serializer.serialize(model, fields) if resource.validator and model: resource.validator().format_resource( DictionaryObject(resource_body, immutable=False), request) resource_body = json.dumps(resource_body) response = Response(body=resource_body.encode(), content_type="application/json", status_code=200) self._add_cors_headers(response) return response @Controller(url=BASE_LATEST_URL + "/(?P<resource_id>.*?)$", method="GET") def get_item_latest(self, request, resource_url, resource_id): '''This method provides the latest get_item route for ROA api.''' return self.get_item(request, "latest", self._trim_resource_url(resource_url), resource_id) @Controller(url=BASE_URL + "$", method="POST") def create_item(self, request, version, resource_url): '''This method provides the route for adding new resources into an existing collection. The API is json only and invoke the validator as described in ROA spec. Usually, when a resource is created successfully a similar answer is returned to the client: .. code-block:: html 201 Created Content-Type: application/json Content-Length: 0 Location: /api/2.0/app-settings/123 Below you can find all error response codes which might be returned when creating a new resource: * **10000** - Whenever we try to create a resource with unknown type. (Not registered to ROA). * **10010** - Whenever we try to create a resource which fails validation. * **10020** - Whenever we try to create a resource without passing a valid body. * **10030** - Whenever we try to create a resource and an unexpected database exception occurs. You can find more information about typical REST ROA APIs response on :doc:`/features/roa/rest_responses`.''' if version != "latest": version = float(version) resource = self._resources_registry.find_by_url(resource_url, version) if not resource: return self._handle_resource_notfound(version, resource_url) model = self._validate_resource(resource, request, request.body) access_token = request.context.security.access_token if isinstance(model, Response): return model try: if resource.user_dependent and access_token: model.user_id = access_token.user_id if resource.validator: resource.validator().on_pre_create(model, request) model_facade = self._model_facade_cls( resource.model, self._get_current_connection(request)) model_id = model_facade.create(model)[0] if resource.validator: resource.validator().on_post_create(model, request) except FantasticoDbError as dbex: return self._handle_resource_dberror(resource.version, resource.url, dbex) model_location = roa_helper.calculate_resource_url( self._roa_api, resource, version) model_location += "/%s" % model_id response = Response(status_code=201, content_type="application/json") self._add_cors_headers(response) response.headers["Location"] = model_location return response @Controller(url=BASE_LATEST_URL + "$", method="POST") def create_item_latest(self, request, resource_url): '''This method provides create item latest API version.''' return self.create_item(request, "latest", self._trim_resource_url(resource_url)) @Controller(url=BASE_URL + "/(?P<resource_id>.*?)$", method="PUT") def update_item(self, request, version, resource_url, resource_id): '''This method provides the route for updating existing resources from an existing collection. The API is json only and invokes the validator as described in ROA spec. Usually, when a resource is update successfully a similar answer is returned to the client: .. code-block:: html 204 No Content Content-Type: application/json Content-Length: 0 Below you can find all error response codes which might be returned when creating a new resource: * **10000** - Whenever we try to update a resource with unknown type. (Not registered to ROA). * **10010** - Whenever we try to update a resource which fails validation. * **10020** - Whenever we try to update a resource without passing a valid body. * **10030** - Whenever we try to update a resource and an unexpected database exception occurs. * **10040** - Whenever we try to update a resource which does not exist. You can find more information about typical REST ROA APIs response on :doc:`/features/roa/rest_responses`.''' if version != "latest": version = float(version) resource = self._resources_registry.find_by_url(resource_url, version) if not resource: return self._handle_resource_notfound(version, resource_url) model = self._validate_resource(resource, request, request.body, resource_id) if isinstance(model, Response): return model access_token = request.context.security.access_token model_facade = self._model_facade_cls( resource.model, self._get_current_connection(request)) pk_col = model_facade.model_pk_cols[0] try: existing_model = model_facade.find_by_pk({pk_col: resource_id}) if not existing_model or not self._is_model_owned_by( existing_model, access_token, resource): return self._handle_resource_item_notfound( version, resource_url, resource_id) setattr(model, pk_col.name, resource_id) if resource.validator: resource.validator().on_pre_update(model, request) model_facade.update(model) if resource.validator: resource.validator().on_post_update(model, request) except FantasticoDbError as dbex: return self._handle_resource_dberror(resource.version, resource.url, dbex) response = Response(content_type="application/json", status_code=204) self._add_cors_headers(response) return response @Controller(url=BASE_LATEST_URL + "/(?P<resource_id>.*?)$", method="PUT") def update_item_latest(self, request, resource_url, resource_id): '''This is the route handler for latest update existing item api.''' return self.update_item(request, "latest", self._trim_resource_url(resource_url), resource_id) @Controller(url=BASE_URL + "/(?P<resource_id>.*?)$", method="DELETE") def delete_item(self, request, version, resource_url, resource_id): '''This method provides the route for deleting existing resources from an existing collection. The API is json only. Usually, when a resource is deleted successfully a similar answer is returned to the client: .. code-block:: html 204 No Content Content-Type: application/json Content-Length: 0 Below you can find all error response codes which might be returned when creating a new resource: * **10000** - Whenever we try to delete a resource with unknown type. (Not registered to ROA). * **10030** - Whenever we try to delete a resource and an unexpected database exception occurs. * **10040** - Whenever we try to delete a resource which does not exist. You can find more information about typical REST ROA APIs response on :doc:`/features/roa/rest_responses`.''' if version != "latest": version = float(version) resource = self._resources_registry.find_by_url(resource_url, version) if not resource: return self._handle_resource_notfound(version, resource_url) self._inject_security_context(request, resource.model) access_token = self.validate_security_context(request, "delete") try: model_facade = self._model_facade_cls( resource.model, self._get_current_connection(request)) pk_col = model_facade.model_pk_cols[0] existing_model = model_facade.find_by_pk({pk_col: resource_id}) if not self._is_model_owned_by(existing_model, access_token, resource): existing_model = None if not existing_model: return self._handle_resource_item_notfound( version, resource_url, resource_id) if resource.validator: resource.validator().on_pre_delete(existing_model, request) model_facade.delete(existing_model) if resource.validator: resource.validator().on_post_delete(existing_model, request) except FantasticoDbError as dbex: return self._handle_resource_dberror(version, resource_url, dbex) response = Response(content_type="application/json", status_code=204) self._add_cors_headers(response) return response @Controller(url=BASE_LATEST_URL + "/(?P<resource_id>.*?)$", method="DELETE") def delete_item_latest(self, request, resource_url, resource_id): '''This method provides the functionality for delete item latest version api route.''' return self.delete_item(request, "latest", self._trim_resource_url(resource_url), resource_id) def validate_security_context(self, request, attr_scope): '''This method triggers security context validation and converts unexpected exceptions to OAuth2UnauthorizedError. If everything is fine this method return the access_token from security context.''' security_ctx = request.context.security if not attr_scope: return try: if not security_ctx.validate_context(attr_scope): raise OAuth2UnauthorizedError( "Security context insufficient scopes.") except OAuth2Error: raise except Exception as ex: raise OAuth2UnauthorizedError( "Security context validation failed: %s" % str(ex)) return security_ctx.access_token def _inject_security_context(self, request, resource_model): '''This method enrich the current security context with required scopes (if necessary).''' if not hasattr(resource_model, "get_required_scopes"): return required_scopes_obj = resource_model.get_required_scopes() required_scopes_obj.inject_scopes_in_security(request) def _is_model_owned_by(self, model, access_token, resource): '''This method is used to detect if a given model can be accessed using the access token. If the resource is not user dependent this method always returns true.''' if resource.user_dependent: if model.user_id == access_token.user_id: return True return False return True