botversion-sdk 2.1.0__tar.gz → 2.1.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: botversion-sdk
3
- Version: 2.1.0
3
+ Version: 2.1.1
4
4
  Summary: BotVersion SDK — automatically discover and register your API endpoints
5
5
  Home-page: https://github.com/botversion/botversion-sdk-python
6
6
  Author: BotVersion
@@ -119,11 +119,75 @@ def body_structure_to_json_schema(body_structure):
119
119
  return {"type": "object", "properties": properties}
120
120
 
121
121
 
122
- def extract_trpc_get_input(path, query_string):
122
+ def unwrap_trpc_json_envelope(obj):
123
+ """
124
+ Unwraps a single (non-batched) tRPC/superjson envelope:
125
+ { "json": {...realFields...} } -> {...realFields...}
126
+ """
127
+ if isinstance(obj, dict) and isinstance(obj.get("json"), dict):
128
+ return obj["json"]
129
+ return obj
130
+
131
+
132
+ def split_batch_path(raw_path):
133
+ """
134
+ tRPC batches multiple procedure calls into one URL, e.g.
135
+ /api/trpc/me.get,getUserTopBanners,bookingUnconfirmedCount
136
+ tRPC is always mounted at a fixed base containing "/trpc/". Everything
137
+ after that marker is the comma-separated procedure list, and each
138
+ comma-separated piece is ALREADY a complete procedure path on its own
139
+ (it may itself contain "/" for nested routers) — it must never have
140
+ another procedure's prefix re-attached to it.
141
+ Returns a single-item list unchanged if the path isn't a batch.
142
+ """
143
+ marker = "/trpc/"
144
+ idx = raw_path.find(marker)
145
+ if idx == -1:
146
+ return [raw_path]
147
+
148
+ base = raw_path[: idx + len(marker)]
149
+ tail = raw_path[idx + len(marker):]
150
+
151
+ if "," not in tail:
152
+ return [raw_path]
153
+
154
+ procs = [p.strip() for p in tail.split(",") if p.strip()]
155
+ return [base + p for p in procs]
156
+
157
+
158
+ def split_batch_body(body_obj):
159
+ """
160
+ Splits a batched tRPC request body/input — {"0": {...}, "1": {...}} —
161
+ into a list of individual bodies, in the same order as the batch keys,
162
+ which lines up with the order split_batch_path() returns procedure
163
+ names in. Each slot is unwrapped from its own {"json": {...}} envelope.
164
+ Returns a single-item list unchanged if the body isn't actually batched.
165
+ """
166
+ if not isinstance(body_obj, dict):
167
+ return [body_obj]
168
+
169
+ keys = list(body_obj.keys())
170
+ is_batch = len(keys) > 0 and all(k.isdigit() for k in keys)
171
+
172
+ if not is_batch:
173
+ return [body_obj]
174
+
175
+ ordered_keys = sorted(keys, key=lambda k: int(k))
176
+ slots = []
177
+ for k in ordered_keys:
178
+ entry = body_obj[k]
179
+ slots.append(
180
+ unwrap_trpc_json_envelope(entry) if isinstance(entry, dict) else entry
181
+ )
182
+ return slots
183
+
184
+
185
+ def extract_trpc_get_input_raw(path, query_string):
123
186
  """
124
187
  tRPC GET requests send their input as a URL-encoded JSON query parameter
125
- called 'input'. This function extracts and parses it.
126
- Only activates for tRPC paths with an input param and empty body.
188
+ called 'input'. This returns the raw decoded dict as-is — still batched
189
+ if applicable — WITHOUT merging different procedures' fields together.
190
+ Splitting per-procedure happens later in report_endpoint().
127
191
  Safe for all non-tRPC endpoints — returns None if not applicable.
128
192
  """
129
193
  if "/trpc/" not in path:
@@ -142,41 +206,18 @@ def extract_trpc_get_input(path, query_string):
142
206
  if not isinstance(decoded, dict):
143
207
  return None
144
208
 
145
- keys = list(decoded.keys())
146
-
147
- # Format 1 — batched: { "0": { "json": {...} }, "1": { "json": {...} } }
148
- is_batch = len(keys) > 0 and all(k.isdigit() for k in keys)
149
- if is_batch:
150
- merged = {}
151
- for k in keys:
152
- entry = decoded[k]
153
- if isinstance(entry, dict):
154
- # Unwrap { json: {...} } envelope if present
155
- unwrapped = entry.get("json", entry) if isinstance(entry.get("json"), dict) else entry
156
- merged.update(unwrapped)
157
- return merged if merged else None
158
-
159
- # Format 2 — single with superjson: { "json": {...}, "meta": {...} }
160
- if "json" in decoded and isinstance(decoded["json"], dict):
161
- return decoded["json"]
162
-
163
- # Format 3 — already unwrapped: { "group": {...}, "limit": 10 }
164
- if "meta" not in decoded and "json" not in decoded:
165
- return decoded
209
+ return decoded
166
210
 
