Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions mindtrace/services/mindtrace/services/core/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
ServerStatus,
ShutdownSchema,
StatusSchema,
DetailedEndpointsSchema,
)
from mindtrace.services.core.utils import generate_connection_manager

Expand Down Expand Up @@ -114,6 +115,11 @@ async def lifespan(app: FastAPI):
func=named_lambda("endpoints", lambda: {"endpoints": list(self._endpoints.keys())}),
schema=EndpointsSchema(),
)
self.add_endpoint(
path="/detailed_endpoints",
func=named_lambda("detailed_endpoints", lambda: self.get_detailed_endpoints()),
schema=DetailedEndpointsSchema(),
)
self.add_endpoint(
path="/status", func=named_lambda("status", lambda: {"status": self.status.value}), schema=StatusSchema()
)
Expand Down Expand Up @@ -454,3 +460,15 @@ def add_endpoint(
methods=ifnone(methods, default=["POST"]),
**api_route_kwargs,
)

def get_detailed_endpoints(self):
"""Return detailed schema information for all endpoints."""
endpoints_detail = {}
for endpoint_name, task_schema in self._endpoints.items():
# Convert schema information to serializable format
endpoints_detail[endpoint_name] = {
"name": task_schema.name,
"input_schema": task_schema.input_schema.model_json_schema() if task_schema.input_schema else None,
"output_schema": task_schema.output_schema.model_json_schema() if task_schema.output_schema else None,
}
return {"endpoints": endpoints_detail}
9 changes: 9 additions & 0 deletions mindtrace/services/mindtrace/services/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ class EndpointsSchema(TaskSchema):
output_schema: Type[EndpointsOutput] = EndpointsOutput


class DetailedEndpointsOutput(BaseModel):
endpoints: dict[str, dict[str, Any]]


class DetailedEndpointsSchema(TaskSchema):
name: str = "detailed_endpoints"
output_schema: Type[DetailedEndpointsOutput] = DetailedEndpointsOutput


class StatusOutput(BaseModel):
status: ServerStatus

Expand Down
3 changes: 3 additions & 0 deletions mindtrace/services/mindtrace/services/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ class ServiceConnectionManager(ConnectionManager):
ServiceConnectionManager._service_class = service_cls
ServiceConnectionManager._service_endpoints = temp_service._endpoints

# Store service endpoints in the connection manager class for ProxyConnectionManager access
ServiceConnectionManager._service_endpoints = temp_service._endpoints

# Dynamically define one method per endpoint
for endpoint_name, endpoint in temp_service._endpoints.items():
# Skip if this would override an existing method in ConnectionManager
Expand Down
350 changes: 340 additions & 10 deletions mindtrace/services/mindtrace/services/gateway/gateway.py

Large diffs are not rendered by default.

397 changes: 226 additions & 171 deletions mindtrace/services/mindtrace/services/gateway/proxy_connection_manager.py

Large diffs are not rendered by default.

632 changes: 440 additions & 192 deletions tests/integration/mindtrace/services/test_gateway_integration.py

Large diffs are not rendered by default.

