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.
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/PKG-INFO +3 -1
- api_foundry_query_engine-0.8.83/api_foundry_query_engine/__init__.py +1 -0
- api_foundry_query_engine-0.8.83/api_foundry_query_engine/connectors/connection.py +106 -0
- api_foundry_query_engine-0.8.83/api_foundry_query_engine/connectors/connection_factory.py +169 -0
- api_foundry_query_engine-0.8.83/api_foundry_query_engine/connectors/data_api_connection.py +329 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/connectors/postgres_connection.py +12 -8
- {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
- {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
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/lambda_handler.py +34 -1
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/pyproject.toml +14 -1
- api_foundry_query_engine-0.8.81/api_foundry_query_engine/__init__.py +0 -1
- api_foundry_query_engine-0.8.81/api_foundry_query_engine/connectors/connection.py +0 -32
- api_foundry_query_engine-0.8.81/api_foundry_query_engine/connectors/connection_factory.py +0 -123
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/.gitignore +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/LICENSE +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/.pre-commit-config.yaml +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/adapters/adapter.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/adapters/gateway_adapter.py +0 -0
- {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
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/dao.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/dao/operation_dao.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/operation.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/services/service.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/services/transactional_service.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/api_model.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/app_exception.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/claims_check.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/dependency_resolver.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/logger.py +0 -0
- {api_foundry_query_engine-0.8.81 → api_foundry_query_engine-0.8.83}/api_foundry_query_engine/utils/reference_resolver.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
801
|
+
if self.dialect == "oracle":
|
|
770
802
|
return "SYS_GUID()"
|
|
771
|
-
if self.
|
|
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]
|
|
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":
|
|
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
|
-
|
|
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"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|