167
211
  except Exception:
168
- pass
169
-
170
- return None
212
+ return None
171
213
 
172
214
 
173
- def report_endpoint(client, method, path, body_structure, options):
215
+ def _report_single_endpoint(client, method, normalized_path, body_structure, options):
174
216
  """
175
- Report a newly discovered endpoint to the platform.
217
+ Reports exactly one already-normalized, already-split endpoint.
176
218
  Uses body-key deduplication.
177
219
  """
178
- normalized = normalize_path(path)
179
- endpoint_key = f"{method}:{normalized}"
220
+ endpoint_key = f"{method}:{normalized_path}"
180
221
 
181
222
  body_fields = sorted(body_structure.keys()) if body_structure else []
182
223
  body_key = endpoint_key + ":" + ",".join(body_fields)
@@ -193,7 +234,7 @@ def report_endpoint(client, method, path, body_structure, options):
193
234
  try:
194
235
  client.update_endpoint({
195
236
  "method": method,
196
- "path": normalized,
237
+ "path": normalized_path,
197
238
  "request_body": json_schema,
198
239
  "detected_by": "runtime",
199
240
  })
@@ -204,6 +245,38 @@ def report_endpoint(client, method, path, body_structure, options):
204
245
  t.start()
205
246
 
206
247
 
248
+ def report_endpoint(client, method, path, body_data, query_string, options):
249
+ """
250
+ Splits a (possibly batched) tRPC path into one clean endpoint per real
251
+ procedure — instead of one garbled, comma-joined path — with each
252
+ procedure's own body fields only, never mixed with another procedure's.
253
+ Falls back to reporting a single endpoint unchanged when the path
254
+ isn't a batch.
255
+
256
+ body_data — the raw parsed JSON body (or None/empty for GET requests).
257
+ query_string — the raw query string, used to recover tRPC GET input.
258
+ """
259
+ split_paths = [normalize_path(p) for p in split_batch_path(path)]
260
+
261
+ body_slots = None
262
+
263
+ # tRPC GET requests carry their input in the query string, not the body
264
+ if (not body_data) and query_string:
265
+ raw_input = extract_trpc_get_input_raw(path, query_string)
266
+ if raw_input is not None:
267
+ body_slots = split_batch_body(raw_input)
268
+
269
+ if body_slots is None:
270
+ body_slots = (
271
+ split_batch_body(body_data) if len(split_paths) > 1 else [body_data]
272
+ )
273
+
274
+ for i, single_path in enumerate(split_paths):
275
+ slot_body = body_slots[i] if i < len(body_slots) else None
276
+ body_structure = build_body_structure(slot_body)
277
+ _report_single_endpoint(client, method, single_path, body_structure, options)
278
+
279
+
207
280
  # ── FastAPI middleware ────────────────────────────────────────────────────────
208
281
 
209
282
  def attach_fastapi_interceptor(app, client, options):
@@ -250,18 +323,11 @@ def attach_fastapi_interceptor(app, client, options):
250
323
  return {"type": "http.request", "body": body_bytes}
251
324
  request._receive = receive
252
325
  body_data = _json.loads(body_bytes) if body_bytes else None
253
- body_structure = build_body_structure(body_data)
254
- # tRPC GET: input is in URL query param, not body
255
- if not body_structure:
256
- query_string = request.url.query
257
- trpc_body = extract_trpc_get_input(path, query_string)
258
- if trpc_body:
259
- body_structure = build_body_structure(trpc_body)
260
326
  except Exception:
261
- body_structure = None
327
+ body_data = None
262
328
 
263
329
  if response.status_code < 500:
264
- report_endpoint(client, method, path, body_structure, options)
330
+ report_endpoint(client, method, path, body_data, request.url.query, options)
265
331
 
266
332
  return response
267
333
 
@@ -303,16 +369,11 @@ def attach_flask_interceptor(app, client, options):
303
369
  return
304
370
 
305
371
  try:
306
- body_structure = build_body_structure(flask_request.get_json(silent=True))
307
- # tRPC GET: input is in URL query param, not body
308
- if not body_structure:
309
- trpc_body = extract_trpc_get_input(path, flask_request.query_string.decode("utf-8"))
310
- if trpc_body:
311
- body_structure = build_body_structure(trpc_body)
372
+ body_data = flask_request.get_json(silent=True)
312
373
  except Exception:
