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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Implementation of Webhook utilities for Jac Scale.
|
|
2
|
+
|
|
3
|
+
This module implements HMAC-SHA256 signature generation/verification
|
|
4
|
+
and API key management for webhook authentication.
|
|
5
|
+
"""
|
|
6
|
+
import hmac;
|
|
7
|
+
import hashlib;
|
|
8
|
+
import secrets;
|
|
9
|
+
import jwt;
|
|
10
|
+
import from datetime { UTC, datetime, timedelta }
|
|
11
|
+
import from typing { Any }
|
|
12
|
+
import from jaclang.runtimelib.transport { TransportResponse, Meta }
|
|
13
|
+
import from jac_scale.config_loader { get_scale_config }
|
|
14
|
+
|
|
15
|
+
# Load JWT config for signing API keys
|
|
16
|
+
glob _jwt_config = get_scale_config().get_jwt_config(),
|
|
17
|
+
JWT_SECRET = _jwt_config['secret'],
|
|
18
|
+
JWT_ALGORITHM = _jwt_config['algorithm'];
|
|
19
|
+
|
|
20
|
+
"""Generate HMAC-SHA256 signature for webhook payload."""
|
|
21
|
+
impl WebhookUtils.generate_signature(payload: bytes, secret: str) -> str {
|
|
22
|
+
return hmac.new(secret.encode('utf-8'), payload, hashlib.sha256).hexdigest();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
"""Verify HMAC-SHA256 signature for webhook payload."""
|
|
26
|
+
impl WebhookUtils.verify_signature(payload: bytes, signature: str, secret: str) -> bool {
|
|
27
|
+
expected_signature = WebhookUtils.generate_signature(payload, secret);
|
|
28
|
+
# Use constant-time comparison to prevent timing attacks
|
|
29
|
+
return hmac.compare_digest(signature.lower(), expected_signature.lower());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
"""Generate a cryptographically secure API key."""
|
|
33
|
+
impl WebhookUtils.generate_api_key -> str {
|
|
34
|
+
return secrets.token_hex(32);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
"""Create a JWT-wrapped API key token."""
|
|
38
|
+
impl WebhookUtils.create_api_key_token(
|
|
39
|
+
api_key_id: str, username: str, name: str, expiry_days: int | None = None
|
|
40
|
+
) -> str {
|
|
41
|
+
_webhook_config = get_scale_config().get_webhook_config();
|
|
42
|
+
default_expiry = _webhook_config.get('api_key_expiry_days', 365);
|
|
43
|
+
payload: dict[str, Any] = {
|
|
44
|
+
'type': 'api_key',
|
|
45
|
+
'api_key_id': api_key_id,
|
|
46
|
+
'sub': username,
|
|
47
|
+
'name': name,
|
|
48
|
+
'iat': datetime.now(UTC)
|
|
49
|
+
};
|
|
50
|
+
# Add expiry if specified
|
|
51
|
+
if expiry_days is not None {
|
|
52
|
+
payload['exp'] = datetime.now(UTC) + timedelta(days=expiry_days);
|
|
53
|
+
} elif default_expiry > 0 {
|
|
54
|
+
payload['exp'] = datetime.now(UTC) + timedelta(days=default_expiry);
|
|
55
|
+
}
|
|
56
|
+
# If expiry_days is 0 or negative, no expiry is set (permanent key)
|
|
57
|
+
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
"""Validate an API key token and extract user information."""
|
|
61
|
+
impl WebhookUtils.validate_api_key(api_key: str) -> dict[str, str] | None {
|
|
62
|
+
try {
|
|
63
|
+
payload = jwt.decode(api_key, JWT_SECRET, algorithms=[JWT_ALGORITHM]);
|
|
64
|
+
|
|
65
|
+
# Verify this is an API key token
|
|
66
|
+
if payload.get('type') != 'api_key' {
|
|
67
|
+
return None;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
'username': payload.get('sub', ''),
|
|
72
|
+
'api_key_id': payload.get('api_key_id', ''),
|
|
73
|
+
'name': payload.get('name', '')
|
|
74
|
+
};
|
|
75
|
+
} except jwt.ExpiredSignatureError {
|
|
76
|
+
return None;
|
|
77
|
+
} except jwt.InvalidTokenError {
|
|
78
|
+
return None;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
"""Extract signature from request header value."""
|
|
83
|
+
impl WebhookUtils.extract_signature(header_value: str) -> str {
|
|
84
|
+
# Handle prefixed signatures like "sha256=abc123..."
|
|
85
|
+
if '=' in header_value {
|
|
86
|
+
parts = header_value.split('=', 1);
|
|
87
|
+
if len(parts) == 2 {
|
|
88
|
+
return parts[1];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return header_value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
"""Create a new API key for a user."""
|
|
95
|
+
impl ApiKeyManager.create_api_key(
|
|
96
|
+
username: str, name: str, expiry_days: int | None = None
|
|
97
|
+
) -> TransportResponse {
|
|
98
|
+
# Generate unique API key ID
|
|
99
|
+
api_key_id = secrets.token_hex(16);
|
|
100
|
+
# Create the JWT-wrapped API key
|
|
101
|
+
api_key = WebhookUtils.create_api_key_token(
|
|
102
|
+
api_key_id=api_key_id, username=username, name=name, expiry_days=expiry_days
|
|
103
|
+
);
|
|
104
|
+
created_at = datetime.now(UTC);
|
|
105
|
+
expires_at: datetime | None = None;
|
|
106
|
+
_webhook_config = get_scale_config().get_webhook_config();
|
|
107
|
+
default_expiry = _webhook_config.get('api_key_expiry_days', 365);
|
|
108
|
+
if expiry_days is not None and expiry_days > 0 {
|
|
109
|
+
expires_at = created_at + timedelta(days=expiry_days);
|
|
110
|
+
} elif default_expiry > 0 {
|
|
111
|
+
expires_at = created_at + timedelta(days=default_expiry);
|
|
112
|
+
}
|
|
113
|
+
# Store key metadata (not the key itself for security)
|
|
114
|
+
self._api_keys[api_key_id] = {
|
|
115
|
+
'username': username,
|
|
116
|
+
'name': name,
|
|
117
|
+
'created_at': created_at.isoformat(),
|
|
118
|
+
'expires_at': expires_at.isoformat() if expires_at else None,
|
|
119
|
+
'revoked': False
|
|
120
|
+
};
|
|
121
|
+
# Track keys by user
|
|
122
|
+
if username not in self._user_keys {
|
|
123
|
+
self._user_keys[username] = [];
|
|
124
|
+
}
|
|
125
|
+
self._user_keys[username].append(api_key_id);
|
|
126
|
+
return TransportResponse.success(
|
|
127
|
+
data={
|
|
128
|
+
'api_key': api_key,
|
|
129
|
+
'api_key_id': api_key_id,
|
|
130
|
+
'name': name,
|
|
131
|
+
'created_at': created_at.isoformat(),
|
|
132
|
+
'expires_at': expires_at.isoformat() if expires_at else None
|
|
133
|
+
},
|
|
134
|
+
meta=Meta(extra={'http_status': 201})
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
"""List all API keys for a user (metadata only, not the keys)."""
|
|
139
|
+
impl ApiKeyManager.list_api_keys(username: str) -> TransportResponse {
|
|
140
|
+
user_key_ids = self._user_keys.get(username, []);
|
|
141
|
+
keys: list[dict[str, Any]] = [];
|
|
142
|
+
for key_id in user_key_ids {
|
|
143
|
+
key_info = self._api_keys.get(key_id);
|
|
144
|
+
if key_info and not key_info.get('revoked', False) {
|
|
145
|
+
keys.append(
|
|
146
|
+
{
|
|
147
|
+
'api_key_id': key_id,
|
|
148
|
+
'name': key_info.get('name', ''),
|
|
149
|
+
'created_at': key_info.get('created_at'),
|
|
150
|
+
'expires_at': key_info.get('expires_at'),
|
|
151
|
+
'active': True
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return TransportResponse.success(
|
|
157
|
+
data={'api_keys': keys}, meta=Meta(extra={'http_status': 200})
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
"""Revoke an API key."""
|
|
162
|
+
impl ApiKeyManager.revoke_api_key(username: str, api_key_id: str) -> TransportResponse {
|
|
163
|
+
# Check if key exists
|
|
164
|
+
if api_key_id not in self._api_keys {
|
|
165
|
+
return TransportResponse.fail(
|
|
166
|
+
code='NOT_FOUND',
|
|
167
|
+
message=f"API key '{api_key_id}' not found",
|
|
168
|
+
meta=Meta(extra={'http_status': 404})
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
# Check if key belongs to user
|
|
172
|
+
key_info = self._api_keys[api_key_id];
|
|
173
|
+
if key_info.get('username') != username {
|
|
174
|
+
return TransportResponse.fail(
|
|
175
|
+
code='FORBIDDEN',
|
|
176
|
+
message='Cannot revoke API key owned by another user',
|
|
177
|
+
meta=Meta(extra={'http_status': 403})
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
# Mark as revoked
|
|
181
|
+
self._api_keys[api_key_id]['revoked'] = True;
|
|
182
|
+
return TransportResponse.success(
|
|
183
|
+
data={'message': f"API key '{api_key_id}' has been revoked"},
|
|
184
|
+
meta=Meta(extra={'http_status': 200})
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
"""Validate an API key and return the associated username."""
|
|
189
|
+
impl ApiKeyManager.validate_api_key(api_key: str) -> str | None {
|
|
190
|
+
# Decode the JWT to get key info
|
|
191
|
+
key_info = WebhookUtils.validate_api_key(api_key);
|
|
192
|
+
if not key_info {
|
|
193
|
+
return None;
|
|
194
|
+
}
|
|
195
|
+
api_key_id = key_info.get('api_key_id', '');
|
|
196
|
+
# Check if key is still active (not revoked)
|
|
197
|
+
if not self.is_key_active(api_key_id) {
|
|
198
|
+
return None;
|
|
199
|
+
}
|
|
200
|
+
return key_info.get('username');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
"""Check if an API key ID exists and is not revoked."""
|
|
204
|
+
impl ApiKeyManager.is_key_active(api_key_id: str) -> bool {
|
|
205
|
+
key_info = self._api_keys.get(api_key_id);
|
|
206
|
+
if not key_info {
|
|
207
|
+
# Key not in our store - could be from before server restart
|
|
208
|
+
# Allow it if JWT is valid (handled by validate_api_key)
|
|
209
|
+
return True;
|
|
210
|
+
}
|
|
211
|
+
return not key_info.get('revoked', False);
|
|
212
|
+
}
|
|
@@ -379,6 +379,10 @@ impl JFastApiServer._create_endpoint_function(
|
|
|
379
379
|
if accepts_kwargs {
|
|
380
380
|
param_strs.append('request: Request');
|
|
381
381
|
param_mapping['__request__'] = 'request';
|
|
382
|
+
# If the callback also explicitly accepts a request parameter, map it
|
|
383
|
+
if needs_request {
|
|
384
|
+
param_mapping['request'] = 'request';
|
|
385
|
+
}
|
|
382
386
|
} elif needs_request {
|
|
383
387
|
param_strs.append('request: Request');
|
|
384
388
|
param_mapping['request'] = 'request';
|
jac_scale/plugin_config.jac
CHANGED
jac_scale/serve.jac
CHANGED
|
@@ -6,7 +6,9 @@ import from pathlib { Path }
|
|
|
6
6
|
import from pydantic { BaseModel, Field }
|
|
7
7
|
import from typing { Any }
|
|
8
8
|
import jwt;
|
|
9
|
+
import json;
|
|
9
10
|
import from os { getenv }
|
|
11
|
+
import from fastapi { Request }
|
|
10
12
|
import from fastapi.middleware.cors { CORSMiddleware }
|
|
11
13
|
import from fastapi.responses { HTMLResponse, JSONResponse, Response, RedirectResponse }
|
|
12
14
|
import from jac_scale.jserver.jfast_api { JFastApiServer }
|
|
@@ -24,6 +26,7 @@ import from enum { StrEnum }
|
|
|
24
26
|
import from fastapi_sso.sso.google { GoogleSSO }
|
|
25
27
|
import from jac_scale.utils { generate_random_password }
|
|
26
28
|
import from jac_scale.config_loader { get_scale_config }
|
|
29
|
+
import from jac_scale.webhook { ApiKeyManager, WebhookUtils }
|
|
27
30
|
import from typing { AsyncGenerator }
|
|
28
31
|
import from inspect { isgenerator }
|
|
29
32
|
import from fastapi.responses { StreamingResponse }
|
|
@@ -41,10 +44,13 @@ enum Platforms ( StrEnum ) { GOOGLE = 'google' }
|
|
|
41
44
|
|
|
42
45
|
enum Operations ( StrEnum ) { LOGIN = 'login', REGISTER = 'register' }
|
|
43
46
|
|
|
47
|
+
enum TransportType ( StrEnum ) { HTTP = 'http', WEBHOOK = 'webhook' }
|
|
48
|
+
|
|
44
49
|
obj JacAPIServer(JServer) {
|
|
45
50
|
# HMR (Hot Module Replacement) support fields
|
|
46
51
|
has _hmr_pending: bool = False,
|
|
47
|
-
_hot_reloader: Any | None = None
|
|
52
|
+
_hot_reloader: Any | None = None,
|
|
53
|
+
_api_key_manager: ApiKeyManager | None = None;
|
|
48
54
|
|
|
49
55
|
def postinit -> None;
|
|
50
56
|
def login(username: str, password: str) -> TransportResponse;
|
|
@@ -72,12 +78,15 @@ obj JacAPIServer(JServer) {
|
|
|
72
78
|
) -> Callable[..., TransportResponse];
|
|
73
79
|
|
|
74
80
|
def create_walker_parameters(
|
|
75
|
-
walker_name: str, invoke_on_root: bool
|
|
81
|
+
walker_name: str, invoke_on_root: bool, method: HTTPMethod = HTTPMethod.POST
|
|
76
82
|
) -> list[APIParameter];
|
|
77
83
|
|
|
78
84
|
def register_walkers_endpoints -> None;
|
|
79
85
|
def create_function_callback(func_name: str) -> Callable[..., TransportResponse];
|
|
80
|
-
def create_function_parameters(
|
|
86
|
+
def create_function_parameters(
|
|
87
|
+
func_name: str, method: HTTPMethod = HTTPMethod.POST
|
|
88
|
+
) -> list[APIParameter];
|
|
89
|
+
|
|
81
90
|
def register_functions_endpoints -> None;
|
|
82
91
|
def render_page_callback -> Callable[..., HTMLResponse];
|
|
83
92
|
def render_base_route_callback(app_name: str) -> Callable[..., HTMLResponse];
|
|
@@ -89,12 +98,29 @@ obj JacAPIServer(JServer) {
|
|
|
89
98
|
def register_root_asset_endpoint -> None;
|
|
90
99
|
def serve_root_asset(file_path: str) -> Response;
|
|
91
100
|
def _configure_openapi_security -> None;
|
|
92
|
-
def start(dev: bool = False) -> None;
|
|
101
|
+
def start(dev: bool = False, no_client: bool = False) -> None;
|
|
93
102
|
# HMR (Hot Module Replacement) dynamic routing methods
|
|
94
103
|
def enable_hmr(hot_reloader: Any) -> None;
|
|
95
104
|
def register_dynamic_walker_endpoint -> None;
|
|
96
105
|
def register_dynamic_function_endpoint -> None;
|
|
97
106
|
def register_dynamic_introspection_endpoints -> None;
|
|
107
|
+
# Webhook support methods
|
|
108
|
+
def get_api_key_manager -> ApiKeyManager;
|
|
109
|
+
def create_api_key(
|
|
110
|
+
name: str, expiry_days: int | None = None, Authorization: str | None = None
|
|
111
|
+
) -> TransportResponse;
|
|
112
|
+
|
|
113
|
+
def list_api_keys(Authorization: str | None = None) -> TransportResponse;
|
|
114
|
+
def revoke_api_key(
|
|
115
|
+
api_key_id: str, Authorization: str | None = None
|
|
116
|
+
) -> TransportResponse;
|
|
117
|
+
|
|
118
|
+
def register_api_key_endpoints -> None;
|
|
119
|
+
def register_webhook_endpoints -> None;
|
|
120
|
+
def create_webhook_callback(walker_name: str) -> Callable[..., TransportResponse];
|
|
121
|
+
def create_webhook_parameters(walker_name: str) -> list[APIParameter];
|
|
122
|
+
def register_dynamic_webhook_endpoint -> None;
|
|
123
|
+
def get_transport_type_for_walker(walker_name: str) -> str;
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
class UpdateUsernameRequest(BaseModel) {
|
|
@@ -186,3 +186,63 @@ walker : pub PublicFileUpload {
|
|
|
186
186
|
};
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
# ============================================================================
|
|
191
|
+
# Webhook Walkers
|
|
192
|
+
# ============================================================================
|
|
193
|
+
"""Test 1: Webhook walker with multiple fields using @restspec(webhook=True).
|
|
194
|
+
This walker should be accessible ONLY via /webhook/PaymentReceived endpoint."""
|
|
195
|
+
@restspec(webhook=True)
|
|
196
|
+
walker PaymentReceived {
|
|
197
|
+
has payment_id: str,
|
|
198
|
+
order_id: str,
|
|
199
|
+
amount: float,
|
|
200
|
+
currency: str = 'USD';
|
|
201
|
+
|
|
202
|
+
can process with `root entry {
|
|
203
|
+
response = {
|
|
204
|
+
"status": "success",
|
|
205
|
+
"message": f"Payment {self.payment_id} processed successfully.",
|
|
206
|
+
"payment_id": self.payment_id,
|
|
207
|
+
"order_id": self.order_id,
|
|
208
|
+
"amount": self.amount,
|
|
209
|
+
"currency": self.currency
|
|
210
|
+
};
|
|
211
|
+
report response;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
"""Test 2: Normal walker without @restspec(webhook=True) (defaults to HTTP).
|
|
216
|
+
This walker should be accessible via /walker/NormalPayment endpoint, NOT /webhook/."""
|
|
217
|
+
walker NormalPayment {
|
|
218
|
+
has payment_id: str,
|
|
219
|
+
order_id: str,
|
|
220
|
+
amount: float,
|
|
221
|
+
currency: str = 'USD';
|
|
222
|
+
|
|
223
|
+
can process with `root entry {
|
|
224
|
+
response = {
|
|
225
|
+
"status": "success",
|
|
226
|
+
"message": f"Normal payment {self.payment_id} processed.",
|
|
227
|
+
"payment_id": self.payment_id,
|
|
228
|
+
"order_id": self.order_id,
|
|
229
|
+
"amount": self.amount,
|
|
230
|
+
"currency": self.currency,
|
|
231
|
+
"transport": "http"
|
|
232
|
+
};
|
|
233
|
+
report response;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
"""Test 3: Minimal webhook walker with ONLY @restspec(webhook=True).
|
|
238
|
+
This walker should be accessible ONLY via /webhook/MinimalWebhook endpoint."""
|
|
239
|
+
@restspec(webhook=True)
|
|
240
|
+
walker MinimalWebhook {
|
|
241
|
+
can process with `root entry {
|
|
242
|
+
report {
|
|
243
|
+
"status": "received",
|
|
244
|
+
"message": "Minimal webhook executed successfully",
|
|
245
|
+
"transport": "webhook"
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -35,3 +35,54 @@ def get_func() -> dict {
|
|
|
35
35
|
def custom_path_func() -> dict {
|
|
36
36
|
return {"message": "custom_path_func executed", "path": "/custom/func"};
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
"""Walker with POST method via restspec decorator."""
|
|
40
|
+
@restspec(method=HTTPMethod.POST)
|
|
41
|
+
walker : pub PostWalker {
|
|
42
|
+
can post_data with `root entry {
|
|
43
|
+
report {"message": "PostWalker executed", "method": "POST"};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
"""Walker with default method (POST)."""
|
|
48
|
+
walker : pub DefaultWalker {
|
|
49
|
+
can post_data with `root entry {
|
|
50
|
+
report {"message": "DefaultWalker executed", "method": "DEFAULT"};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
"""Function with POST method via restspec decorator."""
|
|
55
|
+
@restspec(method=HTTPMethod.POST)
|
|
56
|
+
def post_func() -> dict {
|
|
57
|
+
return {"message": "post_func executed", "method": "POST"};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
"""Function with default method (POST)."""
|
|
61
|
+
def default_func() -> dict {
|
|
62
|
+
return {"message": "default_func executed", "method": "DEFAULT"};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
"""Walker with GET method and parameters."""
|
|
66
|
+
@restspec(method=HTTPMethod.GET)
|
|
67
|
+
walker : pub GetWalkerWithParams {
|
|
68
|
+
has name: str;
|
|
69
|
+
has age: int;
|
|
70
|
+
|
|
71
|
+
can get_data with `root entry {
|
|
72
|
+
report {
|
|
73
|
+
"message": "GetWalkerWithParams executed",
|
|
74
|
+
"name": self.name,
|
|
75
|
+
"age": self.age
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
"""Function with GET method and parameters."""
|
|
81
|
+
@restspec(method=HTTPMethod.GET)
|
|
82
|
+
def get_func_with_params(name: str, age: int) -> dict {
|
|
83
|
+
return {
|
|
84
|
+
"message": "get_func_with_params executed",
|
|
85
|
+
"name": name,
|
|
86
|
+
"age": age
|
|
87
|
+
};
|
|
88
|
+
}
|
jac_scale/tests/test_restspec.py
CHANGED
|
@@ -139,6 +139,22 @@ class TestRestSpec:
|
|
|
139
139
|
assert data["reports"][0]["message"] == "CustomPathWalker executed"
|
|
140
140
|
assert data["reports"][0]["path"] == "/custom/walker"
|
|
141
141
|
|
|
142
|
+
def test_post_method_walker(self) -> None:
|
|
143
|
+
"""Test walker with explicit POST method."""
|
|
144
|
+
response = requests.post(f"{self.base_url}/walker/PostWalker", timeout=5)
|
|
145
|
+
assert response.status_code == 200
|
|
146
|
+
data = self._extract_data(response.json())
|
|
147
|
+
assert data["reports"][0]["message"] == "PostWalker executed"
|
|
148
|
+
assert data["reports"][0]["method"] == "POST"
|
|
149
|
+
|
|
150
|
+
def test_default_method_walker(self) -> None:
|
|
151
|
+
"""Test walker with default method (POST)."""
|
|
152
|
+
response = requests.post(f"{self.base_url}/walker/DefaultWalker", timeout=5)
|
|
153
|
+
assert response.status_code == 200
|
|
154
|
+
data = self._extract_data(response.json())
|
|
155
|
+
assert data["reports"][0]["message"] == "DefaultWalker executed"
|
|
156
|
+
assert data["reports"][0]["method"] == "DEFAULT"
|
|
157
|
+
|
|
142
158
|
def test_custom_method_func(self) -> None:
|
|
143
159
|
"""Test function with custom GET method."""
|
|
144
160
|
requests.post(
|
|
@@ -176,6 +192,79 @@ class TestRestSpec:
|
|
|
176
192
|
assert data["result"]["message"] == "custom_path_func executed"
|
|
177
193
|
assert data["result"]["path"] == "/custom/func"
|
|
178
194
|
|
|
195
|
+
def test_post_method_func(self) -> None:
|
|
196
|
+
"""Test function with explicit POST method."""
|
|
197
|
+
# Use existing user token if possible, but simplest is fresh login
|
|
198
|
+
login = requests.post(
|
|
199
|
+
f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
|
|
200
|
+
)
|
|
201
|
+
token = self._extract_data(login.json())["token"]
|
|
202
|
+
|
|
203
|
+
response = requests.post(
|
|
204
|
+
f"{self.base_url}/function/post_func",
|
|
205
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
206
|
+
timeout=5,
|
|
207
|
+
)
|
|
208
|
+
assert response.status_code == 200
|
|
209
|
+
data = self._extract_data(response.json())
|
|
210
|
+
assert data["result"]["message"] == "post_func executed"
|
|
211
|
+
assert data["result"]["method"] == "POST"
|
|
212
|
+
|
|
213
|
+
def test_default_method_func(self) -> None:
|
|
214
|
+
"""Test function with default method (POST)."""
|
|
215
|
+
# Use existing user token if possible, but simplest is fresh login
|
|
216
|
+
login = requests.post(
|
|
217
|
+
f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
|
|
218
|
+
)
|
|
219
|
+
token = self._extract_data(login.json())["token"]
|
|
220
|
+
|
|
221
|
+
response = requests.post(
|
|
222
|
+
f"{self.base_url}/function/default_func",
|
|
223
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
224
|
+
timeout=5,
|
|
225
|
+
)
|
|
226
|
+
assert response.status_code == 200
|
|
227
|
+
data = self._extract_data(response.json())
|
|
228
|
+
assert data["result"]["message"] == "default_func executed"
|
|
229
|
+
assert data["result"]["method"] == "DEFAULT"
|
|
230
|
+
|
|
231
|
+
def test_get_walker_with_params(self) -> None:
|
|
232
|
+
"""Test walker with GET method and query parameters."""
|
|
233
|
+
# Parameters should be passed as query string
|
|
234
|
+
params: dict[str, str | int] = {"name": "Alice", "age": 30}
|
|
235
|
+
response = requests.get(
|
|
236
|
+
f"{self.base_url}/walker/GetWalkerWithParams",
|
|
237
|
+
params=params,
|
|
238
|
+
timeout=5,
|
|
239
|
+
)
|
|
240
|
+
assert response.status_code == 200
|
|
241
|
+
data = self._extract_data(response.json())
|
|
242
|
+
assert data["reports"][0]["message"] == "GetWalkerWithParams executed"
|
|
243
|
+
assert data["reports"][0]["name"] == "Alice"
|
|
244
|
+
assert data["reports"][0]["age"] == 30
|
|
245
|
+
|
|
246
|
+
def test_get_func_with_params(self) -> None:
|
|
247
|
+
"""Test function with GET method and query parameters."""
|
|
248
|
+
# Use existing user token if possible, but simplest is fresh login
|
|
249
|
+
login = requests.post(
|
|
250
|
+
f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
|
|
251
|
+
)
|
|
252
|
+
token = self._extract_data(login.json())["token"]
|
|
253
|
+
|
|
254
|
+
# Parameters should be passed as query string
|
|
255
|
+
params: dict[str, str | int] = {"name": "Bob", "age": 40}
|
|
256
|
+
response = requests.get(
|
|
257
|
+
f"{self.base_url}/function/get_func_with_params",
|
|
258
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
259
|
+
params=params,
|
|
260
|
+
timeout=5,
|
|
261
|
+
)
|
|
262
|
+
assert response.status_code == 200
|
|
263
|
+
data = self._extract_data(response.json())
|
|
264
|
+
assert data["result"]["message"] == "get_func_with_params executed"
|
|
265
|
+
assert data["result"]["name"] == "Bob"
|
|
266
|
+
assert data["result"]["age"] == 40
|
|
267
|
+
|
|
179
268
|
def test_openapi_specs(self) -> None:
|
|
180
269
|
"""Verify OpenAPI documentation reflects custom paths and methods."""
|
|
181
270
|
spec = requests.get(f"{self.base_url}/openapi.json").json()
|
|
@@ -190,3 +279,11 @@ class TestRestSpec:
|
|
|
190
279
|
assert "/walker/GetWalker" in paths
|
|
191
280
|
assert "get" in paths["/walker/GetWalker"]
|
|
192
281
|
assert "post" not in paths["/walker/GetWalker"]
|
|
282
|
+
|
|
283
|
+
assert "/walker/PostWalker" in paths
|
|
284
|
+
assert "post" in paths["/walker/PostWalker"]
|
|
285
|
+
assert "get" not in paths["/walker/PostWalker"]
|
|
286
|
+
|
|
287
|
+
assert "/walker/DefaultWalker" in paths
|
|
288
|
+
assert "post" in paths["/walker/DefaultWalker"]
|
|
289
|
+
assert "get" not in paths["/walker/DefaultWalker"]
|