api-foundry-query-engine 0.8.39__py3-none-any.whl

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/.pre-commit-config.yaml +22 -0
  2. api_foundry_query_engine/__init__.py +1 -0
  3. api_foundry_query_engine/adapters/adapter.py +73 -0
  4. api_foundry_query_engine/adapters/case_change_adapter.py +79 -0
  5. api_foundry_query_engine/adapters/gateway_adapter.py +191 -0
  6. api_foundry_query_engine/adapters/security_adapter.py +106 -0
  7. api_foundry_query_engine/connectors/connection.py +32 -0
  8. api_foundry_query_engine/connectors/connection_factory.py +115 -0
  9. api_foundry_query_engine/connectors/oracle_connector.py +29 -0
  10. api_foundry_query_engine/connectors/postgres_connection.py +142 -0
  11. api_foundry_query_engine/dao/batch_operation_handler.py +295 -0
  12. api_foundry_query_engine/dao/dao.py +23 -0
  13. api_foundry_query_engine/dao/operation_dao.py +186 -0
  14. api_foundry_query_engine/dao/sql_custom_query_handler.py +68 -0
  15. api_foundry_query_engine/dao/sql_delete_query_handler.py +146 -0
  16. api_foundry_query_engine/dao/sql_insert_query_handler.py +187 -0
  17. api_foundry_query_engine/dao/sql_query_handler.py +713 -0
  18. api_foundry_query_engine/dao/sql_restore_query_handler.py +195 -0
  19. api_foundry_query_engine/dao/sql_select_query_handler.py +433 -0
  20. api_foundry_query_engine/dao/sql_subselect_query_handler.py +66 -0
  21. api_foundry_query_engine/dao/sql_update_query_handler.py +198 -0
  22. api_foundry_query_engine/lambda_handler.py +63 -0
  23. api_foundry_query_engine/operation.py +104 -0
  24. api_foundry_query_engine/services/service.py +75 -0
  25. api_foundry_query_engine/services/transactional_service.py +52 -0
  26. api_foundry_query_engine/utils/api_model.py +380 -0
  27. api_foundry_query_engine/utils/app_exception.py +22 -0
  28. api_foundry_query_engine/utils/claims_check.py +471 -0
  29. api_foundry_query_engine/utils/dependency_resolver.py +157 -0
  30. api_foundry_query_engine/utils/gateway_operation.py +279 -0
  31. api_foundry_query_engine/utils/logger.py +60 -0
  32. api_foundry_query_engine/utils/reference_resolver.py +222 -0
  33. api_foundry_query_engine/utils/token_decoder.py +624 -0
  34. api_foundry_query_engine-0.8.39.dist-info/METADATA +21 -0
  35. api_foundry_query_engine-0.8.39.dist-info/RECORD +37 -0
  36. api_foundry_query_engine-0.8.39.dist-info/WHEEL +4 -0
  37. api_foundry_query_engine-0.8.39.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,22 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v3.4.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-added-large-files
