From 52870d611419ebde122aec8dc0da9e1d76aec37c Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Mon, 3 Apr 2023 17:18:10 +0100 Subject: [PATCH] Feature: RDS Data (#6168) --- IMPLEMENTATION_COVERAGE.md | 15 ++++- docs/docs/services/athena.rst | 2 +- docs/docs/services/rds-data.rst | 74 ++++++++++++++++++++++++ moto/__init__.py | 1 + moto/athena/models.py | 2 +- moto/backend_index.py | 1 + moto/moto_api/_internal/models.py | 25 +++++++- moto/moto_api/_internal/responses.py | 31 ++++++++++ moto/moto_api/_internal/urls.py | 1 + moto/rdsdata/__init__.py | 5 ++ moto/rdsdata/models.py | 85 ++++++++++++++++++++++++++++ moto/rdsdata/responses.py | 22 +++++++ moto/rdsdata/urls.py | 14 +++++ setup.cfg | 2 +- tests/test_rdsdata/__init__.py | 0 tests/test_rdsdata/test_rdsdata.py | 54 ++++++++++++++++++ 16 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 docs/docs/services/rds-data.rst create mode 100644 moto/rdsdata/__init__.py create mode 100644 moto/rdsdata/models.py create mode 100644 moto/rdsdata/responses.py create mode 100644 moto/rdsdata/urls.py create mode 100644 tests/test_rdsdata/__init__.py create mode 100644 tests/test_rdsdata/test_rdsdata.py diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index ccb36ef80..2b99d2f58 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -5233,6 +5233,18 @@ - [ ] switchover_read_replica +## rds-data +
+16% implemented + +- [ ] batch_execute_statement +- [ ] begin_transaction +- [ ] commit_transaction +- [ ] execute_sql +- [X] execute_statement +- [ ] rollback_transaction +
+ ## redshift
25% implemented @@ -6999,7 +7011,6 @@ - qldb - qldb-session - rbin -- rds-data - redshift-serverless - resiliencehub - resource-explorer-2 @@ -7056,4 +7067,4 @@ - workspaces - workspaces-web - xray -
+ \ No newline at end of file diff --git a/docs/docs/services/athena.rst b/docs/docs/services/athena.rst index 98a29ff62..32c91addd 100644 --- a/docs/docs/services/athena.rst +++ b/docs/docs/services/athena.rst @@ -85,7 +85,7 @@ athena } resp = requests.post( "http://motoapi.amazonaws.com:5000/moto-api/static/athena/query-results", - json=athena_result, + json=expected_results, ) resp.status_code.should.equal(201) diff --git a/docs/docs/services/rds-data.rst b/docs/docs/services/rds-data.rst new file mode 100644 index 000000000..2358e6166 --- /dev/null +++ b/docs/docs/services/rds-data.rst @@ -0,0 +1,74 @@ +.. _implementedservice_rds-data: + +.. |start-h3| raw:: html + +

+ +.. |end-h3| raw:: html + +

