api-foundry-query-engine 0.8.81__tar.gz → 0.8.83__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/PKG-INFO +3 -1
  2. api_foundry_query_engine-0.8.83/api_foundry_query_engine/__init__.py +1 -0
  3. api_foundry_query_engine-0.8.83/api_foundry_query_engine/connectors/connection.py +106 -0
  4. api_foundry_query_engine-0.8.83/api_foundry_query_engine/connectors/connection_factory.py +169 -0
  5. api_foundry_query_engine-0.8.83/api_foundry_query_engine/connectors/data_api_connection.py +329 -0
  6. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/connectors/postgres_connection.py +12 -8
  7. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/sql_query_handler.py +35 -3
  8. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/sql_select_query_handler.py +1 -12
  9. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/lambda_handler.py +34 -1
  10. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/pyproject.toml +14 -1
  11. api_foundry_query_engine-0.8.81/api_foundry_query_engine/__init__.py +0 -1
  12. api_foundry_query_engine-0.8.81/api_foundry_query_engine/connectors/connection.py +0 -32
  13. api_foundry_query_engine-0.8.81/api_foundry_query_engine/connectors/connection_factory.py +0 -123
  14. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/.gitignore +0 -0
  15. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/LICENSE +0 -0
  16. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/.pre-commit-config.yaml +0 -0
  17. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/adapters/adapter.py +0 -0
  18. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/adapters/gateway_adapter.py +0 -0
  19. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/batch_operation_handler.py +0 -0
  20. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/dao.py +0 -0
  21. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/operation_dao.py +0 -0
  22. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/sql_custom_query_handler.py +0 -0
  23. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/sql_delete_query_handler.py +0 -0
  24. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/sql_insert_query_handler.py +0 -0
  25. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/sql_restore_query_handler.py +0 -0
  26. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/sql_subselect_query_handler.py +0 -0
  27. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/sql_update_query_handler.py +0 -0
  28. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/operation.py +0 -0
  29. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/services/service.py +0 -0
  30. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/services/transactional_service.py +0 -0
  31. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/api_model.py +0 -0
  32. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/app_exception.py +0 -0
  33. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/claims_check.py +0 -0
  34. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/dependency_resolver.py +0 -0
  35. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/logger.py +0 -0
  36. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/reference_resolver.py +0 -0
  37. {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/token_decoder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-foundry-query-engine
3
- Version: 0.8.81
3
+ Version: 0.8.83
4
4
  Summary: The AWS lambda service handler use by the `api_foundry` project is a powerful tool designed to automate the deployment of REST APIs on AWS using Lambda services to access and interact with relational databases (RDBMS). This project leverages the OpenAPI specification to define and manage the APIs
5
5
  Project-URL: Documentation, https://github.com/DanRepik/api-foundry
6
6
  Project-URL: Source, https://github.com/DanRepik/api-foundry
@@ -14,8 +14,10 @@ Requires-Dist: pyhumps
14
14
  Requires-Dist: pyyaml
15
15
  Provides-Extra: dev
16
16
  Requires-Dist: black>=23.0.0; extra == 'dev'
17
+ Requires-Dist: docker; extra == 'dev'
17
18
  Requires-Dist: fixture-foundry>=0.1.0; extra == 'dev'
18
19
  Requires-Dist: isort>=5.12.0; extra == 'dev'
20
+ Requires-Dist: psycopg2-binary; extra == 'dev'
19
21
  Requires-Dist: pulumi>=3.224.0; extra == 'dev'
20
22
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
21
23
  Requires-Dist: pytest>=7.0; extra == 'dev'
@@ -0,0 +1 @@
1
+ __version__ = "0.8.83"
@@ -0,0 +1,106 @@
1
+ import os
2
+ from collections import defaultdict, deque
3
+ from typing import List, Optional, Tuple
4
+
5
+ from api_foundry_query_engine.utils.logger import logger
6
+
7
+ # Initialize the logger
8
+ log = logger(__name__)
9
+
10
+ db_config_map = dict()
11
+
12
+ # The driver a bare "engine" value (no ":driver" suffix) resolves to for
13
+ # each dialect, when nothing else overrides it. This only names a driver --
14
+ # it doesn't guarantee one is actually registered in ConnectionFactory's
15
+ # CONNECTOR_REGISTRY yet (oracle/mysql have neither a real connector nor
16
+ # tests exercising one, only dialect-specific SQL generation in
17
+ # SQLQueryHandler.placeholder/concurrency_generator). Using ConnectionFactory
18
+ # with such a dialect fails there instead, with a clear "unsupported
19
+ # engine" error rather than a "no default driver" one.
20
+ DEFAULT_DRIVERS = {
21
+ "postgres": "psycopg2",
22
+ "oracle": "cx_oracle",
23
+ "mysql": "mysqlclient",
24
+ }
25
+
26
+
27
+ def parse_engine(engine: str) -> Tuple[str, str]:
28
+ """Split an `engine` config value into (dialect, driver).
29
+
30
+ `engine` was originally meant to select a SQL dialect ("postgres",
31
+ "oracle", "mysql") for dialect-specific SQL generation (see
32
+ SQLQueryHandler.placeholder/concurrency_generator). Once a second
33
+ connector (the RDS Data API) existed for the same dialect, driver
34
+ selection got bolted onto the same string as a distinct value
35
+ ("postgres-data-api") instead of an orthogonal axis -- indistinguishable
36
+ from a dialect at a glance, and unable to express e.g. an Oracle Data
37
+ API-style driver without inventing another one-off name.
38
+
39
+ The explicit form is "{dialect}:{driver}", e.g. "postgres:data-api".
40
+ A bare dialect ("postgres") resolves its driver from the
41
+ "{DIALECT}_DEFAULT_DRIVER" environment variable if set, else
42
+ DEFAULT_DRIVERS -- so existing config/tests that only ever named a
43
+ dialect keep working, and a deployment can switch its default driver
44
+ (e.g. postgres -> data-api) without touching every config value that
45
+ names the dialect alone.
46
+ """
47
+ if ":" in engine:
48
+ dialect, driver = engine.split(":", 1)
49
+ return dialect, driver
50
+
51
+ dialect = engine
52
+ driver = os.environ.get(f"{dialect.upper()}_DEFAULT_DRIVER") or DEFAULT_DRIVERS.get(dialect)
53
+ if not driver:
54
+ raise ValueError(
55
+ f"No default driver configured for dialect {dialect!r} -- set "
56
+ f"{dialect.upper()}_DEFAULT_DRIVER or add it to DEFAULT_DRIVERS, "
57
+ f"or specify one explicitly as \"{dialect}:<driver>\"."
58
+ )
59
+ return dialect, driver
60
+
61
+
62
+ def map_columns_to_selection_keys(column_names: List[str], selection_results: dict) -> List[Optional[str]]:
63
+ """Match a result set's bare column names, in cursor order, to
64
+ selection_results' keys (which may be table-alias-qualified, e.g.
65
+ "m.media_type_id" -- cursor/column metadata never returns qualified
66
+ names).
67
+
68
+ Returns one entry per column: selection_results' own key for that
69
+ column, or None if it isn't selected. A join can select the same bare
70
+ column from multiple tables (e.g. both "i.customer_id" and
71
+ "c.customer_id"), so this consumes one queued key per bare name per
72
+ occurrence -- in selection_results' insertion order, which matches the
73
+ SELECT list's column order -- rather than a plain name lookup, which
74
+ would collapse duplicates and silently drop a column's value.
75
+ """
76
+ queues: dict[str, deque] = defaultdict(deque)
77
+ for key in selection_results:
78
+ queues[key.rsplit(".", 1)[-1]].append(key)
79
+
80
+ return [queues[name].popleft() if queues.get(name) else None for name in column_names]
81
+
82
+
83
+ class Cursor:
84
+ def execute(self, sql: str, params: dict, selection_results: dict) -> list[dict]:
85
+ raise NotImplementedError
86
+
87
+ def close(self):
88
+ raise NotImplementedError
89
+
90
+
91
+ class Connection:
92
+ def __init__(self, db_config: dict) -> None:
93
+ super().__init__()
94
+ self.db_config = db_config
95
+
96
+ def engine(self) -> str:
97
+ return self.db_config["engine"]
98
+
99
+ def cursor(self) -> Cursor:
100
+ raise NotImplementedError
101
+
102
+ def commit(self):
103
+ raise NotImplementedError
104
+
105
+ def close(self):
106
+ raise NotImplementedError
@@ -0,0 +1,169 @@
1
+ from typing import Callable, Mapping, Tuple, Type
2
+ import boto3
3
+ import json
4
+
5
+ from api_foundry_query_engine.connectors.connection import Connection, parse_engine
6
+ from api_foundry_query_engine.utils.app_exception import ApplicationException
7
+ from api_foundry_query_engine.utils.logger import logger
8
+
9
+ log = logger(__name__)
10
+
11
+
12
+ def _load_postgres_connection() -> Type[Connection]:
13
+ from api_foundry_query_engine.connectors.postgres_connection import PostgresConnection
14
+
15
+ return PostgresConnection
16
+
17
+
18
+ def _load_data_api_connection() -> Type[Connection]:
19
+ from api_foundry_query_engine.connectors.data_api_connection import DataApiConnection
20
+
21
+ return DataApiConnection
22
+
23
+
24
+ # (dialect, driver) -> a loader returning the Connection subclass to
25
+ # instantiate. A function rather than the class itself so each connector's
26
+ # module (and its own dependencies, e.g. psycopg2) is only imported once a
27
+ # config value actually selects it. Add new dialect/driver combinations
28
+ # here rather than growing an if/elif chain.
29
+ CONNECTOR_REGISTRY: dict[Tuple[str, str], Callable[[], Type[Connection]]] = {
30
+ ("postgres", "psycopg2"): _load_postgres_connection,
31
+ ("postgres", "data-api"): _load_data_api_connection,
32
+ }
33
+
34
+
35
+ class ConnectionFactory:
36
+ db_config_map: dict[str, dict]
37
+ config: Mapping[str, str]
38
+
39
+ def __init__(self, config: Mapping[str, str] = {}):
40
+ self.db_config_map = dict()
41
+ self.config = config
42
+
43
+ def get_connection(self, database: str) -> Connection:
44
+ """
45
+ Factory function to create a database connector based on the
46
+ specified engine and schema.
47
+
48
+ Args:
49
+ - engine (str): The database engine type
50
+ ('postgres', 'oracle', or 'mysql').
51
+ - schema (str): The schema for the database.
52
+
53
+ Returns:
54
+ - Connector: An instance of the appropriate Connector subclass.
55
+ """
56
+
57
+ # Get the secret name based on the engine and database from the secrets map
58
+ log.info("database: %s", database)
59
+ db_config = self.db_config_map.get(database)
60
+ if not db_config:
61
+ # A <DATABASE>_DATA_API_CLUSTER_ARN config value selects the
62
+ # RDS Data API connector and, like the DSN branch below, never
63
+ # fetches the secret itself -- the Data API resolves the
64
+ # credentials server-side from secret_arn, so no password is
65
+ # ever in this Lambda's environment or memory. The Data API
66
+ # does that lookup with the *caller's* IAM permissions, though,
67
+ # so the role still needs secretsmanager:GetSecretValue on
68
+ # secret_arn alongside the rds-data:* actions.
69
+ data_api_cluster_arn = self.config.get(f"{database.upper()}_DATA_API_CLUSTER_ARN")
70
+ if data_api_cluster_arn:
71
+ db_config = {
72
+ "engine": "postgres:data-api",
73
+ "resource_arn": data_api_cluster_arn,
74
+ "secret_arn": self.config.get(f"{database.upper()}_DATA_API_SECRET_ARN"),
75
+ "database": self.config.get(f"{database.upper()}_DATA_API_DATABASE"),
76
+ }
77
+ schema = self.config.get(f"{database.upper()}_DATA_API_SCHEMA")
78
+ if schema:
79
+ db_config["schema"] = schema
80
+ endpoint_url = self.config.get("AWS_ENDPOINT_URL") # LocalStack endpoint
81
+ if endpoint_url:
82
+ db_config["endpoint_url"] = endpoint_url
83
+ else:
84
+ # A <DATABASE>_DSN config value (e.g. CONTRACT_DB_DSN) skips
85
+ # Secrets Manager entirely. This matters for a Lambda that's
86
+ # VPC-attached for private database access but has no route
87
+ # to the Secrets Manager API (no NAT gateway, no interface
88
+ # endpoint) -- the SECRETS/get_secret_value path would just
89
+ # hang. PostgresConnection already accepts a raw "dsn" key.
90
+ dsn = self.config.get(f"{database.upper()}_DSN")
91
+ if dsn:
92
+ db_config = {"engine": self.config.get(f"{database.upper()}_ENGINE", "postgres"), "dsn": dsn}
93
+ else:
94
+ # Use config dict for secrets
95
+ secrets_map = self.config.get("SECRETS", {})
96
+ if isinstance(secrets_map, str):
97
+ secrets_map = json.loads(secrets_map)
98
+ secret_name = secrets_map.get(database)
99
+ log.debug("secret_name: %s", secret_name)
100
+
101
+ if secret_name:
102
+ db_config = self.__get_secret(secret_name)
103
+ else:
104
+ raise ValueError(f"Secret not found for database: {database}")
105
+
106
+ engine = db_config.get("engine")
107
+ if not engine:
108
+ raise ApplicationException(500, "Database 'engine' is not defined in the secret.")
109
+
110
+ dialect, driver = parse_engine(engine)
111
+ loader = CONNECTOR_REGISTRY.get((dialect, driver))
112
+ if loader is None:
113
+ raise ValueError(
114
+ f"Unsupported database engine: {engine!r} (dialect={dialect!r}, driver={driver!r}). "
115
+ f"Registered: {sorted(CONNECTOR_REGISTRY.keys())}"
116
+ )
117
+ return loader()(db_config)
118
+
119
+ def __get_secret(self, db_secret_name: str):
120
+ """
121
+ Get the secret from AWS Secrets Manager.
122
+
123
+ Parameters:
124
+ - db_secret_name (str): The name of the AWS Secrets Manager secret.
125
+
126
+ Returns:
127
+ - dict: The database configuration obtained from the secret.
128
+ """
129
+ if self.config.get(db_secret_name):
130
+ return self.config.get(db_secret_name)
131
+
132
+ endpoint_url = self.config.get("AWS_ENDPOINT_URL") # LocalStack endpoint
133
+ sts_client = boto3.client("sts", endpoint_url=endpoint_url)
134
+
135
+ secret_account_id = self.config.get("SECRET_ACCOUNT_ID", None)
136
+ log.debug("secret_account_id: %s", secret_account_id)
137
+
138
+ if secret_account_id:
139
+ # If a secret account ID is provided, assume a role in that account
140
+ secret_role = self.config.get("ROLE_NAME", None)
141
+ assume_role_response = sts_client.assume_role(
142
+ RoleArn=f"arn:aws:iam::{secret_account_id}:role/{secret_role}",
143
+ RoleSessionName="AssumeRoleSession",
144
+ )
145
+
146
+ credentials = assume_role_response["Credentials"]
147
+
148
+ secretsmanager = boto3.client(
149
+ "secretsmanager",
150
+ aws_access_key_id=credentials["AccessKeyId"],
151
+ aws_secret_access_key=credentials["SecretAccessKey"],
152
+ aws_session_token=credentials["SessionToken"],
153
+ endpoint_url=endpoint_url,
154
+ )
155
+ else:
156
+ # If no secret account ID is provided, use the default account
157
+ log.info("endpoint_url: %s", endpoint_url)
158
+ secretsmanager = boto3.client(
159
+ "secretsmanager",
160
+ endpoint_url=endpoint_url,
161
+ )
162
+
163
+ # Get the secret value from AWS Secrets Manager
164
+ log.info("db_secret_name: %s", db_secret_name)
165
+ db_secret = secretsmanager.get_secret_value(SecretId=db_secret_name)
166
+ log.debug("loading secret name: %s", db_secret)
167
+
168
+ # Return the parsed JSON secret string
169
+ return json.loads(db_secret.get("SecretString"))
@@ -0,0 +1,329 @@
1
+ import json
2
+ import re
3
+ import time
4
+ from datetime import date, datetime, time as time_type, timezone
5
+ from decimal import Decimal
6
+ from typing import Any, Optional
7
+
8
+ import boto3
9
+ from botocore.config import Config as BotoConfig
10
+
11
+ from api_foundry_query_engine.connectors.connection import (
12
+ Connection,
13
+ Cursor,
14
+ map_columns_to_selection_keys,
15
+ )
16
+ from api_foundry_query_engine.utils.app_exception import ApplicationException
17
+ from api_foundry_query_engine.utils.logger import logger
18
+
19
+ log = logger(__name__)
20
+
21
+ # The Data API's own statement timeout is 45s (AWS docs); a boto3
22
+ # read_timeout below that would surface as a generic SDK timeout instead
23
+ # of the service's own, more useful error, so stay comfortably above it.
24
+ READ_TIMEOUT_SECONDS = 65
25
+
26
+ # A paused Aurora Serverless v2 cluster resumes in well under this budget
27
+ # (AWS docs put it at ~15s), but the caller (API Gateway) has a hard 29s
28
+ # cap end to end, so don't retry for longer than that leaves room for.
29
+ RESUME_RETRY_BUDGET_SECONDS = 20
30
+ RESUME_RETRY_INTERVAL_SECONDS = 2
31
+
32
+ _WRITE_KEYWORDS = ("INSERT", "UPDATE", "DELETE")
33
+
34
+
35
+ def _is_write_statement(sql: str) -> bool:
36
+ stripped = sql.strip()
37
+ if not stripped:
38
+ return False
39
+ return stripped.split(None, 1)[0].upper() in _WRITE_KEYWORDS
40
+
41
+
42
+ def _encode_parameter(value: Any) -> dict:
43
+ """Encode a Python value into an RDS Data API parameter `value` field.
44
+
45
+ Values arriving here have already been through
46
+ SchemaObjectProperty.convert_to_db_value (or json.dumps, for embedded
47
+ objects/arrays), so this only needs to get each Python type onto the
48
+ wire faithfully -- columns a plain string can't be implicitly coerced
49
+ into (uuid, jsonb, timestamptz, ...) are handled by an explicit SQL
50
+ cast on the placeholder instead (see SQLQueryHandler.placeholder).
51
+ """
52
+ if value is None:
53
+ return {"isNull": True}
54
+ if isinstance(value, bool):
55
+ return {"booleanValue": value}
56
+ if isinstance(value, int):
57
+ return {"longValue": value}
58
+ if isinstance(value, float):
59
+ return {"doubleValue": value}
60
+ if isinstance(value, Decimal):
61
+ return {"stringValue": str(value)}
62
+ if isinstance(value, datetime):
63
+ return {"stringValue": value.strftime("%Y-%m-%d %H:%M:%S.%f")}
64
+ if isinstance(value, date):
65
+ return {"stringValue": value.isoformat()}
66
+ if isinstance(value, time_type):
67
+ return {"stringValue": value.strftime("%H:%M:%S.%f")}
68
+ if isinstance(value, (dict, list)):
69
+ # Not expected on this path today (object/array properties are
70
+ # already JSON-text by the time they reach here), but dumping to
71
+ # JSON is the only non-lossy fallback if one ever does.
72
+ return {"stringValue": json.dumps(value)}
73
+ return {"stringValue": str(value)}
74
+
75
+
76
+ def _build_parameters(params: dict) -> list:
77
+ return [{"name": name, "value": _encode_parameter(value)} for name, value in (params or {}).items()]
78
+
79
+
80
+ def _parse_data_api_timestamp(value: str) -> datetime:
81
+ for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
82
+ try:
83
+ return datetime.strptime(value, fmt)
84
+ except ValueError:
85
+ continue
86
+ raise ApplicationException(500, f"Unrecognized Data API timestamp value: {value!r}")
87
+
88
+
89
+ def _parse_data_api_time(value: str) -> time_type:
90
+ for fmt in ("%H:%M:%S.%f", "%H:%M:%S"):
91
+ try:
92
+ return datetime.strptime(value, fmt).time()
93
+ except ValueError:
94
+ continue
95
+ raise ApplicationException(500, f"Unrecognized Data API time value: {value!r}")
96
+
97
+
98
+ def _decode_array(array_value: dict) -> list:
99
+ for key in ("stringValues", "longValues", "doubleValues", "booleanValues"):
100
+ values = array_value.get(key)
101
+ if values is not None:
102
+ return list(values)
103
+ nested = array_value.get("arrayValues")
104
+ if nested is not None:
105
+ return [_decode_array(v) for v in nested]
106
+ return []
107
+
108
+
109
+ def _rehydrate_string(value: str, type_name: Optional[str]) -> Any:
110
+ """Turn a Data API stringValue back into the Python type psycopg2
111
+ would have produced for the same column, so marshal_record and
112
+ SchemaObjectProperty.convert_to_api_value behave identically
113
+ regardless of which connector ran the query. Without this, e.g. a
114
+ jsonb column would come back as an escaped string instead of a dict,
115
+ and a timestamptz would lose its offset.
116
+ """
117
+ if type_name in ("json", "jsonb"):
118
+ return json.loads(value)
119
+ if type_name == "timestamptz":
120
+ return _parse_data_api_timestamp(value).replace(tzinfo=timezone.utc)
121
+ if type_name == "timestamp":
122
+ return _parse_data_api_timestamp(value)
123
+ if type_name == "date":
124
+ return date.fromisoformat(value)
125
+ if type_name in ("time", "timetz"):
126
+ return _parse_data_api_time(value)
127
+ return value
128
+
129
+
130
+ def _decode_field(field: dict, type_name: Optional[str]) -> Any:
131
+ if field.get("isNull"):
132
+ return None
133
+ if "booleanValue" in field:
134
+ return field["booleanValue"]
135
+ if "longValue" in field:
136
+ return field["longValue"]
137
+ if "doubleValue" in field:
138
+ return field["doubleValue"]
139
+ if "blobValue" in field:
140
+ return field["blobValue"]
141
+ if "arrayValue" in field:
142
+ return _decode_array(field["arrayValue"])
143
+ value = field.get("stringValue")
144
+ if value is None:
145
+ return None
146
+ return _rehydrate_string(value, type_name)
147
+
148
+
149
+ def _sqlstate_class(message: str) -> str:
150
+ match = re.search(r"SQLState:\s*([0-9A-Za-z]+)", message or "")
151
+ return match.group(1)[:2] if match else ""
152
+
153
+
154
+ def _map_database_error(message: str) -> ApplicationException:
155
+ # Mirrors PostgresCursor's IntegrityError/ProgrammingError/Error
156
+ # mapping, driven off the SQLState class Postgres appends to the
157
+ # DatabaseErrorException message instead of a driver-specific type.
158
+ sqlstate_class = _sqlstate_class(message)
159
+ if sqlstate_class == "23":
160
+ return ApplicationException(409, message)
161
+ if sqlstate_class == "42":
162
+ return ApplicationException(400, message)
163
+ return ApplicationException(500, message)
164
+
165
+
166
+ class DataApiCursor(Cursor):
167
+ def __init__(self, connection: "DataApiConnection"):
168
+ self.__connection = connection
169
+
170
+ def execute(self, sql: str, params: dict, selection_results: dict) -> list[dict]:
171
+ log.info("sql: %s", sql)
172
+
173
+ transaction_id = self.__connection.begin_if_needed(sql)
174
+ response = self.__connection.execute_statement(sql, _build_parameters(params), transaction_id)
175
+
176
+ records = response.get("records")
177
+ if not records:
178
+ return []
179
+
180
+ column_metadata = response.get("columnMetadata") or []
181
+ columns = [(meta.get("name"), meta.get("typeName")) for meta in column_metadata]
182
+ # column_metadata's "name" is always bare, even when
183
+ # selection_results' keys are table-alias-qualified (e.g.
184
+ # "m.media_type_id") -- see postgres_connection.py's PostgresCursor
185
+ # for the same mapping against psycopg2's cursor.description.
186
+ position_keys = map_columns_to_selection_keys([name for name, _ in columns], selection_results)
187
+
188
+ result = []
189
+ for record in records:
190
+ row = {}
191
+ for key, (_, type_name), field in zip(position_keys, columns, record):
192
+ if key is not None:
193
+ row[key] = _decode_field(field, type_name)
194
+ result.append(row)
195
+ return result
196
+
197
+ def close(self):
198
+ # Each execute() is a single stateless HTTPS call; there is no
199
+ # per-cursor resource to release. The connection, not the cursor,
200
+ # owns the transaction lifecycle -- see DataApiConnection.close().
201
+ pass
202
+
203
+
204
+ class DataApiConnection(Connection):
205
+ """RDS Data API connection.
206
+
207
+ Lets the query engine reach a private, PubliclyAccessible=False Aurora
208
+ cluster from a Lambda that is *not* VPC-attached: every call is a
209
+ plain HTTPS request to the `rds-data` API, authenticated by IAM, with
210
+ the database credentials looked up server-side from the secret named
211
+ by `secret_arn`. Unlike the VPC-attached DSN-in-env-var workaround
212
+ this replaces, the password never reaches the Lambda's environment or
213
+ memory. The Data API reads the secret with the caller's own IAM
214
+ permissions, so the role needs `secretsmanager:GetSecretValue` on
215
+ `secret_arn` as well as the `rds-data` actions (ExecuteStatement,
216
+ BeginTransaction, CommitTransaction, RollbackTransaction) on the
217
+ cluster.
218
+
219
+ db_config keys: `resource_arn` (the cluster ARN), `secret_arn`,
220
+ `database`, and optionally `schema` and `endpoint_url` (LocalStack).
221
+ """
222
+
223
+ def __init__(self, db_config: dict) -> None:
224
+ super().__init__(db_config)
225
+ self.resource_arn = db_config["resource_arn"]
226
+ self.secret_arn = db_config["secret_arn"]
227
+ self.database = db_config["database"]
228
+ self.schema = db_config.get("schema")
229
+ self.transaction_id: Optional[str] = None
230
+ self.client = boto3.client(
231
+ "rds-data",
232
+ endpoint_url=db_config.get("endpoint_url"),
233
+ config=BotoConfig(read_timeout=READ_TIMEOUT_SECONDS, retries={"max_attempts": 1}),
234
+ )
235
+
236
+ def cursor(self) -> Cursor:
237
+ return DataApiCursor(self)
238
+
239
+ def begin_if_needed(self, sql: str) -> Optional[str]:
240
+ """Begin a transaction on the first write seen on this connection,
241
+ and reuse it for every statement (read or write) after that, so a
242
+ read that depends on an earlier write in the same request or
243
+ batch sees it. A pure read sequence never begins one, avoiding
244
+ the extra begin/commit round trips for the common read-only path.
245
+ """
246
+ if self.transaction_id is not None:
247
+ return self.transaction_id
248
+ if not _is_write_statement(sql):
249
+ return None
250
+
251
+ kwargs = {"resourceArn": self.resource_arn, "secretArn": self.secret_arn, "database": self.database}
252
+ if self.schema:
253
+ kwargs["schema"] = self.schema
254
+ response = self.client.begin_transaction(**kwargs)
255
+ self.transaction_id = response["transactionId"]
256
+ return self.transaction_id
257
+
258
+ def execute_statement(self, sql: str, parameters: list, transaction_id: Optional[str]) -> dict:
259
+ kwargs = {
260
+ "resourceArn": self.resource_arn,
261
+ "secretArn": self.secret_arn,
262
+ "database": self.database,
263
+ "sql": sql,
264
+ "parameters": parameters,
265
+ "includeResultMetadata": True,
266
+ }
267
+ if self.schema:
268
+ kwargs["schema"] = self.schema
269
+ if transaction_id:
270
+ kwargs["transactionId"] = transaction_id
271
+
272
+ deadline = time.monotonic() + RESUME_RETRY_BUDGET_SECONDS
273
+ while True:
274
+ try:
275
+ return self.client.execute_statement(**kwargs)
276
+ except self.client.exceptions.DatabaseResumingException:
277
+ if time.monotonic() >= deadline:
278
+ raise ApplicationException(
279
+ 503,
280
+ "Database is resuming from auto-pause; retry the request shortly.",
281
+ )
282
+ log.info("database resuming, retrying in %ss", RESUME_RETRY_INTERVAL_SECONDS)
283
+ time.sleep(RESUME_RETRY_INTERVAL_SECONDS)
284
+ except self.client.exceptions.UnsupportedResultException as err:
285
+ raise ApplicationException(
286
+ 400,
287
+ "Result exceeds the RDS Data API's response size limit; " "narrow the query or add pagination.",
288
+ ) from err
289
+ except self.client.exceptions.DatabaseErrorException as err:
290
+ raise _map_database_error(_error_message(err)) from err
291
+ except self.client.exceptions.BadRequestException as err:
292
+ raise ApplicationException(400, _error_message(err)) from err
293
+ except self.client.exceptions.TransactionNotFoundException as err:
294
+ # The transaction's idle timeout elapsed server-side; the
295
+ # connection can't keep using it.
296
+ self.transaction_id = None
297
+ raise ApplicationException(500, "Database transaction expired before the request completed.") from err
298
+ except self.client.exceptions.StatementTimeoutException as err:
299
+ raise ApplicationException(504, _error_message(err)) from err
300
+
301
+ def commit(self):
302
+ if self.transaction_id is None:
303
+ return
304
+ self.client.commit_transaction(
305
+ resourceArn=self.resource_arn, secretArn=self.secret_arn, transactionId=self.transaction_id
306
+ )
307
+ self.transaction_id = None
308
+
309
+ def rollback(self):
310
+ if self.transaction_id is None:
311
+ return
312
+ self.client.rollback_transaction(
313
+ resourceArn=self.resource_arn, secretArn=self.secret_arn, transactionId=self.transaction_id
314
+ )
315
+ self.transaction_id = None
316
+
317
+ def close(self):
318
+ # Mirrors psycopg2: closing a connection with an open, uncommitted
319
+ # transaction implicitly rolls it back, rather than leaving a Data
320
+ # API transaction dangling until its own idle timeout.
321
+ if self.transaction_id is not None:
322
+ try:
323
+ self.rollback()
324
+ except Exception:
325
+ log.error("failed to roll back open Data API transaction on close")
326
+
327
+
328
+ def _error_message(err) -> str:
329
+ return err.response.get("Error", {}).get("Message", str(err))
@@ -1,4 +1,8 @@
1
- from api_foundry_query_engine.connectors.connection import Connection, Cursor
1
+ from api_foundry_query_engine.connectors.connection import (
2
+ Connection,
3
+ Cursor,
4
+ map_columns_to_selection_keys,
5
+ )
2
6
  from api_foundry_query_engine.utils.logger import logger
3
7
 
4
8
  # Initialize the logger
@@ -32,16 +36,16 @@ class PostgresCursor(Cursor):
32
36
  try:
33
37
  # Execute the SQL statement with parameters
34
38
  self.__cursor.execute(sql, params)
35
- selected_columns = set(selection_results)
39
+ # cursor.description always returns bare column names, even
40
+ # when selection_results' keys are table-alias-qualified (e.g.
41
+ # "m.media_type_id"). Match on the bare name but keep
42
+ # selection_results' own key (qualified or not) in the output,
43
+ # since downstream code looks records up by that exact key.
36
44
  column_names = [desc[0] for desc in (self.__cursor.description or ())]
45
+ position_keys = map_columns_to_selection_keys(column_names, selection_results)
37
46
  result = []
38
47
  for record in self.__cursor:
39
- # Use cursor metadata so SQL aliases define the output mapping.
40
- row = {
41
- col: value
42
- for col, value in zip(column_names, record)
43
- if col in selected_columns
44
- }
48
+ row = {key: value for key, value in zip(position_keys, record) if key is not None}
45
49
  result.append(row)
46
50
 
47
51
  return result
@@ -4,6 +4,7 @@ import uuid
4
4
  from typing import Optional, List, Dict
5
5
  from datetime import datetime, date
6
6
 
7
+ from api_foundry_query_engine.connectors.connection import parse_engine
7
8
  from api_foundry_query_engine.utils.app_exception import ApplicationException
8
9
  from api_foundry_query_engine.operation import Operation
9
10
  from api_foundry_query_engine.utils.api_model import SchemaObject, SchemaObjectProperty
@@ -108,14 +109,35 @@ RELATIONAL_TYPES = {
108
109
  "not-like": "not-like",
109
110
  }
110
111
 
112
+ # The RDS Data API sends every parameter as a plain typed value (string,
113
+ # long, double, boolean, null) with no column context, so Postgres can't
114
+ # implicitly coerce it the way it does a psycopg2 %s literal -- comparing
115
+ # an untyped string against a uuid/jsonb/timestamptz column fails with
116
+ # "operator does not exist". Casting the placeholder itself (`:x::uuid`)
117
+ # fixes this under the Data API and is a no-op under psycopg2, which
118
+ # never sees this branch. Keyed on SchemaObjectProperty.column_type.
119
+ DATA_API_COLUMN_CASTS = {
120
+ "uuid": "uuid",
121
+ "date": "date",
122
+ "time": "time",
123
+ "timetz": "timetz",
124
+ "date-time": "timestamptz",
125
+ "datetime": "timestamptz",
126
+ "timestamp": "timestamptz",
127
+ "timestamptz": "timestamptz",
128
+ }
129
+
111
130
 
112
131
  class SQLQueryHandler:
113
132
  operation: Operation
114
133
  engine: str
134
+ dialect: str
135
+ driver: str
115
136
 
116
137
  def __init__(self, operation: Operation, engine: str): # , schema_object: SchemaObject):
117
138
  self.operation = operation
118
139
  self.engine = engine
140
+ self.dialect, self.driver = parse_engine(engine)
119
141
  self.__select_list_columns = None
120
142
 
121
143
  @property
@@ -155,7 +177,7 @@ class SQLQueryHandler:
155
177
  if len(param) == 0:
156
178
  param = property.api_name if property.api_name is not None else ""
157
179
 
158
- if self.engine == "oracle":
180
+ if self.dialect == "oracle":
159
181
  if property.column_type == "date":
160
182
  return f"TO_DATE(:{param}, 'YYYY-MM-DD')"
161
183
  elif property.column_type == "datetime":
@@ -163,6 +185,16 @@ class SQLQueryHandler:
163
185
  elif property.column_type == "time":
164
186
  return f"TO_TIME(:{param}, 'HH24:MI:SS.FF')"
165
187
  return f":{param}"
188
+ if self.driver == "data-api":
189
+ # JSON-serialized properties (embedded objects/arrays, see
190
+ # SQLInsertSchemaQueryHandler.insert_values) always need a
191
+ # jsonb cast regardless of the declared column_type, since
192
+ # they arrive as a JSON-text string rather than going through
193
+ # convert_to_db_value.
194
+ if property.api_type in ("object", "array"):
195
+ return f":{param}::jsonb"
196
+ cast = DATA_API_COLUMN_CASTS.get(property.column_type)
197
+ return f":{param}::{cast}" if cast else f":{param}"
166
198
  return f"%({param})s"
167
199
 
168
200
  def check_permissions(
@@ -766,9 +798,9 @@ class SQLSchemaQueryHandler(SQLQueryHandler):
766
798
  elif property.api_type == "integer":
767
799
  return f"{property.column_name} + 1"
768
800
  elif property.api_type in ["string", "uuid"]:
769
- if self.engine == "oracle":
801
+ if self.dialect == "oracle":
770
802
  return "SYS_GUID()"
771
- if self.engine == "mysql":
803
+ if self.dialect == "mysql":
772
804
  return "UUID()"
773
805
  return "gen_random_uuid()"
774
806
  raise ApplicationException(
@@ -281,21 +281,10 @@ class SQLSelectSchemaQueryHandler(SQLSchemaQueryHandler):
281
281
  schema_object.permissions,
282
282
  schema_object.properties,
283
283
  )
284
- # Only genuine joined relations need their columns
285
- # qualified (e.g. "customer.name", to disambiguate from
286
- # the parent's own "name") -- the primary entity's own
287
- # properties come back from the cursor under their bare
288
- # column names, and marshal_record/the post-fetch column
289
- # filter in PostgresCursor.execute both key off that.
290
- # Prefixing them too (self.prefix_map[relation], as
291
- # before) produced selection_results keys like "q.slug"
292
- # that never matched any real result column, so every
293
- # row came back empty and marshal_record KeyError'd
294
- # trying to unwrap it.
295
284
  filtered_keys = self.filter_and_prefix_keys(
296
285
  reg_exs,
297
286
  allowed_properties,
298
- self.prefix_map[relation] if relation_property else None,
287
+ self.prefix_map[relation],
299
288
  )
300
289
 
301
290
  # Extend the result map with the filtered keys
@@ -13,21 +13,54 @@ logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
13
13
  log = logging.getLogger(__name__)
14
14
 
15
15
 
16
+ # Lambda rejects synchronous responses over 6 MB (6,291,556 bytes including
17
+ # its own envelope); API Gateway then surfaces that as an opaque
18
+ # "502 Internal server error". The margin leaves room for the statusCode/
19
+ # headers wrapper so a body under the limit can't still be refused.
20
+ DEFAULT_MAX_RESPONSE_BYTES = 6 * 1024 * 1024 - 4096
21
+
22
+
16
23
  class QueryEngine:
17
24
  def __init__(self, config: Mapping[str, str]):
18
25
  self.adapter = GatewayAdapter(config)
26
+ self.max_response_bytes = int(
27
+ config.get("MAX_RESPONSE_BYTES") or DEFAULT_MAX_RESPONSE_BYTES
28
+ )
19
29
 
20
30
  def handler(self, event) -> dict[str, Any]:
21
31
  log.debug("event: %s", event)
22
32
  try:
23
33
  response = self.adapter.process_event(event)
34
+ body = json.dumps(response)
35
+
36
+ # json.dumps escapes to ASCII by default, so len(body) is bytes.
37
+ if len(body) > self.max_response_bytes:
38
+ log.error(
39
+ "response of %d bytes exceeds limit of %d bytes",
40
+ len(body),
41
+ self.max_response_bytes,
42
+ )
43
+ return {
44
+ "isBase64Encoded": False,
45
+ "statusCode": 502,
46
+ "headers": {"Content-Type": "application/json"},
47
+ "body": json.dumps(
48
+ {
49
+ "message": (
50
+ f"response of {len(body)} bytes exceeds the "
51
+ f"maximum of {self.max_response_bytes} bytes; "
52
+ "narrow the request with filters"
53
+ )
54
+ }
55
+ ),
56
+ }
24
57
 
25
58
  # Ensure the response conforms to API Gateway requirements
26
59
  return {
27
60
  "isBase64Encoded": False,
28
61
  "statusCode": 200,
29
62
  "headers": {"Content-Type": "application/json"},
30
- "body": json.dumps(response),
63
+ "body": body,
31
64
  }
32
65
  except ApplicationException as e:
33
66
  log.error("exception: %s", e, exc_info=True)
@@ -1,5 +1,10 @@
1
1
  [build-system]
2
- requires = ["hatchling>=1.0"]
2
+ # hatchling>=1.32.0 emits Metadata-Version: 2.5, which PyPI's Warehouse
3
+ # rejects outright with a 400 on every upload regardless of package
4
+ # version -- see https://github.com/pypi/warehouse/issues/19083 (open,
5
+ # unresolved). Pinned below that until Warehouse adds 2.5 support (same
6
+ # fix applied to cloud_foundry, see DanRepik/cloud_foundry#10).
7
+ requires = ["hatchling>=1.0,<1.32.0"]
3
8
  build-backend = "hatchling.build"
4
9
 
5
10
  [project]
@@ -28,6 +33,14 @@ dev = [
28
33
  "fixture_foundry>=0.1.0",
29
34
  "simple-oauth-server",
30
35
  "pulumi>=3.224.0",
36
+ # tests/conftest.py imports this directly; was missing here entirely
37
+ # until CI first ran the test suite and caught it (same gap found in
38
+ # api_foundry, which uses psycopg2-binary the same way).
39
+ "psycopg2-binary",
40
+ # fixture_foundry imports this at module load time (fixtures.py);
41
+ # missing here caused an ImportError from conftest.py's own
42
+ # `from fixture_foundry import deploy` before any test could run.
43
+ "docker",
31
44
  ]
32
45
 
33
46
  [project.urls]
@@ -1 +0,0 @@
1
- __version__ = "0.8.81"
@@ -1,32 +0,0 @@
1
- from api_foundry_query_engine.utils.logger import logger
2
-
3
- # Initialize the logger
4
- log = logger(__name__)
5
-
6
- db_config_map = dict()
7
-
8
-
9
- class Cursor:
10
- def execute(self, sql: str, params: dict, selection_results: dict) -> list[dict]:
11
- raise NotImplementedError
12
-
13
- def close(self):
14
- raise NotImplementedError
15
-
16
-
17
- class Connection:
18
- def __init__(self, db_config: dict) -> None:
19
- super().__init__()
20
- self.db_config = db_config
21
-
22
- def engine(self) -> str:
23
- return self.db_config["engine"]
24
-
25
- def cursor(self) -> Cursor:
26
- raise NotImplementedError
27
-
28
- def commit(self):
29
- raise NotImplementedError
30
-
31
- def close(self):
32
- raise NotImplementedError
@@ -1,123 +0,0 @@
1
- from typing import Mapping
2
- import boto3
3
- import json
4
-
5
- from api_foundry_query_engine.connectors.connection import Connection
6
- from api_foundry_query_engine.utils.app_exception import ApplicationException
7
- from api_foundry_query_engine.utils.logger import logger
8
-
9
- log = logger(__name__)
10
-
11
-
12
- class ConnectionFactory:
13
- db_config_map: dict[str, dict]
14
- config: Mapping[str, str]
15
-
16
- def __init__(self, config: Mapping[str, str] = {}):
17
- self.db_config_map = dict()
18
- self.config = config
19
-
20
- def get_connection(self, database: str) -> Connection:
21
- """
22
- Factory function to create a database connector based on the
23
- specified engine and schema.
24
-
25
- Args:
26
- - engine (str): The database engine type
27
- ('postgres', 'oracle', or 'mysql').
28
- - schema (str): The schema for the database.
29
-
30
- Returns:
31
- - Connector: An instance of the appropriate Connector subclass.
32
- """
33
-
34
- # Get the secret name based on the engine and database from the secrets map
35
- log.info("database: %s", database)
36
- db_config = self.db_config_map.get(database)
37
- if not db_config:
38
- # A <DATABASE>_DSN config value (e.g. CONTRACT_DB_DSN) skips
39
- # Secrets Manager entirely. This matters for a Lambda that's
40
- # VPC-attached for private database access but has no route
41
- # to the Secrets Manager API (no NAT gateway, no interface
42
- # endpoint) -- the SECRETS/get_secret_value path would just
43
- # hang. PostgresConnection already accepts a raw "dsn" key.
44
- dsn = self.config.get(f"{database.upper()}_DSN")
45
- if dsn:
46
- db_config = {"engine": self.config.get(f"{database.upper()}_ENGINE", "postgres"), "dsn": dsn}
47
- else:
48
- # Use config dict for secrets
49
- secrets_map = self.config.get("SECRETS", {})
50
- if isinstance(secrets_map, str):
51
- secrets_map = json.loads(secrets_map)
52
- secret_name = secrets_map.get(database)
53
- log.debug("secret_name: %s", secret_name)
54
-
55
- if secret_name:
56
- db_config = self.__get_secret(secret_name)
57
- else:
58
- raise ValueError(f"Secret not found for database: {database}")
59
-
60
- engine = db_config.get("engine")
61
- if not engine:
62
- raise ApplicationException(500, "Database 'engine' is not defined in the secret.")
63
-
64
- if engine == "postgres":
65
- from .postgres_connection import PostgresConnection
66
-
67
- return PostgresConnection(db_config)
68
-
69
- # Add support for other engines here if needed in the future
70
-
71
- raise ValueError(f"Unsupported database engine: {engine}")
72
-
73
- def __get_secret(self, db_secret_name: str):
74
- """
75
- Get the secret from AWS Secrets Manager.
76
-
77
- Parameters:
78
- - db_secret_name (str): The name of the AWS Secrets Manager secret.
79
-
80
- Returns:
81
- - dict: The database configuration obtained from the secret.
82
- """
83
- if self.config.get(db_secret_name):
84
- return self.config.get(db_secret_name)
85
-
86
- endpoint_url = self.config.get("AWS_ENDPOINT_URL") # LocalStack endpoint
87
- sts_client = boto3.client("sts", endpoint_url=endpoint_url)
88
-
89
- secret_account_id = self.config.get("SECRET_ACCOUNT_ID", None)
90
- log.debug("secret_account_id: %s", secret_account_id)
91
-
92
- if secret_account_id:
93
- # If a secret account ID is provided, assume a role in that account
94
- secret_role = self.config.get("ROLE_NAME", None)
95
- assume_role_response = sts_client.assume_role(
96
- RoleArn=f"arn:aws:iam::{secret_account_id}:role/{secret_role}",
97
- RoleSessionName="AssumeRoleSession",
98
- )
99
-
100
- credentials = assume_role_response["Credentials"]
101
-
102
- secretsmanager = boto3.client(
103
- "secretsmanager",
104
- aws_access_key_id=credentials["AccessKeyId"],
105
- aws_secret_access_key=credentials["SecretAccessKey"],
106
- aws_session_token=credentials["SessionToken"],
107
- endpoint_url=endpoint_url,
108
- )
109
- else:
110
- # If no secret account ID is provided, use the default account
111
- log.info("endpoint_url: %s", endpoint_url)
112
- secretsmanager = boto3.client(
113
- "secretsmanager",
114
- endpoint_url=endpoint_url,
115
- )
116
-
117
- # Get the secret value from AWS Secrets Manager
118
- log.info("db_secret_name: %s", db_secret_name)
119
- db_secret = secretsmanager.get_secret_value(SecretId=db_secret_name)
120
- log.debug("loading secret name: %s", db_secret)
121
-
122
- # Return the parsed JSON secret string
123
- return json.loads(db_secret.get("SecretString"))