Skip to content

Commit 13cc21d

Browse files
committed
feat: Add x-utcp-auth OpenAPI extension with auth inheritance
Add support for the x-utcp-auth extension in OpenAPI specifications with seamless authentication inheritance from manual call templates. ## Problem The OpenAPI converter only processed standard OpenAPI security schemes, which limited flexibility for UTCP-specific authentication requirements. Users needed a way to specify authentication configuration that would be properly recognized and applied to generated UTCP tools without requiring environment variables. ## Solution Extended the OpenAPI converter to: 1. Support x-utcp-auth extension that takes precedence over standard security schemes 2. Inherit authentication configuration from manual call templates 3. Fall back to placeholder generation when auth is incompatible 4. Maintain backward compatibility with existing OpenAPI security ## Benefits - Enables per-operation authentication configuration via x-utcp-auth extension - Seamless auth inheritance from manual call templates eliminates environment variables - Maintains backward compatibility with standard OpenAPI security - Supports real-world authentication patterns where all endpoints use same auth - Provides UTCP-specific auth control without breaking existing specs ## Usage Example ```json { "manual_call_templates": [{ "name": "aws_api", "call_template_type": "http", "url": "https://api.example.com/openapi.json", "auth": { "auth_type": "api_key", "api_key": "Bearer token-123", "var_name": "Authorization", "location": "header" } }] } ``` OpenAPI operations with x-utcp-auth extension: ```json { "paths": { "/protected": { "get": { "operationId": "get_protected_data", "x-utcp-auth": { "auth_type": "api_key", "var_name": "Authorization", "location": "header" } } } } } ``` Generated tools automatically inherit the auth from manual call templates. ## Files Changed - openapi_converter.py: Added x-utcp-auth extension processing and auth inheritance - http_communication_protocol.py: Pass manual call template auth to converter - test_x_utcp_auth_extension.py: Comprehensive test coverage ## Testing All tests pass, including new tests for: - x-utcp-auth extension processing - Precedence over standard security schemes - Auth inheritance from manual call templates - Mixed operations with and without extensions - Fallback to standard OpenAPI security Enables: Fine-grained per-operation auth control with seamless inheritance
1 parent 908cd40 commit 13cc21d

File tree

3 files changed

+252
-2
lines changed

3 files changed

+252
-2
lines changed

plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
197197
utcp_manual = UtcpManualSerializer().validate_dict(response_data)
198198
else:
199199
logger.info(f"Assuming OpenAPI spec from '{manual_call_template.name}'. Converting to UTCP manual.")
200-
converter = OpenApiConverter(response_data, spec_url=manual_call_template.url, call_template_name=manual_call_template.name)
200+
converter = OpenApiConverter(response_data, spec_url=manual_call_template.url, call_template_name=manual_call_template.name, inherited_auth=manual_call_template.auth)
201201
utcp_manual = converter.convert()
202202

