dana-python 2.1.0__py3-none-any.whl → 2.1.2__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.
@@ -1238,6 +1238,13 @@ class DisbursementApi:
1238
1238
  )
1239
1239
 
1240
1240
 
1241
+
1242
+ def _get_transfer_to_dana_path(self) -> str:
1243
+ """Transfer to DANA (topup) path based on environment."""
1244
+ env = os.getenv("DANA_ENV", os.getenv("ENV", "sandbox")).lower()
1245
+ return '/v1.0/emoney/topup.htm' if env == 'production' else '/rest/v1.0/emoney/topup'
1246
+
1247
+
1241
1248
  @validate_call
1242
1249
  def transfer_to_dana(
1243
1250
  self,
@@ -1503,7 +1510,7 @@ class DisbursementApi:
1503
1510
  _auth_settings = SnapHeader.merge_with_snap_runtime_headers(_auth_settings)
1504
1511
  _generated_auth = SnapHeader.get_snap_generated_auth(
1505
1512
  method='POST',
1506
- resource_path='/rest/v1.0/emoney/topup',
1513
+ resource_path=(self._get_transfer_to_dana_path()),
1507
1514
  body=transfer_to_dana_request.to_json(),
1508
1515
  private_key=self.api_client.configuration.get_api_key_with_prefix('PRIVATE_KEY'),
1509
1516
  private_key_path=self.api_client.configuration.get_api_key_with_prefix('PRIVATE_KEY_PATH')
@@ -1511,7 +1518,7 @@ class DisbursementApi:
1511
1518
 
1512
1519
  return self.api_client.param_serialize(
1513
1520
  method='POST',
1514
- resource_path='/rest/v1.0/emoney/topup',
1521
+ resource_path=(self._get_transfer_to_dana_path()),
1515
1522
  path_params=_path_params,
1516
1523
  query_params=_query_params,
1517
1524
  header_params=_header_params,
@@ -1526,6 +1533,13 @@ class DisbursementApi:
1526
1533
  )
1527
1534
 
1528
1535
 
1536
+
1537
+ def _get_transfer_to_dana_inquiry_status_path(self) -> str:
1538
+ """Topup-status inquiry path based on environment."""
1539
+ env = os.getenv("DANA_ENV", os.getenv("ENV", "sandbox")).lower()
1540
+ return '/v1.0/emoney/topup-status.htm' if env == 'production' else '/rest/v1.0/emoney/topup-status'
1541
+
1542
+
1529
1543
  @validate_call
1530
1544
  def transfer_to_dana_inquiry_status(
1531
1545
  self,
@@ -1791,7 +1805,7 @@ class DisbursementApi:
1791
1805
  _auth_settings = SnapHeader.merge_with_snap_runtime_headers(_auth_settings)
1792
1806
  _generated_auth = SnapHeader.get_snap_generated_auth(
1793
1807
  method='POST',
1794
- resource_path='/rest/v1.0/emoney/topup-status',
1808
+ resource_path=(self._get_transfer_to_dana_inquiry_status_path()),
1795
1809
  body=transfer_to_dana_inquiry_status_request.to_json(),
1796
1810
  private_key=self.api_client.configuration.get_api_key_with_prefix('PRIVATE_KEY'),
1797
1811
  private_key_path=self.api_client.configuration.get_api_key_with_prefix('PRIVATE_KEY_PATH')
@@ -1799,7 +1813,7 @@ class DisbursementApi:
1799
1813
 
1800
1814
  return self.api_client.param_serialize(
1801
1815
  method='POST',
1802
- resource_path='/rest/v1.0/emoney/topup-status',
1816
+ resource_path=(self._get_transfer_to_dana_inquiry_status_path()),
1803
1817
  path_params=_path_params,
1804
1818
  query_params=_query_params,
1805
1819
  header_params=_header_params,
dana/exceptions.py CHANGED
@@ -22,7 +22,7 @@
22
22
  Do not edit the class manually.
23
23
  """ # noqa: E501
24
24
 
25
- from typing import Any, Optional
25
+ from typing import Any, Dict, List, Optional
26
26
  from typing_extensions import Self
27
27
 
28
28
  class OpenApiException(Exception):
@@ -122,7 +122,11 @@ class ApiException(OpenApiException):
122
122
  *,
123
123
  body: Optional[str] = None,
124
124
  data: Optional[Any] = None,
125
+ contexts: Optional[List[Dict[str, str]]] = None,
125
126
  ) -> None:
127
+ self.contexts = contexts
128
+ if contexts is not None and reason is None:
129
+ reason = "; ".join(f"{c['field']}: {c['message']}" for c in contexts)
126
130
  self.status = status
127
131
  self.reason = reason
128
132
  self.body = body
@@ -21,33 +21,38 @@ Validations are registered in the validation_registry and executed via custom_va
21
21
 
22
22
  import os
23
23
  import re
24
- from typing import Any, Dict, List, Callable
24
+ from typing import Any, Callable, Dict, FrozenSet, List, Set
25
+
25
26
  from dana.utils.date_validation import validate_valid_up_to_date
26
27
  from dana.exceptions import ApiException
27
28
 
28
29
  # Money value pattern: digits (1-16) + "." + exactly 2 digits (e.g. 10000.00)
29
30
  MONEY_VALUE_PATTERN = re.compile(r'^\d{1,16}\.\d{2}$')
30
31
 
31
- # In sandbox, only these payMethods are available (Payment Gateway).
32
- SANDBOX_ALLOWED_PAY_METHODS = frozenset({
32
+ SANDBOX_ALLOWED_PAY_METHODS: FrozenSet[str] = frozenset({
33
33
  'BALANCE', 'CREDIT_CARD', 'DEBIT_CARD', 'VIRTUAL_ACCOUNT', 'NETWORK_PAY',
34
34
  })
35
35
 
36
- # In sandbox, only these payOptions are available (Payment Gateway).
37
- # API may send full identifiers (e.g. VIRTUAL_ACCOUNT_BRI); we accept exact match or suffix _OPTION.
38
- SANDBOX_ALLOWED_PAY_OPTIONS = frozenset({
36
+ SANDBOX_ALLOWED_PAY_OPTIONS: FrozenSet[str] = frozenset({
39
37
  'CARD', 'QRIS', 'BRI', 'PANIN', 'CIMB', 'MANDIRI', 'BTPN',
40
38
  })
41
39
 
40
+ CREDIT_DEBIT_CARD_PAY_METHODS: Set[str] = {'CREDIT_CARD', 'DEBIT_CARD'}
41
+ NETWORK_PAY_PG_CARD = 'NETWORK_PAY_PG_CARD'
42
+ EWALLET_PAY_OPTIONS: Set[str] = {
43
+ 'NETWORK_PAY_PG_SPAY',
44
+ 'NETWORK_PAY_PG_OVO',
45
+ 'NETWORK_PAY_PG_GOPAY',
46
+ 'NETWORK_PAY_PG_LINKAJA',
47
+ }
48
+
42
49
 
43
50
  def _is_sandbox() -> bool:
44
- """Return True if current environment is sandbox (DANA_ENV or ENV)."""
45
51
  env = os.getenv('DANA_ENV', os.getenv('ENV', 'sandbox')).lower()
46
52
  return env == 'sandbox'
47
53
 
48
54
 
49
55
  def _pay_option_allowed_in_sandbox(value: str) -> bool:
50
- """Check if payOption value is allowed in sandbox (exact or suffix match, e.g. VIRTUAL_ACCOUNT_BRI)."""
51
56
  if not value or not str(value).strip():
52
57
  return False
53
58
  s = str(value).strip()
@@ -59,160 +64,222 @@ def _pay_option_allowed_in_sandbox(value: str) -> bool:
59
64
  return False
60
65
 
61
66
 
62
- def validate_additional_info_required(request: Any) -> None:
63
- """
64
- Validate that additionalInfo must exist.
67
+ def _normalize_value(value: Any) -> str:
68
+ if value is None:
69
+ return ''
70
+ if hasattr(value, 'value'):
71
+ return str(getattr(value, 'value')).strip()
72
+ return str(value).strip()
73
+
74
+
75
+ def _trim_str(value: Any) -> str:
76
+ if value is None:
77
+ return ''
78
+ return str(value).strip()
79
+
65
80
 
66
- Raises:
67
- ApiException: If additionalInfo is missing
68
- """
81
+ def _rune_len(s: str) -> int:
82
+ return len(list(s))
83
+
84
+
85
+ def _ctx(field: str, message: str) -> Dict[str, str]:
86
+ return {'field': field, 'message': message}
87
+
88
+
89
+ def validate_additional_info_required(request: Any) -> None:
69
90
  if request is None:
70
91
  return
71
92
  if hasattr(request, 'additional_info') and request.additional_info is None:
72
- raise ApiException(
73
- status=0,
74
- reason='additionalInfo is required'
75
- )
93
+ raise ApiException(status=0, contexts=[_ctx('additionalInfo', 'additionalInfo is required')])
76
94
 
77
95
 
78
96
  def validate_money_value_pattern(request: Any) -> None:
79
- """
80
- Validate that Money value matches pattern (e.g. 10000.00): ^\\d{1,16}\\.\\d{2}$
81
-
82
- Raises:
83
- ApiException: If amount.value is missing or does not match pattern
84
- """
85
97
  if request is None:
86
98
  return
87
99
  if not hasattr(request, 'amount') or request.amount is None:
88
100
  return
89
101
  value = getattr(request.amount, 'value', None)
90
102
  if value is None or value == '':
91
- raise ApiException(
92
- status=0,
93
- reason='amount.value is required'
94
- )
103
+ raise ApiException(status=0, contexts=[_ctx('amount.value', 'amount.value is required')])
95
104
  if not MONEY_VALUE_PATTERN.match(str(value)):
96
- raise ApiException(
97
- status=0,
98
- reason=f'amount.value must match pattern (e.g. 10000.00): got {value!r}'
99
- )
105
+ raise ApiException(status=0, contexts=[
106
+ _ctx('amount.value', f'amount.value must match pattern (e.g. 10000.00): got {value!r}')
107
+ ])
100
108
 
101
109
 
102
110
  def validate_valid_up_to_create_order_request(request: Any) -> None:
103
- """
104
- Validate validUpTo field in CreateOrderByApiRequest or CreateOrderByRedirectRequest.
105
-
106
- This function handles both request types directly (not wrapped in CreateOrderRequest):
107
- - CreateOrderByApiRequest: validates valid_up_to directly
108
- - CreateOrderByRedirectRequest: validates valid_up_to directly
109
-
110
- Args:
111
- request: The request object to validate
112
-
113
- Raises:
114
- ApiException: If validation fails
115
- """
116
111
  if request is None:
117
112
  return
118
-
119
- # Handle CreateOrderByApiRequest or CreateOrderByRedirectRequest directly
120
113
  if hasattr(request, 'valid_up_to') and request.valid_up_to is not None:
121
114
  try:
122
115
  validate_valid_up_to_date(request.valid_up_to)
123
116
  except ValueError as e:
124
- raise ApiException(
125
- status=0,
126
- reason=f'validUpTo validation failed: {str(e)}'
127
- ) from e
117
+ raise ApiException(status=0, contexts=[
118
+ _ctx('validUpTo', f'validUpTo validation failed: {str(e)}')
119
+ ]) from e
128
120
 
129
121
 
130
122
  def validate_external_store_id_for_qris(request: Any) -> None:
131
- """
132
- Validate that externalStoreId is required when payOption is NETWORK_PAY_PG_QRIS.
133
-
134
- This function checks if any payOption in payOptionDetails is NETWORK_PAY_PG_QRIS,
135
- and if so, ensures externalStoreId is provided.
136
-
137
- Args:
138
- request: The request object to validate
139
-
140
- Raises:
141
- ApiException: If validation fails
142
- """
143
123
  if request is None:
144
124
  return
145
-
146
- # Check if request has payOptionDetails
147
125
  if not hasattr(request, 'pay_option_details') or request.pay_option_details is None:
148
126
  return
149
-
150
- # Check if any payOption is NETWORK_PAY_PG_QRIS
151
127
  has_qris = False
152
128
  if isinstance(request.pay_option_details, list):
153
129
  for pay_option_detail in request.pay_option_details:
154
130
  if hasattr(pay_option_detail, 'pay_option') and pay_option_detail.pay_option == 'NETWORK_PAY_PG_QRIS':
155
131
  has_qris = True
156
132
  break
157
-
158
- # If QRIS is found, externalStoreId must be provided
159
133
  if has_qris:
160
134
  external_store_id = None
161
135
  if hasattr(request, 'external_store_id'):
162
136
  external_store_id = request.external_store_id
163
-
164
137
  if not external_store_id or (isinstance(external_store_id, str) and external_store_id.strip() == ''):
165
- raise ApiException(
166
- status=0,
167
- reason='externalStoreId is required when payOption is NETWORK_PAY_PG_QRIS'
168
- )
138
+ raise ApiException(status=0, contexts=[
139
+ _ctx('externalStoreId', 'externalStoreId is required when payOption is NETWORK_PAY_PG_QRIS')
140
+ ])
169
141
 
170
142
 
171
143
  def validate_sandbox_pay_method_and_pay_option(request: Any) -> None:
172
- """
173
- In sandbox, only certain payMethod and payOption values are available.
174
-
175
- - payMethod: only BALANCE, CREDIT_CARD, DEBIT_CARD, VIRTUAL_ACCOUNT, NETWORK_PAY.
176
- - payOption: only CARD, QRIS, BRI, PANIN, CIMB, MANDIRI, BTPN (exact or suffix, e.g. VIRTUAL_ACCOUNT_BRI).
177
-
178
- Skipped when not in sandbox (DANA_ENV / ENV != sandbox).
179
-
180
- Raises:
181
- ApiException: If in sandbox and a payMethod or payOption is not allowed.
182
- """
183
144
  if request is None or not _is_sandbox():
184
145
  return
185
- if not hasattr(request, 'pay_option_details') or request.pay_option_details is None:
146
+ pay_option_details = getattr(request, 'pay_option_details', None)
147
+ if not pay_option_details or not isinstance(pay_option_details, list):
186
148
  return
187
- if not isinstance(request.pay_option_details, list):
188
- return
189
- for idx, detail in enumerate(request.pay_option_details):
149
+ for i, detail in enumerate(pay_option_details):
150
+ if not detail:
151
+ continue
190
152
  if hasattr(detail, 'pay_method') and detail.pay_method is not None:
191
- pm = getattr(detail.pay_method, 'value', detail.pay_method) if hasattr(detail.pay_method, 'value') else detail.pay_method
192
- pm_str = str(pm).strip()
153
+ pm_str = _normalize_value(detail.pay_method)
193
154
  if pm_str and pm_str not in SANDBOX_ALLOWED_PAY_METHODS:
194
- raise ApiException(
195
- status=0,
196
- reason=(
197
- f'In sandbox, payMethod must be one of {sorted(SANDBOX_ALLOWED_PAY_METHODS)}; '
198
- f'got {pm_str!r} in payOptionDetails[{idx}]'
155
+ raise ApiException(status=0, contexts=[
156
+ _ctx(
157
+ f'payOptionDetails[{i}].payMethod',
158
+ (
159
+ f'In sandbox, payMethod must be one of [{", ".join(sorted(SANDBOX_ALLOWED_PAY_METHODS))}]; '
160
+ f'got {pm_str}'
161
+ ),
199
162
  )
200
- )
163
+ ])
201
164
  if hasattr(detail, 'pay_option') and detail.pay_option is not None:
202
- po = getattr(detail.pay_option, 'value', detail.pay_option) if hasattr(detail.pay_option, 'value') else detail.pay_option
203
- po_str = str(po).strip()
204
- # Empty payOption is allowed (e.g. for BALANCE)
165
+ po_str = _normalize_value(detail.pay_option)
205
166
  if po_str and not _pay_option_allowed_in_sandbox(po_str):
206
- raise ApiException(
207
- status=0,
208
- reason=(
209
- f'In sandbox, payOption must be one of {sorted(SANDBOX_ALLOWED_PAY_OPTIONS)} '
210
- f'(or suffix like VIRTUAL_ACCOUNT_BRI); got {po!r} in payOptionDetails[{idx}]'
167
+ raise ApiException(status=0, contexts=[
168
+ _ctx(
169
+ f'payOptionDetails[{i}].payOption',
170
+ (
171
+ f'In sandbox, payOption must be one of [{", ".join(sorted(SANDBOX_ALLOWED_PAY_OPTIONS))}] '
172
+ f'(or suffix like VIRTUAL_ACCOUNT_BRI); got {po_str}'
173
+ ),
211
174
  )
175
+ ])
176
+
177
+
178
+ def validate_conditional_pay_option_additional_info_create_order_request(request: Any) -> None:
179
+ if request is None:
180
+ return
181
+ pay_option_details = getattr(request, 'pay_option_details', None)
182
+ if not pay_option_details or not isinstance(pay_option_details, list):
183
+ return
184
+
185
+ contexts: List[Dict[str, str]] = []
186
+
187
+ for i, detail in enumerate(pay_option_details):
188
+ if not detail:
189
+ continue
190
+ pay_method = _trim_str(getattr(detail, 'pay_method', None))
191
+ pay_option = _trim_str(getattr(detail, 'pay_option', None))
192
+ additional_info = getattr(detail, 'additional_info', None)
193
+ phone_raw = None
194
+ if additional_info is not None:
195
+ phone_raw = getattr(additional_info, 'phone_number', None)
196
+ phone_number = _trim_str(phone_raw)
197
+
198
+ is_card = pay_method in CREDIT_DEBIT_CARD_PAY_METHODS or pay_option == NETWORK_PAY_PG_CARD
199
+ is_ewallet = pay_option in EWALLET_PAY_OPTIONS
200
+
201
+ if is_card or is_ewallet:
202
+ field = f'payOptionDetails[{i}].additionalInfo.phoneNumber'
203
+ if not phone_number:
204
+ contexts.append(
205
+ _ctx(field, f'phoneNumber is required for card/e-wallet payment (payOptionDetails[{i}])')
212
206
  )
207
+ else:
208
+ ln = _rune_len(phone_number)
209
+ if ln < 1 or ln > 15:
210
+ contexts.append(
211
+ _ctx(field, f'phoneNumber must be between 1 and 15 characters (payOptionDetails[{i}])')
212
+ )
213
+
214
+ if contexts:
215
+ raise ApiException(status=0, contexts=contexts)
216
+
217
+
218
+ def validate_optional_fields_with_required_nested_create_order_request(request: Any) -> None:
219
+ if request is None:
220
+ return
221
+ additional_info = getattr(request, 'additional_info', None)
222
+ if additional_info is None:
223
+ return
224
+ order = getattr(additional_info, 'order', None)
225
+ if not order:
226
+ return
227
+
228
+ contexts: List[Dict[str, str]] = []
229
+
230
+ buyer = getattr(order, 'buyer', None)
231
+ if buyer:
232
+ ext_type = _trim_str(getattr(buyer, 'external_user_type', None))
233
+ ext_id = _trim_str(getattr(buyer, 'external_user_id', None))
234
+ has_type = bool(ext_type)
235
+ has_id = bool(ext_id)
236
+ if has_id and not has_type:
237
+ contexts.append(
238
+ _ctx(
239
+ 'additionalInfo.order.buyer.externalUserType',
240
+ 'externalUserType is required when externalUserId is filled',
241
+ )
242
+ )
243
+ if has_type and not has_id:
244
+ contexts.append(
245
+ _ctx(
246
+ 'additionalInfo.order.buyer.externalUserId',
247
+ 'externalUserId is required when externalUserType is filled',
248
+ )
249
+ )
250
+
251
+ goods = getattr(order, 'goods', None)
252
+ if isinstance(goods, list) and len(goods) > 0:
253
+ for i, g in enumerate(goods):
254
+ if not g:
255
+ continue
256
+ name = _trim_str(getattr(g, 'name', None))
257
+ if not name:
258
+ contexts.append(
259
+ _ctx(
260
+ f'additionalInfo.order.goods[{i}].name',
261
+ 'name is required when goods is filled',
262
+ )
263
+ )
264
+
265
+ shipping_info = getattr(order, 'shipping_info', None)
266
+ if isinstance(shipping_info, list) and len(shipping_info) > 0:
267
+ for i, s in enumerate(shipping_info):
268
+ if not s:
269
+ continue
270
+ first_name = _trim_str(getattr(s, 'first_name', None))
271
+ if not first_name:
272
+ contexts.append(
273
+ _ctx(
274
+ f'additionalInfo.order.shippingInfo[{i}].firstName',
275
+ 'firstName is required when shippingInfo is filled',
276
+ )
277
+ )
278
+
279
+ if contexts:
280
+ raise ApiException(status=0, contexts=contexts)
213
281
 
214
282
 
215
- # Validation registry maps request class names to their validation functions
216
283
  validation_registry: Dict[str, List[Callable[[Any], None]]] = {
217
284
  'CreateOrderByApiRequest': [
218
285
  validate_additional_info_required,
@@ -220,12 +287,16 @@ validation_registry: Dict[str, List[Callable[[Any], None]]] = {
220
287
  validate_valid_up_to_create_order_request,
221
288
  validate_external_store_id_for_qris,
222
289
  validate_sandbox_pay_method_and_pay_option,
290
+ validate_conditional_pay_option_additional_info_create_order_request,
291
+ validate_optional_fields_with_required_nested_create_order_request,
223
292
  ],
224
293
  'CreateOrderByRedirectRequest': [
225
294
  validate_additional_info_required,
226
295
  validate_money_value_pattern,
227
296
  validate_valid_up_to_create_order_request,
228
297
  validate_sandbox_pay_method_and_pay_option,
298
+ validate_conditional_pay_option_additional_info_create_order_request,
299
+ validate_optional_fields_with_required_nested_create_order_request,
229
300
  ],
230
301
  'CreateOrderRequest': [
231
302
  validate_additional_info_required,
@@ -233,30 +304,30 @@ validation_registry: Dict[str, List[Callable[[Any], None]]] = {
233
304
  validate_valid_up_to_create_order_request,
234
305
  validate_external_store_id_for_qris,
235
306
  validate_sandbox_pay_method_and_pay_option,
307
+ validate_conditional_pay_option_additional_info_create_order_request,
308
+ validate_optional_fields_with_required_nested_create_order_request,
236
309
  ],
237
- # Add more request types and their validations here as needed
238
310
  }
239
311
 
240
312
 
241
313
  def custom_validation(request: Any) -> None:
242
- """
243
- Perform custom validations on the request based on its type.
244
-
245
- This function checks the request type and runs the appropriate validations from the registry.
246
-
247
- Args:
248
- request: The request object to validate (can be any type)
249
-
250
- Raises:
251
- ApiException: If validation fails
252
- """
314
+ """Run all validators for the request type and aggregate client validation contexts."""
253
315
  if request is None:
254
316
  return
255
-
256
- # Get the class name of the request
317
+
257
318
  class_name = request.__class__.__name__
258
-
259
- # Check if this request type has validations registered
260
- if class_name in validation_registry:
261
- for validator in validation_registry[class_name]:
319
+ if class_name not in validation_registry:
320
+ return
321
+
322
+ aggregated: List[Dict[str, str]] = []
323
+ for validator in validation_registry[class_name]:
324
+ try:
262
325
  validator(request)
326
+ except ApiException as e:
327
+ if e.contexts:
328
+ aggregated.extend(e.contexts)
329
+ else:
330
+ raise
331
+
332
+ if aggregated:
333
+ raise ApiException(status=0, contexts=aggregated)
dana/utils/script.py CHANGED
@@ -15,10 +15,23 @@
15
15
  import importlib
16
16
  import pkgutil
17
17
  import os
18
+ import re
18
19
  from typing import List
19
20
 
20
21
  PACKAGE_NAME = 'dana'
21
22
 
23
+ # Only single path segments that are valid Python identifiers (no traversal / injection).
24
+ _SAFE_PKG_SEGMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
25
+
26
+
27
+ def _assert_safe_import_segment(name: str, label: str) -> None:
28
+ if not name or not _SAFE_PKG_SEGMENT.fullmatch(name):
29
+ raise ValueError(
30
+ f"Refusing dynamic import: disallowed {label} {name!r} "
31
+ "(expected a single identifier-like package segment)"
32
+ )
33
+
34
+
22
35
  def import_all_models(base_path: str) -> None:
23
36
  """
24
37
  Imports all model modules from subdirectories of the specified base path.
@@ -48,6 +61,8 @@ def import_all_models(base_path: str) -> None:
48
61
  path_to_domain: List[str] = getattr(subdomain.module_finder, 'path', '')
49
62
  domain = os.path.basename(path_to_domain)
50
63
 
51
- # Construct the full module path and import
64
+ # Construct the full module path and import (whitelist segments — no dynamic/untrusted strings)
65
+ _assert_safe_import_segment(domain, "domain")
66
+ _assert_safe_import_segment(subdomain.name, "subdomain")
52
67
  module_name = f"{PACKAGE_NAME}.{domain}.{subdomain.name}.models"
53
68
  importlib.import_module(module_name)
@@ -19,62 +19,94 @@ This module provides custom validation functions for Widget API requests.
19
19
  Validations are registered in the validation_registry and executed via custom_validation().
20
20
  """
21
21
 
22
- from typing import Any, Dict, List, Callable
22
+ from typing import Any, Callable, Dict, List
23
+
23
24
  from dana.utils.date_validation import validate_valid_up_to_date
24
25
  from dana.exceptions import ApiException
25
26
 
26
27
 
28
+ def _ctx(field: str, message: str) -> Dict[str, str]:
29
+ return {'field': field, 'message': message}
30
+
31
+
27
32
  def validate_valid_up_to_widget_payment_request(request: Any) -> None:
28
- """
29
- Validate validUpTo field in WidgetPaymentRequest.
30
-
31
- Args:
32
- request: The request object to validate
33
-
34
- Raises:
35
- ApiException: If validation fails
36
- """
33
+ """Validate validUpTo field in WidgetPaymentRequest."""
37
34
  if request is None:
38
35
  return
39
-
40
36
  if hasattr(request, 'valid_up_to') and request.valid_up_to is not None:
41
37
  try:
42
38
  validate_valid_up_to_date(request.valid_up_to)
43
39
  except ValueError as e:
44
- raise ApiException(
45
- status=0,
46
- reason=f'validUpTo validation failed: {str(e)}'
47
- ) from e
40
+ raise ApiException(status=0, contexts=[
41
+ _ctx('validUpTo', f'validUpTo validation failed: {str(e)}')
42
+ ]) from e
43
+
44
+
45
+ def _contains_forbidden_auth_code_delimiters(auth_code: str) -> bool:
46
+ return '&' in auth_code or '=' in auth_code
47
+
48
+
49
+ def validate_apply_token_auth_code_authorization_code(request: Any) -> None:
50
+ """authCode must not contain URL query delimiter characters (pasted query string)."""
51
+ if request is None:
52
+ return
53
+ auth_code = getattr(request, 'auth_code', None)
54
+ if auth_code is None:
55
+ return
56
+ s = str(auth_code)
57
+ if s and _contains_forbidden_auth_code_delimiters(s):
58
+ raise ApiException(status=0, contexts=[
59
+ _ctx('authCode', "authCode must not contain URL query delimiter characters ('&' or '=')")
60
+ ])
61
+
62
+
63
+ def validate_apply_token_auth_code_refresh_token(request: Any) -> None:
64
+ """If authCode is present on refresh-token request, apply the same rule."""
65
+ if request is None:
66
+ return
67
+ auth_code = getattr(request, 'auth_code', None)
68
+ if auth_code is None:
69
+ return
70
+ trimmed = str(auth_code).strip()
71
+ if not trimmed:
72
+ return
73
+ if _contains_forbidden_auth_code_delimiters(trimmed):
74
+ raise ApiException(status=0, contexts=[
75
+ _ctx('authCode', "authCode must not contain URL query delimiter characters ('&' or '=')")
76
+ ])
48
77
 
49
78
 
50
- # Validation registry maps request class names to their validation functions
51
79
  validation_registry: Dict[str, List[Callable[[Any], None]]] = {
52
80
  'WidgetPaymentRequest': [
53
81
  validate_valid_up_to_widget_payment_request,
54
82
  ],
55
- # Add more request types and their validations here as needed
83
+ 'ApplyTokenAuthorizationCodeRequest': [
84
+ validate_apply_token_auth_code_authorization_code,
85
+ ],
86
+ 'ApplyTokenRefreshTokenRequest': [
87
+ validate_apply_token_auth_code_refresh_token,
88
+ ],
56
89
  }
57
90
 
58
91
 
59
92
  def custom_validation(request: Any) -> None:
60
- """
61
- Perform custom validations on the request based on its type.
62
-
63
- This function checks the request type and runs the appropriate validations from the registry.
64
-
65
- Args:
66
- request: The request object to validate (can be any type)
67
-
68
- Raises:
69
- ApiException: If validation fails
70
- """
93
+ """Run all validators for the request type and aggregate client validation contexts."""
71
94
  if request is None:
72
95
  return
73
-
74
- # Get the class name of the request
96
+
75
97
  class_name = request.__class__.__name__
76
-
77
- # Check if this request type has validations registered
78
- if class_name in validation_registry:
79
- for validator in validation_registry[class_name]:
98
+ if class_name not in validation_registry:
99
+ return
100
+
101
+ aggregated: List[Dict[str, str]] = []
102
+ for validator in validation_registry[class_name]:
103
+ try:
80
104
  validator(request)
105
+ except ApiException as e:
106
+ if e.contexts:
107
+ aggregated.extend(e.contexts)
108
+ else:
109
+ raise
110
+
111
+ if aggregated:
112
+ raise ApiException(status=0, contexts=aggregated)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dana-python
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: API Client (SDK) for DANA APIs based on https://dashboard.dana.id/api-docs
5
5
  Author-email: DANA Package Manager <package-manager@dana.id>
6
6
  Maintainer-email: DANA Package Manager <package-manager@dana.id>
@@ -12,9 +12,9 @@ Requires-Python: >3.9.1
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: annotated-types==0.7.0
15
- Requires-Dist: cffi==1.17.1
15
+ Requires-Dist: cffi==2.0.0
16
16
  Requires-Dist: cryptography<46.0.0,>=44.0.2
17
- Requires-Dist: pycparser==2.22
17
+ Requires-Dist: pycparser==2.23
18
18
  Requires-Dist: pydantic<3.0.0,>=2.10.6
19
19
  Requires-Dist: pydantic-core<3.0.0,>=2.27.2
20
20
  Requires-Dist: python-dateutil==2.9.0.post0
@@ -88,7 +88,7 @@ Before using the SDK, please make sure to set the following environment variable
88
88
  | ---------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
89
89
  | `ENV` or `DANA_ENV` | Defines which environment the SDK will use. Possible values: `SANDBOX` or `PRODUCTION`. | `SANDBOX` |
90
90
  | `X_PARTNER_ID` | Unique identifier for partner, provided by DANA, also known as `clientId`. | 1970010100000000000000 |
91
- | `PRIVATE_KEY` | Your private key string. | `-----BEGIN PRIVATE KEY-----MIIBVgIBADANBg...LsvTqw==-----END PRIVATE KEY-----` |
91
+ | `PRIVATE_KEY` | Your private key string. | |
92
92
  | `PRIVATE_KEY_PATH` | Path to your private key file. If both are set, `PRIVATE_KEY_PATH` is used. | /path/to/your_private_key.pem |
93
93
  | `DANA_PUBLIC_KEY` | DANA public key string for parsing webhook. | `-----BEGIN PUBLIC KEY-----MIIBIjANBgkq...Do/QIDAQAB-----END PUBLIC KEY-----` |
94
94
  | `DANA_PUBLIC_KEY_PATH` | Path to DANA public key file for parsing webhook. If both set, `DANA_PUBLIC_KEY_PATH is used. | /path/to/dana_public_key.pem |
@@ -1,7 +1,7 @@
1
1
  dana/__init__.py,sha256=_1fR4wVGZVf1d5G_ogKEEQkJ2fc6z_Zv4qDTlALTCHg,593
2
2
  dana/api_client.py,sha256=Rn8Wyuz77U4A9uI_2wfOsasWtT8BqKxNl0XSC6dVYgA,28653
3
3
  dana/api_response.py,sha256=J58dA1ZxuIRYq9ZaK6xJRUdtwAvdOfB3J_hy6QV3KcM,1245
4
- dana/exceptions.py,sha256=yklJ7bXMsd4adEsJcdAb-MIOFZC2dchZOQFZh5Zbj5M,6940
4
+ dana/exceptions.py,sha256=DD075byy8lQC56nP6x1S7rc-kSab9OBb9tS84OPQutQ,7176
5
5
  dana/rest.py,sha256=XvaqswSR6cYFiKj6n-qNUenYjB2FbjqlyG9tuo2-lqM,10268
6
6
  dana/base/__init__.py,sha256=bMn0d6EvbwtmxNyZya0vkccpsMwofvp0LvA_siJ0pxI,593
7
7
  dana/base/configuration.py,sha256=dVMKHdZ94rlP1IErcis29oE7Nvr4ewrCMGh-3lKcc64,18410
@@ -11,7 +11,7 @@ dana/disbursement/__init__.py,sha256=_1fR4wVGZVf1d5G_ogKEEQkJ2fc6z_Zv4qDTlALTCHg
11
11
  dana/disbursement/v1/__init__.py,sha256=C981Ksji-mo9bOhFhI-9g0upO279XZ1sn8a6GIHkU-k,2983
12
12
  dana/disbursement/v1/enum.py,sha256=yRZLRYwyvzfg06XJhKTPbIZT8__IzPMgzDRnknCM-lA,861
13
13
  dana/disbursement/v1/api/__init__.py,sha256=g22N5qjTc2jl1wsbCydP1g7cqWBthEaOB5g4SWlp6_c,711
14
- dana/disbursement/v1/api/disbursement_api.py,sha256=YG_LLN3MjCPqo7AomREVpH9FtL8IKd_9R5AsTOWLdj0,74540
14
+ dana/disbursement/v1/api/disbursement_api.py,sha256=6I5liei3pVDM02wUMvQYTvT82X3Xj0_M1wPPjyEvksc,75191
15
15
  dana/disbursement/v1/models/__init__.py,sha256=eCiQWHiKPdQvcbAz7kclRMGoHL4qMIm2DPakBtGxVm4,2858
16
16
  dana/disbursement/v1/models/bank_account_inquiry_request.py,sha256=-qK599Biioyhn-RMUxq02hnzT2qkYOGEuFLDxIaOfJk,5431
17
17
  dana/disbursement/v1/models/bank_account_inquiry_request_additional_info.py,sha256=cDP4HFvHWlOp6ZWZ00JTF1kbBAz2om7Kpvvx0ADylzE,6028
@@ -83,7 +83,7 @@ dana/merchant_management/v1/models/update_shop_response_response_head.py,sha256=
83
83
  dana/merchant_management/v1/models/user_name.py,sha256=eRNJu5iVsMZZdU7nA9QA1g_4RV-bxxGy4MGIBNbXevs,4366
84
84
  dana/payment_gateway/__init__.py,sha256=_1fR4wVGZVf1d5G_ogKEEQkJ2fc6z_Zv4qDTlALTCHg,593
85
85
  dana/payment_gateway/v1/__init__.py,sha256=cJRNM80F042f3ZsUXeP5t9wTajH5NDBbpnXSV5y_k4E,4243
86
- dana/payment_gateway/v1/custom_validation.py,sha256=yyKYszahK9wF1cleYPC345yqtEahGHyCuEZy_Q7Jkso,9659
86
+ dana/payment_gateway/v1/custom_validation.py,sha256=GAWbY2tjVtraBQih1ImkFRYlvcv3qZ25RffhY_fLKVI,12312
87
87
  dana/payment_gateway/v1/enum.py,sha256=PXgJwvF2UXXxTPTAJiN5kV-z2kHYWplpXDGd3u3LzRk,2623
88
88
  dana/payment_gateway/v1/api/__init__.py,sha256=xYZF0wAVxZ7tn-36ofOKJWEJzc-u4ixG8-gKbGK2Bng,942
89
89
  dana/payment_gateway/v1/api/payment_gateway_api.py,sha256=oLu6Y1WtV7qSyXuaQCtgfR_oSdeyevMyXTtp-2RVCGY,63315
@@ -131,7 +131,7 @@ dana/utils/date_validation.py,sha256=W3t5OH98jf2EJ8PhcTOSNNUWXoH8ykaSd7S76jY6bBI
131
131
  dana/utils/models.py,sha256=5MYKKkZjO76k-hmrlK8S_LR1jbFRHQBAfZKmyMciX0I,1718
132
132
  dana/utils/open_api_configuration.py,sha256=IQfn3c7Zs4dqOOrm1tKWBoTRdCus2alFnzRL3Yjf5ec,14805
133
133
  dana/utils/open_api_header.py,sha256=uYUpmnnO5PI2tu30LNx3hE6fI-NOc2VMkc1P3G9xZpw,9537
134
- dana/utils/script.py,sha256=MjJQAXAjako7oeZai25VJy0PGQObbbE9zH-26xOAi5A,1850
134
+ dana/utils/script.py,sha256=zel7LB6GmIGIFyaegYdtmGZj-0tcwe6xuTXvGz-8vLw,2480
135
135
  dana/utils/snap_configuration.py,sha256=MZy4dBydo1PqvcB8XVVTBysWdoPmQ8ct7Gzp7jIMFQA,21531
136
136
  dana/utils/snap_header.py,sha256=fCmUJlwpb-cvkPTE3MseKNEeVRPVSJXixHxqFrAuBUU,13637
137
137
  dana/utils/url.py,sha256=wCDthoodIDANoth6SqSoiIdNwc-hJLNjFBdf_fSjU_4,1965
@@ -146,7 +146,7 @@ dana/webhook/shop_info.py,sha256=LtPO10Jhlq2K70AiFUB2I-YSKR-mU9m_6OvvCgalXxY,476
146
146
  dana/webhook/webhook.py,sha256=Pi0rJOG4ogrxXcqEdYWPBPL7iCXoY--LEo_YtqRRLwI,9569
147
147
  dana/widget/__init__.py,sha256=_1fR4wVGZVf1d5G_ogKEEQkJ2fc6z_Zv4qDTlALTCHg,593
148
148
  dana/widget/v1/__init__.py,sha256=ibJjQQ3NDAK_UwYZKHZzBVAScX4mkQixi_LD3RGTr4U,5576
149
- dana/widget/v1/custom_validation.py,sha256=S5HxeW5qzHd3tsBBKTdDi1Cjw9Y6V5fMnSAQLF1EW5M,2565
149
+ dana/widget/v1/custom_validation.py,sha256=KDaglDbKrFksCq7bSlgTqUxWeILGavGfFYLXxXt8_r8,3802
150
150
  dana/widget/v1/enum.py,sha256=LhI4IjMAb9mm0DB--L86KFWAoWcMXJK-4vCpLLu5yeQ,3443
151
151
  dana/widget/v1/util.py,sha256=qxoYnUij5x3QmXb3R8JmcaoJCTnvMcFgGtEUqxITCbA,11250
152
152
  dana/widget/v1/api/__init__.py,sha256=0M_z47tKg4vjaHIDSSE7f9COYZd1KmxVl40ZMi1kjog,922
@@ -209,8 +209,8 @@ dana/widget/v1/models/virtual_account_info.py,sha256=qCFyTqwOFY0KwawiQhRfWYwv0Hv
209
209
  dana/widget/v1/models/widget_payment_request.py,sha256=KZPOauNqtDvtETLSAgkfgLagOiX2H5KaOGroSbRKCAg,8469
210
210
  dana/widget/v1/models/widget_payment_request_additional_info.py,sha256=Qa4Akl-KxeBdh3ITnb0_LxVmgZA_YJsYgW_MHKTHj1A,6273
211
211
  dana/widget/v1/models/widget_payment_response.py,sha256=KNfxpyOeNjyRG8aNRzTCaY1W5i0PgSVj3x2O8I6b_kE,6037
212
- dana_python-2.1.0.dist-info/licenses/LICENSE,sha256=7CXCr_1HV_P6dPKoffVsU3lk2eVjnRacBDnQIeJMgFc,10232
213
- dana_python-2.1.0.dist-info/METADATA,sha256=LkKVQymXEhh_A7fqIbBfgrcHibdsJDDqwm7IQqxfnK8,5649
214
- dana_python-2.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
215
- dana_python-2.1.0.dist-info/top_level.txt,sha256=uvbw-Siay0DC-rXYYx11_k0lqDnrOl5tFeSkE-3Mb8I,5
216
- dana_python-2.1.0.dist-info/RECORD,,
212
+ dana_python-2.1.2.dist-info/licenses/LICENSE,sha256=7CXCr_1HV_P6dPKoffVsU3lk2eVjnRacBDnQIeJMgFc,10232
213
+ dana_python-2.1.2.dist-info/METADATA,sha256=UaXAzPy-Ar2Eg1ADNdPklqIXC9hU-T_fwSLLAkTXu9s,5568
214
+ dana_python-2.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
215
+ dana_python-2.1.2.dist-info/top_level.txt,sha256=uvbw-Siay0DC-rXYYx11_k0lqDnrOl5tFeSkE-3Mb8I,5
216
+ dana_python-2.1.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.0)
2
+ Generator: setuptools (82.0.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5