kumudraj01 commited on
Commit
2d9b352
·
1 Parent(s): c888f0a

Add application file

Browse files
Files changed (45) hide show
  1. Dockerfile +34 -0
  2. LICENSE +23 -0
  3. Makefile +87 -0
  4. main.py +427 -0
  5. pytest.ini +35 -0
  6. requirements.txt +23 -0
  7. run.py +26 -0
  8. static/css/styles.css +236 -0
  9. static/images/TD-logo.jpeg +0 -0
  10. templates/compare.html +481 -0
  11. templates/error.html +80 -0
  12. templates/login.html +57 -0
  13. templates/results.html +343 -0
  14. templates/session_timeout.html +60 -0
  15. templates/unauthorized.html +60 -0
  16. tests/__pycache__/conftest.cpython-312-pytest-7.4.3.pyc +0 -0
  17. tests/__pycache__/conftest.cpython-312-pytest-8.0.0.pyc +0 -0
  18. tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc +0 -0
  19. tests/__pycache__/test_api_comparison.cpython-312-pytest-7.4.3.pyc +0 -0
  20. tests/__pycache__/test_api_comparison.cpython-312-pytest-8.0.0.pyc +0 -0
  21. tests/__pycache__/test_api_comparison.cpython-312-pytest-8.3.5.pyc +0 -0
  22. tests/__pycache__/test_auth.cpython-312-pytest-7.4.3.pyc +0 -0
  23. tests/__pycache__/test_auth.cpython-312-pytest-8.0.0.pyc +0 -0
  24. tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc +0 -0
  25. tests/__pycache__/test_frontend.cpython-312-pytest-7.4.3.pyc +0 -0
  26. tests/__pycache__/test_frontend.cpython-312-pytest-8.0.0.pyc +0 -0
  27. tests/__pycache__/test_frontend.cpython-312-pytest-8.3.5.pyc +0 -0
  28. tests/__pycache__/test_utility.cpython-312-pytest-7.4.3.pyc +0 -0
  29. tests/__pycache__/test_utility.cpython-312-pytest-8.0.0.pyc +0 -0
  30. tests/__pycache__/test_utility.cpython-312-pytest-8.3.5.pyc +0 -0
  31. tests/__pycache__/test_websocket.cpython-312-pytest-7.4.3.pyc +0 -0
  32. tests/__pycache__/test_websocket.cpython-312-pytest-8.3.5.pyc +0 -0
  33. tests/conftest.py +65 -0
  34. tests/test_api_comparison.py +128 -0
  35. tests/test_auth.py +70 -0
  36. tests/test_frontend.py +101 -0
  37. tests/test_utility.py +143 -0
  38. utils/__pycache__/api_utils.cpython-312.pyc +0 -0
  39. utils/__pycache__/logger.cpython-312.pyc +0 -0
  40. utils/__pycache__/utility.cpython-312.pyc +0 -0
  41. utils/api_comparator.py +284 -0
  42. utils/api_utils.py +36 -0
  43. utils/logger.py +52 -0
  44. utils/middleware.py +22 -0
  45. utils/utility.py +276 -0
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.12-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONUNBUFFERED=1 \
6
+ PYTHONDONTWRITEBYTECODE=1
7
+
8
+ # Set work directory
9
+ WORKDIR /app
10
+
11
+ # Install system dependencies
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ build-essential \
14
+ curl \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Copy requirements file
18
+ COPY requirements.txt .
19
+
20
+ # Install Python dependencies
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
+
23
+ # Copy project files
24
+ COPY . .
25
+
26
+ # Expose the port
27
+ EXPOSE 5001
28
+
29
+ # Set healthcheck
30
+ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
31
+ CMD curl -f http://localhost:5001/ || exit 1
32
+
33
+ # Run the application
34
+ CMD ["python", "run.py"]
LICENSE ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright (c) 2025 Kumud Raj, TellusDigital. All rights reserved.
2
+
3
+ CONFIDENTIAL AND PROPRIETARY INFORMATION
4
+
5
+ This software and its accompanying documentation are the confidential and proprietary
6
+ information of TellusDigital ("Company"). This software is provided for internal use
7
+ within TellusDigital only and may not be used, copied, modified, merged, published,
8
+ distributed, sublicensed, or sold without explicit written permission from the Company.
9
+
10
+ Terms of Use:
11
+
12
+ 1. This software is restricted to internal use within TellusDigital only.
13
+ 2. No part of this software may be reproduced, distributed, or transmitted in any form
14
+ or by any means without the prior written permission of TellusDigital.
15
+ 3. Unauthorized copying or distribution of this software, via any medium, is strictly
16
+ prohibited.
17
+ 4. The software is provided "AS IS", without warranty of any kind, express or implied.
18
+
19
+ For permission requests, please contact:
20
+ TellusDigital
21
+ [kumud.raj01@telusinternational.com]
22
+
23
+ This license supersedes any prior agreements or licenses.
Makefile ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker image and container configuration
2
+ IMAGE_NAME = api-comparator
3
+ CONTAINER_NAME = api-comparator-app
4
+ PORT = 5001
5
+
6
+ # Colors for terminal output
7
+ CYAN = \033[0;36m
8
+ RESET = \033[0m
9
+
10
+ .PHONY: help build run stop remove clean logs restart ps test lint coverage
11
+
12
+ # Display help information
13
+ help:
14
+ @echo "$(CYAN)Available commands:$(RESET)"
15
+ @echo " make build - Build Docker image"
16
+ @echo " make run - Run container (builds if image doesn't exist)"
17
+ @echo " make stop - Stop running container"
18
+ @echo " make remove - Remove container"
19
+ @echo " make clean - Remove container and image"
20
+ @echo " make logs - View container logs"
21
+ @echo " make restart - Restart container"
22
+ @echo " make ps - Show container status"
23
+ @echo " make test - Run all tests"
24
+ @echo " make coverage - Run tests with coverage report"
25
+
26
+ # Build Docker image
27
+ build:
28
+ @echo "$(CYAN)Building Docker image...$(RESET)"
29
+ docker build -t $(IMAGE_NAME) .
30
+
31
+ # Run container
32
+ run:
33
+ @echo "$(CYAN)Checking for existing container...$(RESET)"
34
+ @if [ "$$(docker ps -aq -f name=$(CONTAINER_NAME))" ]; then \
35
+ echo "$(CYAN)Removing existing container...$(RESET)"; \
36
+ docker rm -f $(CONTAINER_NAME) || true; \
37
+ fi
38
+ @if [ "$$(docker images -q $(IMAGE_NAME) 2> /dev/null)" = "" ]; then \
39
+ echo "$(CYAN)Image not found, building first...$(RESET)"; \
40
+ make build; \
41
+ fi
42
+ @echo "$(CYAN)Starting new container...$(RESET)"
43
+ docker run -d \
44
+ --name $(CONTAINER_NAME) \
45
+ -p $(PORT):$(PORT) \
46
+ --restart unless-stopped \
47
+ $(IMAGE_NAME)
48
+ @echo "$(CYAN)Container started! Access the application at http://localhost:$(PORT)$(RESET)"
49
+
50
+
51
+ # Stop container
52
+ stop:
53
+ @echo "$(CYAN)Stopping container...$(RESET)"
54
+ @docker stop $(CONTAINER_NAME) 2>/dev/null || true
55
+
56
+ # Remove container
57
+ remove: stop
58
+ @echo "$(CYAN)Removing container...$(RESET)"
59
+ @docker rm $(CONTAINER_NAME) 2>/dev/null || true
60
+
61
+ # Clean everything (container and image)
62
+ clean: remove
63
+ @echo "$(CYAN)Removing image...$(RESET)"
64
+ @docker rmi $(IMAGE_NAME) 2>/dev/null || true
65
+
66
+ # View container logs
67
+ logs:
68
+ @echo "$(CYAN)Showing container logs...$(RESET)"
69
+ @docker logs -f $(CONTAINER_NAME)
70
+
71
+ # Restart container
72
+ restart: stop run
73
+
74
+ # Show container status
75
+ ps:
76
+ @echo "$(CYAN)Container status:$(RESET)"
77
+ @docker ps -a --filter name=$(CONTAINER_NAME)
78
+
79
+ # Run tests
80
+ test:
81
+ @echo "$(CYAN)Running tests...$(RESET)"
82
+ pytest
83
+
84
+ # Run tests with coverage
85
+ coverage:
86
+ @echo "$(CYAN)Running tests with coverage...$(RESET)"
87
+ pytest --cov=. --cov-report=term-missing
main.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TellusDigital API Response Comparator - Main Application Module
3
+
4
+ This module implements the FastAPI application for comparing responses from two different APIs.
5
+ It provides authentication, WebSocket support for real-time updates, and comparison functionality.
6
+ """
7
+
8
+ import html
9
+ import json
10
+ import secrets
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ from fastapi import FastAPI, Request, Form, HTTPException, status, WebSocket, Depends
15
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
16
+ from fastapi.security import HTTPBasic
17
+ from fastapi.staticfiles import StaticFiles
18
+ from fastapi.templating import Jinja2Templates
19
+ from starlette.middleware.sessions import SessionMiddleware
20
+ from starlette.responses import JSONResponse
21
+
22
+ from utils.logger import structlog
23
+ from utils.utility import (
24
+ USER_CREDENTIALS,
25
+ parse_json_input,
26
+ fetch_api_responses,
27
+ compare_responses,
28
+ json_line_diff,
29
+ validate_urls,
30
+ validate_json_inputs
31
+ )
32
+ from utils.middleware import SmartSchemeMiddleware
33
+
34
+ log = structlog.get_logger()
35
+
36
+
37
+ class CustomJSONEncoder(json.JSONEncoder):
38
+ """Custom JSON encoder that handles DeepDiff types."""
39
+
40
+ def default(self, obj: Any) -> Any:
41
+ """Convert DeepDiff types to JSON-serializable format.
42
+
43
+ Args:
44
+ obj: The object to serialize
45
+
46
+ Returns:
47
+ JSON serializable format of the object
48
+ """
49
+ try:
50
+ values = getattr(obj, '_values', None)
51
+ if values is not None:
52
+ return list(values)
53
+ return json.JSONEncoder.default(self, obj)
54
+ except TypeError:
55
+ return str(obj)
56
+
57
+
58
+ # Generate a random secret key
59
+ SECRET_KEY = secrets.token_urlsafe(32)
60
+
61
+ # Session settings
62
+ SESSION_TIMEOUT = 20 * 60 # 20 minutes in seconds
63
+
64
+ # Create FastAPI app
65
+ app = FastAPI(
66
+ title="API Response Comparator",
67
+ description="TellusDigital Internal API Comparison Tool",
68
+ version="1.0.0",
69
+ terms_of_service="Internal use only",
70
+ contact={
71
+ "name": "Kumud Raj",
72
+ "organization": "TellusDigital",
73
+ "email": "kumud.raj01@telusdigital.com"
74
+ },
75
+ license_info={
76
+ "name": "Proprietary - TellusDigital",
77
+ "url": "https://www.telusdigital.com/",
78
+ }
79
+ )
80
+
81
+ # custom middleware for URL check/convert
82
+ app.add_middleware(SmartSchemeMiddleware)
83
+
84
+ # Custom middleware for authentication before session middleware
85
+ @app.middleware("http")
86
+ async def auth_middleware(request: Request, call_next):
87
+ """Authentication middleware to handle redirects consistently."""
88
+ # Allow access to login, static files and root without authentication
89
+ if request.url.path in ["/login", "/"] or request.url.path.startswith("/static/"):
90
+ return await call_next(request)
91
+
92
+ # Check authentication for all other routes
93
+ if not hasattr(request, "session") or "username" not in request.session:
94
+ return RedirectResponse(
95
+ url="/login",
96
+ status_code=status.HTTP_302_FOUND
97
+ )
98
+
99
+ # Proceed with authenticated request
100
+ response = await call_next(request)
101
+
102
+ # Convert 401 responses to 302 redirects
103
+ if response.status_code == status.HTTP_401_UNAUTHORIZED:
104
+ return RedirectResponse(
105
+ url="/login",
106
+ status_code=status.HTTP_302_FOUND
107
+ )
108
+
109
+ return response
110
+
111
+
112
+ # Add session middleware after auth middleware
113
+ app.add_middleware(
114
+ SessionMiddleware,
115
+ secret_key=SECRET_KEY,
116
+ session_cookie="api_comparator_session",
117
+ max_age=SESSION_TIMEOUT
118
+ )
119
+
120
+ # Mount static files and templates
121
+ app.mount("/static", StaticFiles(directory="static"), name="static")
122
+ templates = Jinja2Templates(directory="templates")
123
+ security = HTTPBasic()
124
+
125
+ # WebSocket connections store
126
+ active_connections: list[WebSocket] = []
127
+
128
+
129
+ # Update get_current_user to return the username directly
130
+ async def get_current_user(request: Request) -> str:
131
+ """Validate current user session and return username."""
132
+ if not hasattr(request, "session") or "username" not in request.session:
133
+ return RedirectResponse(
134
+ url="/login",
135
+ status_code=status.HTTP_302_FOUND
136
+ )
137
+ return request.session["username"]
138
+
139
+
140
+ @app.websocket("/ws")
141
+ async def websocket_endpoint(websocket: WebSocket) -> None:
142
+ """Handle WebSocket connections for real-time progress updates.
143
+
144
+ Args:
145
+ websocket: The WebSocket connection
146
+ """
147
+ await websocket.accept()
148
+ active_connections.append(websocket)
149
+ try:
150
+ while True:
151
+ await websocket.receive_text()
152
+ except WebSocketDisconnect:
153
+ active_connections.remove(websocket)
154
+ except Exception as e:
155
+ log.error("WebSocket error", error=str(e))
156
+ if websocket in active_connections:
157
+ active_connections.remove(websocket)
158
+
159
+
160
+ async def broadcast_progress(message: str) -> None:
161
+ """Broadcast progress updates to all connected WebSocket clients.
162
+
163
+ Args:
164
+ message: The status message to broadcast
165
+ """
166
+ connections = active_connections.copy()
167
+ for connection in connections:
168
+ try:
169
+ await connection.send_json({"type": "processing", "status": message})
170
+ except Exception as e:
171
+ log.error("Broadcast error", error=str(e))
172
+ if connection in active_connections:
173
+ active_connections.remove(connection)
174
+
175
+
176
+ @app.get("/", response_class=RedirectResponse)
177
+ async def root() -> RedirectResponse:
178
+ """Redirect root path to login page."""
179
+ return RedirectResponse("/login")
180
+
181
+
182
+ @app.get("/login", response_class=HTMLResponse)
183
+ async def login_page(request: Request) -> Response:
184
+ """Render the login page."""
185
+ return templates.TemplateResponse("login.html", {"request": request})
186
+
187
+
188
+ @app.post("/login")
189
+ async def login(
190
+ request: Request,
191
+ username: str = Form(...),
192
+ password: str = Form(...)
193
+ ) -> Response:
194
+ """Handle user login."""
195
+ if username in USER_CREDENTIALS and USER_CREDENTIALS[username] == password:
196
+ request.session["username"] = username
197
+ request.session["last_activity"] = datetime.now().timestamp()
198
+ return RedirectResponse(url="/compare", status_code=status.HTTP_302_FOUND)
199
+
200
+ return templates.TemplateResponse(
201
+ "login.html",
202
+ {"request": request, "error": "Invalid credentials"},
203
+ status_code=status.HTTP_401_UNAUTHORIZED
204
+ )
205
+
206
+
207
+ @app.get("/compare", response_class=HTMLResponse)
208
+ async def compare_page(request: Request) -> Response:
209
+ """Render the API comparison page."""
210
+ if not hasattr(request, "session") or "username" not in request.session:
211
+ return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
212
+ return templates.TemplateResponse("compare.html", {
213
+ "request": request,
214
+ "username": request.session["username"],
215
+ "methods": ["GET", "POST", "PUT", "DELETE", "PATCH"]
216
+ })
217
+
218
+
219
+ @app.post("/compare")
220
+ async def compare_apis(
221
+ request: Request,
222
+ api1_url: str = Form(...),
223
+ api1_method: str = Form(...),
224
+ api1_payload: str = Form(...),
225
+ api1_headers: str = Form(...),
226
+ api2_url: str = Form(...),
227
+ api2_method: str = Form(...),
228
+ api2_payload: str = Form(...),
229
+ api2_headers: str = Form(...),
230
+ view_mode: str = Form(default="line"),
231
+ username: str = Depends(get_current_user)
232
+ ) -> Response:
233
+ """Compare responses from two APIs and render the results page."""
234
+ try:
235
+ await broadcast_progress("starting")
236
+
237
+ # Validate inputs
238
+ validate_urls(api1_url, api2_url)
239
+ validate_json_inputs(
240
+ (api1_payload, "API 1 Payload"),
241
+ (api2_payload, "API 2 Payload"),
242
+ (api1_headers, "API 1 Headers"),
243
+ (api2_headers, "API 2 Headers")
244
+ )
245
+
246
+ await broadcast_progress("validating")
247
+
248
+ # Parse inputs
249
+ payload1 = parse_json_input(api1_payload, "API 1 Payload")
250
+ payload2 = parse_json_input(api2_payload, "API 2 Payload")
251
+ headers1 = parse_json_input(api1_headers, "API 1 Headers")
252
+ headers2 = parse_json_input(api2_headers, "API 2 Headers")
253
+
254
+ await broadcast_progress("processing")
255
+
256
+ # Fetch API responses
257
+ responses = await fetch_api_responses(
258
+ api1_url, api1_method, payload1, headers1,
259
+ api2_url, api2_method, payload2, headers2
260
+ )
261
+ (response1, execution_time_api1), (response2, execution_time_api2) = responses
262
+
263
+ await broadcast_progress("comparing")
264
+
265
+ # Get JSON data from responses
266
+ json1 = response1['data']
267
+ json2 = response2['data']
268
+
269
+ # Generate diffs
270
+ diff_tree = compare_responses(json1, json2, view='tree')
271
+ diff_text = compare_responses(json1, json2, view='text')
272
+ diff_html = json_line_diff(json1, json2)
273
+
274
+ # Prepare results
275
+ api1_result = html.escape(json.dumps(json1, indent=4))
276
+ api2_result = html.escape(json.dumps(json2, indent=4))
277
+
278
+ diff_result = (
279
+ html.escape(diff_tree) if isinstance(diff_tree, str) else
280
+ html.escape(
281
+ json.dumps(diff_tree, indent=4, cls=CustomJSONEncoder))
282
+ ) if view_mode == "tree" else diff_html
283
+
284
+ return templates.TemplateResponse(
285
+ "results.html",
286
+ {
287
+ "request": request,
288
+ "username": username,
289
+ "execution_time_api1": f"{execution_time_api1:.2f}",
290
+ "execution_time_api2": f"{execution_time_api2:.2f}",
291
+ "status_code1": response1['status'],
292
+ "status_code2": response2['status'],
293
+ "api1_result": api1_result,
294
+ "api2_result": api2_result,
295
+ "diff_result": diff_result,
296
+ "view_mode": view_mode,
297
+ "diff_text": html.escape(str(diff_text)),
298
+ "api1_url": api1_url,
299
+ "api2_url": api2_url,
300
+ "api1_method": api1_method,
301
+ "api2_method": api2_method
302
+ }
303
+ )
304
+ except Exception as e:
305
+ return templates.TemplateResponse(
306
+ "compare.html",
307
+ {
308
+ "request": request,
309
+ "username": username,
310
+ "error": str(e),
311
+ "methods": ["GET", "POST", "PUT", "DELETE", "PATCH"]
312
+ }
313
+ )
314
+
315
+
316
+ @app.post("/extend-session")
317
+ async def extend_session(
318
+ request: Request,
319
+ username: str = Depends(get_current_user)
320
+ ) -> JSONResponse:
321
+ """Extend the user session."""
322
+ request.session["last_activity"] = datetime.now().timestamp()
323
+ return JSONResponse({"status": "success"})
324
+
325
+
326
+ @app.get("/logout")
327
+ async def logout(request: Request) -> RedirectResponse:
328
+ """Log out the user and clear the session."""
329
+ if not hasattr(request, "session") or "username" not in request.session:
330
+ return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
331
+ request.session.clear()
332
+ return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
333
+
334
+
335
+ @app.post("/download/")
336
+ async def download_result(
337
+ request: Request,
338
+ content: str = Form(...),
339
+ filename: str = Form(...),
340
+ username: str = Form(...)
341
+ ) -> Response:
342
+ """Handle file download requests."""
343
+ try:
344
+ # Verify authentication
345
+ if username != request.session.get("username"):
346
+ log.error("Authentication failed for download", username=username)
347
+ return RedirectResponse(
348
+ url="/login",
349
+ status_code=status.HTTP_302_FOUND
350
+ )
351
+
352
+ content_type = "application/json" if filename.endswith(".json") else "text/plain"
353
+
354
+ # Handle JSON content
355
+ if content_type == "application/json":
356
+ try:
357
+ # Try to parse and re-format JSON content
358
+ json_data = json.loads(html.unescape(content)) # Unescape HTML entities
359
+ formatted_content = json.dumps(json_data, indent=2)
360
+ except json.JSONDecodeError as e:
361
+ log.error("Invalid JSON content in download", error=str(e))
362
+ return templates.TemplateResponse(
363
+ "error.html",
364
+ {
365
+ "request": request,
366
+ "error": "Invalid JSON content",
367
+ "detail": f"The content could not be parsed as JSON: {str(e)}"
368
+ },
369
+ status_code=400
370
+ )
371
+ else:
372
+ # For text content, use as is but unescape HTML entities
373
+ formatted_content = html.unescape(content)
374
+
375
+ headers = {
376
+ "Content-Disposition": f'attachment; filename="{filename}"',
377
+ "Content-Type": content_type
378
+ }
379
+
380
+ return Response(
381
+ content=formatted_content,
382
+ headers=headers
383
+ )
384
+
385
+ except HTTPException as he:
386
+ raise he
387
+ except Exception as e:
388
+ log.error("Download error", error=str(e))
389
+ return templates.TemplateResponse(
390
+ "error.html",
391
+ {
392
+ "request": request,
393
+ "error": "Something went wrong",
394
+ "detail": "Failed to process your download request. Please try again."
395
+ },
396
+ status_code=500
397
+ )
398
+
399
+
400
+ @app.exception_handler(401)
401
+ async def unauthorized_handler(request: Request, exc: HTTPException) -> Response:
402
+ """Handle unauthorized access errors with redirect."""
403
+ return RedirectResponse(
404
+ url="/login",
405
+ status_code=status.HTTP_302_FOUND
406
+ )
407
+
408
+
409
+ @app.exception_handler(Exception)
410
+ async def general_exception_handler(request: Request, exc: Exception) -> Response:
411
+ """Handle general exceptions."""
412
+ # For authentication errors, redirect to login
413
+ if isinstance(exc, HTTPException) and exc.status_code in [401, 403]:
414
+ return RedirectResponse(
415
+ url="/login",
416
+ status_code=status.HTTP_302_FOUND
417
+ )
418
+
419
+ return templates.TemplateResponse(
420
+ "error.html",
421
+ {
422
+ "request": request,
423
+ "error": "An unexpected error occurred. Please try again.",
424
+ "detail": str(exc) if app.debug else None
425
+ },
426
+ status_code=500
427
+ )
pytest.ini ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ python_classes = Test
5
+ python_functions = test_*
6
+ asyncio_mode = auto
7
+ pythonpath = .
8
+
9
+ addopts =
10
+ -v
11
+ --tb=short
12
+ --cov=.
13
+ --cov-report=term-missing
14
+ --cov-report=html
15
+ --cov-fail-under=80
16
+ --no-cov-on-fail
17
+
18
+ # Ignore some directories from coverage
19
+ [coverage:run]
20
+ omit =
21
+ tests/*
22
+ venv/*
23
+ */site-packages/*
24
+ .pytest_cache/*
25
+ __pycache__/*
26
+ .venv/*
27
+
28
+ [coverage:report]
29
+ exclude_lines =
30
+ pragma: no cover
31
+ def __repr__
32
+ if __name__ == .__main__.:
33
+ raise NotImplementedError
34
+ pass
35
+ raise ImportError
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit==1.29.0
2
+ requests==2.31.0
3
+ deepdiff==6.3.1
4
+ httpx==0.25.0
5
+ black==25.1.0
6
+ fastapi==0.104.0
7
+ uvicorn[standard]==0.24.0
8
+ jinja2==3.1.2
9
+ python-multipart==0.0.6
10
+ aiofiles==23.2.1
11
+ python-jose[cryptography]==3.3.0
12
+ passlib[bcrypt]==1.7.4
13
+ itsdangerous==2.1.2
14
+ starlette==0.27.0
15
+ structlog==23.2.0
16
+ pytest==8.0.0
17
+ pytest-asyncio==0.23.5
18
+ pytest-cov==4.1.0
19
+ pytest-mock==3.12.0
20
+ aiohttp==3.11.18
21
+ beautifulsoup4==4.12.2
22
+ pyrefly==0.15.2
23
+
run.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API Comparator Service Runner
3
+
4
+ This module is the entry point for the API Comparator service. It configures and starts
5
+ the FastAPI application using uvicorn server with specified host and port settings.
6
+ """
7
+
8
+ import uvicorn
9
+ from utils.logger import structlog
10
+
11
+ log = structlog.get_logger()
12
+
13
+ # Constants for server configuration
14
+ HOST = "0.0.0.0"
15
+ PORT = 5001
16
+
17
+ if __name__ == "__main__":
18
+ log.info("Starting API Comparator service, Host:%s, Port:%d", HOST, PORT)
19
+ uvicorn.run(
20
+ "main:app",
21
+ host=HOST,
22
+ port=PORT,
23
+ reload=True,
24
+ log_level="warning",
25
+ access_log=False
26
+ )
static/css/styles.css ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: Arial, sans-serif;
3
+ margin: 0;
4
+ padding: 20px;
5
+ background-color: #f5f5f5;
6
+ }
7
+
8
+ .container {
9
+ max-width: 1200px;
10
+ margin: 0 auto;
11
+ padding: 20px;
12
+ background-color: white;
13
+ border-radius: 8px;
14
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
15
+ }
16
+
17
+ .header {
18
+ display: flex;
19
+ justify-content: space-between;
20
+ align-items: center;
21
+ margin-bottom: 20px;
22
+ }
23
+
24
+ .logo {
25
+ width: 100px;
26
+ height: auto;
27
+ }
28
+
29
+ .form-group {
30
+ margin-bottom: 15px;
31
+ }
32
+
33
+ .form-group label {
34
+ display: block;
35
+ margin-bottom: 5px;
36
+ font-weight: bold;
37
+ }
38
+
39
+ .form-control {
40
+ width: 100%;
41
+ padding: 8px;
42
+ border: 1px solid #ddd;
43
+ border-radius: 4px;
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ .button {
48
+ background-color: #4CAF50;
49
+ color: white;
50
+ padding: 10px 20px;
51
+ border: none;
52
+ border-radius: 4px;
53
+ cursor: pointer;
54
+ font-size: 16px;
55
+ }
56
+
57
+ .button:hover {
58
+ background-color: #45a049;
59
+ }
60
+
61
+ .error {
62
+ color: #dc3545;
63
+ margin-bottom: 10px;
64
+ }
65
+
66
+ .success {
67
+ color: #28a745;
68
+ margin-bottom: 10px;
69
+ }
70
+
71
+ .grid {
72
+ display: grid;
73
+ grid-template-columns: 1fr 1fr;
74
+ gap: 20px;
75
+ }
76
+
77
+ .results {
78
+ margin-top: 20px;
79
+ padding: 20px;
80
+ background-color: #f8f9fa;
81
+ border-radius: 4px;
82
+ }
83
+
84
+ .download-button {
85
+ display: inline-block;
86
+ margin: 5px;
87
+ padding: 8px 15px;
88
+ background-color: #007bff;
89
+ color: white;
90
+ text-decoration: none;
91
+ border-radius: 4px;
92
+ }
93
+
94
+ .download-button:hover {
95
+ background-color: #0056b3;
96
+ }
97
+
98
+ .radio-group {
99
+ margin: 10px 0;
100
+ }
101
+
102
+ .radio-group label {
103
+ margin-right: 15px;
104
+ }
105
+
106
+ pre {
107
+ background-color: #f8f9fa;
108
+ padding: 15px;
109
+ border-radius: 4px;
110
+ overflow-x: auto;
111
+ }
112
+
113
+ .login-container {
114
+ max-width: 400px;
115
+ margin: 100px auto;
116
+ padding: 20px;
117
+ background-color: white;
118
+ border-radius: 8px;
119
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
120
+ }
121
+
122
+ .error-container {
123
+ max-width: 600px;
124
+ margin: 100px auto;
125
+ padding: 20px;
126
+ background-color: white;
127
+ border-radius: 8px;
128
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
129
+ text-align: center;
130
+ }
131
+
132
+ .error-detail {
133
+ margin-top: 10px;
134
+ padding: 10px;
135
+ background-color: #f8f9fa;
136
+ border-radius: 4px;
137
+ color: #666;
138
+ font-family: monospace;
139
+ white-space: pre-wrap;
140
+ }
141
+
142
+ .json-content {
143
+ background-color: #f8f9fa;
144
+ padding: 15px;
145
+ border-radius: 4px;
146
+ margin-top: 10px;
147
+ max-height: 500px;
148
+ overflow-y: auto;
149
+ }
150
+
151
+ .json-content pre {
152
+ margin: 0;
153
+ white-space: pre-wrap;
154
+ word-wrap: break-word;
155
+ }
156
+
157
+ .diff-content {
158
+ background-color: #f8f9fa;
159
+ padding: 15px;
160
+ border-radius: 4px;
161
+ margin-top: 10px;
162
+ max-height: 600px;
163
+ overflow-y: auto;
164
+ }
165
+
166
+ .diff-content span {
167
+ display: block;
168
+ font-family: monospace;
169
+ white-space: pre-wrap;
170
+ word-wrap: break-word;
171
+ line-height: 1.4;
172
+ }
173
+
174
+ .user-menu {
175
+ position: relative;
176
+ display: inline-block;
177
+ cursor: pointer;
178
+ }
179
+
180
+ .user-menu-content {
181
+ display: none;
182
+ position: absolute;
183
+ right: 0;
184
+ background-color: #f9f9f9;
185
+ min-width: 160px;
186
+ box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
187
+ z-index: 1;
188
+ border-radius: 4px;
189
+ }
190
+
191
+ .user-menu-content a {
192
+ color: black;
193
+ padding: 12px 16px;
194
+ text-decoration: none;
195
+ display: block;
196
+ }
197
+
198
+ .user-menu-content a:hover {
199
+ background-color: #f1f1f1;
200
+ }
201
+
202
+ .user-menu:hover .user-menu-content {
203
+ display: block;
204
+ }
205
+
206
+ .session-timeout-warning {
207
+ display: none;
208
+ position: fixed;
209
+ top: 50%;
210
+ left: 50%;
211
+ transform: translate(-50%, -50%);
212
+ background-color: white;
213
+ padding: 20px;
214
+ border-radius: 8px;
215
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
216
+ z-index: 1000;
217
+ text-align: center;
218
+ }
219
+
220
+ .session-timeout-warning button {
221
+ margin: 10px 5px;
222
+ padding: 8px 16px;
223
+ border: none;
224
+ border-radius: 4px;
225
+ cursor: pointer;
226
+ }
227
+
228
+ .session-timeout-warning button.continue {
229
+ background-color: #4CAF50;
230
+ color: white;
231
+ }
232
+
233
+ .session-timeout-warning button.logout {
234
+ background-color: #dc3545;
235
+ color: white;
236
+ }
static/images/TD-logo.jpeg ADDED
templates/compare.html ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Compare APIs</title>
5
+ <link
6
+ rel="stylesheet"
7
+ href="{{ url_for('static', path='/css/styles.css') }}"
8
+ />
9
+ <style>
10
+ .progress-container {
11
+ display: none;
12
+ width: 100%;
13
+ margin: 20px 0;
14
+ text-align: center;
15
+ }
16
+ .progress-bar {
17
+ width: 100%;
18
+ height: 20px;
19
+ background-color: #f3f3f3;
20
+ border-radius: 10px;
21
+ overflow: hidden;
22
+ }
23
+ .progress {
24
+ width: 0%;
25
+ height: 100%;
26
+ background-color: #4caf50;
27
+ transition: width 0.3s ease;
28
+ }
29
+ .loading-text {
30
+ margin-top: 10px;
31
+ color: #666;
32
+ }
33
+ .spinner {
34
+ display: inline-block;
35
+ width: 20px;
36
+ height: 20px;
37
+ border: 3px solid #f3f3f3;
38
+ border-top: 3px solid #4caf50;
39
+ border-radius: 50%;
40
+ animation: spin 1s linear infinite;
41
+ margin-right: 10px;
42
+ }
43
+ @keyframes spin {
44
+ 0% {
45
+ transform: rotate(0deg);
46
+ }
47
+ 100% {
48
+ transform: rotate(360deg);
49
+ }
50
+ }
51
+ #processingStatus {
52
+ margin-top: 10px;
53
+ font-weight: bold;
54
+ color: #4caf50;
55
+ }
56
+ .progress-steps {
57
+ display: flex;
58
+ justify-content: space-between;
59
+ margin-bottom: 10px;
60
+ position: relative;
61
+ }
62
+ .progress-step {
63
+ background: #f3f3f3;
64
+ border-radius: 50%;
65
+ width: 30px;
66
+ height: 30px;
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: center;
70
+ position: relative;
71
+ z-index: 1;
72
+ }
73
+ .progress-step.active {
74
+ background: #4caf50;
75
+ color: white;
76
+ }
77
+ .progress-step-label {
78
+ position: absolute;
79
+ top: 35px;
80
+ font-size: 12px;
81
+ width: 80px;
82
+ text-align: center;
83
+ left: 50%;
84
+ transform: translateX(-50%);
85
+ }
86
+ .step-line {
87
+ position: absolute;
88
+ top: 15px;
89
+ height: 2px;
90
+ background: #f3f3f3;
91
+ width: calc(100% - 30px);
92
+ left: 15px;
93
+ }
94
+ .step-line-progress {
95
+ height: 100%;
96
+ width: 0;
97
+ background: #4caf50;
98
+ transition: width 0.3s ease;
99
+ }
100
+ .session-timeout-warning {
101
+ display: none;
102
+ position: fixed;
103
+ top: 50%;
104
+ left: 50%;
105
+ transform: translate(-50%, -50%);
106
+ background: white;
107
+ border: 1px solid #ccc;
108
+ padding: 20px;
109
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
110
+ z-index: 1000;
111
+ }
112
+ .session-timeout-warning h3 {
113
+ margin: 0 0 10px;
114
+ }
115
+ .session-timeout-warning p {
116
+ margin: 0 0 20px;
117
+ }
118
+ .session-timeout-warning button {
119
+ margin-right: 10px;
120
+ }
121
+ </style>
122
+ </head>
123
+ <body>
124
+ <div class="container">
125
+ <div class="header">
126
+ <img
127
+ src="{{ url_for('static', path='/images/TD-logo.jpeg') }}"
128
+ alt="Logo"
129
+ class="logo"
130
+ />
131
+ <div class="user-menu">
132
+ <div>Welcome, {{ username }}!</div>
133
+ <div class="user-menu-content">
134
+ <a href="/logout">Logout</a>
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ {% if error %}
140
+ <div class="error">{{ error }}</div>
141
+ {% endif %}
142
+
143
+ <div id="session-timeout" class="session-timeout-warning">
144
+ <h3>Session Timeout Warning</h3>
145
+ <p>
146
+ Your session will expire in
147
+ <span id="timeout-countdown">60</span> seconds due to inactivity.
148
+ </p>
149
+ <button class="continue" onclick="extendSession()">
150
+ Continue Session
151
+ </button>
152
+ <button class="logout" onclick="window.location.href='/logout'">
153
+ Logout
154
+ </button>
155
+ </div>
156
+
157
+ <div class="progress-container" id="progressContainer">
158
+ <div class="progress-steps">
159
+ <div class="step-line">
160
+ <div class="step-line-progress" id="stepLineProgress"></div>
161
+ </div>
162
+ <div class="progress-step" id="step1">
163
+ 1
164
+ <div class="progress-step-label">Validating</div>
165
+ </div>
166
+ <div class="progress-step" id="step2">
167
+ 2
168
+ <div class="progress-step-label">Processing</div>
169
+ </div>
170
+ <div class="progress-step" id="step3">
171
+ 3
172
+ <div class="progress-step-label">Fetching</div>
173
+ </div>
174
+ <div class="progress-step" id="step4">
175
+ 4
176
+ <div class="progress-step-label">Comparing</div>
177
+ </div>
178
+ </div>
179
+ <div class="progress-bar">
180
+ <div class="progress" id="progressBar"></div>
181
+ </div>
182
+ <div class="loading-text">
183
+ <span class="spinner"></span>
184
+ <span id="loadingText">Processing API requests...</span>
185
+ </div>
186
+ <div id="processingStatus"></div>
187
+ </div>
188
+
189
+ <form method="POST" action="/compare" id="compareForm">
190
+ <div class="grid">
191
+ <div>
192
+ <h3>First API</h3>
193
+ <div class="form-group">
194
+ <label for="api1_url">URL:</label>
195
+ <input
196
+ type="url"
197
+ id="api1_url"
198
+ name="api1_url"
199
+ class="form-control"
200
+ required
201
+ />
202
+ </div>
203
+
204
+ <div class="form-group">
205
+ <label for="api1_method">Method:</label>
206
+ <select
207
+ id="api1_method"
208
+ name="api1_method"
209
+ class="form-control"
210
+ required
211
+ >
212
+ {% for method in methods %}
213
+ <option value="{{ method }}">{{ method }}</option>
214
+ {% endfor %}
215
+ </select>
216
+ </div>
217
+
218
+ <div class="form-group">
219
+ <label for="api1_headers">Headers (JSON):</label>
220
+ <textarea
221
+ id="api1_headers"
222
+ name="api1_headers"
223
+ class="form-control"
224
+ rows="4"
225
+ >
226
+ {}</textarea
227
+ >
228
+ </div>
229
+
230
+ <div class="form-group">
231
+ <label for="api1_payload">Payload (JSON):</label>
232
+ <textarea
233
+ id="api1_payload"
234
+ name="api1_payload"
235
+ class="form-control"
236
+ rows="6"
237
+ >
238
+ {}</textarea
239
+ >
240
+ </div>
241
+ </div>
242
+
243
+ <div>
244
+ <h3>Second API</h3>
245
+ <div class="form-group">
246
+ <label for="api2_url">URL:</label>
247
+ <input
248
+ type="url"
249
+ id="api2_url"
250
+ name="api2_url"
251
+ class="form-control"
252
+ required
253
+ />
254
+ </div>
255
+
256
+ <div class="form-group">
257
+ <label for="api2_method">Method:</label>
258
+ <select
259
+ id="api2_method"
260
+ name="api2_method"
261
+ class="form-control"
262
+ required
263
+ >
264
+ {% for method in methods %}
265
+ <option value="{{ method }}">{{ method }}</option>
266
+ {% endfor %}
267
+ </select>
268
+ </div>
269
+
270
+ <div class="form-group">
271
+ <label for="api2_headers">Headers (JSON):</label>
272
+ <textarea
273
+ id="api2_headers"
274
+ name="api2_headers"
275
+ class="form-control"
276
+ rows="4"
277
+ >
278
+ {}</textarea
279
+ >
280
+ </div>
281
+
282
+ <div class="form-group">
283
+ <label for="api2_payload">Payload (JSON):</label>
284
+ <textarea
285
+ id="api2_payload"
286
+ name="api2_payload"
287
+ class="form-control"
288
+ rows="6"
289
+ >
290
+ {}</textarea
291
+ >
292
+ </div>
293
+ </div>
294
+ </div>
295
+
296
+ <div class="form-group">
297
+ <label>View Mode:</label>
298
+ <div class="radio-group">
299
+ <input
300
+ type="radio"
301
+ id="line"
302
+ name="view_mode"
303
+ value="line"
304
+ checked
305
+ />
306
+ <label for="line">Line by Line</label>
307
+ <input type="radio" id="tree" name="view_mode" value="tree" />
308
+ <label for="tree">Tree View</label>
309
+ </div>
310
+ </div>
311
+
312
+ <button type="submit" class="button" id="compareButton">
313
+ Compare APIs
314
+ </button>
315
+ </form>
316
+ </div>
317
+
318
+ <script>
319
+ // Session timeout handling
320
+ let warningTimeout;
321
+ let logoutTimeout;
322
+ const activityEvents = [
323
+ "mousedown",
324
+ "mousemove",
325
+ "keypress",
326
+ "scroll",
327
+ "touchstart",
328
+ ];
329
+ const warningTime = 19 * 60 * 1000; // Show warning 1 minute before timeout (19 minutes)
330
+ const logoutTime = 20 * 60 * 1000; // Logout after 20 minutes
331
+
332
+ function resetTimers() {
333
+ clearTimeout(warningTimeout);
334
+ clearTimeout(logoutTimeout);
335
+ startTimers();
336
+ }
337
+
338
+ function startTimers() {
339
+ warningTimeout = setTimeout(showTimeoutWarning, warningTime);
340
+ logoutTimeout = setTimeout(logout, logoutTime);
341
+ }
342
+
343
+ function showTimeoutWarning() {
344
+ const warningDialog = document.getElementById("session-timeout");
345
+ warningDialog.style.display = "block";
346
+
347
+ // Start countdown
348
+ let countdown = 60;
349
+ const countdownElement = document.getElementById("timeout-countdown");
350
+ const countdownInterval = setInterval(() => {
351
+ countdown--;
352
+ countdownElement.textContent = countdown;
353
+ if (countdown <= 0) {
354
+ clearInterval(countdownInterval);
355
+ }
356
+ }, 1000);
357
+ }
358
+
359
+ function extendSession() {
360
+ const warningDialog = document.getElementById("session-timeout");
361
+ warningDialog.style.display = "none";
362
+ resetTimers();
363
+
364
+ // Call backend to extend session
365
+ fetch("/extend-session", { method: "POST" });
366
+ }
367
+
368
+ function logout() {
369
+ window.location.href = "/logout";
370
+ }
371
+
372
+ // Add activity event listeners
373
+ activityEvents.forEach((event) => {
374
+ document.addEventListener(event, resetTimers);
375
+ });
376
+
377
+ // Start timers when page loads
378
+ startTimers();
379
+
380
+ document
381
+ .getElementById("compareForm")
382
+ .addEventListener("submit", function (e) {
383
+ // Show progress container
384
+ document.getElementById("progressContainer").style.display = "block";
385
+ document.getElementById("compareButton").disabled = true;
386
+
387
+ // Connect to WebSocket for progress updates
388
+ const ws = new WebSocket(`ws://${window.location.host}/ws`);
389
+ ws.onmessage = function (event) {
390
+ const data = JSON.parse(event.data);
391
+ if (data.type === "processing") {
392
+ updateProgress(data.status);
393
+ }
394
+ };
395
+
396
+ function updateProgress(status) {
397
+ const progressBar = document.getElementById("progressBar");
398
+ const loadingText = document.getElementById("loadingText");
399
+ const processingStatus =
400
+ document.getElementById("processingStatus");
401
+ const steps = {
402
+ starting: {
403
+ step: 1,
404
+ progress: 25,
405
+ text: "Starting API comparison...",
406
+ },
407
+ validating: {
408
+ step: 1,
409
+ progress: 50,
410
+ text: "Validating inputs...",
411
+ },
412
+ processing: {
413
+ step: 2,
414
+ progress: 60,
415
+ text: "Processing request...",
416
+ },
417
+ fetching: {
418
+ step: 3,
419
+ progress: 75,
420
+ text: "Fetching API responses...",
421
+ },
422
+ comparing: {
423
+ step: 4,
424
+ progress: 90,
425
+ text: "Comparing results...",
426
+ },
427
+ };
428
+
429
+ if (steps[status]) {
430
+ const { step, progress, text } = steps[status];
431
+ progressBar.style.width = `${progress}%`;
432
+ loadingText.textContent = text;
433
+ processingStatus.textContent = text;
434
+
435
+ // Update step indicators
436
+ for (let i = 1; i <= 4; i++) {
437
+ const stepEl = document.getElementById(`step${i}`);
438
+ if (i <= step) {
439
+ stepEl.classList.add("active");
440
+ }
441
+ }
442
+
443
+ // Update progress line
444
+ document.getElementById("stepLineProgress").style.width = `${
445
+ (step - 1) * 33.33
446
+ }%`;
447
+ }
448
+ }
449
+ });
450
+
451
+ // Validate JSON input
452
+ function validateJson(input, fieldName) {
453
+ try {
454
+ if (input.value.trim()) {
455
+ JSON.parse(input.value);
456
+ }
457
+ input.style.borderColor = "";
458
+ return true;
459
+ } catch (e) {
460
+ input.style.borderColor = "red";
461
+ alert(`Invalid JSON in ${fieldName}`);
462
+ return false;
463
+ }
464
+ }
465
+
466
+ // Add JSON validation to payload and headers fields
467
+ ["api1_payload", "api2_payload", "api1_headers", "api2_headers"].forEach(
468
+ (id) => {
469
+ const element = document.getElementById(id);
470
+ element.addEventListener("blur", () => validateJson(element, id));
471
+ }
472
+ );
473
+ </script>
474
+ <div
475
+ style="text-align: center; margin-top: 20px; color: #666; font-size: 12px"
476
+ >
477
+ Copyright © 2025 TellusDigital. All rights reserved.<br />
478
+ For internal use only.
479
+ </div>
480
+ </body>
481
+ </html>
templates/error.html ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Error - API Comparator</title>
5
+ <style>
6
+ /* Inline critical CSS */
7
+ body {
8
+ font-family: Arial, sans-serif;
9
+ margin: 0;
10
+ padding: 20px;
11
+ background-color: #f5f5f5;
12
+ }
13
+ .container {
14
+ max-width: 600px;
15
+ margin: 100px auto;
16
+ padding: 20px;
17
+ background-color: white;
18
+ border-radius: 8px;
19
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
20
+ text-align: center;
21
+ }
22
+ .header {
23
+ margin-bottom: 20px;
24
+ }
25
+ .logo {
26
+ width: 100px;
27
+ height: auto;
28
+ }
29
+ .error {
30
+ color: #dc3545;
31
+ margin: 20px 0;
32
+ }
33
+ .error-detail {
34
+ margin-top: 10px;
35
+ padding: 10px;
36
+ background-color: #f8f9fa;
37
+ border-radius: 4px;
38
+ color: #666;
39
+ font-family: monospace;
40
+ white-space: pre-wrap;
41
+ }
42
+ .button {
43
+ display: inline-block;
44
+ background-color: #4caf50;
45
+ color: white;
46
+ padding: 10px 20px;
47
+ text-decoration: none;
48
+ border-radius: 4px;
49
+ margin-top: 20px;
50
+ }
51
+ .button:hover {
52
+ background-color: #45a049;
53
+ }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <div class="container">
58
+ <div class="header">
59
+ <img src="/static/images/TD-logo.jpeg" alt="Logo" class="logo" />
60
+ </div>
61
+
62
+ <div class="error-container">
63
+ <h2>Error</h2>
64
+ <div class="error">{{ error }}</div>
65
+ {% if detail %}
66
+ <div class="error-detail">{{ detail }}</div>
67
+ {% endif %}
68
+ <div style="margin-top: 20px">
69
+ <a href="/" class="button">Return to Home</a>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ <div
74
+ style="text-align: center; margin-top: 20px; color: #666; font-size: 12px"
75
+ >
76
+ Copyright © 2025 TellusDigital. All rights reserved.<br />
77
+ For internal use only.
78
+ </div>
79
+ </body>
80
+ </html>
templates/login.html ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Login - API Comparator</title>
5
+ <link
6
+ rel="stylesheet"
7
+ href="{{ url_for('static', path='/css/styles.css') }}"
8
+ />
9
+ </head>
10
+ <body>
11
+ <div class="login-container">
12
+ <div class="header">
13
+ <img
14
+ src="{{ url_for('static', path='/images/TD-logo.jpeg') }}"
15
+ alt="Logo"
16
+ class="logo"
17
+ />
18
+ </div>
19
+
20
+ {% if error %}
21
+ <div class="error">{{ error }}</div>
22
+ {% endif %}
23
+
24
+ <form method="POST" action="/login">
25
+ <div class="form-group">
26
+ <label for="username">Username:</label>
27
+ <input
28
+ type="text"
29
+ id="username"
30
+ name="username"
31
+ class="form-control"
32
+ required
33
+ />
34
+ </div>
35
+
36
+ <div class="form-group">
37
+ <label for="password">Password:</label>
38
+ <input
39
+ type="password"
40
+ id="password"
41
+ name="password"
42
+ class="form-control"
43
+ required
44
+ />
45
+ </div>
46
+
47
+ <button type="submit" class="button">Login</button>
48
+ </form>
49
+ </div>
50
+ <div
51
+ style="text-align: center; margin-top: 20px; color: #666; font-size: 12px"
52
+ >
53
+ Copyright © 2025 TellusDigital. All rights reserved.<br />
54
+ For internal use only.
55
+ </div>
56
+ </body>
57
+ </html>
templates/results.html ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>API Comparison Results</title>
5
+ <link
6
+ rel="stylesheet"
7
+ href="{{ url_for('static', path='/css/styles.css') }}"
8
+ />
9
+ <style>
10
+ .diff-highlight-add {
11
+ background-color: #e6ffe6;
12
+ color: green;
13
+ }
14
+ .diff-highlight-remove {
15
+ background-color: #ffe6e6;
16
+ color: red;
17
+ }
18
+ .spinner {
19
+ display: none;
20
+ width: 50px;
21
+ height: 50px;
22
+ border: 5px solid #f3f3f3;
23
+ border-top: 5px solid #4caf50;
24
+ border-radius: 50%;
25
+ animation: spin 1s linear infinite;
26
+ margin: 20px auto;
27
+ }
28
+ @keyframes spin {
29
+ 0% {
30
+ transform: rotate(0deg);
31
+ }
32
+ 100% {
33
+ transform: rotate(360deg);
34
+ }
35
+ }
36
+ .api-info {
37
+ background-color: #f8f9fa;
38
+ padding: 10px;
39
+ border-radius: 4px;
40
+ margin-bottom: 10px;
41
+ border-left: 4px solid #4caf50;
42
+ }
43
+ .api-endpoint {
44
+ font-family: monospace;
45
+ color: #0056b3;
46
+ word-break: break-all;
47
+ }
48
+ .session-timeout-warning {
49
+ display: none;
50
+ background-color: #fff3cd;
51
+ border: 1px solid #ffeeba;
52
+ padding: 10px;
53
+ border-radius: 4px;
54
+ margin: 20px auto;
55
+ text-align: center;
56
+ }
57
+ .session-timeout-warning h3 {
58
+ margin: 0;
59
+ font-size: 18px;
60
+ color: #856404;
61
+ }
62
+ .session-timeout-warning p {
63
+ margin: 10px 0;
64
+ font-size: 14px;
65
+ color: #856404;
66
+ }
67
+ .session-timeout-warning button {
68
+ margin: 5px;
69
+ padding: 5px 10px;
70
+ border: none;
71
+ border-radius: 4px;
72
+ cursor: pointer;
73
+ }
74
+ .session-timeout-warning .continue {
75
+ background-color: #4caf50;
76
+ color: white;
77
+ }
78
+ .session-timeout-warning .logout {
79
+ background-color: #f44336;
80
+ color: white;
81
+ }
82
+ .alert {
83
+ padding: 15px;
84
+ margin-bottom: 20px;
85
+ border: 1px solid transparent;
86
+ border-radius: 4px;
87
+ }
88
+ .alert-error {
89
+ color: #721c24;
90
+ background-color: #f8d7da;
91
+ border-color: #f5c6cb;
92
+ }
93
+ .alert-error .close {
94
+ float: right;
95
+ font-size: 20px;
96
+ font-weight: bold;
97
+ line-height: 20px;
98
+ cursor: pointer;
99
+ }
100
+ </style>
101
+ </head>
102
+ <body>
103
+ <div class="container">
104
+ <div class="header">
105
+ <a href="/compare">
106
+ <img
107
+ src="{{ url_for('static', path='/images/TD-logo.jpeg') }}"
108
+ alt="Logo"
109
+ class="logo"
110
+ />
111
+ </a>
112
+ <div class="user-menu">
113
+ <div>Welcome, {{ username }}!</div>
114
+ <div class="user-menu-content">
115
+ <a href="/logout">Logout</a>
116
+ </div>
117
+ </div>
118
+ </div>
119
+
120
+ <div id="errorAlert" class="alert alert-error" style="display: none">
121
+ <span class="close" onclick="this.parentElement.style.display='none';"
122
+ >&times;</span
123
+ >
124
+ <strong>Error!</strong> <span id="errorMessage"></span>
125
+ </div>
126
+
127
+ <div id="session-timeout" class="session-timeout-warning">
128
+ <h3>Session Timeout Warning</h3>
129
+ <p>
130
+ Your session will expire in
131
+ <span id="timeout-countdown">60</span> seconds due to inactivity.
132
+ </p>
133
+ <button class="continue" onclick="extendSession()">
134
+ Continue Session
135
+ </button>
136
+ <button class="logout" onclick="window.location.href='/logout'">
137
+ Logout
138
+ </button>
139
+ </div>
140
+
141
+ <div class="results">
142
+ <h2>API Comparison Results</h2>
143
+ <div id="loading-spinner" class="spinner"></div>
144
+
145
+ <div class="grid">
146
+ <div>
147
+ <h3>First API</h3>
148
+ <div class="api-info">
149
+ <p>
150
+ <strong>Endpoint:</strong>
151
+ <span class="api-endpoint"
152
+ >{{ api1_url }} ({{ api1_method }})</span
153
+ >
154
+ </p>
155
+ <p>Execution Time: {{ execution_time_api1 }}s</p>
156
+ <p>Status Code: {{ status_code1 }}</p>
157
+ </div>
158
+ <form
159
+ method="POST"
160
+ action="/download/"
161
+ style="display: inline"
162
+ onsubmit="return handleDownload(event, this)"
163
+ >
164
+ <input type="hidden" name="content" value="{{ api1_result }}" />
165
+ <input type="hidden" name="filename" value="api1_result.json" />
166
+ <input type="hidden" name="username" value="{{ username }}" />
167
+ <button type="submit" class="download-button">
168
+ Download API 1 Result
169
+ </button>
170
+ </form>
171
+ <div class="json-content">
172
+ <pre id="api1-content">{{ api1_result | safe }}</pre>
173
+ </div>
174
+ </div>
175
+
176
+ <div>
177
+ <h3>Second API</h3>
178
+ <div class="api-info">
179
+ <p>
180
+ <strong>Endpoint:</strong>
181
+ <span class="api-endpoint"
182
+ >{{ api2_url }} ({{ api2_method }})</span
183
+ >
184
+ </p>
185
+ <p>Execution Time: {{ execution_time_api2 }}s</p>
186
+ <p>Status Code: {{ status_code2 }}</p>
187
+ </div>
188
+ <form
189
+ method="POST"
190
+ action="/download/"
191
+ style="display: inline"
192
+ onsubmit="return handleDownload(event, this)"
193
+ >
194
+ <input type="hidden" name="content" value="{{ api2_result }}" />
195
+ <input type="hidden" name="filename" value="api2_result.json" />
196
+ <input type="hidden" name="username" value="{{ username }}" />
197
+ <button type="submit" class="download-button">
198
+ Download API 2 Result
199
+ </button>
200
+ </form>
201
+ <div class="json-content">
202
+ <pre id="api2-content">{{ api2_result | safe }}</pre>
203
+ </div>
204
+ </div>
205
+ </div>
206
+
207
+ <div class="comparison-results">
208
+ <h3>Differences</h3>
209
+ <form
210
+ method="POST"
211
+ action="/download/"
212
+ style="display: inline"
213
+ onsubmit="return handleDownload(event, this)"
214
+ >
215
+ <input type="hidden" name="content" value="{{ diff_text }}" />
216
+ <input
217
+ type="hidden"
218
+ name="filename"
219
+ value="comparison_result.txt"
220
+ />
221
+ <input type="hidden" name="username" value="{{ username }}" />
222
+ <button type="submit" class="download-button">
223
+ Download Comparison Result
224
+ </button>
225
+ </form>
226
+
227
+ <div class="results-view">
228
+ {% if view_mode == "line" %} {% if diff_result.strip() == '' %}
229
+ <div class="success">Both responses are identical.</div>
230
+ {% else %}
231
+ <div class="diff-content">{{ diff_result | safe }}</div>
232
+ {% endif %} {% else %}
233
+ <pre>{{ diff_result | safe }}</pre>
234
+ {% endif %}
235
+ </div>
236
+ </div>
237
+
238
+ <div style="margin-top: 20px">
239
+ <a href="/compare" class="button">Compare More APIs</a>
240
+ </div>
241
+ </div>
242
+ </div>
243
+
244
+ <script>
245
+ function highlightDifferences() {
246
+ const api1Content = document.getElementById("api1-content");
247
+ const api2Content = document.getElementById("api2-content");
248
+
249
+ if (!api1Content || !api2Content) return;
250
+
251
+ const lines1 = api1Content.textContent.split("\n");
252
+ const lines2 = api2Content.textContent.split("\n");
253
+
254
+ let html1 = "";
255
+ let html2 = "";
256
+
257
+ const maxLines = Math.max(lines1.length, lines2.length);
258
+
259
+ for (let i = 0; i < maxLines; i++) {
260
+ const line1 = lines1[i] || "";
261
+ const line2 = lines2[i] || "";
262
+
263
+ if (line1 !== line2) {
264
+ html1 += `<span class="diff-highlight-remove">${line1}</span>\n`;
265
+ html2 += `<span class="diff-highlight-remove">${line2}</span>\n`;
266
+ } else {
267
+ html1 += line1 + "\n";
268
+ html2 += line2 + "\n";
269
+ }
270
+ }
271
+
272
+ api1Content.innerHTML = html1;
273
+ api2Content.innerHTML = html2;
274
+ }
275
+
276
+ document.addEventListener("DOMContentLoaded", highlightDifferences);
277
+
278
+ function extendSession() {
279
+ document.getElementById("session-timeout").style.display = "none";
280
+ }
281
+
282
+ setTimeout(() => {
283
+ document.getElementById("session-timeout").style.display = "block";
284
+ }, 300000);
285
+
286
+ async function handleDownload(event, form) {
287
+ event.preventDefault();
288
+
289
+ try {
290
+ const formData = new FormData(form);
291
+ const response = await fetch(form.action, {
292
+ method: "POST",
293
+ body: formData,
294
+ });
295
+
296
+ if (!response.ok) {
297
+ const data = await response.json().catch(() => null);
298
+ throw new Error(
299
+ data?.detail || "Failed to download file. Please try again."
300
+ );
301
+ }
302
+
303
+ const blob = await response.blob();
304
+ const url = window.URL.createObjectURL(blob);
305
+ const a = document.createElement("a");
306
+ a.href = url;
307
+ a.download = formData.get("filename");
308
+ document.body.appendChild(a);
309
+ a.click();
310
+ window.URL.revokeObjectURL(url);
311
+ document.body.removeChild(a);
312
+ } catch (error) {
313
+ showError(error.message || "Something went wrong. Please try again.");
314
+ }
315
+ return false;
316
+ }
317
+
318
+ function showError(message) {
319
+ const errorAlert = document.getElementById("errorAlert");
320
+ const errorMessage = document.getElementById("errorMessage");
321
+ errorMessage.textContent = message;
322
+ errorAlert.style.display = "block";
323
+
324
+ setTimeout(() => {
325
+ errorAlert.style.display = "none";
326
+ }, 5000);
327
+ }
328
+
329
+ window.addEventListener("unhandledrejection", function (event) {
330
+ showError(
331
+ "Network error occurred. Please check your connection and try again."
332
+ );
333
+ });
334
+ </script>
335
+
336
+ <div
337
+ style="text-align: center; margin-top: 20px; color: #666; font-size: 12px"
338
+ >
339
+ Copyright © 2025 TellusDigital. All rights reserved.<br />
340
+ For internal use only.
341
+ </div>
342
+ </body>
343
+ </html>
templates/session_timeout.html ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Session Timeout - API Comparator</title>
5
+ <link
6
+ rel="stylesheet"
7
+ href="{{ url_for('static', path='/css/styles.css') }}"
8
+ />
9
+ <style>
10
+ .timeout-container {
11
+ max-width: 500px;
12
+ margin: 100px auto;
13
+ padding: 30px;
14
+ text-align: center;
15
+ background-color: white;
16
+ border-radius: 8px;
17
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
18
+ }
19
+ .timeout-icon {
20
+ font-size: 48px;
21
+ color: #dc3545;
22
+ margin-bottom: 20px;
23
+ }
24
+ .timeout-message {
25
+ margin: 20px 0;
26
+ color: #666;
27
+ }
28
+ .login-button {
29
+ display: inline-block;
30
+ margin-top: 20px;
31
+ padding: 10px 30px;
32
+ background-color: #4caf50;
33
+ color: white;
34
+ text-decoration: none;
35
+ border-radius: 4px;
36
+ transition: background-color 0.3s;
37
+ }
38
+ .login-button:hover {
39
+ background-color: #45a049;
40
+ }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <div class="timeout-container">
45
+ <div class="timeout-icon">⏰</div>
46
+ <h2>Session Expired</h2>
47
+ <div class="timeout-message">
48
+ Your session has timed out due to inactivity.<br />
49
+ Please log in again to continue.
50
+ </div>
51
+ <a href="/login" class="login-button">Log In Again</a>
52
+ </div>
53
+ <div
54
+ style="text-align: center; margin-top: 20px; color: #666; font-size: 12px"
55
+ >
56
+ Copyright © 2025 TellusDigital. All rights reserved.<br />
57
+ For internal use only.
58
+ </div>
59
+ </body>
60
+ </html>
templates/unauthorized.html ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Authentication Required - API Comparator</title>
5
+ <link
6
+ rel="stylesheet"
7
+ href="{{ url_for('static', path='/css/styles.css') }}"
8
+ />
9
+ <style>
10
+ .unauthorized-container {
11
+ max-width: 500px;
12
+ margin: 100px auto;
13
+ padding: 30px;
14
+ text-align: center;
15
+ background-color: white;
16
+ border-radius: 8px;
17
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
18
+ }
19
+ .unauthorized-icon {
20
+ font-size: 48px;
21
+ color: #dc3545;
22
+ margin-bottom: 20px;
23
+ }
24
+ .unauthorized-message {
25
+ margin: 20px 0;
26
+ color: #666;
27
+ }
28
+ .login-button {
29
+ display: inline-block;
30
+ margin-top: 20px;
31
+ padding: 10px 30px;
32
+ background-color: #4caf50;
33
+ color: white;
34
+ text-decoration: none;
35
+ border-radius: 4px;
36
+ transition: background-color 0.3s;
37
+ }
38
+ .login-button:hover {
39
+ background-color: #45a049;
40
+ }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <div class="unauthorized-container">
45
+ <div class="unauthorized-icon">🔒</div>
46
+ <h2>Authentication Required</h2>
47
+ <div class="unauthorized-message">
48
+ You need to be logged in to access this page.<br />
49
+ Please log in to continue.
50
+ </div>
51
+ <a href="/login" class="login-button">Log In</a>
52
+ </div>
53
+ <div
54
+ style="text-align: center; margin-top: 20px; color: #666; font-size: 12px"
55
+ >
56
+ Copyright © 2025 TellusDigital. All rights reserved.<br />
57
+ For internal use only.
58
+ </div>
59
+ </body>
60
+ </html>
tests/__pycache__/conftest.cpython-312-pytest-7.4.3.pyc ADDED
Binary file (2.5 kB). View file
 
tests/__pycache__/conftest.cpython-312-pytest-8.0.0.pyc ADDED
Binary file (2.5 kB). View file
 
tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc ADDED
Binary file (1.52 kB). View file
 
tests/__pycache__/test_api_comparison.cpython-312-pytest-7.4.3.pyc ADDED
Binary file (12 kB). View file
 
tests/__pycache__/test_api_comparison.cpython-312-pytest-8.0.0.pyc ADDED
Binary file (12 kB). View file
 
tests/__pycache__/test_api_comparison.cpython-312-pytest-8.3.5.pyc ADDED
Binary file (11 kB). View file
 
tests/__pycache__/test_auth.cpython-312-pytest-7.4.3.pyc ADDED
Binary file (11.1 kB). View file
 
tests/__pycache__/test_auth.cpython-312-pytest-8.0.0.pyc ADDED
Binary file (11.1 kB). View file
 
tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc ADDED
Binary file (9.71 kB). View file
 
tests/__pycache__/test_frontend.cpython-312-pytest-7.4.3.pyc ADDED
Binary file (14.7 kB). View file
 
tests/__pycache__/test_frontend.cpython-312-pytest-8.0.0.pyc ADDED
Binary file (14.7 kB). View file
 
tests/__pycache__/test_frontend.cpython-312-pytest-8.3.5.pyc ADDED
Binary file (9.88 kB). View file
 
tests/__pycache__/test_utility.cpython-312-pytest-7.4.3.pyc ADDED
Binary file (18.9 kB). View file
 
tests/__pycache__/test_utility.cpython-312-pytest-8.0.0.pyc ADDED
Binary file (19.3 kB). View file
 
tests/__pycache__/test_utility.cpython-312-pytest-8.3.5.pyc ADDED
Binary file (9.16 kB). View file
 
tests/__pycache__/test_websocket.cpython-312-pytest-7.4.3.pyc ADDED
Binary file (3.57 kB). View file
 
tests/__pycache__/test_websocket.cpython-312-pytest-8.3.5.pyc ADDED
Binary file (3.33 kB). View file
 
tests/conftest.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pytest fixtures for testing FastAPI application components"""
2
+
3
+ import pytest
4
+ from fastapi import FastAPI
5
+ from fastapi.testclient import TestClient
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.templating import Jinja2Templates
8
+ from starlette.middleware.sessions import SessionMiddleware
9
+ from utils.utility import USER_CREDENTIALS
10
+ from main import app, SECRET_KEY, SESSION_TIMEOUT
11
+
12
+
13
+ @pytest.fixture
14
+ def client():
15
+ """Create a test client with session middleware"""
16
+ # Create a fresh FastAPI application for testing
17
+ test_app = FastAPI()
18
+
19
+ # Add SessionMiddleware
20
+ test_app.add_middleware(
21
+ SessionMiddleware,
22
+ secret_key=SECRET_KEY,
23
+ session_cookie="api_comparator_session",
24
+ max_age=SESSION_TIMEOUT
25
+ )
26
+
27
+ # Configure templates and static files
28
+ test_app.mount("/static", StaticFiles(directory="static"), name="static")
29
+ templates = Jinja2Templates(directory="templates")
30
+ test_app.state.templates = templates
31
+
32
+ # Include all routes from the main app
33
+ for route in app.routes:
34
+ test_app.router.routes.append(route)
35
+
36
+ # Create and return the test client
37
+ return TestClient(test_app)
38
+
39
+
40
+ @pytest.fixture
41
+ def test_user():
42
+ """Return test user credentials"""
43
+ return {
44
+ "username": next(iter(USER_CREDENTIALS.keys())),
45
+ "password": next(iter(USER_CREDENTIALS.values()))
46
+ }
47
+
48
+
49
+ @pytest.fixture
50
+ def mock_apis():
51
+ """Return mock API configurations"""
52
+ return {
53
+ "api1": {
54
+ "url": "http://api1.example.com",
55
+ "method": "GET",
56
+ "payload": "{}",
57
+ "headers": "{}"
58
+ },
59
+ "api2": {
60
+ "url": "http://api2.example.com",
61
+ "method": "GET",
62
+ "payload": "{}",
63
+ "headers": "{}"
64
+ }
65
+ }
tests/test_api_comparison.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test module for API comparison functionality"""
2
+
3
+ from unittest.mock import AsyncMock, patch # Standard library
4
+
5
+ import aiohttp
6
+ import pytest # Third-party
7
+ from fastapi import status
8
+
9
+
10
+ def test_compare_page_protected(client):
11
+ """Verify that compare page requires authentication"""
12
+ response = client.get("/compare", follow_redirects=False)
13
+ assert response.status_code == status.HTTP_302_FOUND
14
+ assert response.headers["location"] == "/login"
15
+
16
+
17
+ def test_compare_page_authenticated(client, test_user):
18
+ """Verify authenticated user can access compare page"""
19
+ client.post("/login", data=test_user)
20
+ response = client.get("/compare")
21
+ assert response.status_code == status.HTTP_200_OK
22
+
23
+
24
+ def test_compare_apis_validation(client, test_user):
25
+ """Test API comparison form validation"""
26
+ client.post("/login", data=test_user)
27
+
28
+ response = client.post("/compare", data={
29
+ "api1_url": "not-a-url",
30
+ "api1_method": "GET",
31
+ "api1_payload": "{}",
32
+ "api1_headers": "{}",
33
+ "api2_url": "http://example.com",
34
+ "api2_method": "GET",
35
+ "api2_payload": "{}",
36
+ "api2_headers": "{}",
37
+ "view_mode": "line"
38
+ })
39
+ assert "valid URL" in response.text
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_compare_apis_success(client, test_user):
44
+ """Test successful API comparison with mocked responses, including SSL setting."""
45
+ mock_data1 = {"test": "data1"}
46
+ mock_data2 = {"test": "data2"}
47
+
48
+ # Mock responses
49
+ mock_response1 = AsyncMock()
50
+ mock_response1.json.return_value = mock_data1
51
+ mock_response1.status = 200
52
+ mock_response1.__aenter__.return_value = mock_response1
53
+
54
+ mock_response2 = AsyncMock()
55
+ mock_response2.json.return_value = mock_data2
56
+ mock_response2.status = 200
57
+ mock_response2.__aenter__.return_value = mock_response2
58
+
59
+ # Mock session
60
+ mock_session = AsyncMock(spec=aiohttp.ClientSession)
61
+ mock_session.__aenter__.return_value = mock_session
62
+ mock_session.request.side_effect = [mock_response1, mock_response2]
63
+
64
+ # Login the test user
65
+ client.post("/login", data=test_user)
66
+
67
+ # Patch aiohttp ClientSession
68
+ with patch('aiohttp.ClientSession', return_value=mock_session):
69
+ response = client.post("/compare", data={
70
+ "api1_url": "http://api1.example.com",
71
+ "api1_method": "GET",
72
+ "api1_payload": "{}",
73
+ "api1_headers": "{}",
74
+ "api2_url": "http://api2.example.com",
75
+ "api2_method": "GET",
76
+ "api2_payload": "{}",
77
+ "api2_headers": "{}",
78
+ "view_mode": "line"
79
+ }, follow_redirects=True)
80
+
81
+ assert response.status_code == status.HTTP_200_OK
82
+ assert "data1" in response.text
83
+ assert "data2" in response.text
84
+ assert mock_session.request.call_count == 2
85
+
86
+ # Check that requests include the ssl argument (which can be None or a context)
87
+ mock_session.request.assert_any_call(
88
+ method='GET',
89
+ url="http://api1.example.com",
90
+ json=None,
91
+ params={},
92
+ headers={},
93
+ timeout=30,
94
+ ssl=None # 🛡️ Include SSL param check
95
+ )
96
+ mock_session.request.assert_any_call(
97
+ method='GET',
98
+ url="http://api2.example.com",
99
+ json=None,
100
+ params={},
101
+ headers={},
102
+ timeout=30,
103
+ ssl=None
104
+ )
105
+
106
+
107
+ def test_download_result_protected(client):
108
+ """Test that download requires authentication"""
109
+ response = client.post("/download/", data={
110
+ "content": "{}",
111
+ "filename": "test.json",
112
+ "username": "admin"
113
+ }, follow_redirects=False)
114
+ assert response.status_code == status.HTTP_302_FOUND
115
+ assert response.headers["location"] == "/login"
116
+
117
+
118
+ def test_download_result_authenticated(client, test_user):
119
+ """Test authenticated download with valid JSON"""
120
+ client.post("/login", data=test_user)
121
+
122
+ response = client.post("/download/", data={
123
+ "content": '{"test": "data"}',
124
+ "filename": "test.json",
125
+ "username": test_user["username"]
126
+ })
127
+ assert response.status_code == status.HTTP_200_OK
128
+ assert response.headers["content-type"] == "application/json"
tests/test_auth.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test module for authentication functionality"""
2
+ from fastapi import status
3
+
4
+
5
+ def test_login_success(client, test_user):
6
+ """Test successful login"""
7
+ response = client.post(
8
+ "/login",
9
+ data=test_user,
10
+ follow_redirects=False
11
+ )
12
+ assert response.status_code == status.HTTP_302_FOUND
13
+ assert response.headers["location"] == "/compare"
14
+
15
+ # Verify session is set
16
+ response = client.get("/compare")
17
+ assert response.status_code == status.HTTP_200_OK
18
+
19
+
20
+ def test_login_failure(client):
21
+ """Test login with invalid credentials"""
22
+ response = client.post(
23
+ "/login",
24
+ data={"username": "invalid", "password": "wrong"},
25
+ follow_redirects=False
26
+ )
27
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
28
+ assert "Invalid credentials" in response.text
29
+
30
+
31
+ def test_session_timeout(client, test_user):
32
+ """Test session timeout behavior"""
33
+ # First login
34
+ client.post("/login", data=test_user)
35
+
36
+ # Access protected route should succeed
37
+ response = client.get("/compare")
38
+ assert response.status_code == status.HTTP_200_OK
39
+
40
+ # Clear session cookie to simulate timeout
41
+ client.cookies.clear()
42
+
43
+ # Access should redirect to login
44
+ response = client.get("/compare", follow_redirects=False)
45
+ assert response.status_code == status.HTTP_302_FOUND
46
+ assert response.headers["location"] == "/login"
47
+
48
+
49
+ def test_session_timeout_page(client):
50
+ """Test session timeout page content"""
51
+ # When session times out, user is redirected to login
52
+ response = client.get("/login")
53
+ assert response.status_code == status.HTTP_200_OK
54
+ assert "Login" in response.text
55
+
56
+
57
+ def test_logout(client, test_user):
58
+ """Test logout functionality"""
59
+ # First login
60
+ client.post("/login", data=test_user)
61
+
62
+ # Then logout
63
+ response = client.get("/logout", follow_redirects=False)
64
+ assert response.status_code == status.HTTP_302_FOUND
65
+ assert response.headers["location"] == "/login"
66
+
67
+ # Verify can't access protected route anymore
68
+ response = client.get("/compare", follow_redirects=False)
69
+ assert response.status_code == status.HTTP_302_FOUND
70
+ assert response.headers["location"] == "/login"
tests/test_frontend.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test module for frontend functionality"""
2
+ from bs4 import BeautifulSoup
3
+ from fastapi import status
4
+
5
+
6
+ def test_compare_form_validation(client, test_user):
7
+ """Test form validation for API comparison"""
8
+ client.post("/login", data=test_user)
9
+
10
+ response = client.post("/compare", data={
11
+ "api1_url": "not-a-url",
12
+ "api1_method": "GET",
13
+ "api1_payload": "{}",
14
+ "api1_headers": "{}",
15
+ "api2_url": "http://example.com",
16
+ "api2_method": "GET",
17
+ "api2_payload": "{}",
18
+ "api2_headers": "{}",
19
+ "view_mode": "line"
20
+ })
21
+ assert "valid URL" in response.text
22
+
23
+
24
+ def test_download_error_handling(client, test_user):
25
+ """Test error handling for file downloads"""
26
+ client.post("/login", data=test_user)
27
+
28
+ response = client.post("/download/", data={
29
+ "content": "{invalid-json}",
30
+ "filename": "test.json",
31
+ "username": test_user["username"]
32
+ })
33
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
34
+ assert "Invalid JSON content" in response.text
35
+
36
+
37
+ def test_session_timeout_redirect(client, test_user):
38
+ """Test session timeout redirects to login page"""
39
+ client.post("/login", data=test_user)
40
+
41
+ response = client.get("/compare")
42
+ assert response.status_code == status.HTTP_200_OK
43
+
44
+ client.cookies.clear()
45
+
46
+ response = client.get("/compare", follow_redirects=False)
47
+ assert response.status_code == status.HTTP_302_FOUND
48
+ assert response.headers["location"] == "/login"
49
+
50
+
51
+ def test_session_timeout_page_content(client):
52
+ """Test session timeout page content"""
53
+ response = client.get("/login")
54
+ assert response.status_code == status.HTTP_200_OK
55
+
56
+ soup = BeautifulSoup(response.text, 'html.parser')
57
+
58
+ assert soup.find('form', {'action': '/login'}) is not None
59
+ assert soup.find('input', {'name': 'username'}) is not None
60
+ assert soup.find('input', {'name': 'password'}) is not None
61
+
62
+
63
+ def test_error_page_content(client, test_user):
64
+ """Test error page content"""
65
+ client.post("/login", data=test_user)
66
+
67
+ response = client.post("/compare", data={
68
+ "api1_url": "http://example.com",
69
+ "api1_method": "GET",
70
+ "api1_payload": "{}",
71
+ "api1_headers": "{}",
72
+ "api2_url": "http://example.com",
73
+ "api2_method": "GET",
74
+ "api2_payload": "{}",
75
+ "api2_headers": "{}"
76
+ })
77
+ assert response.status_code == status.HTTP_200_OK
78
+ assert "API URLs cannot be identical" in response.text
79
+
80
+
81
+ def test_error_handler_internal_server_error(client, test_user):
82
+ """Test internal server error handling"""
83
+ client.post("/login", data=test_user)
84
+
85
+ response = client.post("/compare", data={
86
+ "api1_url": "http://api1.example.com",
87
+ "api1_method": "GET",
88
+ "api1_payload": "{invalid-json}",
89
+ "api1_headers": "{}",
90
+ "api2_url": "http://api2.example.com",
91
+ "api2_method": "GET",
92
+ "api2_payload": "{}",
93
+ "api2_headers": "{}"
94
+ })
95
+ assert response.status_code == status.HTTP_200_OK
96
+ assert "error" in response.text.lower()
97
+ soup = BeautifulSoup(response.text, 'html.parser')
98
+ error_div = soup.find('div', {'class': 'error'})
99
+ assert error_div is not None
100
+ assert "invalid" in error_div.text.lower()
101
+
tests/test_utility.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for utility functions used in API comparison."""
2
+
3
+ from unittest.mock import AsyncMock, patch
4
+
5
+ import aiohttp
6
+ import pytest
7
+
8
+ from utils.utility import (
9
+ validate_urls,
10
+ validate_json_inputs,
11
+ parse_json_input,
12
+ compare_responses,
13
+ json_line_diff,
14
+ highlight_json_diff,
15
+ fetch_api_responses
16
+ )
17
+
18
+
19
+ def test_validate_urls():
20
+ """Test URL validation including valid, invalid, and duplicate cases."""
21
+ validate_urls("http://example.com", "http://api.example.com")
22
+
23
+ with pytest.raises(ValueError):
24
+ validate_urls("not-a-url", "http://example.com")
25
+
26
+ with pytest.raises(ValueError):
27
+ validate_urls("http://example.com", "http://example.com")
28
+
29
+
30
+ def test_validate_json_inputs():
31
+ """Test validation of JSON inputs, including valid and invalid cases."""
32
+ validate_json_inputs(
33
+ ('{"key": "value"}', "Test 1"),
34
+ ('[]', "Test 2")
35
+ )
36
+
37
+ with pytest.raises(ValueError):
38
+ validate_json_inputs(
39
+ ('{invalid}', "Test Invalid"),
40
+ ('{}', "Test Valid")
41
+ )
42
+
43
+
44
+ def test_parse_json_input():
45
+ """Test parsing of valid and invalid JSON strings."""
46
+ assert parse_json_input('{"test": "data"}', "Test") == {"test": "data"}
47
+ assert parse_json_input('[]', "Test") == []
48
+ assert parse_json_input('{}', "Test") == {}
49
+
50
+ with pytest.raises(ValueError):
51
+ parse_json_input('{invalid}', "Test Invalid")
52
+
53
+
54
+ def test_compare_responses():
55
+ """Test comparison of two JSON responses in tree and text views."""
56
+ json1 = {"key1": "val1", "key2": "val2"}
57
+ json2 = {"key1": "val1", "key3": "val3"}
58
+
59
+ tree_diff = compare_responses(json1, json2, view="tree")
60
+ assert isinstance(tree_diff, dict)
61
+ assert "dictionary_item_removed" in str(tree_diff)
62
+ assert "dictionary_item_added" in str(tree_diff)
63
+
64
+ text_diff = compare_responses(json1, json2, view="text")
65
+ assert isinstance(text_diff, str)
66
+ assert "key2" in text_diff
67
+ assert "key3" in text_diff
68
+
69
+
70
+ def test_json_line_diff():
71
+ """Test line-by-line difference between two JSON objects."""
72
+ json1 = {"test": "data1"}
73
+ json2 = {"test": "data2"}
74
+
75
+ diff = json_line_diff(json1, json2)
76
+ assert isinstance(diff, str)
77
+ assert "data1" in diff
78
+ assert "data2" in diff
79
+
80
+
81
+ def test_highlight_json_diff():
82
+ """Test HTML highlighting of differences between two JSON objects."""
83
+ json1 = {"test": "data1"}
84
+ json2 = {"test": "data2"}
85
+
86
+ html1, html2 = highlight_json_diff(json1, json2)
87
+ assert isinstance(html1, str)
88
+ assert isinstance(html2, str)
89
+ assert "data1" in html1
90
+ assert "data2" in html2
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_fetch_api_responses():
95
+ """Test the fetch_api_responses function with proper async mocks, including SSL."""
96
+ mock_data = {"test": "data"}
97
+
98
+ # Mock a single response
99
+ mock_response = AsyncMock()
100
+ mock_response.json.return_value = mock_data
101
+ mock_response.status = 200
102
+ mock_response.__aenter__.return_value = mock_response
103
+
104
+ # Create session mock
105
+ mock_session = AsyncMock(spec=aiohttp.ClientSession)
106
+ mock_session.__aenter__.return_value = mock_session
107
+ mock_session.request.side_effect = [mock_response, mock_response]
108
+
109
+ with patch('aiohttp.ClientSession', return_value=mock_session):
110
+ responses = await fetch_api_responses(
111
+ "http://api1.example.com", "GET", {}, {},
112
+ "http://api2.example.com", "GET", {}, {}
113
+ )
114
+
115
+ assert len(responses) == 2
116
+ response1, time1 = responses[0]
117
+ response2, time2 = responses[1]
118
+
119
+ assert response1['data'] == mock_data
120
+ assert response2['data'] == mock_data
121
+ assert response1['status'] == 200
122
+ assert response2['status'] == 200
123
+ assert isinstance(time1, float)
124
+ assert isinstance(time2, float)
125
+ assert mock_session.request.call_count == 2
126
+
127
+ expected_kwargs = {
128
+ 'method': 'GET',
129
+ 'json': None,
130
+ 'params': {},
131
+ 'headers': {},
132
+ 'timeout': 30,
133
+ 'ssl': None
134
+ }
135
+
136
+ mock_session.request.assert_any_call(
137
+ url="http://api1.example.com",
138
+ **expected_kwargs
139
+ )
140
+ mock_session.request.assert_any_call(
141
+ url="http://api2.example.com",
142
+ **expected_kwargs
143
+ )
utils/__pycache__/api_utils.cpython-312.pyc ADDED
Binary file (2.1 kB). View file
 
utils/__pycache__/logger.cpython-312.pyc ADDED
Binary file (2.73 kB). View file
 
utils/__pycache__/utility.cpython-312.pyc ADDED
Binary file (14.2 kB). View file
 
utils/api_comparator.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API Comparator Module
3
+
4
+ This module provides the Streamlit-based web interface for comparing API responses.
5
+ It implements the UI components, API request handling, and response comparison functionality.
6
+ """
7
+
8
+ import difflib
9
+ import json
10
+ from json.decoder import JSONDecodeError
11
+
12
+ import streamlit as st
13
+ from requests.exceptions import RequestException
14
+
15
+ from utility import compare_responses, json_line_diff, USER_CREDENTIALS
16
+ from utility import validate_urls, validate_json_inputs, parse_json_input, fetch_api_responses
17
+ from utils.logger import structlog
18
+
19
+ log = structlog.get_logger()
20
+
21
+ st.set_page_config(page_title="ARC", layout="wide")
22
+
23
+ # Custom CSS including download buttons
24
+ CUSTOM_CSS = """
25
+ div.stButton > button:first-child, div.stDownloadButton > button:first-child {
26
+ background-color: #4CAF50;
27
+ color: white;
28
+ border-radius: 8px;
29
+ border: none;
30
+ padding: 10px 24px;
31
+ text-align: center;
32
+ text-decoration: none;
33
+ display: inline-block;
34
+ font-size: 16px;
35
+ margin: 4px 2px;
36
+ cursor: pointer;
37
+ transition-duration: 0.4s;
38
+ }
39
+ div.stButton > button:first-child:hover,
40
+ div.stDownloadButton > button:first-child:hover {
41
+ background-color: #45a049;
42
+ }
43
+ """
44
+
45
+ # Apply custom CSS
46
+ st.markdown(CUSTOM_CSS, unsafe_allow_html=True)
47
+
48
+ # Move logo to upper right corner with smaller size
49
+ _, logo_col = st.columns([9, 1])
50
+ with logo_col:
51
+ st.image("TD-logo.jpeg", width=100)
52
+
53
+ # Streamlit UI
54
+ st.title("API Response Comparator")
55
+
56
+ col1, col2 = st.columns(2)
57
+
58
+
59
+ def get_supported_methods():
60
+ """Return list of supported HTTP methods."""
61
+ return ["GET", "POST", "PUT", "DELETE", "PATCH"]
62
+
63
+
64
+ def login():
65
+ """Handle user login through Streamlit interface."""
66
+ st.title("Login")
67
+
68
+ username = st.text_input("Username")
69
+ password = st.text_input("Password", type="password")
70
+
71
+ if st.button("Login"):
72
+ if username in USER_CREDENTIALS and USER_CREDENTIALS[username] == password:
73
+ st.session_state.logged_in = True
74
+ st.session_state.username = username
75
+ log.info("User logged in successfully", username=username)
76
+ st.success(f"Welcome, {username}!")
77
+ st.rerun()
78
+ else:
79
+ log.warning("Failed login attempt", username=username)
80
+ st.error("Invalid username or password")
81
+
82
+
83
+ if "logged_in" not in st.session_state:
84
+ st.session_state.logged_in = False
85
+
86
+ if not st.session_state.logged_in:
87
+ login()
88
+ st.stop()
89
+
90
+ # Set defaults before widgets
91
+ defaults = {
92
+ "api1_url": "",
93
+ "api1_method": "GET",
94
+ "api1_payload": "{}",
95
+ "api2_url": "",
96
+ "api2_method": "GET",
97
+ "api2_payload": "{}",
98
+ }
99
+
100
+ with col1:
101
+ st.header("API 1")
102
+ api1_url = st.text_input("Enter API 1 URL:", key="api1_url", value="")
103
+ api1_method = st.selectbox("Select API 1 Method:", get_supported_methods(), key="api1_method")
104
+ api1_payload = st.text_area(
105
+ "Enter API 1 Payload (JSON format):",
106
+ key="api1_payload",
107
+ value="{}"
108
+ )
109
+ api1_headers = st.text_area(
110
+ "Enter API 1 Headers (JSON format):",
111
+ key="api1_headers",
112
+ value="{}"
113
+ )
114
+
115
+ with col2:
116
+ st.header("API 2")
117
+ api2_url = st.text_input("Enter API 2 URL:", key="api2_url", value="")
118
+ api2_method = st.selectbox("Select API 2 Method:", get_supported_methods(), key="api2_method")
119
+ api2_payload = st.text_area(
120
+ "Enter API 2 Payload (JSON format):",
121
+ key="api2_payload", value="{}"
122
+ )
123
+ api2_headers = st.text_area(
124
+ "Enter API 2 Headers (JSON format):",
125
+ key="api2_headers",
126
+ value="{}"
127
+ )
128
+
129
+ # Add diff view selection
130
+ view_mode = st.radio(
131
+ "Comparison View Mode:",
132
+ ["Line-by-line Diff",
133
+ "Tree (DeepDiff)"],
134
+ horizontal=True,
135
+ index=0
136
+ ) # Setting index=0 makes Line-by-line the default
137
+
138
+ # Place Compare and Clear buttons in parallel
139
+ button_col1, button_col2 = st.columns([1, 1])
140
+ with button_col1:
141
+ compare_clicked = st.button("Compare APIs")
142
+
143
+ if compare_clicked:
144
+ try:
145
+ log.info("Starting API comparison",
146
+ api1_url=api1_url,
147
+ api2_url=api2_url)
148
+
149
+ # Input validation
150
+ try:
151
+ validate_urls(api1_url, api2_url)
152
+ validate_json_inputs(
153
+ (api1_payload, "API 1 Payload"),
154
+ (api2_payload, "API 2 Payload"),
155
+ (api1_headers, "API 1 Headers"),
156
+ (api2_headers, "API 2 Headers")
157
+ )
158
+ except ValueError as e:
159
+ log.error("Validation error", error=str(e))
160
+ st.error(str(e))
161
+ st.stop()
162
+
163
+ # Parse and validate input JSON
164
+ try:
165
+ payload1 = parse_json_input(api1_payload, "API 1 Payload")
166
+ payload2 = parse_json_input(api2_payload, "API 2 Payload")
167
+ headers1 = parse_json_input(api1_headers, "API 1 Headers")
168
+ headers2 = parse_json_input(api2_headers, "API 2 Headers")
169
+ except JSONDecodeError as e:
170
+ log.error("JSON parsing error", error=str(e))
171
+ st.error(f"Invalid JSON format: {e}")
172
+ st.stop()
173
+
174
+ # Fetch API responses
175
+ try:
176
+ (response1, execution_time_api1), (response2, execution_time_api2) = fetch_api_responses(
177
+ api1_url, api1_method, payload1, headers1,
178
+ api2_url, api2_method, payload2, headers2
179
+ )
180
+ except RequestException as e:
181
+ log.error("API request failed", error=str(e))
182
+ st.error(f"API request failed: {e}")
183
+ st.stop()
184
+ except Exception as e:
185
+ log.error("Unexpected error in API request", error=str(e))
186
+ st.error(f"An unexpected error occurred: {e}")
187
+ st.stop()
188
+
189
+ # Parse JSON responses
190
+ try:
191
+ json1 = response1.json()
192
+ log.info("API 1 response parsed as JSON")
193
+ except JSONDecodeError as e:
194
+ log.error("API 1 invalid JSON response", error=str(e))
195
+ st.error(f"API 1 did not return valid JSON: {e}")
196
+ st.stop()
197
+ try:
198
+ json2 = response2.json()
199
+ log.info("API 2 response parsed as JSON")
200
+ except JSONDecodeError as e:
201
+ log.error("API 2 invalid JSON response", error=str(e))
202
+ st.error(f"API 2 did not return valid JSON: {e}")
203
+ st.stop()
204
+
205
+ # Compare responses
206
+ try:
207
+ diff_tree = compare_responses(json1, json2, view='tree')
208
+ diff_text = compare_responses(json1, json2, view='text')
209
+ _html_diff = json_line_diff(json1, json2)
210
+ log.info("Generated comparison results",
211
+ view_mode=view_mode)
212
+ except ValueError as e:
213
+ log.error("Comparison error", error=str(e))
214
+ st.error(f"Error comparing responses: {e}")
215
+ st.stop()
216
+
217
+ # Show results and handle downloads
218
+ try:
219
+ # Display successful API calls
220
+ st.subheader("API Results")
221
+ col_api1, col_api2 = st.columns(2)
222
+ with col_api1:
223
+ st.success(
224
+ f"API 1 call executed in {execution_time_api1:.2f} seconds with status code {response1.status_code}.")
225
+ st.markdown("**API 1 Result**")
226
+ st.json(json1)
227
+ st.download_button(
228
+ label="Download API 1 Result",
229
+ data=json.dumps(json1, indent=4),
230
+ file_name="api1_result.json",
231
+ mime="application/json",
232
+ key="download_api1_result"
233
+ )
234
+ with col_api2:
235
+ st.success(
236
+ f"API 2 call executed in {execution_time_api2:.2f} seconds with status code {response2.status_code}.")
237
+ st.markdown("**API 2 Result**")
238
+ st.json(json2)
239
+ st.download_button(
240
+ label="Download API 2 Result",
241
+ data=json.dumps(json2, indent=4),
242
+ file_name="api2_result.json",
243
+ mime="application/json",
244
+ key="download_api2_result"
245
+ )
246
+
247
+ st.subheader("Comparison Result")
248
+ if view_mode == "Tree (DeepDiff)":
249
+ if isinstance(diff_tree, str):
250
+ st.text(diff_tree)
251
+ else:
252
+ st.json(diff_tree)
253
+ elif view_mode == "Line-by-line Diff":
254
+ if _html_diff.strip() == '':
255
+ st.success("Both responses are identical.")
256
+ else:
257
+ st.markdown(_html_diff, unsafe_allow_html=True)
258
+
259
+ # Prepare download data based on view mode
260
+ _download_label = "Download Diff as Text"
261
+ if view_mode == "Line-by-line Diff":
262
+ json1_str = json.dumps(json1, indent=4, sort_keys=True).splitlines()
263
+ json2_str = json.dumps(json2, indent=4, sort_keys=True).splitlines()
264
+ download_data = '\n'.join(difflib.unified_diff(json1_str, json2_str, lineterm=''))
265
+ else:
266
+ download_data = str(diff_text)
267
+
268
+ st.download_button(
269
+ label=_download_label,
270
+ data=download_data,
271
+ file_name="diff_result.txt",
272
+ mime="text/plain",
273
+ key=f"download_diff_Text_{view_mode.replace(' ', '_')}"
274
+ )
275
+ log.info("Successfully displayed and prepared comparison results")
276
+
277
+ except (ValueError, AttributeError) as e:
278
+ log.error("Error displaying results", error=str(e))
279
+ st.error(f"Error displaying results: {e}")
280
+ st.stop()
281
+ except Exception as e:
282
+ log.error("Unexpected error in comparison", error=str(e))
283
+ st.error(f"An unexpected error occurred during comparison: {e}")
284
+ st.stop()
utils/api_utils.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API utility functions and classes for the TellusDigital API Response Comparator
3
+ """
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from utils.logger import structlog
9
+
10
+ log = structlog.get_logger()
11
+
12
+ class CustomJSONEncoder(json.JSONEncoder):
13
+ """Custom JSON encoder that handles DeepDiff types."""
14
+
15
+ def default(self, obj: Any) -> Any:
16
+ """Convert DeepDiff types to JSON-serializable format."""
17
+ try:
18
+ values = getattr(obj, '_values', None)
19
+ if values is not None:
20
+ return list(values)
21
+ return json.JSONEncoder.default(self, obj)
22
+ except TypeError:
23
+ return str(obj)
24
+
25
+ async def broadcast_progress(active_connections: list, message: str) -> None:
26
+ """Broadcast progress updates to all connected WebSocket clients."""
27
+ connections = active_connections.copy()
28
+ for connection in connections:
29
+ try:
30
+ await connection.send_json({"type": "processing", "status": message})
31
+ except Exception as e:
32
+ log.error("Broadcast error", error=str(e))
33
+ if connection in active_connections:
34
+ active_connections.remove(connection)
35
+
36
+
utils/logger.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Logger Configuration Module
3
+
4
+ This module configures structured logging for the API Comparator service using structlog.
5
+ It provides customized log formatting with source file information and exception handling.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from typing import Dict, Any
11
+
12
+ import structlog
13
+
14
+
15
+ def custom_processor(_, __, event_dict: Dict[str, Any]) -> Dict[str, Any]:
16
+ """Process log events to add source file information and handle exceptions.
17
+
18
+ Args:
19
+ _: Unused logger parameter
20
+ __: Unused name parameter
21
+ event_dict: The log event dictionary to process
22
+
23
+ Returns:
24
+ Dict[str, Any]: The processed log event dictionary with added source information
25
+ """
26
+ event_dict["source"] = f"{event_dict.pop('filename')}:{event_dict.pop('lineno')}"
27
+ if event_dict.get("level") == "error" and "exception" in event_dict:
28
+ exception_info = event_dict.pop("exception")
29
+ match = re.search(r'File \".*?\", line (\d+)', exception_info)
30
+ if match:
31
+ event_dict["source"] = f"{event_dict['source'].split(':')[0]}:{match.group(1)}"
32
+ return event_dict
33
+
34
+ # Configure structlog
35
+ structlog.configure(
36
+ processors=[
37
+ structlog.processors.TimeStamper(fmt="iso"),
38
+ structlog.processors.add_log_level,
39
+ structlog.processors.StackInfoRenderer(),
40
+ structlog.processors.format_exc_info,
41
+ structlog.processors.CallsiteParameterAdder(
42
+ [structlog.processors.CallsiteParameter.FILENAME,
43
+ structlog.processors.CallsiteParameter.LINENO]
44
+ ),
45
+ custom_processor,
46
+ structlog.processors.JSONRenderer(),
47
+ ],
48
+ wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
49
+ context_class=dict,
50
+ logger_factory=structlog.PrintLoggerFactory(),
51
+ cache_logger_on_first_use=True,
52
+ )
utils/middleware.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from starlette.middleware.base import BaseHTTPMiddleware
2
+ from starlette.requests import Request
3
+
4
+ class SmartSchemeMiddleware(BaseHTTPMiddleware):
5
+ """
6
+ Middleware to set the correct URL scheme (http or https) based on environment.
7
+ - When running locally (host is 'localhost' or '127.0.0.1'), the scheme remains 'http'.
8
+ - When deployed behind a proxy (e.g., on Hugging Face Spaces), the middleware checks for
9
+ the 'x-forwarded-proto' header and uses its value to set the scheme (typically 'https').
10
+ This helps avoid mixed content issues (e.g., when generating static URLs in templates),
11
+ especially when deploying to environments where HTTPS is terminated at the proxy level.
12
+ """
13
+ async def dispatch(self, request: Request, call_next):
14
+ # Detect local development by checking host
15
+ host = request.headers.get("host", "")
16
+ is_local = host.startswith("127.0.0.1") or host.startswith("localhost")
17
+
18
+ # Only override scheme if not running locally
19
+ if not is_local and "x-forwarded-proto" in request.headers:
20
+ request.scope["scheme"] = request.headers["x-forwarded-proto"]
21
+
22
+ return await call_next(request)
utils/utility.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API Comparator Utility Module
3
+
4
+ This module provides core functionality for the API Comparator service including:
5
+ - API request handling and response fetching
6
+ - JSON validation and comparison
7
+ - User authentication utilities
8
+ - Response diff generation in various formats
9
+ """
10
+
11
+ import asyncio
12
+ import difflib
13
+ import html
14
+ import json
15
+ import ssl
16
+ from dataclasses import dataclass
17
+ from datetime import datetime
18
+ from typing import Tuple, Dict
19
+ from typing import Union, Any
20
+ from urllib.parse import urlparse
21
+
22
+ import aiohttp
23
+ import structlog
24
+ from aiohttp.client_exceptions import ClientSSLError
25
+ from deepdiff import DeepDiff
26
+
27
+ log = structlog.get_logger()
28
+
29
+ # User credentials (in real app, this would be in a secure database)
30
+ USER_CREDENTIALS = {
31
+ "admin": "password123", # For testing only
32
+ "user1": "admin"
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class ApiRequest:
38
+ """Configuration class for API requests.
39
+
40
+ Attributes:
41
+ url: The API endpoint URL
42
+ method: HTTP method to use (GET, POST, etc)
43
+ payload: Request payload data
44
+ headers: Request headers
45
+ timeout: Request timeout in seconds
46
+ max_retries: Maximum number of retry attempts
47
+ """
48
+ url: str
49
+ method: str
50
+ payload: Dict
51
+ headers: Dict
52
+ timeout: int = 30
53
+ max_retries: int = 3
54
+ verify_ssl: bool = True
55
+
56
+
57
+ async def fetch_api_response(
58
+ request: ApiRequest,
59
+ semaphore: asyncio.Semaphore
60
+ ) -> Tuple[Dict, float]:
61
+ """Fetch response from a single API with retries and optional SSL verification."""
62
+ start_time = datetime.now()
63
+
64
+ # Optional SSL context to ignore certificate verification
65
+ ssl_context = None
66
+ if not getattr(request, 'verify_ssl', True):
67
+ ssl_context = ssl.create_default_context()
68
+ ssl_context.check_hostname = False
69
+ ssl_context.verify_mode = ssl.CERT_NONE
70
+
71
+ async with semaphore:
72
+ for attempt in range(1, request.max_retries + 1):
73
+ try:
74
+ async with aiohttp.ClientSession() as session:
75
+ async with session.request(
76
+ method=request.method,
77
+ url=request.url,
78
+ json=request.payload if request.method in ['POST', 'PUT', 'PATCH'] else None,
79
+ params=request.payload if request.method == 'GET' else None,
80
+ headers=request.headers,
81
+ timeout=request.timeout,
82
+ ssl=ssl_context # 🔐 This handles the certificate issue
83
+ ) as response:
84
+ json_response = await response.json()
85
+ execution_time = (datetime.now() - start_time).total_seconds()
86
+ return {
87
+ 'status': response.status,
88
+ 'data': json_response
89
+ }, execution_time
90
+
91
+ except ClientSSLError as ssl_err:
92
+ log.error("SSL Certificate Verification Failed", error=str(ssl_err), url=request.url)
93
+ raise ssl_err
94
+
95
+ except Exception as e:
96
+ if attempt < request.max_retries:
97
+ log.warning("Async API call failed, retrying",
98
+ url=request.url,
99
+ method=request.method,
100
+ attempt=attempt,
101
+ error=str(e))
102
+ await asyncio.sleep(attempt)
103
+ else:
104
+ log.error("Async API call failed after all retries",
105
+ url=request.url,
106
+ method=request.method,
107
+ attempts=request.max_retries,
108
+ error=str(e))
109
+ raise
110
+
111
+
112
+ async def fetch_api_responses(
113
+ url1: str,
114
+ method1: str,
115
+ payload1: Dict,
116
+ headers1: Dict,
117
+ url2: str,
118
+ method2: str,
119
+ payload2: Dict,
120
+ headers2: Dict,
121
+ timeout: int = 30
122
+ ) -> Tuple[Tuple[aiohttp.ClientResponse, float], Tuple[aiohttp.ClientResponse, float]]:
123
+ """Fetch responses from both APIs concurrently"""
124
+ log.info("Starting API calls",
125
+ api1={"url": url1, "method": method1},
126
+ api2={"url": url2, "method": method2})
127
+
128
+ # Create request objects
129
+ request1 = ApiRequest(url1, method1, payload1, headers1, timeout)
130
+ request2 = ApiRequest(url2, method2, payload2, headers2, timeout)
131
+
132
+ # Use semaphore to limit concurrent connections
133
+ semaphore = asyncio.Semaphore(2)
134
+
135
+ try:
136
+ # Fetch both responses concurrently
137
+ responses = await asyncio.gather(
138
+ fetch_api_response(request1, semaphore),
139
+ fetch_api_response(request2, semaphore)
140
+ )
141
+ return responses[0], responses[1]
142
+
143
+ except Exception as e:
144
+ log.error("Error fetching API responses", error=str(e))
145
+ raise
146
+
147
+
148
+ def parse_json_input(input_str: str, label: str) -> Dict:
149
+ """Parse JSON input, return empty dict for empty strings"""
150
+ if not input_str.strip():
151
+ return {}
152
+ try:
153
+ return json.loads(input_str)
154
+ except json.JSONDecodeError as e:
155
+ raise ValueError(f"{label} is not valid JSON: {str(e)}") from e
156
+
157
+
158
+ def validate_urls(*urls: str) -> None:
159
+ """Validate that URLs are properly formatted and not identical"""
160
+ url_set = set()
161
+ for url in urls:
162
+ try:
163
+ parsed = urlparse(url)
164
+ if not all([parsed.scheme, parsed.netloc]):
165
+ raise ValueError(f"'{url}' is not a valid URL")
166
+ url_set.add(url)
167
+ except Exception as e:
168
+ raise ValueError(f"'{url}' is not a valid URL") from e
169
+
170
+ if len(url_set) < len(urls):
171
+ raise ValueError("API URLs cannot be identical")
172
+
173
+
174
+ def validate_json_inputs(*inputs: Tuple[str, str]) -> None:
175
+ """Validate that all inputs are valid JSON"""
176
+ for input_str, name in inputs:
177
+ try:
178
+ if input_str.strip(): # Only try to parse non-empty strings
179
+ json.loads(input_str)
180
+ except json.JSONDecodeError as e:
181
+ raise ValueError(f"Invalid JSON in {name}: {str(e)}") from e
182
+
183
+
184
+ def compare_responses(json1: Any, json2: Any, view: str = 'tree') -> Union[Dict, str]:
185
+ """Compare two JSON responses and return the differences"""
186
+ try:
187
+ diff_result = DeepDiff(json1, json2, ignore_order=True)
188
+
189
+ if not diff_result:
190
+ return {}
191
+
192
+ if view == 'tree':
193
+ # Convert DeepDiff to a plain dict that's JSON serializable
194
+ diff_dict = {}
195
+ for change_type, changes in diff_result.items():
196
+ if isinstance(changes, dict):
197
+ diff_dict[change_type] = {}
198
+ for path, value in changes.items():
199
+ if hasattr(value, '_values'):
200
+ diff_dict[change_type][str(path)] = list(value._values)
201
+ else:
202
+ diff_dict[change_type][str(path)] = str(value)
203
+ else:
204
+ diff_dict[change_type] = str(changes)
205
+ return diff_dict
206
+
207
+ # Convert DeepDiff to readable text format
208
+ text_diff = []
209
+ for change_type, changes in diff_result.items():
210
+ text_diff.append(f"\n{change_type}:")
211
+ if isinstance(changes, dict):
212
+ for path, value in changes.items():
213
+ text_diff.append(f" {path}: {value}")
214
+ else:
215
+ text_diff.append(f" {changes}")
216
+ return "\n".join(text_diff)
217
+
218
+ except Exception as e:
219
+ return {"error": f"Error comparing responses: {str(e)}"}
220
+
221
+
222
+ def json_line_diff(json1: Any, json2: Any) -> str:
223
+ """Generate a line-by-line HTML diff of two JSON objects."""
224
+ try:
225
+ # Convert JSON to formatted strings
226
+ str1 = json.dumps(json1, indent=4, sort_keys=True).splitlines()
227
+ str2 = json.dumps(json2, indent=4, sort_keys=True).splitlines()
228
+
229
+ # Generate diff and convert to HTML
230
+ diff_lines = []
231
+ for line in difflib.unified_diff(str1, str2, lineterm=''):
232
+ if line.startswith('+'):
233
+ diff_lines.append(f'<span style="color: green;">{html.escape(line)}</span>')
234
+ elif line.startswith('-'):
235
+ diff_lines.append(f'<span style="color: red;">{html.escape(line)}</span>')
236
+ else:
237
+ diff_lines.append(html.escape(line))
238
+
239
+ return '<br>'.join(diff_lines)
240
+ except Exception as e:
241
+ return f"Error generating line diff: {str(e)}"
242
+
243
+
244
+ def highlight_json_diff(json1: Any, json2: Any) -> Tuple[str, str]:
245
+ """Highlight differences between two JSON objects and return HTML formatted strings."""
246
+ try:
247
+ # Convert both objects to formatted strings
248
+ str1 = json.dumps(json1, indent=4, sort_keys=True)
249
+ str2 = json.dumps(json2, indent=4, sort_keys=True)
250
+
251
+ # Split into lines
252
+ lines1 = str1.splitlines()
253
+ lines2 = str2.splitlines()
254
+
255
+ # Get differences
256
+ diff = difflib.SequenceMatcher(None, lines1, lines2)
257
+
258
+ # Format the differences
259
+ html1 = []
260
+ html2 = []
261
+
262
+ for tag, i1, i2, j1, j2 in diff.get_opcodes():
263
+ if tag == 'equal':
264
+ # Add unchanged lines
265
+ html1.extend(html.escape(line) for line in lines1[i1:i2])
266
+ html2.extend(html.escape(line) for line in lines2[j1:j2])
267
+ else:
268
+ # Add changed lines with highlighting
269
+ html1.extend(f'<span class="diff-highlight-remove">{html.escape(line)}</span>'
270
+ for line in lines1[i1:i2])
271
+ html2.extend(f'<span class="diff-highlight-add">{html.escape(line)}</span>'
272
+ for line in lines2[j1:j2])
273
+
274
+ return '\n'.join(html1), '\n'.join(html2)
275
+ except Exception as e:
276
+ return str(e), str(e)