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,198 @@
1
+ import re
2
+ from typing import Match
3
+ from api_foundry_query_engine.dao.sql_query_handler import SQLSchemaQueryHandler
4
+ from api_foundry_query_engine.operation import Operation
5
+ from api_foundry_query_engine.utils.app_exception import ApplicationException
6
+ from api_foundry_query_engine.utils.api_model import SchemaObject
7
+
8
+
9
+ class SQLUpdateSchemaQueryHandler(SQLSchemaQueryHandler):
10
+ def __init__(
11
+ self, operation: Operation, schema_object: SchemaObject, engine: str
12
+ ) -> None:
13
+ super().__init__(operation, schema_object, engine)
14
+
15
+ def _template_where(self, expr: str) -> str:
16
+ """Template substitution for WHERE clause expressions with claim values."""
17
+ if not expr:
18
+ return expr
19
+
20
+ def _quote(val: object) -> str:
21
+ if val is None:
22
+ return "NULL"
23
+ if isinstance(val, (int, float)):
24
+ return str(val)
25
+ s = str(val).replace("'", "''")
26
+ return f"'{s}'"
27
+
28
+ def _replace(m: Match[str]) -> str:
29
+ key = m.group(1)
30
+ claims = self.operation.claims or {}
31
+ return _quote(claims.get(key))
32
+
33
+ return re.sub(r"\$\{claims\.([A-Za-z0-9_]+)\}", _replace, expr)
34
+
35
+ def _row_where_clause(self) -> str:
36
+ """
37
+ Generate row-level WHERE clause based on write permissions.
38
+ Only applies to UPDATE operations, not CREATE.
39
+ """
40
+ perms = getattr(self.schema_object, "permissions", None) or {}
41
+ # provider-first default
42
+ if "default" in perms:
43
+ provider = perms.get("default", {}) or {}
44
+ write_map = provider.get("write", {}) or {}
45
+ role_permissions = perms.get("default", {})
46
+ else:
47
+ # legacy role-first -> synthesize write map
48
+ write_map = {}
49
+ role_permissions = perms
50
+ for role, role_perms in perms.items():
51
+ if isinstance(role_perms, dict):
52
+ write_map[role] = role_perms.get("write")
53
+
54
+ filters = []
55
+ for role in self.operation.roles or []:
56
+ # Check for role-level WHERE clause (hybrid approach)
57
+ role_where = None
58
+ if isinstance(role_permissions.get(role), dict):
59
+ role_where = role_permissions[role].get("where")
60
+
61
+ # Check for operation-level WHERE clause
62
+ operation_where = None
63
+ rule = write_map.get(role)
64
+ if isinstance(rule, dict):
65
+ operation_where = rule.get("where")
66
+
67
+ # Operation-level takes precedence, fallback to role-level
68
+ where_clause = operation_where if operation_where else role_where
69
+
70
+ if isinstance(where_clause, str) and where_clause.strip():
71
+ filters.append(self._template_where(where_clause))
72
+
73
+ if not filters:
74
+ return ""
75
+ return "(" + ") OR (".join(filters) + ")"
76
+
77
+ @property
78
+ def sql(self) -> str:
79
+ concurrency_property = self.schema_object.concurrency_property
80
+ if not concurrency_property:
81
+ return (
82
+ f"UPDATE {self.table_expression}{self.update_values}"
83
+ + f"{self.search_condition} RETURNING {self.select_list}"
84
+ )
85
+
86
+ if not self.operation.query_params.get(concurrency_property.api_name):
87
+ raise ApplicationException(
88
+ 400,
89
+ "Missing required concurrency management property. "
90
+ + f"schema_object: {self.schema_object.api_name}, "
91
+ + f"property: {concurrency_property.api_name}",
92
+ )
93
+ if self.operation.store_params.get(concurrency_property.api_name):
94
+ raise ApplicationException(
95
+ 400,
96
+ "For updating concurrency managed schema objects the current version "
97
+ + " may not be supplied as a storage parameter. "
98
+ + f"schema_object: {self.schema_object.api_name}, "
99
+ + f"property: {concurrency_property.api_name}",
100
+ )
101
+
102
+ return f"UPDATE {self.table_expression}{self.update_values}, {concurrency_property.column_name} = {self.concurrency_generator(concurrency_property)} {self.search_condition} RETURNING {self.select_list}" # noqa E501
103
+
104
+ @property
105
+ def update_values(self) -> str:
106
+ allowed_property_names = self.check_permissions(
107
+ "write", self.schema_object.permissions, self.schema_object.properties
108
+ )
109
+ allowed_properties = {
110
+ k: v
111
+ for k, v in self.schema_object.properties.items()
112
+ if k in allowed_property_names
113
+ }
114
+ self.store_placeholders = {}
115
+ columns = []
116
+ invalid_columns = []
117
+
118
+ import json
119
+
120
+ # First, validate that user is not trying to set injected properties
121
+ for property_name, property in self.schema_object.properties.items():
122
+ if property.inject_value and property_name in self.operation.store_params:
123
+ raise ApplicationException(
124
+ 403,
125
+ f"Property '{property_name}' is auto-injected and "
126
+ + "cannot be set manually",
127
+ )
128
+
129
+ for name, value in self.operation.store_params.items():
130
+ property = allowed_properties.get(name, None)
131
+ if property is None:
132
+ invalid_columns.append(name)
133
+ continue
134
+
135
+ placeholder = (
136
+ str(property.api_name) if property.api_name is not None else name
137
+ )
138
+ column_name = property.column_name
139
+
140
+ columns.append(f"{column_name} = {self.placeholder(property, placeholder)}")
141
+ # Serialize embedded objects to JSON
142
+ if property.api_type == "object":
143
+ self.store_placeholders[placeholder] = json.dumps(value)
144
+ else:
145
+ self.store_placeholders[placeholder] = property.convert_to_db_value(
146
+ value
147
+ )
148
+
149
+ # Inject values from claims/timestamps/etc for properties with
150
+ # x-af-inject-value on UPDATE
151
+ for property_name, property in self.schema_object.properties.items():
152
+ if property.inject_value and "update" in property.inject_on:
153
+ injected_value = self.extract_injected_value(property.inject_value)
154
+ if injected_value is not None:
155
+ placeholder_key = f"__inject_{property_name}"
156
+ column_name = property.column_name
157
+ columns.append(
158
+ f"{column_name} = {self.placeholder(property, placeholder_key)}"
159
+ )
160
+ self.store_placeholders[
161
+ placeholder_key
162
+ ] = property.convert_to_db_value(injected_value)
163
+ elif property.required:
164
+ raise ApplicationException(
165
+ 400,
166
+ f"Required injected property '{property_name}' "
167
+ + f"could not be populated from '{property.inject_value}'",
168
+ )
169
+
170
+ if invalid_columns:
171
+ raise ApplicationException(
172
+ 403,
173
+ f"Subject does not have permission to update properties: "
174
+ f"{invalid_columns}",
175
+ )
176
+ return f" SET {', '.join(columns)}"
177
+
178
+ @property
179
+ def search_condition(self) -> str:
180
+ """
181
+ Override to add permission-based WHERE clause for UPDATE operations.
182
+ """
183
+ # Get the base search condition from parent class
184
+ base_condition = super().search_condition
185
+
186
+ # Get row-level permission filters
187
+ row_filter = self._row_where_clause()
188
+
189
+ # If we have both, combine them
190
+ if base_condition and row_filter:
191
+ # base_condition already has " WHERE ", so add AND
192
+ return f"{base_condition} AND ({row_filter})"
193
+ elif row_filter:
194
+ # Only row filter, add WHERE prefix
195
+ return f" WHERE ({row_filter})"
196
+ else:
197
+ # Only base condition or neither
198
+ return base_condition
@@ -0,0 +1,63 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from typing import Mapping, Any
5
+
6
+ from api_foundry_query_engine.utils.api_model import set_api_model
7
+ from api_foundry_query_engine.utils.app_exception import ApplicationException
8
+ from api_foundry_query_engine.adapters.gateway_adapter import GatewayAdapter
9
+ from api_foundry_query_engine.utils.token_decoder import token_decoder
10
+ from api_foundry_query_engine.utils.claims_check import claims_check
11
+
12
+ logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ class QueryEngine:
17
+ def __init__(self, config: Mapping[str, str]):
18
+ self.adapter = GatewayAdapter(config)
19
+
20
+ def handler(self, event) -> dict[str, Any]:
21
+ log.debug("event: %s", event)
22
+ try:
23
+ response = self.adapter.process_event(event)
24
+
25
+ # Ensure the response conforms to API Gateway requirements
26
+ return {
27
+ "isBase64Encoded": False,
28
+ "statusCode": 200,
29
+ "headers": {"Content-Type": "application/json"},
30
+ "body": json.dumps(response),
31
+ }
32
+ except ApplicationException as e:
33
+ log.error("exception: %s", e, exc_info=True)
34
+ return {
35
+ "isBase64Encoded": False,
36
+ "statusCode": e.status_code,
37
+ "headers": {"Content-Type": "application/json"},
38
+ "body": json.dumps({"message": "exception: %s" % e}),
39
+ }
40
+ except RuntimeError as e:
41
+ log.error("runtime error: %s", e, exc_info=True)
42
+ return {
43
+ "isBase64Encoded": False,
44
+ "statusCode": 500,
45
+ "headers": {"Content-Type": "application/json"},
46
+ "body": json.dumps({"message": f"runtime error: {e}"}),
47
+ }
48
+
49
+
50
+ @token_decoder()
51
+ @claims_check()
52
+ def handler(event, _):
53
+ if not hasattr(handler, "engine_config"):
54
+ log.info("Loading engine config from environment variables")
55
+ handler.engine_config = os.environ
56
+ log.info("engine_config: %s", handler.engine_config)
57
+
58
+ if not hasattr(handler, "query_engine"):
59
+ set_api_model(handler.engine_config)
60
+ log.info("Creating QueryEngine instance")
61
+ handler.query_engine = QueryEngine(handler.engine_config)
62
+
63
+ return handler.query_engine.handler(event)
@@ -0,0 +1,104 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from api_foundry_query_engine.utils.logger import logger
4
+
5
+ logger = logger(__name__)
6
+
7
+
8
+ class Operation:
9
+ """
10
+ Represents an action to be performed on an entity.
11
+
12
+ The `Operation` class encapsulates the details required
13
+ to execute an operation on a given entity, including
14
+ query parameters for selecting records, parameters for
15
+ storing or updating values, metadata for operational
16
+ instructions, and roles defining the contexts in which
17
+ the operation can be performed.
18
+
19
+ Attributes:
20
+ entity (str): The name of the resource or object being
21
+ targeted (e.g., "User", "Order").
22
+ action (str): The type of action being performed
23
+ (e.g., "create", "read", "update", "delete").
24
+ query_params (dict): Parameters used to filter or select
25
+ the records affected by the operation.
26
+ store_params (dict): Parameters that define the values to
27
+ be stored or updated for the selected records.
28
+ metadata_params (dict): Additional instructions for the
29
+ operation, such as sorting or pagination.
30
+ roles (dict): Defines the roles under which the operation
31
+ is allowed to be performed.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ entity: str,
38
+ action: str,
39
+ query_params: Optional[Dict[str, Any]] = None,
40
+ store_params: Optional[Dict[str, Any]] = None,
41
+ metadata_params: Optional[Dict[str, Any]] = None,
42
+ claims: Optional[Dict[str, Any]] = None,
43
+ ):
44
+ """
45
+ Initializes the Operation instance.
46
+
47
+ Args:
48
+ entity (str): The name of the entity to perform the operation on.
49
+ action (str): The action to perform on the entity.
50
+ query_params (dict, optional): Parameters for selecting
51
+ affected records (default: {}).
52
+ store_params (dict, optional): Parameters defining new or
53
+ updated values (default: {}).
54
+ metadata_params (dict, optional): Metadata for the
55
+ operation, such as sorting or pagination (default: {}).
56
+ roles (dict, optional): Defines the roles allowed to
57
+ perform the operation (default: {}).
58
+ """
59
+ # The target entity for the operation (e.g., "User", "Order").
60
+ self.entity = entity
61
+
62
+ # The type of action to perform (e.g., "create",
63
+ # "read", "update", "delete").
64
+ self.action = action
65
+
66
+ # Query parameters to filter or identify the affected records.
67
+ self.query_params = query_params or {}
68
+
69
+ # Parameters defining the values to be stored or updated
70
+ # for the operation.
71
+ self.store_params = store_params or {}
72
+
73
+ # Metadata for operational instructions like sorting,
74
+ # limiting, or offsetting results.
75
+ self.metadata_params = metadata_params or {}
76
+
77
+ # Roles defining the context in which the operation is allowed.
78
+ self.claims = claims or {}
79
+
80
+ # Log the operation for debugging and audit purposes
81
+ logger.info(
82
+ "Operation created: entity=%s, action=%s, "
83
+ "query_params=%s, store_params=%s, "
84
+ "metadata_params=%s, claims=%s",
85
+ self.entity,
86
+ self.action,
87
+ self.query_params,
88
+ self.store_params,
89
+ self.metadata_params,
90
+ self.claims,
91
+ )
92
+
93
+ @property
94
+ def roles(self) -> List[str]:
95
+ """Get the roles from claims."""
96
+ return self.claims.get("roles", []) if self.claims else []
97
+
98
+ def subject(self) -> Optional[str]:
99
+ """Get the subject from claims."""
100
+ return self.claims.get("sub") if self.claims else None
101
+
102
+ def groups(self) -> List[str]:
103
+ """Get the groups from claims."""
104
+ return self.claims.get("groups", []) if self.claims else []
@@ -0,0 +1,75 @@
1
+ import hashlib
2
+ import json
3
+
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 Service:
11
+ def __init__(self, config: dict = None):
12
+ if not config:
13
+ config = {}
14
+ self.config = config
15
+
16
+ def execute(self, operation: Operation) -> list[dict]:
17
+ raise NotImplementedError
18
+
19
+
20
+ class ServiceAdapter(Service):
21
+ def __init__(self, config: dict = None):
22
+ if not config:
23
+ config = {}
24
+ super().__init__(config)
25
+
26
+ def execute(self, operation: Operation) -> list[dict]:
27
+ return super().execute(operation)
28
+
29
+
30
+ class MutationPublisher(ServiceAdapter):
31
+ def __init__(self, config: dict = None):
32
+ if not config:
33
+ config = {}
34
+ super().__init__(config)
35
+
36
+ def execute(self, operation):
37
+ result = super().execute(operation)
38
+ self.publish_notification(operation)
39
+ return result
40
+
41
+ def publish_notification(self, operation):
42
+ topic_arn = self.config.get("BROADCAST_TOPIC", None)
43
+ log.debug("Topic ARN: %s", topic_arn)
44
+
45
+ if topic_arn is not None:
46
+ log.debug("Sending message")
47
+ message = {
48
+ "entity": operation.api_name,
49
+ "action": operation.action,
50
+ "store_params": operation.store_params,
51
+ "query_params": operation.query_params,
52
+ }
53
+
54
+ message_str = json.dumps({"default": json.dumps(message)})
55
+ log.debug("message_str: %s", message_str)
56
+ hash_object = hashlib.sha256(message_str.encode("utf-8"))
57
+ hex_dig = hash_object.hexdigest()
58
+
59
+ msg_id = self.__client("sns").publish(
60
+ TopicArn=topic_arn,
61
+ MessageStructure="json",
62
+ MessageDeduplicationId=hex_dig,
63
+ MessageGroupId=operation.api_name,
64
+ Message=message_str,
65
+ )
66
+ log.info("publish msg id %s", msg_id)
67
+
68
+ def __client(self, client_type):
69
+ import boto3
70
+
71
+ region = self.config.get("AWS_REGION", "us-east-1")
72
+ session = boto3.Session()
73
+ if session:
74
+ return session.client(client_type, region_name=region)
75
+ return boto3.client(client_type, region_name=region)
@@ -0,0 +1,52 @@
1
+ import traceback
2
+ from typing import Mapping
3
+
4
+ from api_foundry_query_engine.utils.logger import logger
5
+ from api_foundry_query_engine.utils.app_exception import ApplicationException
6
+ from api_foundry_query_engine.operation import Operation
7
+ from api_foundry_query_engine.services.service import ServiceAdapter
8
+ from api_foundry_query_engine.connectors.connection_factory import ConnectionFactory
9
+ from api_foundry_query_engine.dao.operation_dao import OperationDAO
10
+ from api_foundry_query_engine.utils.api_model import (
11
+ get_path_operation,
12
+ get_schema_object,
13
+ )
14
+
15
+ log = logger(__name__)
16
+
17
+
18
+ class TransactionalService(ServiceAdapter):
19
+ def __init__(self, config: Mapping[str, str]):
20
+ super().__init__()
21
+ self.config = config
22
+ self.connection_factory = ConnectionFactory(config)
23
+
24
+ def execute(self, operation: Operation) -> list[dict]:
25
+ path_operation = get_path_operation(operation.entity, operation.action)
26
+ if path_operation:
27
+ database = path_operation.database
28
+ else:
29
+ schema_object = get_schema_object(operation.entity)
30
+ if schema_object:
31
+ database = schema_object.database
32
+ else:
33
+ raise ApplicationException(
34
+ 500, f"Unknown operation: {operation.entity}"
35
+ )
36
+
37
+ # Pass config to connection_factory if needed (future extension)
38
+ connection = self.connection_factory.get_connection(database)
39
+
40
+ try:
41
+ result = OperationDAO(operation, connection.engine()).execute(connection)
42
+ if operation.action != "read":
43
+ connection.commit()
44
+ if isinstance(result, dict):
45
+ return [result]
46
+ return result
47
+ except Exception as error:
48
+ log.error("transaction exception: %s", error)
49
+ log.error("traceback: %s", traceback.format_exc())
50
+ raise error
51
+ finally:
52
+ connection.close()