49 changes: 30 additions & 19 deletions tests/unit/mindtrace/services/gateway/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@ async def test_enhanced_connection_manager_register_app_with_proxy(self, gateway
mock_base_cm.register_app = Mock(return_value={'status': 'registered'})
mock_base_cm.aregister_app = AsyncMock(return_value={'status': 'registered'})

# Mock the list_apps_with_schemas method that registered_apps() calls
from mindtrace.services.gateway.gateway import AppInfo, ListAppsWithSchemasResponse
mock_app_info = AppInfo(name="test-service", url="http://localhost:8001/", endpoints={})
mock_response = ListAppsWithSchemasResponse(apps=[mock_app_info])
mock_base_cm.list_apps_with_schemas = Mock(return_value=mock_response)

# The mock should return a constructor function that returns the mock instance
mock_generate.return_value = lambda url: mock_base_cm

Expand Down Expand Up @@ -262,8 +268,9 @@ async def test_enhanced_connection_manager_register_app_with_proxy(self, gateway
assert hasattr(enhanced_cm, 'test-service')
assert getattr(enhanced_cm, 'test-service') == mock_proxy_instance

# Test that registered_apps property works
assert 'test-service' in enhanced_cm.registered_apps
# Test that registered_apps shows the tracked app
registered_apps_result = enhanced_cm.registered_apps()
assert len(registered_apps_result) == 1

@pytest.mark.asyncio
async def test_enhanced_connection_manager_aregister_app_with_proxy(self, gateway):
Expand Down Expand Up @@ -314,6 +321,12 @@ def test_enhanced_connection_manager_without_proxy(self, gateway):
mock_base_cm.url = "http://localhost:8090/"
mock_base_cm.register_app = Mock(return_value={'status': 'registered'})

# Mock the list_apps_with_schemas method that registered_apps() calls
from mindtrace.services.gateway.gateway import AppInfo, ListAppsWithSchemasResponse
mock_app_info = AppInfo(name="simple-service", url="http://localhost:8003/", endpoints={})
mock_response = ListAppsWithSchemasResponse(apps=[mock_app_info])
mock_base_cm.list_apps_with_schemas = Mock(return_value=mock_response)

mock_generate.return_value = lambda url: mock_base_cm

with patch.object(Gateway, 'status_at_host', return_value=ServerStatus.AVAILABLE):
Expand All @@ -331,12 +344,15 @@ def test_enhanced_connection_manager_without_proxy(self, gateway):
# Test that original method was called by verifying the result
assert result == {'status': 'registered'}

# Test that no proxy attribute was created (Mock objects auto-create attributes,
# so we check that _registered_apps is empty instead)
assert len(enhanced_cm._registered_apps) == 0
# Test that no proxy attribute was created
# When registering without connection_manager, the app should be tracked but no proxy created
assert len(enhanced_cm._registered_apps) == 1
assert "simple-service" in enhanced_cm._registered_apps
assert enhanced_cm._registered_apps["simple-service"] is None

# Test that registered_apps is empty
assert len(enhanced_cm.registered_apps) == 0
# Test that registered_apps shows the tracked app
registered_apps_result = enhanced_cm.registered_apps()
assert len(registered_apps_result) == 1


class TestProxyConnectionManagerIntegration:
Expand Down Expand Up @@ -391,23 +407,18 @@ def test_proxy_method_call_success(self, proxy_cm):

def test_proxy_method_call_no_args(self, proxy_cm):
"""Test method call with no arguments through proxy."""
with patch('requests.post') as mock_post:
with patch('requests.get') as mock_get:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'status': 'ok'}
mock_post.return_value = mock_response
mock_get.return_value = mock_response

# Get the dynamically created method directly from instance dict to avoid __getattribute__
instance_dict = object.__getattribute__(proxy_cm, "__dict__")
status_method = instance_dict["status"]
# Test calling a no-arg method (status method should use GET since it has no required params)
result = proxy_cm.status()

# Test calling a no-arg method
result = status_method()

# Verify POST was used (all proxy methods use POST)
mock_post.assert_called_once_with(
# Verify GET was used for no-arg method
mock_get.assert_called_once_with(
"http://localhost:8090/test-service/status",
json={},
timeout=60
)

Expand All @@ -426,7 +437,7 @@ def test_proxy_method_call_failure(self, proxy_cm):
with pytest.raises(RuntimeError) as exc_info:
echo_method(message="test")

assert "Gateway proxy request failed: Internal Server Error" in str(exc_info.value)
assert "Gateway proxy request failed for 'echo': 500 - Internal Server Error" in str(exc_info.value)

def test_proxy_attribute_access(self, proxy_cm, mock_original_cm):
"""Test accessing internal attributes of proxy."""
Expand Down
Loading