+ +======== +rds-data +======== + +.. autoclass:: moto.rdsdata.models.RDSDataServiceBackend + +|start-h3| Example usage |end-h3| + +.. sourcecode:: python + + @mock_rdsdata + def test_rdsdata_behaviour: + boto3.client("rds-data") + ... + + + +|start-h3| Implemented features for this service |end-h3| + +- [ ] batch_execute_statement +- [ ] begin_transaction +- [ ] commit_transaction +- [ ] execute_sql +- [X] execute_statement + + There is no validation yet on any of the input parameters. + + SQL statements are not executed by Moto, so this call will always return 0 records by default. + + You can use a dedicated API to override this, by configuring a queue of expected results. + + A request to `execute_statement` will take the first result from that queue, and assign it to the provided SQL-query. Subsequent requests using the same SQL-query will return the same result. Other requests using a different SQL-query will take the next result from the queue, or return an empty result if the queue is empty. + + Configure this queue by making an HTTP request to `/moto-api/static/rds-data/statement-results`. An example invocation looks like this: + + .. sourcecode:: python + + expected_results = { + "account_id": "123456789012", # This is the default - can be omitted + "region": "us-east-1", # This is the default - can be omitted + "results": [ + { + "records": [...], + "columnMetadata": [...], + "numberOfRecordsUpdated": 42, + "generatedFields": [...], + "formattedRecords": "some json" + }, + # other results as required + ], + } + resp = requests.post( + "http://motoapi.amazonaws.com:5000/moto-api/static/rds-data/statement-results", + json=expected_results, + ) + resp.status_code.should.equal(201) + + rdsdata = boto3.client("rds-data", region_name="us-east-1") + resp = rdsdata.execute_statement(resourceArn="not applicable", secretArn="not applicable", sql="SELECT some FROM thing") + + + +- [ ] rollback_transaction + diff --git a/moto/__init__.py b/moto/__init__.py index 09db17d3f..b18f450ae 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -123,6 +123,7 @@ mock_polly = lazy_load(".polly", "mock_polly") mock_quicksight = lazy_load(".quicksight", "mock_quicksight") mock_ram = lazy_load(".ram", "mock_ram") mock_rds = lazy_load(".rds", "mock_rds") +mock_rdsdata = lazy_load(".rdsdata", "mock_rdsdata") mock_redshift = lazy_load(".redshift", "mock_redshift") mock_redshiftdata = lazy_load( ".redshiftdata", "mock_redshiftdata", boto3_name="redshift-data" diff --git a/moto/athena/models.py b/moto/athena/models.py index 1b1936cc7..78eee9313 100644 --- a/moto/athena/models.py +++ b/moto/athena/models.py @@ -259,7 +259,7 @@ class AthenaBackend(BaseBackend): } resp = requests.post( "http://motoapi.amazonaws.com:5000/moto-api/static/athena/query-results", - json=athena_result, + json=expected_results, ) resp.status_code.should.equal(201) diff --git a/moto/backend_index.py b/moto/backend_index.py index bca7e62fa..dccc30d37 100644 --- a/moto/backend_index.py +++ b/moto/backend_index.py @@ -121,6 +121,7 @@ backend_url_patterns = [ ("ram", re.compile("https?://ram\\.(.+)\\.amazonaws.com")), ("rds", re.compile("https?://rds\\.(.+)\\.amazonaws\\.com")), ("rds", re.compile("https?://rds\\.amazonaws\\.com")), + ("rdsdata", re.compile("https?://rds-data\\.(.+)\\.amazonaws\\.com")), ("redshift", re.compile("https?://redshift\\.(.+)\\.amazonaws\\.com")), ("redshift-data", re.compile("https?://redshift-data\\.(.+)\\.amazonaws\\.com")), ("rekognition", re.compile("https?://rekognition\\.(.+)\\.amazonaws\\.com")), diff --git a/moto/moto_api/_internal/models.py b/moto/moto_api/_internal/models.py index 373c00938..ab9d02914 100644 --- a/moto/moto_api/_internal/models.py +++ b/moto/moto_api/_internal/models.py @@ -1,6 +1,6 @@ from moto.core import BaseBackend, DEFAULT_ACCOUNT_ID from moto.core.model_instances import reset_model_data -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional class MotoAPIBackend(BaseBackend): @@ -46,5 +46,28 @@ class MotoAPIBackend(BaseBackend): results = QueryResults(rows=rows, column_info=column_info) backend.query_results_queue.append(results) + def set_rds_data_result( + self, + records: Optional[List[List[Dict[str, Any]]]], + column_metadata: Optional[List[Dict[str, Any]]], + nr_of_records_updated: Optional[int], + generated_fields: Optional[List[Dict[str, Any]]], + formatted_records: Optional[str], + account_id: str, + region: str, + ) -> None: + from moto.rdsdata.models import rdsdata_backends, QueryResults + + backend = rdsdata_backends[account_id][region] + backend.results_queue.append( + QueryResults( + records=records, + column_metadata=column_metadata, + number_of_records_updated=nr_of_records_updated, + generated_fields=generated_fields, + formatted_records=formatted_records, + ) + ) + moto_api_backend = MotoAPIBackend(region_name="global", account_id=DEFAULT_ACCOUNT_ID) diff --git a/moto/moto_api/_internal/responses.py b/moto/moto_api/_internal/responses.py index 03a0ac684..bcde07da2 100644 --- a/moto/moto_api/_internal/responses.py +++ b/moto/moto_api/_internal/responses.py @@ -167,3 +167,34 @@ class MotoAPIResponse(BaseResponse): region=region, ) return 201, {}, "" + + def set_rds_data_result( + self, + request: Any, + full_url: str, # pylint: disable=unused-argument + headers: Any, + ) -> TYPE_RESPONSE: + from .models import moto_api_backend + + request_body_size = int(headers["Content-Length"]) + body = request.environ["wsgi.input"].read(request_body_size).decode("utf-8") + body = json.loads(body) + account_id = body.get("account_id", DEFAULT_ACCOUNT_ID) + region = body.get("region", "us-east-1") + + for result in body.get("results", []): + records = result.get("records") + column_metadata = result.get("columnMetadata") + nr_of_records_updated = result.get("numberOfRecordsUpdated") + generated_fields = result.get("generatedFields") + formatted_records = result.get("formattedRecords") + moto_api_backend.set_rds_data_result( + records=records, + column_metadata=column_metadata, + nr_of_records_updated=nr_of_records_updated, + generated_fields=generated_fields, + formatted_records=formatted_records, + account_id=account_id, + region=region, + ) + return 201, {}, "" diff --git a/moto/moto_api/_internal/urls.py b/moto/moto_api/_internal/urls.py index 0d1dd46a2..41506174c 100644 --- a/moto/moto_api/_internal/urls.py +++ b/moto/moto_api/_internal/urls.py @@ -13,6 +13,7 @@ url_paths = { "{0}/moto-api/reset-auth": response_instance.reset_auth_response, "{0}/moto-api/seed": response_instance.seed, "{0}/moto-api/static/athena/query-results": response_instance.set_athena_result, + "{0}/moto-api/static/rds-data/statement-results": response_instance.set_rds_data_result, "{0}/moto-api/state-manager/get-transition": response_instance.get_transition, "{0}/moto-api/state-manager/set-transition": response_instance.set_transition, "{0}/moto-api/state-manager/unset-transition": response_instance.unset_transition, diff --git a/moto/rdsdata/__init__.py b/moto/rdsdata/__init__.py new file mode 100644 index 000000000..41ff3651f --- /dev/null +++ b/moto/rdsdata/__init__.py @@ -0,0 +1,5 @@ +"""rdsdata module initialization; sets value for base decorator.""" +from .models import rdsdata_backends +from ..core.models import base_decorator + +mock_rdsdata = base_decorator(rdsdata_backends) diff --git a/moto/rdsdata/models.py b/moto/rdsdata/models.py new file mode 100644 index 000000000..bb85a6052 --- /dev/null +++ b/moto/rdsdata/models.py @@ -0,0 +1,85 @@ +from moto.core import BaseBackend, BackendDict + +from typing import Any, Dict, List, Optional, Tuple + + +class QueryResults: + def __init__( + self, + records: Optional[List[List[Dict[str, Any]]]] = None, + column_metadata: Optional[List[Dict[str, Any]]] = None, + number_of_records_updated: Optional[int] = None, + generated_fields: Optional[List[Dict[str, Any]]] = None, + formatted_records: Optional[str] = None, + ): + self.records = records + self.column_metadata = column_metadata + self.number_of_records_updated = number_of_records_updated + self.generated_fields = generated_fields + self.formatted_records = formatted_records + + def to_json(self) -> Dict[str, Any]: + return { + "records": self.records, + "columnMetadata": self.column_metadata, + "numberOfRecordsUpdated": self.number_of_records_updated, + "generatedFields": self.generated_fields, + "formattedRecords": self.formatted_records, + } + + +class RDSDataServiceBackend(BaseBackend): + def __init__(self, region_name: str, account_id: str): + super().__init__(region_name, account_id) + self.results_queue: List[QueryResults] = [] + self.sql_results: Dict[Tuple[str, str], QueryResults] = dict() + + def execute_statement(self, resource_arn: str, sql: str) -> QueryResults: + """ + There is no validation yet on any of the input parameters. + + SQL statements are not executed by Moto, so this call will always return 0 records by default. + + You can use a dedicated API to override this, by configuring a queue of expected results. + + A request to `execute_statement` will take the first result from that queue, and assign it to the provided SQL-query. Subsequent requests using the same SQL-query will return the same result. Other requests using a different SQL-query will take the next result from the queue, or return an empty result if the queue is empty. + + Configure this queue by making an HTTP request to `/moto-api/static/rds-data/statement-results`. An example invocation looks like this: + + .. sourcecode:: python + + expected_results = { + "account_id": "123456789012", # This is the default - can be omitted + "region": "us-east-1", # This is the default - can be omitted + "results": [ + { + "records": [...], + "columnMetadata": [...], + "numberOfRecordsUpdated": 42, + "generatedFields": [...], + "formattedRecords": "some json" + }, + # other results as required + ], + } + resp = requests.post( + "http://motoapi.amazonaws.com:5000/moto-api/static/rds-data/statement-results", + json=expected_results, + ) + resp.status_code.should.equal(201) + + rdsdata = boto3.client("rds-data", region_name="us-east-1") + resp = rdsdata.execute_statement(resourceArn="not applicable", secretArn="not applicable", sql="SELECT some FROM thing") + + """ + + if (resource_arn, sql) in self.sql_results: + return self.sql_results[(resource_arn, sql)] + elif self.results_queue: + self.sql_results[(resource_arn, sql)] = self.results_queue.pop() + return self.sql_results[(resource_arn, sql)] + else: + return QueryResults(records=[]) + + +rdsdata_backends = BackendDict(RDSDataServiceBackend, "rds-data") diff --git a/moto/rdsdata/responses.py b/moto/rdsdata/responses.py new file mode 100644 index 000000000..f3bd89cb0 --- /dev/null +++ b/moto/rdsdata/responses.py @@ -0,0 +1,22 @@ +import json + +from moto.core.responses import BaseResponse +from .models import rdsdata_backends, RDSDataServiceBackend + + +class RDSDataServiceResponse(BaseResponse): + def __init__(self) -> None: + super().__init__(service_name="rds-data") + + @property + def rdsdata_backend(self) -> RDSDataServiceBackend: + """Return backend instance specific for this region.""" + return rdsdata_backends[self.current_account][self.region] + + def execute_statement(self) -> str: + resource_arn = self._get_param("resourceArn") + sql = self._get_param("sql") + query_result = self.rdsdata_backend.execute_statement( + resource_arn=resource_arn, sql=sql + ) + return json.dumps(query_result.to_json()) diff --git a/moto/rdsdata/urls.py b/moto/rdsdata/urls.py new file mode 100644 index 000000000..2e9f60128 --- /dev/null +++ b/moto/rdsdata/urls.py @@ -0,0 +1,14 @@ +"""rdsdata base URL and path.""" +from .responses import RDSDataServiceResponse + +url_bases = [ + r"https?://rds-data\.(.+)\.amazonaws\.com", +] + + +response = RDSDataServiceResponse() + + +url_paths = { + "{0}/Execute$": response.dispatch, +} diff --git a/setup.cfg b/setup.cfg index 48b11dbf4..e57eaf2c4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -235,7 +235,7 @@ disable = W,C,R,E enable = anomalous-backslash-in-string, arguments-renamed, dangerous-default-value, deprecated-module, function-redefined, import-self, redefined-builtin, redefined-outer-name, reimported, pointless-statement, super-with-arguments, unused-argument, unused-import, unused-variable, useless-else-on-loop, wildcard-import [mypy] -files= moto/a*,moto/b*,moto/c*,moto/d*,moto/e*,moto/f*,moto/g*,moto/i*,moto/k*,moto/l*,moto/managedblockchain,moto/moto_api,moto/neptune,moto/opensearch +files= moto/a*,moto/b*,moto/c*,moto/d*,moto/e*,moto/f*,moto/g*,moto/i*,moto/k*,moto/l*,moto/managedblockchain,moto/moto_api,moto/neptune,moto/opensearch,moto/rdsdata show_column_numbers=True show_error_codes = True disable_error_code=abstract diff --git a/tests/test_rdsdata/__init__.py b/tests/test_rdsdata/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_rdsdata/test_rdsdata.py b/tests/test_rdsdata/test_rdsdata.py new file mode 100644 index 000000000..b528de520 --- /dev/null +++ b/tests/test_rdsdata/test_rdsdata.py @@ -0,0 +1,54 @@ +import boto3 +import requests + +from moto import mock_rdsdata, settings + +# See our Development Tips on writing tests for hints on how to write good tests: +# http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html + + +@mock_rdsdata +def test_execute_statement(): + rdsdata = boto3.client("rds-data", region_name="eu-west-1") + + resp = rdsdata.execute_statement( + resourceArn="not applicable", + secretArn="not applicable", + sql="SELECT some FROM thing", + ) + + assert resp["records"] == [] + + +@mock_rdsdata +def test_set_query_results(): + base_url = ( + "localhost:5000" if settings.TEST_SERVER_MODE else "motoapi.amazonaws.com" + ) + + sql_result = { + "results": [ + { + "records": [[{"isNull": True}], [{"isNull": False}]], + "columnMetadata": [{"name": "a"}], + "formattedRecords": "some json", + } + ], + "region": "us-west-1", + } + resp = requests.post( + f"http://{base_url}/moto-api/static/rds-data/statement-results", + json=sql_result, + ) + assert resp.status_code == 201 + + rdsdata = boto3.client("rds-data", region_name="us-west-1") + + resp = rdsdata.execute_statement( + resourceArn="not applicable", + secretArn="not applicable", + sql="SELECT some FROM thing", + ) + + assert resp["records"] == [[{"isNull": True}], [{"isNull": False}]] + assert resp["formattedRecords"] == "some json"