313
- body_structure = None
374
+ body_data = None
314
375
 
315
- report_endpoint(client, method, path, body_structure, options)
376
+ report_endpoint(client, method, path, body_data, flask_request.query_string.decode("utf-8"), options)
316
377
 
317
378
  except ImportError:
318
379
  pass
@@ -356,21 +417,16 @@ class BotVersionDjangoMiddleware:
356
417
  if not self.__class__._options.get("api_prefix") or path.startswith(self.__class__._options["api_prefix"]):
357
418
  try:
358
419
  body_data = json.loads(request.body) if request.body else None
359
- body_structure = build_body_structure(body_data)
360
- # tRPC GET: input is in URL query param, not body
361
- if not body_structure:
362
- trpc_body = extract_trpc_get_input(path, request.META.get("QUERY_STRING", ""))
363
- if trpc_body:
364
- body_structure = build_body_structure(trpc_body)
365
420
  except Exception:
366
- body_structure = None
421
+ body_data = None
367
422
 
368
423
  if self.__class__._client:
369
424
  report_endpoint(
370
425
  self.__class__._client,
371
426
  method,
372
427
  path,
373
- body_structure,
428
+ body_data,
429
+ request.META.get("QUERY_STRING", ""),
374
430
  self.__class__._options,
375
431
  )
376
432
 
@@ -443,18 +499,11 @@ def attach_starlette_interceptor(app, client, options):
443
499
  try:
444
500
  body_bytes = await request.body()
445
501
  body_data = _json.loads(body_bytes) if body_bytes else None
446
- body_structure = build_body_structure(body_data)
447
- # tRPC GET: input is in URL query param, not body
448
- if not body_structure:
449
- query_string = request.url.query
450
- trpc_body = extract_trpc_get_input(path, query_string)
451
- if trpc_body:
452
- body_structure = build_body_structure(trpc_body)
453
502
  except Exception:
454
- body_structure = None
503
+ body_data = None
455
504
 
456
505
  if response.status_code < 500:
457
- report_endpoint(client, method, path, body_structure, options)
506
+ report_endpoint(client, method, path, body_data, request.url.query, options)
458
507
 
459
508
  return response
460
509
 
@@ -496,16 +545,10 @@ def attach_sanic_interceptor(app, client, options):
496
545
 
497
546
  try:
498
547
  body_data = request.json if request.body else None
499
- body_structure = build_body_structure(body_data)
500
- # tRPC GET: input is in URL query param, not body
501
- if not body_structure:
502
- trpc_body = extract_trpc_get_input(path, request.query_string)
503
- if trpc_body:
504
- body_structure = build_body_structure(trpc_body)
505
548
  except Exception:
506
- body_structure = None
549
+ body_data = None
507
550
 
508
- report_endpoint(client, method, path, body_structure, options)
551
+ report_endpoint(client, method, path, body_data, request.query_string, options)
509
552
 
510
553
  except Exception:
511
554
  pass
@@ -551,19 +594,13 @@ def attach_falcon_interceptor(app, client, options):
551
594
  try:
552
595
  body_bytes = req.bounded_stream.read()
553
596
  body_data = _json.loads(body_bytes) if body_bytes else None
554
- body_structure = build_body_structure(body_data)
555
- # tRPC GET: input is in URL query param, not body
556
- if not body_structure:
557
- trpc_body = extract_trpc_get_input(path, req.query_string)
558
- if trpc_body:
559
- body_structure = build_body_structure(trpc_body)
560
597
  # Put body back so the actual handler can still read it
561
598
  import io
562
599
  req.bounded_stream = io.BytesIO(body_bytes)
563
600
  except Exception:
564
- body_structure = None
601
+ body_data = None
565
602
 
566
- report_endpoint(client, method, path, body_structure, options)
603
+ report_endpoint(client, method, path, body_data, req.query_string, options)
567
604
 
568
605
  app.add_middleware(BotVersionFalconMiddleware())
569
606
 
@@ -617,16 +654,10 @@ def attach_bottle_interceptor(app, client, options):
617
654
 
618
655
  try:
619
656
  body_data = bottle_request.json
620
- body_structure = build_body_structure(body_data)
621
- # tRPC GET: input is in URL query param, not body
622
- if not body_structure:
623
- trpc_body = extract_trpc_get_input(path, bottle_request.query_string)
624
- if trpc_body:
625
- body_structure = build_body_structure(trpc_body)
626
657
  except Exception:
627
- body_structure = None
658
+ body_data = None
628
659
 
629
- report_endpoint(client, method, path, body_structure, options)
660
+ report_endpoint(client, method, path, body_data, bottle_request.query_string, options)
630
661
 
