From bab7aab475269a4f2705090ebdabb6c688ba07df Mon Sep 17 00:00:00 2001 From: Cicatriz Date: Thu, 28 Aug 2025 13:07:23 -0400 Subject: [PATCH] feat(http): auto-convert OpenAPI JSON on register; docs: update README OpenAPI section (#6) --- README.md | 24 ++++++- src/Utcp.Http/HttpCommunicationProtocol.cs | 69 ++++++++++++++++++- .../HttpCommunicationProtocolTests.cs | 26 +++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9d0fe2c..b59bb27 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,29 @@ var substituted = substitutor.Substitute( ``` ### OpenAPI conversion -End-to-end conversion using `OpenApiToUtcpConverter`: +You can register an OpenAPI spec by passing its URL as an `HttpCallTemplate` to `RegisterManualAsync`. The HTTP protocol automatically fetches and converts JSON OpenAPI documents into a UTCP manual at registration time. + +Example (automatic conversion on register): +```csharp +using Utcp.Http; + +var client = await UtcpClientImplementation.CreateAsync(config: new UtcpClientConfig +{ + ToolRepository = new Utcp.Core.Repositories.InMemToolRepository(), + ToolSearchStrategy = new Utcp.Core.Search.TagAndDescriptionWordMatchStrategy(), +}); + +await client.RegisterManualAsync(new HttpCallTemplate +{ + CallTemplateType = "http", + Name = "petstore", + Url = new Uri("https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.json"), +}); + +var tools = await client.SearchToolsAsync("pet store", 5); +``` + +End-to-end conversion using `OpenApiToUtcpConverter` (manual control): ```csharp using Utcp.Http.OpenApi; using System.Net.Http; diff --git a/src/Utcp.Http/HttpCommunicationProtocol.cs b/src/Utcp.Http/HttpCommunicationProtocol.cs index d6e91f3..f09053b 100644 --- a/src/Utcp.Http/HttpCommunicationProtocol.cs +++ b/src/Utcp.Http/HttpCommunicationProtocol.cs @@ -7,6 +7,7 @@ namespace Utcp.Http; using Utcp.Core; using Utcp.Core.Interfaces; using Utcp.Core.Models; +using Utcp.Http.OpenApi; public sealed class HttpCommunicationProtocol : ICommunicationProtocol { @@ -17,9 +18,73 @@ public HttpCommunicationProtocol(IHttpClientFactory httpClientFactory) this.httpClientFactory = httpClientFactory; } - public Task RegisterManualAsync(UtcpClient caller, CallTemplate manualCallTemplate, CancellationToken cancellationToken = default) + public async Task RegisterManualAsync(UtcpClient caller, CallTemplate manualCallTemplate, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + // Auto-convert OpenAPI specs into UTCP manuals if the manual template points to an OpenAPI document + if (manualCallTemplate is HttpCallTemplate http && http.Url is not null) + { + var client = this.httpClientFactory.CreateClient("utcp"); + if (http.Timeout is not null) + { + client.Timeout = http.Timeout.Value; + } + + using var req = new HttpRequestMessage(HttpMethod.Get, http.Url); + if (http.Headers is not null) + { + foreach (var (k, v) in http.Headers) + { + req.Headers.TryAddWithoutValidation(k, v); + } + } + + try + { + using var resp = await client.SendAsync(req, cancellationToken).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + var specText = await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + // Heuristic: treat JSON OpenAPI documents (we look for root object text) + var trimmed = specText.TrimStart(); + if (trimmed.StartsWith("{")) + { + var converter = new OpenApiToUtcpConverter(); + var manual = converter.FromJson(specText, http.Name ?? string.Empty); + return new RegisterManualResult + { + ManualCallTemplate = manualCallTemplate, + Manual = manual, + Success = true, + }; + } + } + catch (Exception ex) + { + return new RegisterManualResult + { + ManualCallTemplate = manualCallTemplate, + Manual = new UtcpManual { Tools = Array.Empty() }, + Success = false, + Errors = new[] { ex.Message }, + }; + } + + // Not an OpenAPI JSON document; default to no tools + return new RegisterManualResult + { + ManualCallTemplate = manualCallTemplate, + Manual = new UtcpManual { Tools = Array.Empty() }, + Success = true, + }; + } + + // For unsupported template types, return empty manual by default + return new RegisterManualResult + { + ManualCallTemplate = manualCallTemplate, + Manual = new UtcpManual { Tools = Array.Empty() }, + Success = true, + }; } public Task DeregisterManualAsync(UtcpClient caller, CallTemplate manualCallTemplate, CancellationToken cancellationToken = default) diff --git a/tests/Utcp.Http.Tests/HttpCommunicationProtocolTests.cs b/tests/Utcp.Http.Tests/HttpCommunicationProtocolTests.cs index 972f435..b23221a 100644 --- a/tests/Utcp.Http.Tests/HttpCommunicationProtocolTests.cs +++ b/tests/Utcp.Http.Tests/HttpCommunicationProtocolTests.cs @@ -9,9 +9,35 @@ using Utcp.Http; using FluentAssertions; using Xunit; +using Utcp.Http.OpenApi; public class HttpCommunicationProtocolTests { + [Fact] + public async Task RegisterManual_OpenApiJson_ConvertsToTools() + { + const string openApi = """ + {"openapi":"3.0.0","info":{"title":"t","version":"1.0.0"},"servers":[{"url":"https://api.example.com"}],"paths":{"/status":{"get":{"operationId":"getStatus","summary":"status"}}}} + """; + + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Get, "https://api.example.com/openapi.json") + .Respond("application/json", openApi); + + var httpClientFactory = new MockFactory(mock); + var protocol = new HttpCommunicationProtocol(httpClientFactory); + + var template = new HttpCallTemplate + { + CallTemplateType = "http", + Name = "manual", + Url = new Uri("https://api.example.com/openapi.json"), + }; + + var result = await protocol.RegisterManualAsync(new DummyClient(), template); + result.Success.Should().BeTrue(); + result.Manual.Tools.Should().ContainSingle(t => t.Name == "manual.getStatus"); + } [Fact] public async Task CallTool_BasicGet_ReturnsBody() {