jac-scale 0.1.3__py3-none-any.whl → 0.1.4__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.
- jac_scale/config_loader.jac +2 -1
- jac_scale/impl/config_loader.impl.jac +28 -3
- jac_scale/impl/context.impl.jac +1 -3
- jac_scale/impl/serve.impl.jac +667 -32
- jac_scale/impl/webhook.impl.jac +212 -0
- jac_scale/jserver/impl/jfast_api.impl.jac +4 -0
- jac_scale/plugin_config.jac +1 -1
- jac_scale/serve.jac +30 -4
- jac_scale/tests/fixtures/test_api.jac +60 -0
- jac_scale/tests/fixtures/test_restspec.jac +51 -0
- jac_scale/tests/test_restspec.py +97 -0
- jac_scale/tests/test_serve.py +357 -4
- jac_scale/webhook.jac +93 -0
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/METADATA +4 -4
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/RECORD +18 -16
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/WHEEL +0 -0
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/entry_points.txt +0 -0
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/top_level.txt +0 -0
jac_scale/impl/serve.impl.jac
CHANGED
|
@@ -57,10 +57,10 @@ def _transport_response_to_json_response(
|
|
|
57
57
|
);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
impl JacAPIServer.start(dev: bool = False) -> None {
|
|
60
|
+
impl JacAPIServer.start(dev: bool = False, no_client: bool = False) -> None {
|
|
61
61
|
self.introspector.load();
|
|
62
|
-
# Eagerly build client bundle if there are client exports (skip in dev mode)
|
|
63
|
-
if not dev {
|
|
62
|
+
# Eagerly build client bundle if there are client exports (skip in dev or no_client mode)
|
|
63
|
+
if not dev and not no_client {
|
|
64
64
|
client_exports = self.introspector._client_manifest.get('exports', []);
|
|
65
65
|
if client_exports {
|
|
66
66
|
import time;
|
|
@@ -96,14 +96,17 @@ impl JacAPIServer.start(dev: bool = False) -> None {
|
|
|
96
96
|
self.register_static_file_endpoint();
|
|
97
97
|
self.register_update_username_endpoint();
|
|
98
98
|
self.register_update_password_endpoint();
|
|
99
|
+
self.register_api_key_endpoints();
|
|
99
100
|
# Use dynamic routing for HMR support, static routing for production
|
|
100
101
|
if dev {
|
|
101
102
|
self.register_dynamic_walker_endpoint();
|
|
102
103
|
self.register_dynamic_function_endpoint();
|
|
103
104
|
self.register_dynamic_introspection_endpoints();
|
|
105
|
+
self.register_dynamic_webhook_endpoint();
|
|
104
106
|
} else {
|
|
105
107
|
self.register_walkers_endpoints();
|
|
106
108
|
self.register_functions_endpoints();
|
|
109
|
+
self.register_webhook_endpoints();
|
|
107
110
|
}
|
|
108
111
|
self.register_root_asset_endpoint();
|
|
109
112
|
self._configure_openapi_security();
|
|
@@ -177,19 +180,35 @@ impl JacAPIServer._configure_openapi_security -> None {
|
|
|
177
180
|
self.server.app.openapi = custom_openapi;
|
|
178
181
|
}
|
|
179
182
|
|
|
180
|
-
"""Serve root-level assets like /img.png, /icons/logo.svg, etc.
|
|
183
|
+
"""Serve root-level assets like /img.png, /icons/logo.svg, etc.
|
|
184
|
+
Falls back to SPA HTML for extensionless paths when base_route_app is configured."""
|
|
181
185
|
impl JacAPIServer.serve_root_asset(file_path: str) -> Response {
|
|
182
186
|
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
187
|
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
188
|
import from jaclang.project.config { get_config }
|
|
188
189
|
config = get_config();
|
|
189
190
|
cl_route_prefix = config.serve.cl_route_prefix if config else "cl";
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
api_prefixes = (f'{cl_route_prefix}/', 'walker/', 'function/', 'user/', 'static/');
|
|
192
|
+
if (not file_ext or (file_ext not in allowed_extensions)) {
|
|
193
|
+
# SPA catch-all: serve base_route_app for extensionless paths
|
|
194
|
+
base_route_app = config.serve.base_route_app if config else "";
|
|
195
|
+
if (not file_ext and base_route_app and not file_path.startswith(api_prefixes)) {
|
|
196
|
+
try {
|
|
197
|
+
render_payload = self.introspector.render_page(
|
|
198
|
+
base_route_app, {}, '__guest__'
|
|
199
|
+
);
|
|
200
|
+
return HTMLResponse(content=render_payload['html']);
|
|
201
|
+
} except ValueError {
|
|
202
|
+
return HTMLResponse(content="<h1>404 Not Found</h1>", status_code=404);
|
|
203
|
+
} except RuntimeError {
|
|
204
|
+
return HTMLResponse(
|
|
205
|
+
content="<h1>503 Service Unavailable</h1>", status_code=503
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return Response(status_code=404, content='Not found', media_type='text/plain');
|
|
210
|
+
}
|
|
211
|
+
if file_path.startswith(api_prefixes) {
|
|
193
212
|
return Response(status_code=404, content='Not found', media_type='text/plain');
|
|
194
213
|
}
|
|
195
214
|
# Find project root (where jac.toml is) instead of using base_path_dir
|
|
@@ -498,7 +517,9 @@ impl JacAPIServer.register_functions_endpoints -> None {
|
|
|
498
517
|
method=spec_method,
|
|
499
518
|
path=final_path,
|
|
500
519
|
callback=self.create_function_callback(func_name),
|
|
501
|
-
parameters=self.create_function_parameters(
|
|
520
|
+
parameters=self.create_function_parameters(
|
|
521
|
+
func_name, method=spec_method
|
|
522
|
+
),
|
|
502
523
|
response_model=None,
|
|
503
524
|
tags=['Functions'],
|
|
504
525
|
summary='This is a summary',
|
|
@@ -508,7 +529,9 @@ impl JacAPIServer.register_functions_endpoints -> None {
|
|
|
508
529
|
}
|
|
509
530
|
}
|
|
510
531
|
|
|
511
|
-
impl JacAPIServer.create_function_parameters(
|
|
532
|
+
impl JacAPIServer.create_function_parameters(
|
|
533
|
+
func_name: str, method: HTTPMethod = HTTPMethod.POST
|
|
534
|
+
) -> list[APIParameter] {
|
|
512
535
|
parameters: list[APIParameter] = [];
|
|
513
536
|
if self.introspector.is_auth_required_for_function(func_name) {
|
|
514
537
|
parameters.append(
|
|
@@ -527,8 +550,13 @@ impl JacAPIServer.create_function_parameters(func_name: str) -> list[APIParamete
|
|
|
527
550
|
)['parameters'];
|
|
528
551
|
for field_name in func_fields {
|
|
529
552
|
field_type = func_fields[field_name]['type'];
|
|
530
|
-
# Determine parameter type based on field type
|
|
531
|
-
if (
|
|
553
|
+
# Determine parameter type based on field type and method
|
|
554
|
+
if (
|
|
555
|
+
method == HTTPMethod.GET
|
|
556
|
+
and not ('UploadFile' in field_type or 'uploadfile' in field_type.lower())
|
|
557
|
+
) {
|
|
558
|
+
param_type = ParameterType.QUERY;
|
|
559
|
+
} elif ('UploadFile' in field_type or 'uploadfile' in field_type.lower()) {
|
|
532
560
|
# Support UploadFile type for file uploads
|
|
533
561
|
param_type = ParameterType.FILE;
|
|
534
562
|
} else {
|
|
@@ -607,6 +635,11 @@ impl JacAPIServer.create_function_callback(
|
|
|
607
635
|
|
|
608
636
|
impl JacAPIServer.register_walkers_endpoints -> None {
|
|
609
637
|
for walker_name in self.get_walkers() {
|
|
638
|
+
# Skip walkers configured for webhook transport - they use /webhook/{walker_name} instead
|
|
639
|
+
transport_type = self.get_transport_type_for_walker(walker_name);
|
|
640
|
+
if transport_type == TransportType.WEBHOOK {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
610
643
|
walker_cls = self.get_walkers()[walker_name];
|
|
611
644
|
restspec = walker_cls.restspec if walker_cls?.restspec else None;
|
|
612
645
|
spec_method = restspec.method if restspec?.method else HTTPMethod.POST;
|
|
@@ -618,7 +651,7 @@ impl JacAPIServer.register_walkers_endpoints -> None {
|
|
|
618
651
|
path=f"{spec_path}/{{node}}",
|
|
619
652
|
callback=self.create_walker_callback(walker_name, has_node_param=True),
|
|
620
653
|
parameters=self.create_walker_parameters(
|
|
621
|
-
walker_name, invoke_on_root=False
|
|
654
|
+
walker_name, invoke_on_root=False, method=spec_method
|
|
622
655
|
),
|
|
623
656
|
response_model=None,
|
|
624
657
|
tags=['Walkers'],
|
|
@@ -632,7 +665,7 @@ impl JacAPIServer.register_walkers_endpoints -> None {
|
|
|
632
665
|
path=spec_path,
|
|
633
666
|
callback=self.create_walker_callback(walker_name, has_node_param=False),
|
|
634
667
|
parameters=self.create_walker_parameters(
|
|
635
|
-
walker_name, invoke_on_root=True
|
|
668
|
+
walker_name, invoke_on_root=True, method=spec_method
|
|
636
669
|
),
|
|
637
670
|
response_model=None,
|
|
638
671
|
tags=['Walkers'],
|
|
@@ -644,7 +677,7 @@ impl JacAPIServer.register_walkers_endpoints -> None {
|
|
|
644
677
|
}
|
|
645
678
|
|
|
646
679
|
impl JacAPIServer.create_walker_parameters(
|
|
647
|
-
walker_name: str, invoke_on_root: bool
|
|
680
|
+
walker_name: str, invoke_on_root: bool, method: HTTPMethod = HTTPMethod.POST
|
|
648
681
|
) -> list[APIParameter] {
|
|
649
682
|
parameters: list[APIParameter] = [];
|
|
650
683
|
if self.introspector.is_auth_required_for_walker(walker_name) {
|
|
@@ -667,9 +700,14 @@ impl JacAPIServer.create_walker_parameters(
|
|
|
667
700
|
continue;
|
|
668
701
|
}
|
|
669
702
|
field_type = walker_fields[field_name]['type'];
|
|
670
|
-
# Determine parameter type based on field type
|
|
703
|
+
# Determine parameter type based on field type and method
|
|
671
704
|
if (field_name == '_jac_spawn_node') {
|
|
672
705
|
param_type = ParameterType.PATH;
|
|
706
|
+
} elif (
|
|
707
|
+
method == HTTPMethod.GET
|
|
708
|
+
and not ('UploadFile' in field_type or 'uploadfile' in field_type.lower())
|
|
709
|
+
) {
|
|
710
|
+
param_type = ParameterType.QUERY;
|
|
673
711
|
} elif ('UploadFile' in field_type or 'uploadfile' in field_type.lower()) {
|
|
674
712
|
# Support UploadFile type for file uploads
|
|
675
713
|
param_type = ParameterType.FILE;
|
|
@@ -1109,6 +1147,7 @@ impl JacAPIServer.login(username: str, password: str) -> TransportResponse {
|
|
|
1109
1147
|
|
|
1110
1148
|
impl JacAPIServer.postinit -> None {
|
|
1111
1149
|
super.postinit();
|
|
1150
|
+
self._api_key_manager = ApiKeyManager();
|
|
1112
1151
|
self.server.app.add_middleware(
|
|
1113
1152
|
CORSMiddleware,
|
|
1114
1153
|
allow_origins=['*'],
|
|
@@ -1239,13 +1278,17 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
|
|
|
1239
1278
|
node: str | None = None,
|
|
1240
1279
|
Authorization: str | None = None
|
|
1241
1280
|
) -> TransportResponse {
|
|
1242
|
-
# Parse request body to get walker fields
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1281
|
+
# Parse request body or query params to get walker fields
|
|
1282
|
+
if request.method == 'GET' {
|
|
1283
|
+
kwargs: dict[str, Any] = dict(request.query_params);
|
|
1284
|
+
} else {
|
|
1285
|
+
try {
|
|
1286
|
+
body = await request.json();
|
|
1287
|
+
} except Exception {
|
|
1288
|
+
body = {};
|
|
1289
|
+
}
|
|
1290
|
+
kwargs = dict(body) if body else {};
|
|
1247
1291
|
}
|
|
1248
|
-
kwargs: dict[str, Any] = dict(body) if body else {};
|
|
1249
1292
|
|
|
1250
1293
|
# Reload introspector if files changed (HMR)
|
|
1251
1294
|
if self._hmr_pending {
|
|
@@ -1265,6 +1308,16 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
|
|
|
1265
1308
|
);
|
|
1266
1309
|
}
|
|
1267
1310
|
|
|
1311
|
+
# Reject walkers configured for webhook transport - they use /webhook/{walker_name} instead
|
|
1312
|
+
transport_type = self.get_transport_type_for_walker(walker_name);
|
|
1313
|
+
if transport_type == TransportType.WEBHOOK {
|
|
1314
|
+
return TransportResponse.fail(
|
|
1315
|
+
code='BAD_REQUEST',
|
|
1316
|
+
message=f"Walker '{walker_name}' is configured as a webhook. Use /webhook/{walker_name} instead.",
|
|
1317
|
+
meta=Meta(extra={'http_status': 400})
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1268
1321
|
# Handle authentication
|
|
1269
1322
|
username: str | None = None;
|
|
1270
1323
|
authorization = kwargs.pop('Authorization', None);
|
|
@@ -1321,7 +1374,7 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
|
|
|
1321
1374
|
data=result, meta=Meta(extra={'http_status': 200})
|
|
1322
1375
|
);
|
|
1323
1376
|
}
|
|
1324
|
-
# Register catch-all route for walkers with node parameter
|
|
1377
|
+
# Register catch-all route for walkers with node parameter (POST)
|
|
1325
1378
|
self.server.add_endpoint(
|
|
1326
1379
|
JEndPoint(
|
|
1327
1380
|
method=HTTPMethod.POST,
|
|
@@ -1359,7 +1412,45 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
|
|
|
1359
1412
|
description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
|
|
1360
1413
|
)
|
|
1361
1414
|
);
|
|
1362
|
-
# Register catch-all route for walkers
|
|
1415
|
+
# Register catch-all route for walkers with node parameter (GET)
|
|
1416
|
+
self.server.add_endpoint(
|
|
1417
|
+
JEndPoint(
|
|
1418
|
+
method=HTTPMethod.GET,
|
|
1419
|
+
path='/walker/{walker_name}/{node}',
|
|
1420
|
+
callback=dynamic_walker_handler,
|
|
1421
|
+
parameters=[
|
|
1422
|
+
APIParameter(
|
|
1423
|
+
name='walker_name',
|
|
1424
|
+
data_type='string',
|
|
1425
|
+
required=True,
|
|
1426
|
+
default=None,
|
|
1427
|
+
description='Name of the walker to execute',
|
|
1428
|
+
type=ParameterType.PATH
|
|
1429
|
+
),
|
|
1430
|
+
APIParameter(
|
|
1431
|
+
name='node',
|
|
1432
|
+
data_type='string',
|
|
1433
|
+
required=True,
|
|
1434
|
+
default=None,
|
|
1435
|
+
description='Node ID to spawn walker on',
|
|
1436
|
+
type=ParameterType.PATH
|
|
1437
|
+
),
|
|
1438
|
+
APIParameter(
|
|
1439
|
+
name='Authorization',
|
|
1440
|
+
data_type='string',
|
|
1441
|
+
required=False,
|
|
1442
|
+
default=None,
|
|
1443
|
+
description='Bearer token for authentication',
|
|
1444
|
+
type=ParameterType.HEADER
|
|
1445
|
+
)
|
|
1446
|
+
],
|
|
1447
|
+
response_model=None,
|
|
1448
|
+
tags=['Walkers (Dynamic)'],
|
|
1449
|
+
summary='Execute walker on node (dynamic HMR)',
|
|
1450
|
+
description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
|
|
1451
|
+
)
|
|
1452
|
+
);
|
|
1453
|
+
# Register catch-all route for walkers without node parameter (root) (POST)
|
|
1363
1454
|
self.server.add_endpoint(
|
|
1364
1455
|
JEndPoint(
|
|
1365
1456
|
method=HTTPMethod.POST,
|
|
@@ -1389,6 +1480,36 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
|
|
|
1389
1480
|
description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
|
|
1390
1481
|
)
|
|
1391
1482
|
);
|
|
1483
|
+
# Register catch-all route for walkers without node parameter (root) (GET)
|
|
1484
|
+
self.server.add_endpoint(
|
|
1485
|
+
JEndPoint(
|
|
1486
|
+
method=HTTPMethod.GET,
|
|
1487
|
+
path='/walker/{walker_name}',
|
|
1488
|
+
callback=dynamic_walker_handler,
|
|
1489
|
+
parameters=[
|
|
1490
|
+
APIParameter(
|
|
1491
|
+
name='walker_name',
|
|
1492
|
+
data_type='string',
|
|
1493
|
+
required=True,
|
|
1494
|
+
default=None,
|
|
1495
|
+
description='Name of the walker to execute',
|
|
1496
|
+
type=ParameterType.PATH
|
|
1497
|
+
),
|
|
1498
|
+
APIParameter(
|
|
1499
|
+
name='Authorization',
|
|
1500
|
+
data_type='string',
|
|
1501
|
+
required=False,
|
|
1502
|
+
default=None,
|
|
1503
|
+
description='Bearer token for authentication',
|
|
1504
|
+
type=ParameterType.HEADER
|
|
1505
|
+
)
|
|
1506
|
+
],
|
|
1507
|
+
response_model=None,
|
|
1508
|
+
tags=['Walkers (Dynamic)'],
|
|
1509
|
+
summary='Execute walker on root (dynamic HMR)',
|
|
1510
|
+
description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
|
|
1511
|
+
)
|
|
1512
|
+
);
|
|
1392
1513
|
}
|
|
1393
1514
|
|
|
1394
1515
|
"""Register a single dynamic endpoint for all functions.
|
|
@@ -1402,13 +1523,17 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
|
|
|
1402
1523
|
async def dynamic_function_handler(
|
|
1403
1524
|
request: Request, function_name: str, Authorization: str | None = None
|
|
1404
1525
|
) -> TransportResponse {
|
|
1405
|
-
# Parse request body to get function arguments
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1526
|
+
# Parse request body or query params to get function arguments
|
|
1527
|
+
if request.method == 'GET' {
|
|
1528
|
+
kwargs: dict[str, Any] = dict(request.query_params);
|
|
1529
|
+
} else {
|
|
1530
|
+
try {
|
|
1531
|
+
body = await request.json();
|
|
1532
|
+
} except Exception {
|
|
1533
|
+
body = {};
|
|
1534
|
+
}
|
|
1535
|
+
kwargs = dict(body) if body else {};
|
|
1410
1536
|
}
|
|
1411
|
-
kwargs: dict[str, Any] = dict(body) if body else {};
|
|
1412
1537
|
|
|
1413
1538
|
# Reload introspector if files changed (HMR)
|
|
1414
1539
|
if self._hmr_pending {
|
|
@@ -1477,6 +1602,7 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
|
|
|
1477
1602
|
data=result, meta=Meta(extra={'http_status': 200})
|
|
1478
1603
|
);
|
|
1479
1604
|
}
|
|
1605
|
+
# Register POST endpoint
|
|
1480
1606
|
self.server.add_endpoint(
|
|
1481
1607
|
JEndPoint(
|
|
1482
1608
|
method=HTTPMethod.POST,
|
|
@@ -1506,6 +1632,36 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
|
|
|
1506
1632
|
description='Dynamically routes to any registered function. Supports HMR - function changes are reflected immediately.'
|
|
1507
1633
|
)
|
|
1508
1634
|
);
|
|
1635
|
+
# Register GET endpoint
|
|
1636
|
+
self.server.add_endpoint(
|
|
1637
|
+
JEndPoint(
|
|
1638
|
+
method=HTTPMethod.GET,
|
|
1639
|
+
path='/function/{function_name}',
|
|
1640
|
+
callback=dynamic_function_handler,
|
|
1641
|
+
parameters=[
|
|
1642
|
+
APIParameter(
|
|
1643
|
+
name='function_name',
|
|
1644
|
+
data_type='string',
|
|
1645
|
+
required=True,
|
|
1646
|
+
default=None,
|
|
1647
|
+
description='Name of the function to call',
|
|
1648
|
+
type=ParameterType.PATH
|
|
1649
|
+
),
|
|
1650
|
+
APIParameter(
|
|
1651
|
+
name='Authorization',
|
|
1652
|
+
data_type='string',
|
|
1653
|
+
required=False,
|
|
1654
|
+
default=None,
|
|
1655
|
+
description='Bearer token for authentication',
|
|
1656
|
+
type=ParameterType.HEADER
|
|
1657
|
+
)
|
|
1658
|
+
],
|
|
1659
|
+
response_model=None,
|
|
1660
|
+
tags=['Functions (Dynamic)'],
|
|
1661
|
+
summary='Call function (dynamic HMR)',
|
|
1662
|
+
description='Dynamically routes to any registered function. Supports HMR - function changes are reflected immediately.'
|
|
1663
|
+
)
|
|
1664
|
+
);
|
|
1509
1665
|
}
|
|
1510
1666
|
|
|
1511
1667
|
"""Register endpoints for runtime introspection of available walkers/functions.
|
|
@@ -1631,3 +1787,482 @@ impl JacAPIServer.register_dynamic_introspection_endpoints -> None {
|
|
|
1631
1787
|
)
|
|
1632
1788
|
);
|
|
1633
1789
|
}
|
|
1790
|
+
|
|
1791
|
+
"""Get or create the API key manager instance."""
|
|
1792
|
+
impl JacAPIServer.get_api_key_manager -> ApiKeyManager {
|
|
1793
|
+
if self._api_key_manager is None {
|
|
1794
|
+
self._api_key_manager = ApiKeyManager();
|
|
1795
|
+
}
|
|
1796
|
+
return self._api_key_manager;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
"""Create a new API key for the authenticated user."""
|
|
1800
|
+
impl JacAPIServer.create_api_key(
|
|
1801
|
+
name: str, expiry_days: int | None = None, Authorization: str | None = None
|
|
1802
|
+
) -> TransportResponse {
|
|
1803
|
+
# Validate JWT token to get username
|
|
1804
|
+
token: str | None = None;
|
|
1805
|
+
if Authorization and Authorization.startswith('Bearer ') {
|
|
1806
|
+
token = Authorization[7:];
|
|
1807
|
+
}
|
|
1808
|
+
username = self.user_manager.validate_jwt_token(token) if token else None;
|
|
1809
|
+
if not username {
|
|
1810
|
+
return TransportResponse.fail(
|
|
1811
|
+
code='UNAUTHORIZED',
|
|
1812
|
+
message='Valid authentication required to create API keys',
|
|
1813
|
+
meta=Meta(extra={'http_status': 401})
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
return self.get_api_key_manager().create_api_key(
|
|
1817
|
+
username=username, name=name, expiry_days=expiry_days
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
"""List all API keys for the authenticated user."""
|
|
1822
|
+
impl JacAPIServer.list_api_keys(Authorization: str | None = None) -> TransportResponse {
|
|
1823
|
+
# Validate JWT token to get username
|
|
1824
|
+
token: str | None = None;
|
|
1825
|
+
if Authorization and Authorization.startswith('Bearer ') {
|
|
1826
|
+
token = Authorization[7:];
|
|
1827
|
+
}
|
|
1828
|
+
username = self.user_manager.validate_jwt_token(token) if token else None;
|
|
1829
|
+
if not username {
|
|
1830
|
+
return TransportResponse.fail(
|
|
1831
|
+
code='UNAUTHORIZED',
|
|
1832
|
+
message='Valid authentication required to list API keys',
|
|
1833
|
+
meta=Meta(extra={'http_status': 401})
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
return self.get_api_key_manager().list_api_keys(username);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
"""Revoke an API key for the authenticated user."""
|
|
1840
|
+
impl JacAPIServer.revoke_api_key(
|
|
1841
|
+
api_key_id: str, Authorization: str | None = None
|
|
1842
|
+
) -> TransportResponse {
|
|
1843
|
+
# Validate JWT token to get username
|
|
1844
|
+
token: str | None = None;
|
|
1845
|
+
if Authorization and Authorization.startswith('Bearer ') {
|
|
1846
|
+
token = Authorization[7:];
|
|
1847
|
+
}
|
|
1848
|
+
username = self.user_manager.validate_jwt_token(token) if token else None;
|
|
1849
|
+
if not username {
|
|
1850
|
+
return TransportResponse.fail(
|
|
1851
|
+
code='UNAUTHORIZED',
|
|
1852
|
+
message='Valid authentication required to revoke API keys',
|
|
1853
|
+
meta=Meta(extra={'http_status': 401})
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
return self.get_api_key_manager().revoke_api_key(username, api_key_id);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
"""Register API key management endpoints."""
|
|
1860
|
+
impl JacAPIServer.register_api_key_endpoints -> None {
|
|
1861
|
+
# Create API key
|
|
1862
|
+
self.server.add_endpoint(
|
|
1863
|
+
JEndPoint(
|
|
1864
|
+
method=HTTPMethod.POST,
|
|
1865
|
+
path='/api-key/create',
|
|
1866
|
+
callback=self.create_api_key,
|
|
1867
|
+
parameters=[
|
|
1868
|
+
APIParameter(
|
|
1869
|
+
name='name',
|
|
1870
|
+
data_type='string',
|
|
1871
|
+
required=True,
|
|
1872
|
+
default=None,
|
|
1873
|
+
description='A friendly name for the API key',
|
|
1874
|
+
type=ParameterType.BODY
|
|
1875
|
+
),
|
|
1876
|
+
APIParameter(
|
|
1877
|
+
name='expiry_days',
|
|
1878
|
+
data_type='integer',
|
|
1879
|
+
required=False,
|
|
1880
|
+
default=None,
|
|
1881
|
+
description='Number of days until expiry (default from config)',
|
|
1882
|
+
type=ParameterType.BODY
|
|
1883
|
+
),
|
|
1884
|
+
APIParameter(
|
|
1885
|
+
name='Authorization',
|
|
1886
|
+
data_type='string',
|
|
1887
|
+
required=True,
|
|
1888
|
+
default=None,
|
|
1889
|
+
description='Bearer token for authentication',
|
|
1890
|
+
type=ParameterType.HEADER
|
|
1891
|
+
)
|
|
1892
|
+
],
|
|
1893
|
+
response_model=None,
|
|
1894
|
+
tags=['API Keys'],
|
|
1895
|
+
summary='Create a new API key',
|
|
1896
|
+
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.'
|
|
1897
|
+
)
|
|
1898
|
+
);
|
|
1899
|
+
# List API keys
|
|
1900
|
+
self.server.add_endpoint(
|
|
1901
|
+
JEndPoint(
|
|
1902
|
+
method=HTTPMethod.GET,
|
|
1903
|
+
path='/api-key/list',
|
|
1904
|
+
callback=self.list_api_keys,
|
|
1905
|
+
parameters=[
|
|
1906
|
+
APIParameter(
|
|
1907
|
+
name='Authorization',
|
|
1908
|
+
data_type='string',
|
|
1909
|
+
required=True,
|
|
1910
|
+
default=None,
|
|
1911
|
+
description='Bearer token for authentication',
|
|
1912
|
+
type=ParameterType.HEADER
|
|
1913
|
+
)
|
|
1914
|
+
],
|
|
1915
|
+
response_model=None,
|
|
1916
|
+
tags=['API Keys'],
|
|
1917
|
+
summary='List all API keys',
|
|
1918
|
+
description='Lists all API keys for the authenticated user (metadata only, not the actual keys).'
|
|
1919
|
+
)
|
|
1920
|
+
);
|
|
1921
|
+
# Revoke API key
|
|
1922
|
+
self.server.add_endpoint(
|
|
1923
|
+
JEndPoint(
|
|
1924
|
+
method=HTTPMethod.DELETE,
|
|
1925
|
+
path='/api-key/{api_key_id}',
|
|
1926
|
+
callback=self.revoke_api_key,
|
|
1927
|
+
parameters=[
|
|
1928
|
+
APIParameter(
|
|
1929
|
+
name='api_key_id',
|
|
1930
|
+
data_type='string',
|
|
1931
|
+
required=True,
|
|
1932
|
+
default=None,
|
|
1933
|
+
description='The ID of the API key to revoke',
|
|
1934
|
+
type=ParameterType.PATH
|
|
1935
|
+
),
|
|
1936
|
+
APIParameter(
|
|
1937
|
+
name='Authorization',
|
|
1938
|
+
data_type='string',
|
|
1939
|
+
required=True,
|
|
1940
|
+
default=None,
|
|
1941
|
+
description='Bearer token for authentication',
|
|
1942
|
+
type=ParameterType.HEADER
|
|
1943
|
+
)
|
|
1944
|
+
],
|
|
1945
|
+
response_model=None,
|
|
1946
|
+
tags=['API Keys'],
|
|
1947
|
+
summary='Revoke an API key',
|
|
1948
|
+
description='Revokes an API key, making it invalid for future webhook requests.'
|
|
1949
|
+
)
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
"""Get the transport type for a walker by checking its restspec."""
|
|
1954
|
+
impl JacAPIServer.get_transport_type_for_walker(walker_name: str) -> str {
|
|
1955
|
+
walkers = self.get_walkers();
|
|
1956
|
+
if walker_name not in walkers {
|
|
1957
|
+
return TransportType.HTTP;
|
|
1958
|
+
}
|
|
1959
|
+
walker_cls = walkers[walker_name];
|
|
1960
|
+
restspec = walker_cls.restspec if walker_cls?.restspec else None;
|
|
1961
|
+
if restspec?.webhook {
|
|
1962
|
+
return TransportType.WEBHOOK;
|
|
1963
|
+
}
|
|
1964
|
+
return TransportType.HTTP;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
"""Create webhook callback for a walker with HMAC-SHA256 signature verification."""
|
|
1968
|
+
impl JacAPIServer.create_webhook_callback(
|
|
1969
|
+
walker_name: str
|
|
1970
|
+
) -> Callable[..., TransportResponse] {
|
|
1971
|
+
async def callback(request: Request, **kwargs: JsonValue) -> TransportResponse {
|
|
1972
|
+
webhook_config = get_scale_config().get_webhook_config();
|
|
1973
|
+
signature_header = webhook_config.get(
|
|
1974
|
+
'signature_header', 'X-Webhook-Signature'
|
|
1975
|
+
);
|
|
1976
|
+
verify_signature = webhook_config.get('verify_signature', True);
|
|
1977
|
+
|
|
1978
|
+
# Get API key from header
|
|
1979
|
+
api_key = request.headers.get('X-API-Key');
|
|
1980
|
+
if not api_key {
|
|
1981
|
+
return TransportResponse.fail(
|
|
1982
|
+
code='UNAUTHORIZED',
|
|
1983
|
+
message='Missing X-API-Key header',
|
|
1984
|
+
meta=Meta(extra={'http_status': 401})
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
# Validate API key and get username
|
|
1989
|
+
username = self.get_api_key_manager().validate_api_key(api_key);
|
|
1990
|
+
if not username {
|
|
1991
|
+
return TransportResponse.fail(
|
|
1992
|
+
code='UNAUTHORIZED',
|
|
1993
|
+
message='Invalid or expired API key',
|
|
1994
|
+
meta=Meta(extra={'http_status': 401})
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
# Verify HMAC-SHA256 signature if enabled
|
|
1999
|
+
if verify_signature {
|
|
2000
|
+
signature = request.headers.get(signature_header);
|
|
2001
|
+
if not signature {
|
|
2002
|
+
return TransportResponse.fail(
|
|
2003
|
+
code='UNAUTHORIZED',
|
|
2004
|
+
message=f"Missing {signature_header} header",
|
|
2005
|
+
meta=Meta(extra={'http_status': 401})
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
# Get raw body for signature verification
|
|
2009
|
+
body = await request.body();
|
|
2010
|
+
extracted_signature = WebhookUtils.extract_signature(signature);
|
|
2011
|
+
# Use the API key as the secret for HMAC verification
|
|
2012
|
+
if not WebhookUtils.verify_signature(body, extracted_signature, api_key) {
|
|
2013
|
+
return TransportResponse.fail(
|
|
2014
|
+
code='UNAUTHORIZED',
|
|
2015
|
+
message='Invalid webhook signature',
|
|
2016
|
+
meta=Meta(extra={'http_status': 401})
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
# Parse body for walker fields
|
|
2022
|
+
try {
|
|
2023
|
+
body_bytes = await request.body();
|
|
2024
|
+
body_data = json.loads(body_bytes) if body_bytes else {};
|
|
2025
|
+
} except Exception {
|
|
2026
|
+
body_data = {};
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
walker_kwargs: dict[str, Any] = dict(body_data) if body_data else {};
|
|
2030
|
+
|
|
2031
|
+
# Execute the walker
|
|
2032
|
+
result = await self.execution_manager.spawn_walker(
|
|
2033
|
+
self.get_walkers()[walker_name], walker_kwargs, username
|
|
2034
|
+
);
|
|
2035
|
+
|
|
2036
|
+
if 'error' in result {
|
|
2037
|
+
return TransportResponse.fail(
|
|
2038
|
+
code='EXECUTION_ERROR',
|
|
2039
|
+
message=result.get('error', 'Walker execution failed'),
|
|
2040
|
+
details=result.get('traceback') if 'traceback' in result else None,
|
|
2041
|
+
meta=Meta(extra={'http_status': 500})
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
return TransportResponse.success(
|
|
2046
|
+
data=result, meta=Meta(extra={'http_status': 200})
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
return callback;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
"""Create parameters for webhook endpoint."""
|
|
2053
|
+
impl JacAPIServer.create_webhook_parameters(walker_name: str) -> list[APIParameter] {
|
|
2054
|
+
parameters: list[APIParameter] = [];
|
|
2055
|
+
# API key header (required for webhooks)
|
|
2056
|
+
# Use underscore naming for valid Python identifiers - FastAPI auto-converts to hyphenated headers
|
|
2057
|
+
parameters.append(
|
|
2058
|
+
APIParameter(
|
|
2059
|
+
name='x_api_key',
|
|
2060
|
+
data_type='string',
|
|
2061
|
+
required=True,
|
|
2062
|
+
default=None,
|
|
2063
|
+
description='API key for webhook authentication (X-API-Key header)',
|
|
2064
|
+
type=ParameterType.HEADER
|
|
2065
|
+
)
|
|
2066
|
+
);
|
|
2067
|
+
# Signature header (for HMAC verification)
|
|
2068
|
+
# Use underscore naming for valid Python identifiers
|
|
2069
|
+
parameters.append(
|
|
2070
|
+
APIParameter(
|
|
2071
|
+
name='x_webhook_signature',
|
|
2072
|
+
data_type='string',
|
|
2073
|
+
required=False,
|
|
2074
|
+
default=None,
|
|
2075
|
+
description='HMAC-SHA256 signature of the request body (X-Webhook-Signature header)',
|
|
2076
|
+
type=ParameterType.HEADER
|
|
2077
|
+
)
|
|
2078
|
+
);
|
|
2079
|
+
# Add walker fields as body parameters (excluding transport_type)
|
|
2080
|
+
walker_fields = self.introspector.introspect_walker(
|
|
2081
|
+
self.get_walkers()[walker_name]
|
|
2082
|
+
)['fields'];
|
|
2083
|
+
for field_name in walker_fields {
|
|
2084
|
+
if field_name in ('_jac_spawn_node', ) {
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
field_type = walker_fields[field_name]['type'];
|
|
2088
|
+
parameters.append(
|
|
2089
|
+
APIParameter(
|
|
2090
|
+
name=field_name,
|
|
2091
|
+
data_type=field_type,
|
|
2092
|
+
required=walker_fields[field_name]['required'],
|
|
2093
|
+
default=walker_fields[field_name]['default'],
|
|
2094
|
+
description=f"Field {field_name} for webhook walker {walker_name}",
|
|
2095
|
+
type=ParameterType.BODY
|
|
2096
|
+
)
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
return parameters;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
"""Register webhook endpoints for walkers with transport_type=WEBHOOK."""
|
|
2103
|
+
impl JacAPIServer.register_webhook_endpoints -> None {
|
|
2104
|
+
for walker_name in self.get_walkers() {
|
|
2105
|
+
transport_type = self.get_transport_type_for_walker(walker_name);
|
|
2106
|
+
|
|
2107
|
+
if transport_type == TransportType.WEBHOOK {
|
|
2108
|
+
self.server.add_endpoint(
|
|
2109
|
+
JEndPoint(
|
|
2110
|
+
method=HTTPMethod.POST,
|
|
2111
|
+
path=f"/webhook/{walker_name}",
|
|
2112
|
+
callback=self.create_webhook_callback(walker_name),
|
|
2113
|
+
parameters=self.create_webhook_parameters(walker_name),
|
|
2114
|
+
response_model=None,
|
|
2115
|
+
tags=['Webhooks'],
|
|
2116
|
+
summary=f'Webhook endpoint for {walker_name}',
|
|
2117
|
+
description=f'Webhook endpoint for {walker_name}. Requires API key authentication and HMAC-SHA256 signature verification.'
|
|
2118
|
+
)
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
"""Register dynamic webhook endpoint for HMR support."""
|
|
2125
|
+
impl JacAPIServer.register_dynamic_webhook_endpoint -> None {
|
|
2126
|
+
async def dynamic_webhook_handler(
|
|
2127
|
+
request: Request, walker_name: str
|
|
2128
|
+
) -> TransportResponse {
|
|
2129
|
+
# Reload introspector if files changed (HMR)
|
|
2130
|
+
if self._hmr_pending {
|
|
2131
|
+
self.introspector.load(force_reload=True);
|
|
2132
|
+
self._hmr_pending = False;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
walkers = self.get_walkers();
|
|
2136
|
+
|
|
2137
|
+
if walker_name not in walkers {
|
|
2138
|
+
return TransportResponse.fail(
|
|
2139
|
+
code='NOT_FOUND',
|
|
2140
|
+
message=f"Webhook walker '{walker_name}' not found",
|
|
2141
|
+
meta=Meta(extra={'http_status': 404})
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
# Verify this walker is configured for webhook transport
|
|
2146
|
+
transport_type = self.get_transport_type_for_walker(walker_name);
|
|
2147
|
+
if transport_type != TransportType.WEBHOOK {
|
|
2148
|
+
return TransportResponse.fail(
|
|
2149
|
+
code='BAD_REQUEST',
|
|
2150
|
+
message=f"Walker '{walker_name}' is not configured as a webhook. Use /walker/{walker_name} instead.",
|
|
2151
|
+
meta=Meta(extra={'http_status': 400})
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
webhook_config = get_scale_config().get_webhook_config();
|
|
2156
|
+
signature_header = webhook_config.get(
|
|
2157
|
+
'signature_header', 'X-Webhook-Signature'
|
|
2158
|
+
);
|
|
2159
|
+
verify_signature = webhook_config.get('verify_signature', True);
|
|
2160
|
+
|
|
2161
|
+
# Get API key from header
|
|
2162
|
+
api_key = request.headers.get('X-API-Key');
|
|
2163
|
+
if not api_key {
|
|
2164
|
+
return TransportResponse.fail(
|
|
2165
|
+
code='UNAUTHORIZED',
|
|
2166
|
+
message='Missing X-API-Key header',
|
|
2167
|
+
meta=Meta(extra={'http_status': 401})
|
|
2168
|
+
);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
# Validate API key and get username
|
|
2172
|
+
username = self.get_api_key_manager().validate_api_key(api_key);
|
|
2173
|
+
if not username {
|
|
2174
|
+
return TransportResponse.fail(
|
|
2175
|
+
code='UNAUTHORIZED',
|
|
2176
|
+
message='Invalid or expired API key',
|
|
2177
|
+
meta=Meta(extra={'http_status': 401})
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
# Verify HMAC-SHA256 signature if enabled
|
|
2182
|
+
if verify_signature {
|
|
2183
|
+
signature = request.headers.get(signature_header);
|
|
2184
|
+
if not signature {
|
|
2185
|
+
return TransportResponse.fail(
|
|
2186
|
+
code='UNAUTHORIZED',
|
|
2187
|
+
message=f"Missing {signature_header} header",
|
|
2188
|
+
meta=Meta(extra={'http_status': 401})
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
body = await request.body();
|
|
2192
|
+
extracted_signature = WebhookUtils.extract_signature(signature);
|
|
2193
|
+
if not WebhookUtils.verify_signature(body, extracted_signature, api_key) {
|
|
2194
|
+
return TransportResponse.fail(
|
|
2195
|
+
code='UNAUTHORIZED',
|
|
2196
|
+
message='Invalid webhook signature',
|
|
2197
|
+
meta=Meta(extra={'http_status': 401})
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
# Parse body for walker fields
|
|
2203
|
+
try {
|
|
2204
|
+
body_bytes = await request.body();
|
|
2205
|
+
body_data = json.loads(body_bytes) if body_bytes else {};
|
|
2206
|
+
} except Exception {
|
|
2207
|
+
body_data = {};
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
walker_kwargs: dict[str, Any] = dict(body_data) if body_data else {};
|
|
2211
|
+
|
|
2212
|
+
# Execute the walker
|
|
2213
|
+
result = await self.execution_manager.spawn_walker(
|
|
2214
|
+
walkers[walker_name], walker_kwargs, username
|
|
2215
|
+
);
|
|
2216
|
+
|
|
2217
|
+
if 'error' in result {
|
|
2218
|
+
return TransportResponse.fail(
|
|
2219
|
+
code='EXECUTION_ERROR',
|
|
2220
|
+
message=result.get('error', 'Walker execution failed'),
|
|
2221
|
+
details=result.get('traceback') if 'traceback' in result else None,
|
|
2222
|
+
meta=Meta(extra={'http_status': 500})
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
return TransportResponse.success(
|
|
2227
|
+
data=result, meta=Meta(extra={'http_status': 200})
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
# Register dynamic webhook route
|
|
2231
|
+
self.server.add_endpoint(
|
|
2232
|
+
JEndPoint(
|
|
2233
|
+
method=HTTPMethod.POST,
|
|
2234
|
+
path='/webhook/{walker_name}',
|
|
2235
|
+
callback=dynamic_webhook_handler,
|
|
2236
|
+
parameters=[
|
|
2237
|
+
APIParameter(
|
|
2238
|
+
name='walker_name',
|
|
2239
|
+
data_type='string',
|
|
2240
|
+
required=True,
|
|
2241
|
+
default=None,
|
|
2242
|
+
description='Name of the webhook walker to execute',
|
|
2243
|
+
type=ParameterType.PATH
|
|
2244
|
+
),
|
|
2245
|
+
APIParameter(
|
|
2246
|
+
name='x_api_key',
|
|
2247
|
+
data_type='string',
|
|
2248
|
+
required=True,
|
|
2249
|
+
default=None,
|
|
2250
|
+
description='API key for webhook authentication (X-API-Key header)',
|
|
2251
|
+
type=ParameterType.HEADER
|
|
2252
|
+
),
|
|
2253
|
+
APIParameter(
|
|
2254
|
+
name='x_webhook_signature',
|
|
2255
|
+
data_type='string',
|
|
2256
|
+
required=False,
|
|
2257
|
+
default=None,
|
|
2258
|
+
description='HMAC-SHA256 signature of the request body (X-Webhook-Signature header)',
|
|
2259
|
+
type=ParameterType.HEADER
|
|
2260
|
+
)
|
|
2261
|
+
],
|
|
2262
|
+
response_model=None,
|
|
2263
|
+
tags=['Webhooks (Dynamic)'],
|
|
2264
|
+
summary='Execute webhook walker (dynamic HMR)',
|
|
2265
|
+
description='Dynamically routes to webhook walkers. Supports HMR - walker changes are reflected immediately.'
|
|
2266
|
+
)
|
|
2267
|
+
);
|
|
2268
|
+
}
|