Spaces:
Sleeping
Sleeping
Commit ·
2d9b352
1
Parent(s): c888f0a
Add application file
Browse files- Dockerfile +34 -0
- LICENSE +23 -0
- Makefile +87 -0
- main.py +427 -0
- pytest.ini +35 -0
- requirements.txt +23 -0
- run.py +26 -0
- static/css/styles.css +236 -0
- static/images/TD-logo.jpeg +0 -0
- templates/compare.html +481 -0
- templates/error.html +80 -0
- templates/login.html +57 -0
- templates/results.html +343 -0
- templates/session_timeout.html +60 -0
- templates/unauthorized.html +60 -0
- tests/__pycache__/conftest.cpython-312-pytest-7.4.3.pyc +0 -0
- tests/__pycache__/conftest.cpython-312-pytest-8.0.0.pyc +0 -0
- tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc +0 -0
- tests/__pycache__/test_api_comparison.cpython-312-pytest-7.4.3.pyc +0 -0
- tests/__pycache__/test_api_comparison.cpython-312-pytest-8.0.0.pyc +0 -0
- tests/__pycache__/test_api_comparison.cpython-312-pytest-8.3.5.pyc +0 -0
- tests/__pycache__/test_auth.cpython-312-pytest-7.4.3.pyc +0 -0
- tests/__pycache__/test_auth.cpython-312-pytest-8.0.0.pyc +0 -0
- tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc +0 -0
- tests/__pycache__/test_frontend.cpython-312-pytest-7.4.3.pyc +0 -0
- tests/__pycache__/test_frontend.cpython-312-pytest-8.0.0.pyc +0 -0
- tests/__pycache__/test_frontend.cpython-312-pytest-8.3.5.pyc +0 -0
- tests/__pycache__/test_utility.cpython-312-pytest-7.4.3.pyc +0 -0
- tests/__pycache__/test_utility.cpython-312-pytest-8.0.0.pyc +0 -0
- tests/__pycache__/test_utility.cpython-312-pytest-8.3.5.pyc +0 -0
- tests/__pycache__/test_websocket.cpython-312-pytest-7.4.3.pyc +0 -0
- tests/__pycache__/test_websocket.cpython-312-pytest-8.3.5.pyc +0 -0
- tests/conftest.py +65 -0
- tests/test_api_comparison.py +128 -0
- tests/test_auth.py +70 -0
- tests/test_frontend.py +101 -0
- tests/test_utility.py +143 -0
- utils/__pycache__/api_utils.cpython-312.pyc +0 -0
- utils/__pycache__/logger.cpython-312.pyc +0 -0
- utils/__pycache__/utility.cpython-312.pyc +0 -0
- utils/api_comparator.py +284 -0
- utils/api_utils.py +36 -0
- utils/logger.py +52 -0
- utils/middleware.py +22 -0
- 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 |
+
>×</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)
|