-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmock_server.py
More file actions
executable file
·445 lines (368 loc) · 17.6 KB
/
mock_server.py
File metadata and controls
executable file
·445 lines (368 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
#!/usr/bin/env python3
"""
Mock Protobuf Server for Charles Proxy
Serves JSON files as encoded protobuf responses dynamically
"""
import os
import sys
import json
import tempfile
import subprocess
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from typing import Optional
class ProtobufMockHandler(BaseHTTPRequestHandler):
"""HTTP request handler that encodes JSON to protobuf on-the-fly"""
# Configuration - will be set by main()
endpoints = {}
def do_GET(self):
"""Handle GET requests"""
self._handle_request()
def do_POST(self):
"""Handle POST requests"""
self._handle_request()
def _handle_request(self):
"""Main request handler - encodes JSON to protobuf and returns it"""
try:
# Match endpoint from path
endpoint_path = self.path.split('?')[0] # Remove query params
if endpoint_path not in self.endpoints:
available = list(self.endpoints.keys())
self._send_error(404, f"Endpoint not found: {endpoint_path}\nAvailable: {available}")
return
# Get configuration for this endpoint
config = self.endpoints[endpoint_path]
json_file = config['json_file']
proto_file = config['proto_file']
message_type = config['message_type']
proto_root = config.get('proto_root') # Optional root for imports
# Log request
print(f"\n📥 Incoming request: {self.command} {self.path}")
print(f" Endpoint: {endpoint_path}")
print(f" JSON: {json_file}")
print(f" Proto: {proto_file}")
print(f" Message: {message_type}")
if proto_root:
print(f" Proto Root: {proto_root}")
# Read and validate JSON
if not os.path.exists(json_file):
self._send_error(404, f"JSON file not found: {json_file}")
return
with open(json_file, 'r') as f:
json_data = json.load(f)
print(f" ✓ JSON loaded: {len(json.dumps(json_data))} bytes")
# Encode to protobuf
proto_data = self._encode_to_protobuf(json_data, proto_file, message_type, proto_root)
if proto_data is None:
self._send_error(500, "Failed to encode protobuf")
return
# Send response
self.send_response(200)
self.send_header('Content-Type', 'application/x-protobuf')
self.send_header('Content-Length', str(len(proto_data)))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(proto_data)
print(f" ✓ Response sent: {len(proto_data)} bytes (protobuf)")
print(f" 📤 Status: 200 OK\n")
except json.JSONDecodeError as e:
self._send_error(400, f"Invalid JSON: {str(e)}")
except Exception as e:
self._send_error(500, f"Server error: {str(e)}")
def _encode_to_protobuf(self, json_data: dict, proto_file: str, message_type: str, proto_root: Optional[str] = None) -> Optional[bytes]:
"""Encode JSON data to protobuf using protoc"""
try:
# Create temporary directory for compilation
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Write JSON to temporary file
json_temp = temp_path / "input.json"
with open(json_temp, 'w') as f:
json.dump(json_data, f)
# Compile .proto file
proto_path = Path(proto_file).absolute()
# Use proto_root if provided, otherwise use parent directory
if proto_root:
proto_include_dir = Path(proto_root).absolute()
else:
proto_include_dir = proto_path.parent
# Find all proto files that need to be compiled (dependencies)
proto_files_to_compile = [proto_path]
if proto_root:
# Parse imports and add them
imports = self._find_proto_imports(proto_path, proto_include_dir)
proto_files_to_compile.extend(imports)
# Deduplicate proto files
proto_files_to_compile = list(dict.fromkeys(proto_files_to_compile))
compile_cmd = [
'protoc',
f'--python_out={temp_dir}',
f'-I{proto_include_dir}',
] + [str(p) for p in proto_files_to_compile]
result = subprocess.run(
compile_cmd,
capture_output=True,
text=True
)
if result.returncode != 0:
print(f" ✗ protoc compilation failed: {result.stderr}")
return None
# Import the generated module
# Calculate the relative path from proto_root to get the module path
if proto_root:
proto_include_dir = Path(proto_root).absolute()
relative_proto = proto_path.relative_to(proto_include_dir)
# Convert path to module name: proto/external/sc/api.proto -> proto.external.sc.api_pb2
module_parts = list(relative_proto.parts[:-1]) + [relative_proto.stem + '_pb2']
proto_module_name = '.'.join(module_parts)
else:
proto_module_name = proto_path.stem + '_pb2'
sys.path.insert(0, temp_dir)
try:
proto_module = __import__(proto_module_name, fromlist=[''])
except ImportError as e:
print(f" ✗ Failed to import generated module: {e}")
print(f" Module name tried: {proto_module_name}")
return None
# Get the message class
try:
message_class = getattr(proto_module, message_type)
except AttributeError:
available = [name for name in dir(proto_module) if not name.startswith('_')]
print(f" ✗ Message type '{message_type}' not found")
print(f" Available types: {available}")
return None
# Create and populate message
message = message_class()
self._populate_message(message, json_data)
# Serialize to binary
proto_bytes = message.SerializeToString()
# Clean up sys.path
sys.path.remove(temp_dir)
return proto_bytes
except Exception as e:
print(f" ✗ Encoding error: {str(e)}")
return None
def _populate_message(self, message, data):
"""Recursively populate protobuf message from JSON data"""
from google.protobuf import struct_pb2
from google.protobuf.descriptor import FieldDescriptor
if not isinstance(data, dict):
return
for key, value in data.items():
# Handle field name variations (e.g., header_ -> header)
actual_key = key
if not hasattr(message, key):
# Try without trailing underscore
if key.endswith('_'):
alternate_key = key[:-1]
if hasattr(message, alternate_key):
actual_key = alternate_key
else:
continue
else:
continue
# Get field descriptor to check the type
try:
field = message.DESCRIPTOR.fields_by_name.get(actual_key)
if field is None:
continue
# Check if this is a google.protobuf.Struct field
if field.message_type and field.message_type.full_name == 'google.protobuf.Struct':
if isinstance(value, dict):
struct_field = getattr(message, actual_key)
# Use ParseDict for google.protobuf.Struct
from google.protobuf.json_format import ParseDict
ParseDict(value, struct_field)
continue
# Check if this is a map field with Struct values
if field.message_type and field.message_type.GetOptions().map_entry:
# This is a map field
map_field = getattr(message, actual_key)
if isinstance(value, dict):
for map_key, map_value in value.items():
# Check if map value is Struct
value_field = field.message_type.fields_by_name.get('value')
if value_field and value_field.message_type and value_field.message_type.full_name == 'google.protobuf.Struct':
# Map value is Struct, parse specially
from google.protobuf.json_format import ParseDict
ParseDict(map_value, map_field[map_key])
elif isinstance(map_value, dict):
# Regular nested message in map
nested = map_field[map_key]
self._populate_message(nested, map_value)
else:
# Simple value in map
map_field[map_key] = map_value
continue
except Exception as e:
# If we can't get field info, fall back to old behavior
pass
if isinstance(value, dict):
# Nested message
nested = getattr(message, actual_key)
self._populate_message(nested, value)
elif isinstance(value, list):
# Repeated field
field = getattr(message, actual_key)
for item in value:
if isinstance(item, dict):
nested = field.add()
self._populate_message(nested, item)
else:
field.append(item)
else:
# Simple field
try:
setattr(message, actual_key, value)
except (TypeError, ValueError):
# Try to handle type mismatches
if isinstance(value, str) and value.isdigit():
setattr(message, actual_key, int(value))
elif isinstance(value, (int, float)):
setattr(message, actual_key, value)
def _find_proto_imports(self, proto_file: Path, proto_root: Path) -> list:
"""Find all proto files imported by this proto file (recursively)"""
imports = []
seen = set()
def parse_imports(pfile: Path):
if pfile in seen:
return
seen.add(pfile)
try:
with open(pfile, 'r') as f:
for line in f:
line = line.strip()
if line.startswith('import '):
# Extract import path: import "proto/path/file.proto";
import_match = line.split('"')
if len(import_match) >= 2:
import_path = import_match[1]
full_import_path = proto_root / import_path
if full_import_path.exists():
imports.append(full_import_path)
# Recursively parse this import
parse_imports(full_import_path)
except Exception as e:
print(f" ⚠️ Warning: Could not parse imports from {pfile}: {e}")
parse_imports(proto_file)
return imports
def _send_error(self, code: int, message: str):
"""Send error response"""
print(f" ✗ Error {code}: {message}\n")
self.send_response(code)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(message.encode())
def log_message(self, format, *args):
"""Override to suppress default logging"""
pass
def load_endpoints_config(config_file='endpoints.json'):
"""Load endpoints configuration from JSON file"""
script_dir = Path(__file__).parent
config_path = script_dir / config_file
if not config_path.exists():
print(f"❌ Configuration file not found: {config_path}")
print(f"💡 Create an 'endpoints.json' file with your endpoint configuration")
sys.exit(1)
try:
with open(config_path, 'r') as f:
config = json.load(f)
# Convert list format to dict format for easier lookup
endpoints_dict = {}
for endpoint in config.get('endpoints', []):
path = endpoint.get('path')
if not path:
continue
# Resolve relative paths to absolute
json_file = endpoint.get('json_file', '')
if json_file and not os.path.isabs(json_file):
json_file = str(script_dir / json_file)
endpoints_dict[path] = {
'json_file': json_file,
'proto_file': endpoint.get('proto_file', ''),
'message_type': endpoint.get('message_type', ''),
'proto_root': endpoint.get('proto_root')
}
return endpoints_dict
except json.JSONDecodeError as e:
print(f"❌ Invalid JSON in config file: {e}")
sys.exit(1)
except Exception as e:
print(f"❌ Error loading config: {e}")
sys.exit(1)
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(
description='Mock Protobuf Server for Charles Proxy - Multiple Endpoints',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
# Start server with default config (endpoints.json)
python3 mock_server.py
# Custom port
python3 mock_server.py -P 8081
# Custom config file
python3 mock_server.py -c my_endpoints.json
# Then in Charles Proxy Map Remote:
# Map From: https://api.yourapp.com/getProfile
# Map To: http://localhost:8080/getProfile
'''
)
parser.add_argument('-P', '--port', type=int, default=8080,
help='Port to run server on (default: 8080)')
parser.add_argument('--host', default='localhost',
help='Host to bind to (default: localhost)')
parser.add_argument('-c', '--config', default='endpoints.json',
help='Path to endpoints config file (default: endpoints.json)')
args = parser.parse_args()
# Load endpoints from config file
ENDPOINTS = load_endpoints_config(args.config)
# Validate all endpoint files exist
errors = []
for endpoint, config in ENDPOINTS.items():
json_file = os.path.abspath(config['json_file'])
proto_file = os.path.abspath(config['proto_file'])
if not os.path.exists(json_file):
errors.append(f" ✗ {endpoint}: JSON file not found: {json_file}")
if not os.path.exists(proto_file):
errors.append(f" ✗ {endpoint}: Proto file not found: {proto_file}")
# Update with absolute paths
config['json_file'] = json_file
config['proto_file'] = proto_file
if errors:
print("❌ Configuration errors:\n")
for error in errors:
print(error)
print("\n💡 Edit the ENDPOINTS dict in the script to configure your endpoints")
sys.exit(1)
# Set configuration
ProtobufMockHandler.endpoints = ENDPOINTS
# Start server
server_address = (args.host, args.port)
httpd = HTTPServer(server_address, ProtobufMockHandler)
print("=" * 70)
print("🚀 Mock Protobuf Server Started")
print("=" * 70)
print(f"📍 Server: http://{args.host}:{args.port}")
print(f"\n📋 Configured Endpoints:")
for endpoint, config in ENDPOINTS.items():
print(f"\n {endpoint}")
print(f" JSON: {os.path.basename(config['json_file'])}")
print(f" Proto: {os.path.basename(config['proto_file'])}")
print(f" Message: {config['message_type']}")
print("\n" + "=" * 70)
print("\n✅ Ready to serve protobuf responses!")
print(f"\n💡 In Charles Proxy Map Remote:")
print(f" Map From: https://api.yourapp.com/getProfile")
print(f" Map To: http://{args.host}:{args.port}/getProfile")
print(f"\n⏸️ Press Ctrl+C to stop\n")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n\n👋 Shutting down server...")
httpd.shutdown()
print("✅ Server stopped\n")
if __name__ == '__main__':
main()