9
+
10
+ - repo: https://github.com/psf/black
11
+ rev: 23.12.1
12
+ hooks:
13
+ - id: black
14
+ language_version: python3.11
15
+
16
+ - repo: https://github.com/PyCQA/flake8
17
+ rev: 7.0.0
18
+ hooks:
19
+ - id: flake8
20
+ args: [--max-line-length=120]
21
+
22
+ exclude: '^$'
@@ -0,0 +1 @@
1
+ __version__ = "0.8.39"
@@ -0,0 +1,73 @@
1
+ import abc
2
+ from typing import Mapping, Optional
3
+
4
+ from api_foundry_query_engine.operation import Operation
5
+ from api_foundry_query_engine.services.transactional_service import TransactionalService
6
+ from api_foundry_query_engine.utils.logger import logger
7
+ from api_foundry_query_engine.services.service import Service
8
+
9
+ log = logger(__name__)
10
+
11
+
12
+ class Adapter(metaclass=abc.ABCMeta):
13
+ service: Service
14
+ config: Mapping[str, str]
15
+
16
+ @classmethod
17
+ def __subclasshook__(cls, __subclass: type) -> bool:
18
+ return (
19
+ hasattr(__subclass, "marshal")
20
+ and callable(__subclass.marshal)
21
+ and hasattr(__subclass, "umarshal")
22
+ and callable(__subclass.unmarshal)
23
+ )
24
+
25
+ def __init__(
26
+ self, config: Mapping[str, str] = {}, service: Optional[Service] = None
27
+ ) -> None:
28
+ self.config = config
29
+ self.service = (
30
+ service if service is not None else TransactionalService(config=self.config)
31
+ )
32
+
33
+ def unmarshal(self, event) -> Operation:
34
+ """
35
+ Unmarshal the event in a tuple for processing
36
+
37
+ Parameters:
38
+ - event (dict): Lambda event object.
39
+
40
+ Returns:
41
+ - Operation containing the entity, action, and parameters
42
+ """
43
+ raise NotImplementedError
44
+
45
+ def marshal(self, result: list[dict]):
46
+ """
47
+ Marshal the result into a event response
48
+
49
+ Parameters:
50
+ - result (list): the data set to return in the response
51
+
52
+ Returns:
53
+ - the event response
54
+ """
55
+ return result
56
+
57
+ def process_event(self, event):
58
+ """
59
+ Process Lambda event using a domain function.
60
+
61
+ Parameters:
62
+ - service_function (callable): The service function to be executed.
63
+ - event (dict): Lambda event object.
64
+
65
+ Returns:
66
+ - any: Result of the domain function.
67
+ """
68
+ operation = self.unmarshal(event)
69
+
70
+ result = self.service.execute(operation)
71
+ log.debug("adapter result: %s", result)
72
+
73
+ return self.marshal(result)
@@ -0,0 +1,79 @@
1
+ from humps import camelize, decamelize
2
+
3
+ from api_foundry_query_engine.adapters.adapter import Adapter
4
+ from api_foundry_query_engine.utils.logger import logger
5
+ from api_foundry_query_engine.operation import Operation
6
+
7
+ log = logger(__name__)
8
+
9
+
10
+ class CaseChangeAdapter(Adapter):
11
+ """
12
+ Handles changing case from snake to camel and back
13
+ """
14
+
15
+ def unmarshal(self, event) -> Operation:
16
+ """
17
+ Unmarshal the event in a tuple for processing
18
+
19
+ Parameters:
20
+ - event (dict): Lambda event object.
21
+
22
+ Returns:
23
+ - tuple: Tuple containing entity operation, store_params, query_params and metadata params.
24
+ """
25
+ operation = super().unmarshal(event)
26
+
27
+ # determine case
28
+ self.camel_case = (
29
+ operation.metadata_params.get("_case", "snake") == "camel"
30
+ or self.__check_camel_case(operation.store_params)
31
+ or self.__check_camel_case(operation.query_params)
32
+ )
33
+ log.info("camel_case: %s", self.camel_case)
34
+
35
+ if self.camel_case:
36
+ return Operation(
37
+ path=operation.entity,
38
+ action=operation.action,
39
+ store_params=decamelize(operation.store_params),
40
+ query_params=decamelize(operation.query_params),
41
+ metadata_params=operation.metadata_params,
42
+ )
43
+
44
+ return operation
45
+
46
+ def marshal(self, result: list[dict]):
47
+ """
48
+ Marshal the result into a event response
49
+
50
+ Parameters:
51
+ - result (list): the data set to return in the response
52
+
53
+ Returns:
54
+ - the event response
55
+ """
56
+ super().marshal(result)
57
+
58
+ if not self.camel_case:
59
+ return result
60
+
61
+ converted_result = []
62
+ for item in result:
63
+ converted_result.append(camelize(item))
64
+
65
+ # convert back to camel case if needed
66
+ return converted_result
67
+
68
+ def __check_camel_case(self, params: dict) -> bool:
69
+ if params is not None:
70
+ # check the keys for an upper case character
71
+ for param in params:
72
+ if (
73
+ param != param.lower()
74
+ and param != param.upper()
75
+ and "_" not in param
76
+ ):
77
+ return True
78
+
79
+ return False
@@ -0,0 +1,191 @@
1
+ import json
2
+ from typing import Any, Dict, List, Optional, Tuple
3
+
4
+ from api_foundry_query_engine.adapters.adapter import Adapter
5
+ from api_foundry_query_engine.operation import Operation
6
+ from api_foundry_query_engine.utils.app_exception import ApplicationException
7
+
8
+ actions_map = {
9
+ "GET": "read",
10
+ "POST": "create",
11
+ "PUT": "update",
12
+ "DELETE": "delete",
13
+ }
14
+
15
+
16
+ class GatewayAdapter(Adapter):
17
+ def marshal(self, result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
18
+ """
19
+ Marshal the result into a event response
20
+
21
+ Parameters:
22
+ - result (list): the data set to return in the response
23
+
24
+ Returns:
25
+ - the event response
26
+ """
27
+ return super().marshal(result)
28
+
29
+ def unmarshal(self, event: Dict[str, Any]) -> Operation:
30
+ """
31
+ Get parameters from the Lambda event.
32
+
33
+ Parameters:
34
+ - event (dict): Lambda event object.
35
+
36
+ Returns:
37
+ - tuple: Tuple containing data, query and metadata parameters.
38
+ """
39
+ resource = event.get("resource")
40
+ if resource is not None and "/" in resource:
41
+ parts = resource.split("/")
42
+ entity = parts[1] if len(parts) > 1 else None
43
+ else:
44
+ entity = None
45
+
46
+ method = str(event.get("httpMethod", "")).upper()
47
+ action = actions_map.get(method, "read")
48
+
49
+ # Extract JWT claims early for batch operations
50
+ claims = event.get("requestContext", {}).get("authorizer", {})
51
+
52
+ # Handle different authorizer types
53
+ if isinstance(claims, dict):
54
+ # TOKEN authorizer puts claims directly in authorizer object
55
+ if "sub" in claims or "iss" in claims:
56
+ # Already have JWT claims at top level
57
+ pass
58
+ elif "claims" in claims:
59
+ # Some configurations nest claims
60
+ claims = claims["claims"]
61
+ elif "iam" in claims:
62
+ # IAM authorizer fallback
63
+ claims = claims["iam"]
64
+ elif "lambda" in claims:
65
+ # Lambda authorizer fallback
66
+ claims = claims["lambda"]
67
+ else:
68
+ # Empty or unknown format
69
+ claims = {}
70
+ else:
71
+ # Non-dict authorizer context
72
+ claims = {}
73
+
74
+ # Handle batch requests
75
+ if entity == "batch" and method == "POST":
76
+ body = event.get("body")
77
+ if body:
78
+ batch_request = json.loads(body)
79
+ return Operation(
80
+ entity="batch",
81
+ action="create",
82
+ store_params=batch_request,
83
+ claims=claims,
84
+ )
85
+
86
+ event_params = {}
87
+
88
+ path_parameters = self._convert_parameters(event.get("pathParameters"))
89
+ if path_parameters is not None:
90
+ event_params.update(path_parameters)
91
+
92
+ queryStringParameters = self._convert_parameters(
93
+ event.get("queryStringParameters")
94
+ )
95
+ if queryStringParameters is not None:
96
+ event_params.update(queryStringParameters)
97
+
98
+ query_params, metadata_params = self.split_params(event_params)
99
+
100
+ store_params = {}
101
+ body = event.get("body")
102
+ if body is not None and len(body) > 0:
103
+ store_params = json.loads(body)
104
+ scope_str = claims.get("scope")
105
+
106
+ # Enforce OAuth scopes (simulating API Gateway authorizer behavior)
107
+ # Required scope pattern: read|write|delete:<entity>
108
+ if entity and scope_str:
109
+ required_action = {
110
+ "GET": "read",
111
+ "POST": "write",
112
+ "PUT": "write",
113
+ "PATCH": "write",
114
+ "DELETE": "delete",
115
+ }.get(method, "read")
116
+ required_scope = f"{required_action}:{entity}"
117
+ token_scopes = set(str(scope_str).split())
118
+
119
+ def _has_scope(required: str) -> bool:
120
+ return (
121
+ required in token_scopes
122
+ or f"{required_action}:*" in token_scopes
123
+ or "*" in token_scopes
124
+ or "*:*" in token_scopes
125
+ )
126
+
127
+ if not _has_scope(required_scope):
128
+ raise ApplicationException(
129
+ 401,
130
+ ("insufficient_scope: required_scope=" + required_scope),
131
+ )
132
+
133
+ return Operation(
134
+ entity=entity,
135
+ action=action,
136
+ store_params=store_params,
137
+ query_params=query_params,
138
+ metadata_params=metadata_params,
139
+ claims=claims,
140
+ )
141
+
142
+ def _convert_parameters(
143
+ self, parameters: Optional[Dict[str, Any]]
144
+ ) -> Optional[Dict[str, Any]]:
145
+ """
146
+ Convert parameters to appropriate types.
147
+
148
+ Parameters:
149
+ - parameters (dict): Dictionary of parameters.
150
+
151
+ Returns:
152
+ - dict: Dictionary with parameters converted to appropriate types.
153
+ """
154
+ if parameters is None:
155
+ return None
156
+
157
+ result = {}
158
+ for parameter, value in parameters.items():
159
+ try:
160
+ result[parameter] = int(value)
161
+ except ValueError:
162
+ try:
163
+ result[parameter] = float(value)
164
+ except ValueError:
165
+ result[parameter] = value
166
+ return result
167
+
168
+ def split_params(
169
+ self, parameters: Dict[str, Any]
170
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
171
+ """
172
+ Split a dictionary into two dictionaries based on keys.
173
+
174
+ Parameters:
175
+ - dictionary (dict): Input dictionary.
176
+
177
+ Returns:
178
+ - tuple: A tuple containing two dictionaries.
179
+ The first dictionary contains metadata_params,
180
+ and the second dictionary query_params.
181
+ """
182
+ query_params = {}
183
+ metadata_params = {}
184
+
185
+ for key, value in parameters.items():
186
+ if key.startswith("__"):
187
+ metadata_params[key] = value
188
+ else:
189
+ query_params[key] = value
190
+
191
+ return query_params, metadata_params
@@ -0,0 +1,106 @@
1
+ from typing import Optional
2
+
3
+ from api_foundry_query_engine.operation import Operation
4
+ from api_foundry_query_engine.services.service import Service
5
+ from api_foundry_query_engine.adapters.adapter import Adapter
6
+ from api_foundry_query_engine.utils.logger import logger
7
+
8
+ log = logger(__name__)
9
+
10
+
11
+ class SecurityAdapter(Adapter):
12
+ def __init__(self, service: Optional[Service] = None, permissions: dict = None):
13
+ """
14
+ Initialize SecurityAdapter with a service and permissions.
15
+
16
+ Parameters:
17
+ - service (Service): The service used to process operations.
18
+ - permissions (dict): A dictionary containing the user's permissions,
19
+ structured as:
20
+ {
21
+ "read": ["field1", "field2", ...],
22
+ "write": ["field3", "field4", ...]
23
+ }
24
+ """
25
+ super().__init__(service)
26
+ self.permissions = permissions or {"read": [], "write": []}
27
+
28
+ def unmarshal(self, event) -> Operation:
29
+ """
30
+ Unmarshal the event into an Operation object after validating query and store params.
31
+
32
+ Parameters:
33
+ - event (dict): Lambda event object.
34
+
35
+ Returns:
36
+ - Operation: The validated operation.
37
+ """
38
+ entity = event.get("entity")
39
+ action = event.get("action")
40
+ query_params = event.get("query_params", {})
41
+ store_params = event.get("store_params", {})
42
+
43
+ # Validate read permissions for query_params
44
+ invalid_query_params = [
45
+ key for key in query_params if key not in self.permissions["read"]
46
+ ]
47
+ if invalid_query_params:
48
+ raise PermissionError(
49
+ f"Query parameters not permitted: {invalid_query_params}"
50
+ )
51
+
52
+ # Validate write permissions for store_params
53
+ invalid_store_params = [
54
+ key for key in store_params if key not in self.permissions["write"]
55
+ ]
56
+ if invalid_store_params:
57
+ raise PermissionError(
58
+ f"Store parameters not permitted: {invalid_store_params}"
59
+ )
60
+
61
+ return Operation(
62
+ entity=entity,
63
+ action=action,
64
+ query_params=query_params,
65
+ store_params=store_params,
66
+ )
67
+
68
+ def marshal(self, result: list[dict]):
69
+ """
70
+ Filter the result based on read permissions before returning.
71
+
72
+ Parameters:
73
+ - result (list[dict]): The data set to return in the response.
74
+
75
+ Returns:
76
+ - list[dict]: Filtered response with only permitted fields.
77
+ """
78
+ filtered_result = []
79
+ for record in result:
80
+ filtered_record = {
81
+ key: value
82
+ for key, value in record.items()
83
+ if key in self.permissions["read"]
84
+ }
85
+ filtered_result.append(filtered_record)
86
+
87
+ return filtered_result
88
+
89
+ def process_event(self, event):
90
+ """
91
+ Process Lambda event using a domain function.
92
+
93
+ Parameters:
94
+ - event (dict): Lambda event object.
95
+
96
+ Returns:
97
+ - any: Result of the domain function.
98
+ """
99
+ try:
100
+ operation = self.unmarshal(event)
101
+ result = self.service.execute(operation)
102
+ log.debug("adapter result: %s", result)
103
+ return self.marshal(result)
104
+ except PermissionError as e:
105
+ log.error("Permission error: %s", e)
106
+ return {"error": str(e), "status": "permission_denied"}
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,115 @@
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
+ # Use config dict for secrets
39
+ secrets_map = self.config.get("SECRETS", {})
40
+ if isinstance(secrets_map, str):
41
+ secrets_map = json.loads(secrets_map)
42
+ secret_name = secrets_map.get(database)
43
+ log.debug("secret_name: %s", secret_name)
44
+
45
+ if secret_name:
46
+ db_config = self.__get_secret(secret_name)
47
+ else:
48
+ raise ValueError(f"Secret not found for database: {database}")
49
+
50
+ engine = db_config.get("engine")
51
+ if not engine:
52
+ raise ApplicationException(
53
+ 500, "Database 'engine' is not defined in the secret."
54
+ )
55
+
56
+ if engine == "postgres":
57
+ from .postgres_connection import PostgresConnection
58
+
59
+ return PostgresConnection(db_config)
60
+
61
+ # Add support for other engines here if needed in the future
62
+
63
+ raise ValueError(f"Unsupported database engine: {engine}")
64
+
65
+ def __get_secret(self, db_secret_name: str):
66
+ """
67
+ Get the secret from AWS Secrets Manager.
68
+
69
+ Parameters:
70
+ - db_secret_name (str): The name of the AWS Secrets Manager secret.
71
+
72
+ Returns:
73
+ - dict: The database configuration obtained from the secret.
74
+ """
75
+ if self.config.get(db_secret_name):
76
+ return self.config.get(db_secret_name)
77
+
78
+ endpoint_url = self.config.get("AWS_ENDPOINT_URL") # LocalStack endpoint
79
+ sts_client = boto3.client("sts", endpoint_url=endpoint_url)
80
+
81
+ secret_account_id = self.config.get("SECRET_ACCOUNT_ID", None)
82
+ log.debug("secret_account_id: %s", secret_account_id)
83
+
84
+ if secret_account_id:
85
+ # If a secret account ID is provided, assume a role in that account
86
+ secret_role = self.config.get("ROLE_NAME", None)
87
+ assume_role_response = sts_client.assume_role(
88
+ RoleArn=f"arn:aws:iam::{secret_account_id}:role/{secret_role}",
89
+ RoleSessionName="AssumeRoleSession",
90
+ )
91
+
92
+ credentials = assume_role_response["Credentials"]
93
+
94
+ secretsmanager = boto3.client(
95
+ "secretsmanager",
96
+ aws_access_key_id=credentials["AccessKeyId"],
97
+ aws_secret_access_key=credentials["SecretAccessKey"],
98
+ aws_session_token=credentials["SessionToken"],
99
+ endpoint_url=endpoint_url,
100
+ )
101
+ else:
102
+ # If no secret account ID is provided, use the default account
103
+ log.info("endpoint_url: %s", endpoint_url)
104
+ secretsmanager = boto3.client(
105
+ "secretsmanager",
106
+ endpoint_url=endpoint_url,
107
+ )
108
+
109
+ # Get the secret value from AWS Secrets Manager
110
+ log.info("db_secret_name: %s", db_secret_name)
111
+ db_secret = secretsmanager.get_secret_value(SecretId=db_secret_name)
112
+ log.debug("loading secret name: %s", db_secret)
113
+
114
+ # Return the parsed JSON secret string
115
+ return json.loads(db_secret.get("SecretString"))
@@ -0,0 +1,29 @@
1
+ from api_foundry_query_engine.utils.logger import logger
2
+ from api_foundry_query_engine.utils.app_exception import ApplicationException
3
+ from api_foundry_query_engine.connectors.connection import Connector
4
+
5
+ log = logger(__name__)
6
+
7
+
8
+ class OracleConnnector(Connector):
9
+ def __init__(self, db_secret_name: str) -> None:
10
+ super().__init__(db_secret_name)
11
+
12
+ def close(self):
13
+ pass
14
+
15
+ def execute(self, cursor, sql: str, parameters: dict):
16
+ from oracledb import Error, IntegrityError, ProgrammingError
17
+
18
+ log.debug("sql: %s, parameters: %s", sql, parameters)
19
+ try:
20
+ cursor.execute(sql, parameters)
21
+ except IntegrityError as err:
22
+ (error,) = err.args
23
+ raise ApplicationException(409, error.message)
24
+ except ProgrammingError as err:
25
+ (error,) = err.args
26
+ raise ApplicationException(400, error.message)
27
+ except Error as err:
28
+ (error,) = err.args
29
+ raise ApplicationException(500, error.message)