diff --git a/controller.py b/controller.py index 1cb297d..c4d1926 100644 --- a/controller.py +++ b/controller.py @@ -39,10 +39,10 @@ def service_event(event, memo: kopf.Memo, logger, **kwargs): if event['type'] == 'DELETED': - if f"{namespace}/{application_name}" in memo.apps: - del memo.apps[f"{namespace}/{application_name}"] + if f"{namespace}.{application_name}" in memo.apps: + del memo.apps[f"{namespace}.{application_name}"] else: - memo.apps.update({f"{namespace}/{application_name}": { + memo.apps.update({f"{namespace}.{application_name}": { 'url': urlunparse(application_url), 'name': application_name, 'header': annotations.get(name_header, ""), @@ -54,4 +54,7 @@ def service_event(event, memo: kopf.Memo, logger, **kwargs): with file_lock: with open('static/openapi/urls.json', 'w') as file: json.dump(urls, file, indent=2) - logger.info("URLs written to file.") \ No newline at end of file + logger.info("URLs written to file.") + with open('static/openapi/services.json', 'w') as file: + json.dump(memo.apps, file, indent=2) + logger.info("Services written to file.") \ No newline at end of file diff --git a/server.py b/server.py index e263b13..512c33c 100644 --- a/server.py +++ b/server.py @@ -6,7 +6,6 @@ import os import requests import logging -from urllib.parse import quote, unquote from starlette.responses import RedirectResponse from authlib.integrations.starlette_client import OAuth from starlette.config import Config @@ -64,29 +63,29 @@ def require_login(request: Request): request.session['user'] = "anonymous" return request.session['user'] - -@app.get("/proxy", include_in_schema=False) -async def proxy(url: str, headers: str = None, user=Depends(require_login)): - """ - Proxy endpoint to fetch the OpenAPI document from a given URL (JSON or YAML). - """ - try: - if headers: - resp = requests.get(url, headers=json.loads(unquote(headers)), timeout=int(os.environ.get("PROXY_TIMEOUT", 10))) - else: - resp = requests.get(url, timeout=int(os.environ.get("PROXY_TIMEOUT", 10))) - content_type = resp.headers.get("content-type", "") - # Se for JSON, repasse como application/json - if "json" in content_type: - return Response(content=resp.content, media_type="application/json") - # Se for YAML, repasse como text/yaml - elif "yaml" in content_type or "yml" in content_type: - return Response(content=resp.content, media_type="text/yaml") - else: - raise HTTPException(status_code=400, detail="Unsupported content type") - except requests.RequestException as e: - logger.error(f"Error fetching OpenAPI document: {e}") - raise HTTPException(status_code=500, detail={"error": "Failed to fetch OpenAPI document", "details": str(e)}) +@app.get("/services/{name}", response_class=HTMLResponse, include_in_schema=False) +async def services(request: Request, name: str, user=Depends(require_login)): + with open('static/openapi/services.json', 'r') as f: + services = json.load(f) + logger.info(f"Loaded {len(services)} services.") + if name not in services: + logger.error(f"Service {name} not found.") + raise HTTPException(status_code=404, detail="Service not found") + service = services[name] + if not service: + logger.error(f"Service {name} not found.") + raise HTTPException(status_code=404, detail="Service not found") + resp = requests.get(service['url'], timeout=int(os.environ.get("PROXY_TIMEOUT", 10)), headers=parse_headers(service.get("header"))) + content_type = resp.headers.get("content-type", "") + # Se for JSON, repasse como application/json + if "json" in content_type: + return Response(content=resp.content, media_type="application/json") + # Se for YAML, repasse como text/yaml + elif "yaml" in content_type or "yml" in content_type: + return Response(content=resp.content, media_type="text/yaml") + else: + logger.error(f"Unsupported content type: {content_type}") + raise HTTPException(status_code=400, detail="Unsupported content type") def parse_headers(header_string: str) -> dict: headers = {} @@ -100,72 +99,50 @@ def parse_headers(header_string: str) -> dict: return headers -def apply_proxy_to_openapi(openapi_url: str, header: str = None) -> str: - """ - Apply the proxy to the OpenAPI URL. - """ - if openapi_url.startswith("http"): - new_url = f"/proxy?url={openapi_url}" - if header: - header = quote(json.dumps(header)) - new_url += f"&headers={header}" - return new_url - return openapi_url - - @app.get("/", response_class=HTMLResponse) -async def docs(request: Request, template:str=None, user=Depends(require_login)): - """ - Main documentation page. - """ +async def index(request: Request, template:str='swagger-ui', user=Depends(require_login)): + if template.lower() not in ["redoc", "swagger-ui"]: + raise HTTPException(status_code=400, detail="Invalid template. Use 'redoc' or 'swagger-ui'.") + try: - with open('static/openapi/urls.json', 'r') as f: - swaggers = json.load(f) - logger.info(f"Loaded {len(swaggers)} URLs.") - except FileNotFoundError: - logger.error("File not found: static/openapi/urls.json") - request.session['error'] = "File not found: static/openapi/urls.json" - swaggers = [ - { - "url": "/openapi.json", - "name": "Swagger Aggregator", - "header": "", - } - ] - except json.JSONDecodeError: - logger.error("Error decoding JSON from static/openapi/urls.json") - request.session['error'] = "Error decoding JSON from static/openapi/urls.json" - swaggers = [ - { - "url": "/openapi.json", - "name": "Swagger Aggregator", - "header": "", - } - ] - - for swagger in swaggers: - swagger["url"] = apply_proxy_to_openapi(swagger.get("url"), parse_headers(swagger.get("header"))) - - if template and template.lower() in ["redoc", "swagger-ui"]: - return templates.TemplateResponse( - f"{template.lower()}.html", - { - "request": request, - "urls": swaggers, - "title": os.environ.get("TITLE", "API Documentation"), - } - ) - interface = os.environ.get("INTERFACE", "swagger-ui").lower() - if interface not in ["swagger-ui", "redoc"]: - interface = "swagger-ui" - return templates.TemplateResponse( - f"{interface}.html", - { - "request": request, - "urls": swaggers, - "title": os.environ.get("TITLE", "API Documentation"), - } - ) + with open('static/openapi/services.json', 'r') as f: + services = json.load(f) + logger.info(f"Loaded {len(services)} services.") + except Exception as e: + logger.error(f"Error loading services file: {e}") + raise HTTPException(status_code=500, detail="Error loading services file.") + + urls = [] + for service_name, service in services.items(): + urls.append({ + "url": f"/services/{service_name}", + "name": service['name'], + "header": service.get("header", ""), + }) + + match template.lower(): + case "redoc": + return templates.TemplateResponse( + "redoc.html", + { + "request": request, + "urls": urls, + "title": os.environ.get("TITLE", "API Documentation"), + } + ) + case "swagger-ui": + return templates.TemplateResponse( + "swagger-ui.html", + { + "request": request, + "urls": urls, + "title": os.environ.get("TITLE", "API Documentation"), + } + ) + case _: + logger.error(f"Invalid template: {template}") + raise HTTPException(status_code=400, detail="Invalid template. Use 'redoc' or 'swagger-ui'.") + @app.get("/config", response_class=HTMLResponse, include_in_schema=False) async def config(request: Request, user=Depends(require_login)): @@ -173,8 +150,8 @@ async def config(request: Request, user=Depends(require_login)): Configuration page for the OpenAPI URLs. """ try: - with open('static/openapi/urls.json') as f: - swaggers = json.load(f) + with open('static/openapi/services.json') as f: + swaggers = json.load(f).values() except Exception as e: logger.error(f"Error loading configuration file: {e}") swaggers = [] diff --git a/tests/test_controller.py b/tests/test_controller.py index 6eb971e..94ef476 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -32,7 +32,7 @@ def test_path_without_slash(fake_memo): logger = MagicMock() with patch("builtins.open"), patch("json.dump"): service_event(event, fake_memo, logger) - key = "default/my-app" + key = "default.my-app" assert key in fake_memo.apps # The final path should contain the original path assert fake_memo.apps[key]['url'].endswith("/openapi.json") @@ -43,7 +43,7 @@ def test_path_with_slash(fake_memo): logger = MagicMock() with patch("builtins.open"), patch("json.dump"): service_event(event, fake_memo, logger) - key = "default/my-app" + key = "default.my-app" assert key in fake_memo.apps assert fake_memo.apps[key]['url'].endswith("/openapi.json") @@ -53,7 +53,7 @@ def test_path_with_host(fake_memo): logger = MagicMock() with patch("builtins.open"), patch("json.dump"): service_event(event, fake_memo, logger) - key = "default/my-app" + key = "default.my-app" assert key in fake_memo.apps # The host should be preserved assert fake_memo.apps[key]['url'].startswith("http://myhost") @@ -72,7 +72,7 @@ def test_missing_annotation(fake_memo): def test_service_event_deleted(fake_memo): # Pre-add an app to memo - fake_memo.apps["default/my-app"] = { + fake_memo.apps["default.my-app"] = { "url": "http://dummy", "name": "my-app", "header": "X-API-KEY" @@ -96,4 +96,4 @@ def test_service_event_deleted(fake_memo): with patch("builtins.open"), patch("json.dump"): service_event(event, fake_memo, logger) # Should remove the app from memo - assert "default/my-app" not in fake_memo.apps \ No newline at end of file + assert "default.my-app" not in fake_memo.apps \ No newline at end of file diff --git a/tests/test_server.py b/tests/test_server.py index 585c846..62b4d04 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,47 +1,54 @@ import json from unittest.mock import patch, mock_open, MagicMock from fastapi.testclient import TestClient -from server import app, apply_proxy_to_openapi -from urllib.parse import unquote +from server import app, parse_headers -client = TestClient(app) - -def test_proxy_invalid_url(): - # Tests error when passing an invalid URL - response = client.get("/proxy", params={"url": "http://invalid-url"}) - assert response.status_code == 500 - assert "Failed to fetch OpenAPI document" in response.text +import pytest -def test_docs_returns_html(): - # Tests if the main route returns HTML - response = client.get("/") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] +client = TestClient(app) -def test_proxy_with_headers(): - url = "http://example.com/openapi.json" - headers_dict = {"Authorization": "Bearer token123"} - headers_encoded = json.dumps(headers_dict) # The endpoint expects headers already in JSON +@pytest.fixture(autouse=True) +def set_proxy_timeout(monkeypatch): + monkeypatch.setenv("PROXY_TIMEOUT", "1") + +def test_services_invalid_name(): + # Tests error when passing an invalid service name + services_dict = { + "default.nginx": { + "url": "http://nginx", + "name": "nginx", + "header": "" + } + } + m = mock_open(read_data=json.dumps(services_dict)) + with patch("builtins.open", m): + response = client.get("/services/i-dont-exist") + assert response.status_code == 404 + assert "Service not found" in response.text +def test_services_json_response(): + # Tests if JSON is returned correctly mock_response = MagicMock() mock_response.content = b'{"openapi": "3.0.0"}' - mock_response.text = '{"openapi": "3.0.0"}' mock_response.headers = {"content-type": "application/json"} - with patch("server.requests.get", return_value=mock_response) as mock_get: - response = client.get("/proxy", params={"url": url, "headers": headers_encoded}) - assert response.status_code == 200 - assert response.json() == {"openapi": "3.0.0"} - mock_get.assert_called_once() - # Checks if the headers were passed correctly - called_headers = mock_get.call_args[1]["headers"] - assert called_headers == headers_dict - -def test_proxy_yaml_response(): - url = "http://example.com/openapi.yaml" - headers_dict = {"Authorization": "Bearer token123"} - headers_json = json.dumps(headers_dict) + services_dict = { + "default.nginx": { + "url": "http://nginx", + "name": "nginx", + "header": "" + } + } + m = mock_open(read_data=json.dumps(services_dict)) + with patch("builtins.open", m): + with patch("server.requests.get", return_value=mock_response): + response = client.get("/services/default.nginx") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + assert response.json() == {"openapi": "3.0.0"} +def test_services_yaml_response(): + # Tests if YAML is returned correctly yaml_content = """ openapi: 3.0.0 info: @@ -51,25 +58,64 @@ def test_proxy_yaml_response(): """ mock_response = MagicMock() mock_response.content = yaml_content.encode("utf-8") - mock_response.text = yaml_content mock_response.headers = {"content-type": "application/yaml"} - with patch("server.requests.get", return_value=mock_response) as mock_get: - response = client.get("/proxy", params={"url": url, "headers": headers_json}) + services_dict = { + "default.timeout": { + "url": "http://timeout", + "name": "timeout", + "header": "" + } + } + m = mock_open(read_data=json.dumps(services_dict)) + with patch("builtins.open", m): + with patch("server.requests.get", return_value=mock_response): + response = client.get("/services/default.timeout") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/yaml") + assert "openapi: 3.0.0" in response.text + +def test_services_unsupported_content_type(): + # Tests error for unsupported content-type + mock_response = MagicMock() + mock_response.content = b"not supported" + mock_response.headers = {"content-type": "text/plain"} + + services_dict = { + "default.nginx": { + "url": "http://nginx", + "name": "nginx", + "header": "" + } + } + m = mock_open(read_data=json.dumps(services_dict)) + with patch("builtins.open", m): + with patch("server.requests.get", return_value=mock_response): + response = client.get("/services/default.nginx") + assert response.status_code == 400 + assert "Unsupported content type" in response.text + +def test_docs_returns_html(): + # Tests if the main route returns HTML + services_dict = { + "default.nginx": { + "url": "http://nginx", + "name": "nginx", + "header": "" + } + } + m = mock_open(read_data=json.dumps(services_dict)) + with patch("builtins.open", m): + response = client.get("/") assert response.status_code == 200 - assert response.headers["content-type"].startswith("text/yaml") - assert "openapi: 3.0.0" in response.text - mock_get.assert_called_once() - called_headers = mock_get.call_args[1]["headers"] - assert called_headers == headers_dict + assert "text/html" in response.headers["content-type"] def test_docs_file_not_found(monkeypatch): # Simulates FileNotFoundError when opening the file with patch("builtins.open", side_effect=FileNotFoundError): response = client.get("/") - assert response.status_code == 200 - # Should contain the default aggregator name - assert "Swagger Aggregator" in response.text + assert response.status_code == 500 + assert "Error loading services file" in response.text def test_docs_json_decode_error(monkeypatch): # Simulates invalid JSON error when opening the file @@ -77,58 +123,45 @@ def test_docs_json_decode_error(monkeypatch): with patch("builtins.open", m): with patch("json.load", side_effect=json.JSONDecodeError("msg", "doc", 0)): response = client.get("/") - assert response.status_code == 200 - assert "Swagger Aggregator" in response.text + assert response.status_code == 500 + assert "Error loading services file" in response.text def test_config_json_decode_error(): - # Simulates invalid JSON error when opening the file + # Simulates invalid JSON error when opening the config file m = mock_open(read_data="not a json") with patch("builtins.open", m): with patch("json.load", side_effect=Exception("invalid json")): response = client.get("/config") assert response.status_code == 200 -def test_apply_proxy_to_openapi_with_http(): - url = "http://example.com/openapi.json" - header = {"Authorization": "Bearer token"} - result = apply_proxy_to_openapi(url, header) - assert result.startswith("/proxy?url=http://example.com/openapi.json") - assert "headers=" in result - headers_param = result.split("headers=")[1] - decoded = json.loads(unquote(headers_param)) # Fixed here! - assert decoded == header - -def test_apply_proxy_to_openapi_with_http_and_headers(): - url = "http://example.com/openapi.json" - header = {"Authorization": "Bearer token"} - result = apply_proxy_to_openapi(url, header) - assert result.startswith("/proxy?url=http://example.com/openapi.json") - assert "headers=" in result - # Decodes and checks the header - headers_param = result.split("headers=")[1] - decoded = json.loads(unquote(headers_param)) - assert decoded == header - -def test_apply_proxy_to_openapi_with_http_no_headers(): - url = "http://example.com/openapi.json" - result = apply_proxy_to_openapi(url) - assert result == f"/proxy?url={url}" - -def test_apply_proxy_to_openapi_with_non_http_url(): - url = "/local/openapi.json" - result = apply_proxy_to_openapi(url) - assert result == url - -def test_apply_proxy_to_openapi_with_empty_header(): - url = "http://example.com/openapi.json" - result = apply_proxy_to_openapi(url, {}) - # Should not add headers if the dict is empty - assert result == f"/proxy?url={url}" - -def test_apply_proxy_to_openapi_with_special_characters_in_header(): - url = "http://example.com/openapi.json" - header = {"X-Test": "çãõ@#%&"} - result = apply_proxy_to_openapi(url, header) - headers_param = result.split("headers=")[1] - decoded = json.loads(unquote(headers_param)) - assert decoded == header +def test_parse_headers_empty(): + # Tests parse_headers with empty input + assert parse_headers("") == {} + assert parse_headers(None) == {} + +def test_parse_headers_valid(): + # Tests parse_headers with valid headers string + header_string = "Authorization: Bearer token\nX-Test: value" + expected = {"Authorization": "Bearer token", "X-Test": "value"} + assert parse_headers(header_string) == expected + +def test_parse_headers_invalid_lines(): + # Tests parse_headers with lines without colon + header_string = "InvalidLine\nKey: Value" + expected = {"Key": "Value"} + assert parse_headers(header_string) == expected + +def test_index_invalid_template(): + # Tests if the main route returns 400 for invalid template + services_dict = { + "default.nginx": { + "url": "http://nginx", + "name": "nginx", + "header": "" + } + } + m = mock_open(read_data=json.dumps(services_dict)) + with patch("builtins.open", m): + response = client.get("/?template=invalid") + assert response.status_code == 400 + assert "Invalid template" in response.text