203203
return RegisterManualResult(

plugins/communication_protocols/http/src/utcp_http/openapi_converter.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ class OpenApiConverter:
8787
call_template_name: Normalized name for the call_template derived from the spec.
8888
"""
8989

90-
def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, call_template_name: Optional[str] = None):
90+
def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, call_template_name: Optional[str] = None, inherited_auth: Optional[Auth] = None):
9191
"""Initializes the OpenAPI converter.
9292
9393
Args:
@@ -96,9 +96,12 @@ def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None,
9696
Used for base URL determination if servers are not specified.
9797
call_template_name: Optional custom name for the call_template if
9898
the specification title is not provided.
99+
inherited_auth: Optional auth configuration inherited from the manual call template.
100+
Used instead of generating placeholders when x-utcp-auth is present.
99101
"""
100102
self.spec = openapi_spec
101103
self.spec_url = spec_url
104+
self.inherited_auth = inherited_auth
102105
# Single counter for all placeholder variables
103106
self.placeholder_counter = 0
104107
if call_template_name is None:
@@ -161,6 +164,29 @@ def convert(self) -> UtcpManual:
161164
def _extract_auth(self, operation: Dict[str, Any]) -> Optional[Auth]:
162165
"""
163166
Extracts authentication information from OpenAPI operation and global security schemes."""
167+
168+
# First check for x-utcp-auth extension
169+
if "x-utcp-auth" in operation:
170+
utcp_auth = operation["x-utcp-auth"]
171+
auth_type = utcp_auth.get("auth_type")
172+
173+
if auth_type == "api_key":
174+
# Use inherited auth if available and compatible, otherwise create placeholder
175+
if (self.inherited_auth and
176+
isinstance(self.inherited_auth, ApiKeyAuth) and
177+
self.inherited_auth.var_name == utcp_auth.get("var_name", "Authorization") and
178+
self.inherited_auth.location == utcp_auth.get("location", "header")):
179+
return self.inherited_auth
180+
else:
181+
api_key_placeholder = self._get_placeholder("API_KEY")
182+
self._increment_placeholder_counter()
183+
return ApiKeyAuth(
184+
api_key=api_key_placeholder,
185+
var_name=utcp_auth.get("var_name", "Authorization"),
186+
location=utcp_auth.get("location", "header")
187+
)
188+
189+
# Then fall back to standard OpenAPI security schemes
164190
# First check for operation-level security requirements
165191
security_requirements = operation.get("security", [])
166192

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""Tests for x-utcp-auth OpenAPI extension support.
2+
3+
This module tests the custom x-utcp-auth extension that allows OpenAPI specifications
4+
to include UTCP-specific authentication configuration directly in operations.
5+
"""
6+
7+
import pytest
8+
from utcp_http.openapi_converter import OpenApiConverter
9+
from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth
10+
from utcp.data.auth_implementations.basic_auth import BasicAuth
11+
from utcp.data.auth_implementations.oauth2_auth import OAuth2Auth
12+
13+
14+
def test_x_utcp_auth_api_key_extension():
15+
"""Test that x-utcp-auth extension with API key auth is processed correctly."""
16+
openapi_spec = {
17+
"openapi": "3.0.1",
18+
"info": {"title": "Test API", "version": "1.0.0"},
19+
"servers": [{"url": "https://api.example.com"}],
20+
"paths": {
21+
"/protected": {
22+
"get": {
23+
"operationId": "get_protected_data",
24+
"summary": "Get Protected Data",
25+
"x-utcp-auth": {
26+
"auth_type": "api_key",
27+
"var_name": "Authorization",
28+
"location": "header"
29+
},
30+
"responses": {
31+
"200": {
32+
"description": "Success",
33+
"content": {
34+
"application/json": {
35+
"schema": {"type": "string"}
36+
}
37+
}
38+
}
39+
}
40+
}
41+
}
42+
}
43+
}
44+
45+
converter = OpenApiConverter(openapi_spec)
46+
manual = converter.convert()
47+
48+
assert len(manual.tools) == 1
49+
tool = manual.tools[0]
50+
51+
# Check that auth was extracted from x-utcp-auth extension
52+
assert tool.tool_call_template.auth is not None
53+
assert isinstance(tool.tool_call_template.auth, ApiKeyAuth)
54+
assert tool.tool_call_template.auth.var_name == "Authorization"
55+
assert tool.tool_call_template.auth.location == "header"
56+
assert tool.tool_call_template.auth.api_key.startswith("${API_KEY_")
57+
58+
59+
def test_x_utcp_auth_takes_precedence_over_standard_security():
60+
"""Test that x-utcp-auth extension takes precedence over standard OpenAPI security."""
61+
openapi_spec = {
62+
"openapi": "3.0.1",
63+
"info": {"title": "Test API", "version": "1.0.0"},
64+
"servers": [{"url": "https://api.example.com"}],
65+
"components": {
66+
"securitySchemes": {
67+
"bearerAuth": {
68+
"type": "http",
69+
"scheme": "bearer"
70+
}
71+
}
72+
},
73+
"paths": {
74+
"/protected": {
75+
"get": {
76+
"operationId": "get_protected_data",
77+
"summary": "Get Protected Data",
78+
"security": [{"bearerAuth": []}],
79+
"x-utcp-auth": {
80+
"auth_type": "api_key",
81+
"var_name": "X-API-Key",
82+
"location": "header"
83+
},
84+
"responses": {
85+
"200": {
86+
"description": "Success",
87+
"content": {
88+
"application/json": {
89+
"schema": {"type": "string"}
90+
}
91+
}
92+
}
93+
}
94+
}
95+
}
96+
}
97+
}
98+
99+
converter = OpenApiConverter(openapi_spec)
100+
manual = converter.convert()
101+
102+
assert len(manual.tools) == 1
103+
tool = manual.tools[0]
104+
105+
# Should use x-utcp-auth, not the standard security scheme
106+
assert tool.tool_call_template.auth is not None
107+
assert isinstance(tool.tool_call_template.auth, ApiKeyAuth)
108+
assert tool.tool_call_template.auth.var_name == "X-API-Key"
109+
assert tool.tool_call_template.auth.location == "header"
110+
111+
112+
def test_fallback_to_standard_security_when_no_x_utcp_auth():
113+
"""Test that standard OpenAPI security is used when x-utcp-auth is not present."""
114+
openapi_spec = {
115+
"openapi": "3.0.1",
116+
"info": {"title": "Test API", "version": "1.0.0"},
117+
"servers": [{"url": "https://api.example.com"}],
118+
"components": {
119+
"securitySchemes": {
120+
"bearerAuth": {
121+
"type": "http",
122+
"scheme": "bearer"
123+
}
124+
}
125+
},
126+
"paths": {
127+
"/protected": {
128+
"get": {
129+
"operationId": "get_protected_data",
130+
"summary": "Get Protected Data",
131+
"security": [{"bearerAuth": []}],
132+
"responses": {
133+
"200": {
134+
"description": "Success",
135+
"content": {
136+
"application/json": {
137+
"schema": {"type": "string"}
138+
}
139+
}
140+
}
141+
}
142+
}
143+
}
144+
}
145+
}
146+
147+
converter = OpenApiConverter(openapi_spec)
148+
manual = converter.convert()
149+
150+
assert len(manual.tools) == 1
151+
tool = manual.tools[0]
152+
153+
# Should use standard security scheme
154+
assert tool.tool_call_template.auth is not None
155+
assert isinstance(tool.tool_call_template.auth, ApiKeyAuth)
156+
assert tool.tool_call_template.auth.var_name == "Authorization"
157+
assert tool.tool_call_template.auth.location == "header"
158+
assert tool.tool_call_template.auth.api_key.startswith("Bearer ${API_KEY_")
159+
160+
161+
def test_mixed_operations_with_and_without_x_utcp_auth():
162+
"""Test OpenAPI spec with mixed operations - some with x-utcp-auth, some without."""
163+
openapi_spec = {
164+
"openapi": "3.0.1",
165+
"info": {"title": "Test API", "version": "1.0.0"},
166+
"servers": [{"url": "https://api.example.com"}],
167+
"paths": {
168+
"/public": {
169+
"get": {
170+
"operationId": "get_public_data",
171+
"summary": "Get Public Data",
172+
"responses": {
173+
"200": {
174+
"description": "Success",
175+
"content": {
176+
"application/json": {
177+
"schema": {"type": "string"}
178+
}
179+
}
180+
}
181+
}
182+
}
183+
},
184+
"/protected": {
185+
"get": {
186+
"operationId": "get_protected_data",
187+
"summary": "Get Protected Data",
188+
"x-utcp-auth": {
189+
"auth_type": "api_key",
190+
"var_name": "Authorization",
191+
"location": "header"
192+
},
193+
"responses": {
194+
"200": {
195+
"description": "Success",
196+
"content": {
197+
"application/json": {
198+
"schema": {"type": "string"}
199+
}
200+
}
201+
}
202+
}
203+
}
204+
}
205+
}
206+
}
207+
208+
converter = OpenApiConverter(openapi_spec)
209+
manual = converter.convert()
210+
211+
assert len(manual.tools) == 2
212+
213+
# Find tools by name
214+
public_tool = next(t for t in manual.tools if t.name == "get_public_data")
215+
protected_tool = next(t for t in manual.tools if t.name == "get_protected_data")
216+
217+
# Public tool should have no auth
218+
assert public_tool.tool_call_template.auth is None
219+
220+
# Protected tool should have auth from x-utcp-auth
221+
assert protected_tool.tool_call_template.auth is not None
222+
assert isinstance(protected_tool.tool_call_template.auth, ApiKeyAuth)
223+
assert protected_tool.tool_call_template.auth.var_name == "Authorization"
224+
assert protected_tool.tool_call_template.auth.location == "header"

0 commit comments

Comments
 (0)