jac-scale 0.1.3__py3-none-any.whl → 0.1.5__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.
@@ -1,3 +1,6 @@
1
+ import from jaclang.cli.console { console }
2
+ import from jaclang.cli.banners { JAC_DISCORD_URL }
3
+
1
4
  """Helper function to convert TransportResponse to dict for JSONResponse."""
2
5
  def _transport_response_to_dict(
3
6
  transport_response: TransportResponse
@@ -57,15 +60,14 @@ def _transport_response_to_json_response(
57
60
  );
58
61
  }
59
62
 
60
- impl JacAPIServer.start(dev: bool = False) -> None {
63
+ impl JacAPIServer.start(dev: bool = False, no_client: bool = False) -> None {
61
64
  self.introspector.load();
62
- # Eagerly build client bundle if there are client exports (skip in dev mode)
63
- if not dev {
65
+ # Eagerly build client bundle if there are client exports (skip in dev or no_client mode)
66
+ if not dev and not no_client {
64
67
  client_exports = self.introspector._client_manifest.get('exports', []);
65
68
  if client_exports {
66
69
  import time;
67
70
  import sys;
68
- import from jaclang.cli.console { console }
69
71
  start_time = time.time();
70
72
  try {
71
73
  with console.status(
@@ -84,6 +86,11 @@ impl JacAPIServer.start(dev: bool = False) -> None {
84
86
  style="muted",
85
87
  file=sys.stderr
86
88
  );
89
+ console.info('Try again after running: jac clean --all', emoji=True);
90
+ console.info(
91
+ f'If it still doesn\'t work, ask for help at {JAC_DISCORD_URL}',
92
+ emoji=True
93
+ );
87
94
  }
88
95
  }
89
96
  }
@@ -96,14 +103,17 @@ impl JacAPIServer.start(dev: bool = False) -> None {
96
103
  self.register_static_file_endpoint();
97
104
  self.register_update_username_endpoint();
98
105
  self.register_update_password_endpoint();
106
+ self.register_api_key_endpoints();
99
107
  # Use dynamic routing for HMR support, static routing for production
100
108
  if dev {
101
109
  self.register_dynamic_walker_endpoint();
102
110
  self.register_dynamic_function_endpoint();
103
111
  self.register_dynamic_introspection_endpoints();
112
+ self.register_dynamic_webhook_endpoint();
104
113
  } else {
105
114
  self.register_walkers_endpoints();
106
115
  self.register_functions_endpoints();
116
+ self.register_webhook_endpoints();
107
117
  }
108
118
  self.register_root_asset_endpoint();
109
119
  self._configure_openapi_security();
@@ -177,19 +187,35 @@ impl JacAPIServer._configure_openapi_security -> None {
177
187
  self.server.app.openapi = custom_openapi;
178
188
  }
179
189
 
180
- """Serve root-level assets like /img.png, /icons/logo.svg, etc."""
190
+ """Serve root-level assets like /img.png, /icons/logo.svg, etc.
191
+ Falls back to SPA HTML for extensionless paths when base_route_app is configured."""
181
192
  impl JacAPIServer.serve_root_asset(file_path: str) -> Response {
182
193
  allowed_extensions = {'.png','.jpg','.jpeg','.gif','.webp','.svg','.ico','.woff','.woff2','.ttf','.otf','.eot','.mp4','.webm','.mp3','.wav','.css','.js','.json','.pdf','.txt','.xml'};
183
194
  file_ext = Path(file_path).suffix.lower();
184
- if (not file_ext or (file_ext not in allowed_extensions)) {
185
- return Response(status_code=404, content='Not found', media_type='text/plain');
186
- }
187
195
  import from jaclang.project.config { get_config }
188
196
  config = get_config();
189
197
  cl_route_prefix = config.serve.cl_route_prefix if config else "cl";
190
- if file_path.startswith(
191
- (f'{cl_route_prefix}/', 'walker/', 'function/', 'user/', 'static/')
192
- ) {
198
+ api_prefixes = (f'{cl_route_prefix}/', 'walker/', 'function/', 'user/', 'static/');
199
+ if (not file_ext or (file_ext not in allowed_extensions)) {
200
+ # SPA catch-all: serve base_route_app for extensionless paths
201
+ base_route_app = config.serve.base_route_app if config else "";
202
+ if (not file_ext and base_route_app and not file_path.startswith(api_prefixes)) {
203
+ try {
204
+ render_payload = self.introspector.render_page(
205
+ base_route_app, {}, '__guest__'
206
+ );
207
+ return HTMLResponse(content=render_payload['html']);
208
+ } except ValueError {
209
+ return HTMLResponse(content="<h1>404 Not Found</h1>", status_code=404);
210
+ } except RuntimeError {
211
+ return HTMLResponse(
212
+ content="<h1>503 Service Unavailable</h1>", status_code=503
213
+ );
214
+ }
215
+ }
216
+ return Response(status_code=404, content='Not found', media_type='text/plain');
217
+ }
218
+ if file_path.startswith(api_prefixes) {
193
219
  return Response(status_code=404, content='Not found', media_type='text/plain');
194
220
  }
195
221
  # Find project root (where jac.toml is) instead of using base_path_dir
@@ -452,7 +478,7 @@ impl JacAPIServer.render_page_callback -> Callable[..., HTMLResponse] {
452
478
  } except ValueError as exc {
453
479
  return HTMLResponse(content=f"<h1>404 Not Found</h1>", status_code=404);
454
480
  } except RuntimeError as exc {
455
- print(f"Error rendering page '{page_name}': {exc}");
481
+ console.print(f"Error rendering page '{page_name}': {exc}");
456
482
  return HTMLResponse(
457
483
  content=f"<h1>503 Service Unavailable</h1>", status_code=503
458
484
  );
@@ -475,7 +501,7 @@ impl JacAPIServer.render_base_route_callback(
475
501
  } except ValueError as exc {
476
502
  return HTMLResponse(content=f"<h1>404 Not Found</h1>", status_code=404);
477
503
  } except RuntimeError as exc {
478
- print(f"Error rendering base route app '{app_name}': {exc}");
504
+ console.print(f"Error rendering base route app '{app_name}': {exc}");
479
505
  return HTMLResponse(
480
506
  content=f"<h1>503 Service Unavailable</h1>", status_code=503
481
507
  );
@@ -498,7 +524,9 @@ impl JacAPIServer.register_functions_endpoints -> None {
498
524
  method=spec_method,
499
525
  path=final_path,
500
526
  callback=self.create_function_callback(func_name),
501
- parameters=self.create_function_parameters(func_name),
527
+ parameters=self.create_function_parameters(
528
+ func_name, method=spec_method
529
+ ),
502
530
  response_model=None,
503
531
  tags=['Functions'],
504
532
  summary='This is a summary',
@@ -508,7 +536,9 @@ impl JacAPIServer.register_functions_endpoints -> None {
508
536
  }
509
537
  }
510
538
 
511
- impl JacAPIServer.create_function_parameters(func_name: str) -> list[APIParameter] {
539
+ impl JacAPIServer.create_function_parameters(
540
+ func_name: str, method: HTTPMethod = HTTPMethod.POST
541
+ ) -> list[APIParameter] {
512
542
  parameters: list[APIParameter] = [];
513
543
  if self.introspector.is_auth_required_for_function(func_name) {
514
544
  parameters.append(
@@ -527,8 +557,13 @@ impl JacAPIServer.create_function_parameters(func_name: str) -> list[APIParamete
527
557
  )['parameters'];
528
558
  for field_name in func_fields {
529
559
  field_type = func_fields[field_name]['type'];
530
- # Determine parameter type based on field type
531
- if ('UploadFile' in field_type or 'uploadfile' in field_type.lower()) {
560
+ # Determine parameter type based on field type and method
561
+ if (
562
+ method == HTTPMethod.GET
563
+ and not ('UploadFile' in field_type or 'uploadfile' in field_type.lower())
564
+ ) {
565
+ param_type = ParameterType.QUERY;
566
+ } elif ('UploadFile' in field_type or 'uploadfile' in field_type.lower()) {
532
567
  # Support UploadFile type for file uploads
533
568
  param_type = ParameterType.FILE;
534
569
  } else {
@@ -574,7 +609,7 @@ impl JacAPIServer.create_function_callback(
574
609
  );
575
610
  }
576
611
  }
577
- print(f"Executing function '{func_name}' with params: {kwargs}");
612
+ console.print(f"Executing function '{func_name}' with params: {kwargs}");
578
613
  result = await self.execution_manager.execute_function(
579
614
  self.get_functions()[func_name], kwargs, (username or '__guest__')
580
615
  );
@@ -607,6 +642,11 @@ impl JacAPIServer.create_function_callback(
607
642
 
608
643
  impl JacAPIServer.register_walkers_endpoints -> None {
609
644
  for walker_name in self.get_walkers() {
645
+ # Skip walkers configured for webhook transport - they use /webhook/{walker_name} instead
646
+ transport_type = self.get_transport_type_for_walker(walker_name);
647
+ if transport_type == TransportType.WEBHOOK {
648
+ continue;
649
+ }
610
650
  walker_cls = self.get_walkers()[walker_name];
611
651
  restspec = walker_cls.restspec if walker_cls?.restspec else None;
612
652
  spec_method = restspec.method if restspec?.method else HTTPMethod.POST;
@@ -618,7 +658,7 @@ impl JacAPIServer.register_walkers_endpoints -> None {
618
658
  path=f"{spec_path}/{{node}}",
619
659
  callback=self.create_walker_callback(walker_name, has_node_param=True),
620
660
  parameters=self.create_walker_parameters(
621
- walker_name, invoke_on_root=False
661
+ walker_name, invoke_on_root=False, method=spec_method
622
662
  ),
623
663
  response_model=None,
624
664
  tags=['Walkers'],
@@ -632,7 +672,7 @@ impl JacAPIServer.register_walkers_endpoints -> None {
632
672
  path=spec_path,
633
673
  callback=self.create_walker_callback(walker_name, has_node_param=False),
634
674
  parameters=self.create_walker_parameters(
635
- walker_name, invoke_on_root=True
675
+ walker_name, invoke_on_root=True, method=spec_method
636
676
  ),
637
677
  response_model=None,
638
678
  tags=['Walkers'],
@@ -644,7 +684,7 @@ impl JacAPIServer.register_walkers_endpoints -> None {
644
684
  }
645
685
 
646
686
  impl JacAPIServer.create_walker_parameters(
647
- walker_name: str, invoke_on_root: bool
687
+ walker_name: str, invoke_on_root: bool, method: HTTPMethod = HTTPMethod.POST
648
688
  ) -> list[APIParameter] {
649
689
  parameters: list[APIParameter] = [];
650
690
  if self.introspector.is_auth_required_for_walker(walker_name) {
@@ -667,9 +707,14 @@ impl JacAPIServer.create_walker_parameters(
667
707
  continue;
668
708
  }
669
709
  field_type = walker_fields[field_name]['type'];
670
- # Determine parameter type based on field type
710
+ # Determine parameter type based on field type and method
671
711
  if (field_name == '_jac_spawn_node') {
672
712
  param_type = ParameterType.PATH;
713
+ } elif (
714
+ method == HTTPMethod.GET
715
+ and not ('UploadFile' in field_type or 'uploadfile' in field_type.lower())
716
+ ) {
717
+ param_type = ParameterType.QUERY;
673
718
  } elif ('UploadFile' in field_type or 'uploadfile' in field_type.lower()) {
674
719
  # Support UploadFile type for file uploads
675
720
  param_type = ParameterType.FILE;
@@ -850,7 +895,7 @@ impl JacAPIServer.create_user(username: str, password: str) -> TransportResponse
850
895
  );
851
896
  } except Exception as e {
852
897
  error_trace = traceback.format_exc();
853
- print(f"Error in create_user: {e}\n{error_trace}");
898
+ console.print(f"Error in create_user: {e}\n{error_trace}");
854
899
  return TransportResponse.fail(
855
900
  code='INTERNAL_ERROR',
856
901
  message=f"Registration failed: {e}",
@@ -1109,6 +1154,7 @@ impl JacAPIServer.login(username: str, password: str) -> TransportResponse {
1109
1154
 
1110
1155
  impl JacAPIServer.postinit -> None {
1111
1156
  super.postinit();
1157
+ self._api_key_manager = ApiKeyManager();
1112
1158
  self.server.app.add_middleware(
1113
1159
  CORSMiddleware,
1114
1160
  allow_origins=['*'],
@@ -1239,13 +1285,17 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1239
1285
  node: str | None = None,
1240
1286
  Authorization: str | None = None
1241
1287
  ) -> TransportResponse {
1242
- # Parse request body to get walker fields
1243
- try {
1244
- body = await request.json();
1245
- } except Exception {
1246
- body = {};
1288
+ # Parse request body or query params to get walker fields
1289
+ if request.method == 'GET' {
1290
+ kwargs: dict[str, Any] = dict(request.query_params);
1291
+ } else {
1292
+ try {
1293
+ body = await request.json();
1294
+ } except Exception {
1295
+ body = {};
1296
+ }
1297
+ kwargs = dict(body) if body else {};
1247
1298
  }
1248
- kwargs: dict[str, Any] = dict(body) if body else {};
1249
1299
 
1250
1300
  # Reload introspector if files changed (HMR)
1251
1301
  if self._hmr_pending {
@@ -1265,6 +1315,16 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1265
1315
  );
1266
1316
  }
1267
1317
 
1318
+ # Reject walkers configured for webhook transport - they use /webhook/{walker_name} instead
1319
+ transport_type = self.get_transport_type_for_walker(walker_name);
1320
+ if transport_type == TransportType.WEBHOOK {
1321
+ return TransportResponse.fail(
1322
+ code='BAD_REQUEST',
1323
+ message=f"Walker '{walker_name}' is configured as a webhook. Use /webhook/{walker_name} instead.",
1324
+ meta=Meta(extra={'http_status': 400})
1325
+ );
1326
+ }
1327
+
1268
1328
  # Handle authentication
1269
1329
  username: str | None = None;
1270
1330
  authorization = kwargs.pop('Authorization', None);
@@ -1321,7 +1381,7 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1321
1381
  data=result, meta=Meta(extra={'http_status': 200})
1322
1382
  );
1323
1383
  }
1324
- # Register catch-all route for walkers with node parameter
1384
+ # Register catch-all route for walkers with node parameter (POST)
1325
1385
  self.server.add_endpoint(
1326
1386
  JEndPoint(
1327
1387
  method=HTTPMethod.POST,
@@ -1359,7 +1419,45 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1359
1419
  description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
1360
1420
  )
1361
1421
  );
1362
- # Register catch-all route for walkers without node parameter (root)
1422
+ # Register catch-all route for walkers with node parameter (GET)
1423
+ self.server.add_endpoint(
1424
+ JEndPoint(
1425
+ method=HTTPMethod.GET,
1426
+ path='/walker/{walker_name}/{node}',
1427
+ callback=dynamic_walker_handler,
1428
+ parameters=[
1429
+ APIParameter(
1430
+ name='walker_name',
1431
+ data_type='string',
1432
+ required=True,
1433
+ default=None,
1434
+ description='Name of the walker to execute',
1435
+ type=ParameterType.PATH
1436
+ ),
1437
+ APIParameter(
1438
+ name='node',
1439
+ data_type='string',
1440
+ required=True,
1441
+ default=None,
1442
+ description='Node ID to spawn walker on',
1443
+ type=ParameterType.PATH
1444
+ ),
1445
+ APIParameter(
1446
+ name='Authorization',
1447
+ data_type='string',
1448
+ required=False,
1449
+ default=None,
1450
+ description='Bearer token for authentication',
1451
+ type=ParameterType.HEADER
1452
+ )
1453
+ ],
1454
+ response_model=None,
1455
+ tags=['Walkers (Dynamic)'],
1456
+ summary='Execute walker on node (dynamic HMR)',
1457
+ description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
1458
+ )
1459
+ );
1460
+ # Register catch-all route for walkers without node parameter (root) (POST)
1363
1461
  self.server.add_endpoint(
1364
1462
  JEndPoint(
1365
1463
  method=HTTPMethod.POST,
@@ -1389,6 +1487,36 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1389
1487
  description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
1390
1488
  )
1391
1489
  );
1490
+ # Register catch-all route for walkers without node parameter (root) (GET)
1491
+ self.server.add_endpoint(
1492
+ JEndPoint(
1493
+ method=HTTPMethod.GET,
1494
+ path='/walker/{walker_name}',
1495
+ callback=dynamic_walker_handler,
1496
+ parameters=[
1497
+ APIParameter(
1498
+ name='walker_name',
1499
+ data_type='string',
1500
+ required=True,
1501
+ default=None,
1502
+ description='Name of the walker to execute',
1503
+ type=ParameterType.PATH
1504
+ ),
1505
+ APIParameter(
1506
+ name='Authorization',
1507
+ data_type='string',
1508
+ required=False,
1509
+ default=None,
1510
+ description='Bearer token for authentication',
1511
+ type=ParameterType.HEADER
1512
+ )
1513
+ ],
1514
+ response_model=None,
1515
+ tags=['Walkers (Dynamic)'],
1516
+ summary='Execute walker on root (dynamic HMR)',
1517
+ description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
1518
+ )
1519
+ );
1392
1520
  }
1393
1521
 
1394
1522
  """Register a single dynamic endpoint for all functions.
@@ -1402,13 +1530,17 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
1402
1530
  async def dynamic_function_handler(
1403
1531
  request: Request, function_name: str, Authorization: str | None = None
1404
1532
  ) -> TransportResponse {
1405
- # Parse request body to get function arguments
1406
- try {
1407
- body = await request.json();
1408
- } except Exception {
1409
- body = {};
1533
+ # Parse request body or query params to get function arguments
1534
+ if request.method == 'GET' {
1535
+ kwargs: dict[str, Any] = dict(request.query_params);
1536
+ } else {
1537
+ try {
1538
+ body = await request.json();
1539
+ } except Exception {
1540
+ body = {};
1541
+ }
1542
+ kwargs = dict(body) if body else {};
1410
1543
  }
1411
- kwargs: dict[str, Any] = dict(body) if body else {};
1412
1544
 
1413
1545
  # Reload introspector if files changed (HMR)
1414
1546
  if self._hmr_pending {
@@ -1477,6 +1609,7 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
1477
1609
  data=result, meta=Meta(extra={'http_status': 200})
1478
1610
  );
1479
1611
  }
1612
+ # Register POST endpoint
1480
1613
  self.server.add_endpoint(
1481
1614
  JEndPoint(
1482
1615
  method=HTTPMethod.POST,
@@ -1506,6 +1639,36 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
1506
1639
  description='Dynamically routes to any registered function. Supports HMR - function changes are reflected immediately.'
1507
1640
  )
1508
1641
  );
1642
+ # Register GET endpoint
1643
+ self.server.add_endpoint(
1644
+ JEndPoint(
1645
+ method=HTTPMethod.GET,
1646
+ path='/function/{function_name}',
1647
+ callback=dynamic_function_handler,
1648
+ parameters=[
1649
+ APIParameter(
1650
+ name='function_name',
1651
+ data_type='string',
1652
+ required=True,
1653
+ default=None,
1654
+ description='Name of the function to call',
1655
+ type=ParameterType.PATH
1656
+ ),
1657
+ APIParameter(
1658
+ name='Authorization',
1659
+ data_type='string',
1660
+ required=False,
1661
+ default=None,
1662
+ description='Bearer token for authentication',
1663
+ type=ParameterType.HEADER
1664
+ )
1665
+ ],
1666
+ response_model=None,
1667
+ tags=['Functions (Dynamic)'],
1668
+ summary='Call function (dynamic HMR)',
1669
+ description='Dynamically routes to any registered function. Supports HMR - function changes are reflected immediately.'
1670
+ )
1671
+ );
1509
1672
  }
1510
1673
 
1511
1674
  """Register endpoints for runtime introspection of available walkers/functions.
@@ -1631,3 +1794,482 @@ impl JacAPIServer.register_dynamic_introspection_endpoints -> None {
1631
1794
  )
1632
1795
  );
1633
1796
  }
1797
+
1798
+ """Get or create the API key manager instance."""
1799
+ impl JacAPIServer.get_api_key_manager -> ApiKeyManager {
1800
+ if self._api_key_manager is None {
1801
+ self._api_key_manager = ApiKeyManager();
1802
+ }
1803
+ return self._api_key_manager;
1804
+ }
1805
+
1806
+ """Create a new API key for the authenticated user."""
1807
+ impl JacAPIServer.create_api_key(
1808
+ name: str, expiry_days: int | None = None, Authorization: str | None = None
1809
+ ) -> TransportResponse {
1810
+ # Validate JWT token to get username
1811
+ token: str | None = None;
1812
+ if Authorization and Authorization.startswith('Bearer ') {
1813
+ token = Authorization[7:];
1814
+ }
1815
+ username = self.user_manager.validate_jwt_token(token) if token else None;
1816
+ if not username {
1817
+ return TransportResponse.fail(
1818
+ code='UNAUTHORIZED',
1819
+ message='Valid authentication required to create API keys',
1820
+ meta=Meta(extra={'http_status': 401})
1821
+ );
1822
+ }
1823
+ return self.get_api_key_manager().create_api_key(
1824
+ username=username, name=name, expiry_days=expiry_days
1825
+ );
1826
+ }
1827
+
1828
+ """List all API keys for the authenticated user."""
1829
+ impl JacAPIServer.list_api_keys(Authorization: str | None = None) -> TransportResponse {
1830
+ # Validate JWT token to get username
1831
+ token: str | None = None;
1832
+ if Authorization and Authorization.startswith('Bearer ') {
1833
+ token = Authorization[7:];
1834
+ }
1835
+ username = self.user_manager.validate_jwt_token(token) if token else None;
1836
+ if not username {
1837
+ return TransportResponse.fail(
1838
+ code='UNAUTHORIZED',
1839
+ message='Valid authentication required to list API keys',
1840
+ meta=Meta(extra={'http_status': 401})
1841
+ );
1842
+ }
1843
+ return self.get_api_key_manager().list_api_keys(username);
1844
+ }
1845
+
1846
+ """Revoke an API key for the authenticated user."""
1847
+ impl JacAPIServer.revoke_api_key(
1848
+ api_key_id: str, Authorization: str | None = None
1849
+ ) -> TransportResponse {
1850
+ # Validate JWT token to get username
1851
+ token: str | None = None;
1852
+ if Authorization and Authorization.startswith('Bearer ') {
1853
+ token = Authorization[7:];
1854
+ }
1855
+ username = self.user_manager.validate_jwt_token(token) if token else None;
1856
+ if not username {
1857
+ return TransportResponse.fail(
1858
+ code='UNAUTHORIZED',
1859
+ message='Valid authentication required to revoke API keys',
1860
+ meta=Meta(extra={'http_status': 401})
1861
+ );
1862
+ }
1863
+ return self.get_api_key_manager().revoke_api_key(username, api_key_id);
1864
+ }
1865
+
1866
+ """Register API key management endpoints."""
1867
+ impl JacAPIServer.register_api_key_endpoints -> None {
1868
+ # Create API key
1869
+ self.server.add_endpoint(
1870
+ JEndPoint(
1871
+ method=HTTPMethod.POST,
1872
+ path='/api-key/create',
1873
+ callback=self.create_api_key,
1874
+ parameters=[
1875
+ APIParameter(
1876
+ name='name',
1877
+ data_type='string',
1878
+ required=True,
1879
+ default=None,
1880
+ description='A friendly name for the API key',
1881
+ type=ParameterType.BODY
1882
+ ),
1883
+ APIParameter(
1884
+ name='expiry_days',
1885
+ data_type='integer',
1886
+ required=False,
1887
+ default=None,
1888
+ description='Number of days until expiry (default from config)',
1889
+ type=ParameterType.BODY
1890
+ ),
1891
+ APIParameter(
1892
+ name='Authorization',
1893
+ data_type='string',
1894
+ required=True,
1895
+ default=None,
1896
+ description='Bearer token for authentication',
1897
+ type=ParameterType.HEADER
1898
+ )
1899
+ ],
1900
+ response_model=None,
1901
+ tags=['API Keys'],
1902
+ summary='Create a new API key',
1903
+ description='Creates a new API key for webhook authentication. The API key is wrapped in a JWT and can be used with HMAC-SHA256 signature verification.'
1904
+ )
1905
+ );
1906
+ # List API keys
1907
+ self.server.add_endpoint(
1908
+ JEndPoint(
1909
+ method=HTTPMethod.GET,
1910
+ path='/api-key/list',
1911
+ callback=self.list_api_keys,
1912
+ parameters=[
1913
+ APIParameter(
1914
+ name='Authorization',
1915
+ data_type='string',
1916
+ required=True,
1917
+ default=None,
1918
+ description='Bearer token for authentication',
1919
+ type=ParameterType.HEADER
1920
+ )
1921
+ ],
1922
+ response_model=None,
1923
+ tags=['API Keys'],
1924
+ summary='List all API keys',
1925
+ description='Lists all API keys for the authenticated user (metadata only, not the actual keys).'
1926
+ )
1927
+ );
1928
+ # Revoke API key
1929
+ self.server.add_endpoint(
1930
+ JEndPoint(
1931
+ method=HTTPMethod.DELETE,
1932
+ path='/api-key/{api_key_id}',
1933
+ callback=self.revoke_api_key,
1934
+ parameters=[
1935
+ APIParameter(
1936
+ name='api_key_id',
1937
+ data_type='string',
1938
+ required=True,
1939
+ default=None,
1940
+ description='The ID of the API key to revoke',
1941
+ type=ParameterType.PATH
1942
+ ),
1943
+ APIParameter(
1944
+ name='Authorization',
1945
+ data_type='string',
1946
+ required=True,
1947
+ default=None,
1948
+ description='Bearer token for authentication',
1949
+ type=ParameterType.HEADER
1950
+ )
1951
+ ],
1952
+ response_model=None,
1953
+ tags=['API Keys'],
1954
+ summary='Revoke an API key',
1955
+ description='Revokes an API key, making it invalid for future webhook requests.'
1956
+ )
1957
+ );
1958
+ }
1959
+
1960
+ """Get the transport type for a walker by checking its restspec."""
1961
+ impl JacAPIServer.get_transport_type_for_walker(walker_name: str) -> str {
1962
+ walkers = self.get_walkers();
1963
+ if walker_name not in walkers {
1964
+ return TransportType.HTTP;
1965
+ }
1966
+ walker_cls = walkers[walker_name];
1967
+ restspec = walker_cls.restspec if walker_cls?.restspec else None;
1968
+ if restspec?.webhook {
1969
+ return TransportType.WEBHOOK;
1970
+ }
1971
+ return TransportType.HTTP;
1972
+ }
1973
+
1974
+ """Create webhook callback for a walker with HMAC-SHA256 signature verification."""
1975
+ impl JacAPIServer.create_webhook_callback(
1976
+ walker_name: str
1977
+ ) -> Callable[..., TransportResponse] {
1978
+ async def callback(request: Request, **kwargs: JsonValue) -> TransportResponse {
1979
+ webhook_config = get_scale_config().get_webhook_config();
1980
+ signature_header = webhook_config.get(
1981
+ 'signature_header', 'X-Webhook-Signature'
1982
+ );
1983
+ verify_signature = webhook_config.get('verify_signature', True);
1984
+
1985
+ # Get API key from header
1986
+ api_key = request.headers.get('X-API-Key');
1987
+ if not api_key {
1988
+ return TransportResponse.fail(
1989
+ code='UNAUTHORIZED',
1990
+ message='Missing X-API-Key header',
1991
+ meta=Meta(extra={'http_status': 401})
1992
+ );
1993
+ }
1994
+
1995
+ # Validate API key and get username
1996
+ username = self.get_api_key_manager().validate_api_key(api_key);
1997
+ if not username {
1998
+ return TransportResponse.fail(
1999
+ code='UNAUTHORIZED',
2000
+ message='Invalid or expired API key',
2001
+ meta=Meta(extra={'http_status': 401})
2002
+ );
2003
+ }
2004
+
2005
+ # Verify HMAC-SHA256 signature if enabled
2006
+ if verify_signature {
2007
+ signature = request.headers.get(signature_header);
2008
+ if not signature {
2009
+ return TransportResponse.fail(
2010
+ code='UNAUTHORIZED',
2011
+ message=f"Missing {signature_header} header",
2012
+ meta=Meta(extra={'http_status': 401})
2013
+ );
2014
+ }
2015
+ # Get raw body for signature verification
2016
+ body = await request.body();
2017
+ extracted_signature = WebhookUtils.extract_signature(signature);
2018
+ # Use the API key as the secret for HMAC verification
2019
+ if not WebhookUtils.verify_signature(body, extracted_signature, api_key) {
2020
+ return TransportResponse.fail(
2021
+ code='UNAUTHORIZED',
2022
+ message='Invalid webhook signature',
2023
+ meta=Meta(extra={'http_status': 401})
2024
+ );
2025
+ }
2026
+ }
2027
+
2028
+ # Parse body for walker fields
2029
+ try {
2030
+ body_bytes = await request.body();
2031
+ body_data = json.loads(body_bytes) if body_bytes else {};
2032
+ } except Exception {
2033
+ body_data = {};
2034
+ }
2035
+
2036
+ walker_kwargs: dict[str, Any] = dict(body_data) if body_data else {};
2037
+
2038
+ # Execute the walker
2039
+ result = await self.execution_manager.spawn_walker(
2040
+ self.get_walkers()[walker_name], walker_kwargs, username
2041
+ );
2042
+
2043
+ if 'error' in result {
2044
+ return TransportResponse.fail(
2045
+ code='EXECUTION_ERROR',
2046
+ message=result.get('error', 'Walker execution failed'),
2047
+ details=result.get('traceback') if 'traceback' in result else None,
2048
+ meta=Meta(extra={'http_status': 500})
2049
+ );
2050
+ }
2051
+
2052
+ return TransportResponse.success(
2053
+ data=result, meta=Meta(extra={'http_status': 200})
2054
+ );
2055
+ }
2056
+ return callback;
2057
+ }
2058
+
2059
+ """Create parameters for webhook endpoint."""
2060
+ impl JacAPIServer.create_webhook_parameters(walker_name: str) -> list[APIParameter] {
2061
+ parameters: list[APIParameter] = [];
2062
+ # API key header (required for webhooks)
2063
+ # Use underscore naming for valid Python identifiers - FastAPI auto-converts to hyphenated headers
2064
+ parameters.append(
2065
+ APIParameter(
2066
+ name='x_api_key',
2067
+ data_type='string',
2068
+ required=True,
2069
+ default=None,
2070
+ description='API key for webhook authentication (X-API-Key header)',
2071
+ type=ParameterType.HEADER
2072
+ )
2073
+ );
2074
+ # Signature header (for HMAC verification)
2075
+ # Use underscore naming for valid Python identifiers
2076
+ parameters.append(
2077
+ APIParameter(
2078
+ name='x_webhook_signature',
2079
+ data_type='string',
2080
+ required=False,
2081
+ default=None,
2082
+ description='HMAC-SHA256 signature of the request body (X-Webhook-Signature header)',
2083
+ type=ParameterType.HEADER
2084
+ )
2085
+ );
2086
+ # Add walker fields as body parameters (excluding transport_type)
2087
+ walker_fields = self.introspector.introspect_walker(
2088
+ self.get_walkers()[walker_name]
2089
+ )['fields'];
2090
+ for field_name in walker_fields {
2091
+ if field_name in ('_jac_spawn_node', ) {
2092
+ continue;
2093
+ }
2094
+ field_type = walker_fields[field_name]['type'];
2095
+ parameters.append(
2096
+ APIParameter(
2097
+ name=field_name,
2098
+ data_type=field_type,
2099
+ required=walker_fields[field_name]['required'],
2100
+ default=walker_fields[field_name]['default'],
2101
+ description=f"Field {field_name} for webhook walker {walker_name}",
2102
+ type=ParameterType.BODY
2103
+ )
2104
+ );
2105
+ }
2106
+ return parameters;
2107
+ }
2108
+
2109
+ """Register webhook endpoints for walkers with transport_type=WEBHOOK."""
2110
+ impl JacAPIServer.register_webhook_endpoints -> None {
2111
+ for walker_name in self.get_walkers() {
2112
+ transport_type = self.get_transport_type_for_walker(walker_name);
2113
+
2114
+ if transport_type == TransportType.WEBHOOK {
2115
+ self.server.add_endpoint(
2116
+ JEndPoint(
2117
+ method=HTTPMethod.POST,
2118
+ path=f"/webhook/{walker_name}",
2119
+ callback=self.create_webhook_callback(walker_name),
2120
+ parameters=self.create_webhook_parameters(walker_name),
2121
+ response_model=None,
2122
+ tags=['Webhooks'],
2123
+ summary=f'Webhook endpoint for {walker_name}',
2124
+ description=f'Webhook endpoint for {walker_name}. Requires API key authentication and HMAC-SHA256 signature verification.'
2125
+ )
2126
+ );
2127
+ }
2128
+ }
2129
+ }
2130
+
2131
+ """Register dynamic webhook endpoint for HMR support."""
2132
+ impl JacAPIServer.register_dynamic_webhook_endpoint -> None {
2133
+ async def dynamic_webhook_handler(
2134
+ request: Request, walker_name: str
2135
+ ) -> TransportResponse {
2136
+ # Reload introspector if files changed (HMR)
2137
+ if self._hmr_pending {
2138
+ self.introspector.load(force_reload=True);
2139
+ self._hmr_pending = False;
2140
+ }
2141
+
2142
+ walkers = self.get_walkers();
2143
+
2144
+ if walker_name not in walkers {
2145
+ return TransportResponse.fail(
2146
+ code='NOT_FOUND',
2147
+ message=f"Webhook walker '{walker_name}' not found",
2148
+ meta=Meta(extra={'http_status': 404})
2149
+ );
2150
+ }
2151
+
2152
+ # Verify this walker is configured for webhook transport
2153
+ transport_type = self.get_transport_type_for_walker(walker_name);
2154
+ if transport_type != TransportType.WEBHOOK {
2155
+ return TransportResponse.fail(
2156
+ code='BAD_REQUEST',
2157
+ message=f"Walker '{walker_name}' is not configured as a webhook. Use /walker/{walker_name} instead.",
2158
+ meta=Meta(extra={'http_status': 400})
2159
+ );
2160
+ }
2161
+
2162
+ webhook_config = get_scale_config().get_webhook_config();
2163
+ signature_header = webhook_config.get(
2164
+ 'signature_header', 'X-Webhook-Signature'
2165
+ );
2166
+ verify_signature = webhook_config.get('verify_signature', True);
2167
+
2168
+ # Get API key from header
2169
+ api_key = request.headers.get('X-API-Key');
2170
+ if not api_key {
2171
+ return TransportResponse.fail(
2172
+ code='UNAUTHORIZED',
2173
+ message='Missing X-API-Key header',
2174
+ meta=Meta(extra={'http_status': 401})
2175
+ );
2176
+ }
2177
+
2178
+ # Validate API key and get username
2179
+ username = self.get_api_key_manager().validate_api_key(api_key);
2180
+ if not username {
2181
+ return TransportResponse.fail(
2182
+ code='UNAUTHORIZED',
2183
+ message='Invalid or expired API key',
2184
+ meta=Meta(extra={'http_status': 401})
2185
+ );
2186
+ }
2187
+
2188
+ # Verify HMAC-SHA256 signature if enabled
2189
+ if verify_signature {
2190
+ signature = request.headers.get(signature_header);
2191
+ if not signature {
2192
+ return TransportResponse.fail(
2193
+ code='UNAUTHORIZED',
2194
+ message=f"Missing {signature_header} header",
2195
+ meta=Meta(extra={'http_status': 401})
2196
+ );
2197
+ }
2198
+ body = await request.body();
2199
+ extracted_signature = WebhookUtils.extract_signature(signature);
2200
+ if not WebhookUtils.verify_signature(body, extracted_signature, api_key) {
2201
+ return TransportResponse.fail(
2202
+ code='UNAUTHORIZED',
2203
+ message='Invalid webhook signature',
2204
+ meta=Meta(extra={'http_status': 401})
2205
+ );
2206
+ }
2207
+ }
2208
+
2209
+ # Parse body for walker fields
2210
+ try {
2211
+ body_bytes = await request.body();
2212
+ body_data = json.loads(body_bytes) if body_bytes else {};
2213
+ } except Exception {
2214
+ body_data = {};
2215
+ }
2216
+
2217
+ walker_kwargs: dict[str, Any] = dict(body_data) if body_data else {};
2218
+
2219
+ # Execute the walker
2220
+ result = await self.execution_manager.spawn_walker(
2221
+ walkers[walker_name], walker_kwargs, username
2222
+ );
2223
+
2224
+ if 'error' in result {
2225
+ return TransportResponse.fail(
2226
+ code='EXECUTION_ERROR',
2227
+ message=result.get('error', 'Walker execution failed'),
2228
+ details=result.get('traceback') if 'traceback' in result else None,
2229
+ meta=Meta(extra={'http_status': 500})
2230
+ );
2231
+ }
2232
+
2233
+ return TransportResponse.success(
2234
+ data=result, meta=Meta(extra={'http_status': 200})
2235
+ );
2236
+ }
2237
+ # Register dynamic webhook route
2238
+ self.server.add_endpoint(
2239
+ JEndPoint(
2240
+ method=HTTPMethod.POST,
2241
+ path='/webhook/{walker_name}',
2242
+ callback=dynamic_webhook_handler,
2243
+ parameters=[
2244
+ APIParameter(
2245
+ name='walker_name',
2246
+ data_type='string',
2247
+ required=True,
2248
+ default=None,
2249
+ description='Name of the webhook walker to execute',
2250
+ type=ParameterType.PATH
2251
+ ),
2252
+ APIParameter(
2253
+ name='x_api_key',
2254
+ data_type='string',
2255
+ required=True,
2256
+ default=None,
2257
+ description='API key for webhook authentication (X-API-Key header)',
2258
+ type=ParameterType.HEADER
2259
+ ),
2260
+ APIParameter(
2261
+ name='x_webhook_signature',
2262
+ data_type='string',
2263
+ required=False,
2264
+ default=None,
2265
+ description='HMAC-SHA256 signature of the request body (X-Webhook-Signature header)',
2266
+ type=ParameterType.HEADER
2267
+ )
2268
+ ],
2269
+ response_model=None,
2270
+ tags=['Webhooks (Dynamic)'],
2271
+ summary='Execute webhook walker (dynamic HMR)',
2272
+ description='Dynamically routes to webhook walkers. Supports HMR - walker changes are reflected immediately.'
2273
+ )
2274
+ );
2275
+ }