631
662
  # Install on the specific app instance, not globally
632
663
  app.add_hook("before_request", botversion_bottle_interceptor)
@@ -676,17 +707,11 @@ def attach_aiohttp_interceptor(app, client, options):
676
707
  try:
677
708
  body_bytes = await request.read()
678
709
  body_data = _json.loads(body_bytes) if body_bytes else None
679
- body_structure = build_body_structure(body_data)
680
- # tRPC GET: input is in URL query param, not body
681
- if not body_structure:
682
- trpc_body = extract_trpc_get_input(path, request.rel_url.query_string)
683
- if trpc_body:
684
- body_structure = build_body_structure(trpc_body)
685
710
  except Exception:
686
- body_structure = None
711
+ body_data = None
687
712
 
688
713
  if response.status < 500:
689
- report_endpoint(client, method, path, body_structure, options)
714
+ report_endpoint(client, method, path, body_data, request.rel_url.query_string, options)
690
715
 
691
716
  return response
692
717
 
@@ -761,17 +786,11 @@ def attach_tornado_interceptor(app, client, options):
761
786
  try:
762
787
  body_bytes = self.request.body
763
788
  body_data = _json.loads(body_bytes) if body_bytes else None
764
- body_structure = build_body_structure(body_data)
765
- # tRPC GET: input is in URL query param, not body
766
- if not body_structure:
767
- trpc_body = extract_trpc_get_input(path, self.request.query)
768
- if trpc_body:
769
- body_structure = build_body_structure(trpc_body)
770
789
  except Exception:
771
- body_structure = None
790
+ body_data = None
772
791
 
773
792
  if self.get_status() < 500:
774
- report_endpoint(client, method, path, body_structure, options)
793
+ report_endpoint(client, method, path, body_data, self.request.query, options)
775
794
  except Exception:
776
795
  pass
777
796
 
@@ -822,17 +841,11 @@ def botversion_pyramid_tween_factory(handler, registry):
822
841
  if not _pyramid_options.get("api_prefix") or path.startswith(_pyramid_options["api_prefix"]):
823
842
  try:
824
843
  body_data = request.json_body if request.content_length else None
825
- body_structure = build_body_structure(body_data)
826
- # tRPC GET: input is in URL query param, not body
827
- if not body_structure:
828
- trpc_body = extract_trpc_get_input(path, request.query_string)
829
- if trpc_body:
830
- body_structure = build_body_structure(trpc_body)
831
844
  except Exception:
832
- body_structure = None
845
+ body_data = None
833
846
 
834
847
  if response.status_int < 500:
835
- report_endpoint(_pyramid_client, method, path, body_structure, _pyramid_options)
848
+ report_endpoint(_pyramid_client, method, path, body_data, request.query_string, _pyramid_options)
836
849
  except Exception:
837
850
  pass
838
851
 
@@ -897,20 +910,14 @@ def attach_cherrypy_interceptor(app, client, options):
897
910
  try:
898
911
  body_bytes = request.body.read() if request.body else None
899
912
  body_data = _json.loads(body_bytes) if body_bytes else None
900
- body_structure = build_body_structure(body_data)
901
- # tRPC GET: input is in URL query param, not body
902
- if not body_structure:
903
- query_string = request.query_string if hasattr(request, "query_string") else ""
904
- trpc_body = extract_trpc_get_input(path, query_string)
905
- if trpc_body:
906
- body_structure = build_body_structure(trpc_body)
907
913
  # Put body back so actual handler can still read it
908
914
  import io
909
915
  request.body = io.BytesIO(body_bytes or b"")
910
916
  except Exception:
911
- body_structure = None
917
+ body_data = None
912
918
 
913
- report_endpoint(client, method, path, body_structure, options)
919
+ query_string = request.query_string if hasattr(request, "query_string") else ""
920
+ report_endpoint(client, method, path, body_data, query_string, options)
914
921
 
915
922
  # Register as a CherryPy tool
916
923
  cherrypy.tools.botversion = cherrypy.Tool(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: botversion-sdk
3
- Version: 2.1.0
3
+ Version: 2.1.1
4
4
  Summary: BotVersion SDK — automatically discover and register your API endpoints
5
5
  Home-page: https://github.com/botversion/botversion-sdk-python
6
6
  Author: BotVersion
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="botversion-sdk",
5
- version="2.1.0",
5
+ version="2.1.1",
6
6
  description="BotVersion SDK — automatically discover and register your API endpoints",
7
7
  long_description=open("README.md").read() if __import__("os").path.exists("README.md") else "",
8
8
  long_description_content_type="text/markdown",
File without changes