Skip to content

Commit 21b57df

Browse files
authored
Openapi converter (#111)
* fix: openapi converter has been fixed * fix: openapi converter * feat: openapi converter works ! * fix openapi converter
1 parent ed87643 commit 21b57df

File tree

4 files changed

+179
-42
lines changed

4 files changed

+179
-42
lines changed

src/openapi/open_api_converter.go

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
package openapi
22

33
import (
4+
"encoding/json"
45
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
11+
"gopkg.in/yaml.v3"
512

613
. "github.com/universal-tool-calling-protocol/go-utcp/src/providers/base"
714
. "github.com/universal-tool-calling-protocol/go-utcp/src/providers/http"
815

916
. "github.com/universal-tool-calling-protocol/go-utcp/src/auth"
1017
. "github.com/universal-tool-calling-protocol/go-utcp/src/manual"
1118
. "github.com/universal-tool-calling-protocol/go-utcp/src/tools"
12-
13-
"net/url"
14-
"strings"
1519
)
1620

17-
// OpenApiConverter converts an OpenAPI JSON spec into a UtcpManual.
21+
// OpenApiConverter converts an OpenAPI JSON/YAML spec into a UtcpManual.
1822
type OpenApiConverter struct {
1923
spec map[string]interface{}
2024
specURL string
@@ -51,6 +55,58 @@ func NewConverter(
5155
}
5256
}
5357

58+
// NewConverterFromURL fetches the spec (YAML or JSON) from the given URL and returns a converter.
59+
// providerName can be empty to auto-derive from the spec.
60+
func NewConverterFromURL(specURL string, providerName string) (*OpenApiConverter, error) {
61+
spec, finalURL, err := LoadSpecFromURL(specURL)
62+
if err != nil {
63+
return nil, fmt.Errorf("failed to load spec from URL %s: %w", specURL, err)
64+
}
65+
return NewConverter(spec, finalURL, providerName), nil
66+
}
67+
68+
// LoadSpecFromURL fetches the content at the URL and attempts to decode it first as JSON,
69+
// and if that fails, as YAML. Returns the spec as a map and the normalized URL used.
70+
func LoadSpecFromURL(rawURL string) (map[string]interface{}, string, error) {
71+
resp, err := http.Get(rawURL)
72+
if err != nil {
73+
return nil, rawURL, fmt.Errorf("http GET failed: %w", err)
74+
}
75+
defer resp.Body.Close()
76+
77+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
78+
return nil, rawURL, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
79+
}
80+
81+
bodyBytes, err := io.ReadAll(resp.Body)
82+
if err != nil {
83+
return nil, rawURL, fmt.Errorf("reading body failed: %w", err)
84+
}
85+
86+
var spec map[string]interface{}
87+
// Try JSON first
88+
if err := json.Unmarshal(bodyBytes, &spec); err == nil {
89+
return spec, resp.Request.URL.String(), nil
90+
}
91+
92+
// Fallback to YAML
93+
var yamlRaw interface{}
94+
if err := yaml.Unmarshal(bodyBytes, &yamlRaw); err != nil {
95+
return nil, rawURL, fmt.Errorf("failed to parse as JSON (%v) or YAML (%v)", err, err)
96+
}
97+
98+
// Convert YAML parsed structure into map[string]interface{} with proper types (via intermediate JSON).
99+
intermediate, err := json.Marshal(yamlRaw)
100+
if err != nil {
101+
return nil, rawURL, fmt.Errorf("failed to re-marshal YAML content: %w", err)
102+
}
103+
if err := json.Unmarshal(intermediate, &spec); err != nil {
104+
return nil, rawURL, fmt.Errorf("failed to unmarshal intermediate YAML->JSON: %w", err)
105+
}
106+
107+
return spec, resp.Request.URL.String(), nil
108+
}
109+
54110
// Convert parses the OpenAPI spec and builds a UtcpManual.
55111
func (c *OpenApiConverter) Convert() UtcpManual {
56112
var tools []Tool
@@ -63,6 +119,15 @@ func (c *OpenApiConverter) Convert() UtcpManual {
63119
baseURL = u
64120
}
65121
}
122+
} else if host, ok := c.spec["host"].(string); ok {
123+
scheme := "https"
124+
if schemes, ok := c.spec["schemes"].([]interface{}); ok && len(schemes) > 0 {
125+
if s, _ := schemes[0].(string); s != "" {
126+
scheme = s
127+
}
128+
}
129+
basePath, _ := c.spec["basePath"].(string)
130+
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, basePath)
66131
} else if c.specURL != "" {
67132
if pu, err := url.Parse(c.specURL); err == nil {
68133
baseURL = fmt.Sprintf("%s://%s", pu.Scheme, pu.Host)
@@ -280,7 +345,9 @@ func (c *OpenApiConverter) createTool(
280345
) (*Tool, error) {
281346
opID, _ := op["operationId"].(string)
282347
if opID == "" {
283-
return nil, nil // skip unnamed ops
348+
// fallback: derive from method and path, e.g., POST /request -> post_request
349+
sanitizedPath := strings.ReplaceAll(strings.Trim(path, "/"), "/", "_")
350+
opID = fmt.Sprintf("%s_%s", strings.ToLower(method), sanitizedPath)
284351
}
285352

286353
desc, _ := op["summary"].(string)
@@ -346,6 +413,9 @@ func (c *OpenApiConverter) extractInputs(
346413
if loc == "header" {
347414
headers = append(headers, name)
348415
}
416+
if loc == "body" {
417+
bodyField = &name
418+
}
349419
sch := c.resolveSchema(param["schema"]).(map[string]interface{})
350420
entry := map[string]interface{}{
351421
"type": sch["type"],
@@ -438,6 +508,33 @@ func (c *OpenApiConverter) extractOutputs(
438508
return out
439509
}
440510
}
511+
} else if schema, ok := resp["schema"].(map[string]interface{}); ok {
512+
sch := c.resolveSchema(schema).(map[string]interface{})
513+
out := ToolInputOutputSchema{
514+
Type: castString(sch["type"], "object"),
515+
Properties: castMap(sch["properties"]),
516+
Required: castStringSlice(sch["required"]),
517+
Description: castString(sch["description"], castString(resp["description"], "")),
518+
Title: castString(sch["title"], ""),
519+
}
520+
if out.Type == "array" {
521+
out.Items = castMap(sch["items"])
522+
}
523+
for _, attr := range []string{"enum", "minimum", "maximum", "format"} {
524+
if v, ok := sch[attr]; ok {
525+
switch attr {
526+
case "enum":
527+
out.Enum = castInterfaceSlice(v)
528+
case "minimum":
529+
out.Minimum = castFloat(v)
530+
case "maximum":
531+
out.Maximum = castFloat(v)
532+
case "format":
533+
out.Format = castString(v, "")
534+
}
535+
}
536+
}
537+
return out
441538
}
442539
return ToolInputOutputSchema{}
443540
}

src/openapi/open_api_converter_additional_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,37 @@ func TestCastHelpers(t *testing.T) {
194194
t.Fatalf("castFloat float")
195195
}
196196
}
197+
198+
func TestConvert_Basic(t *testing.T) {
199+
spec := map[string]interface{}{
200+
"info": map[string]interface{}{"title": "Test API"},
201+
"servers": []interface{}{map[string]interface{}{"url": "https://api.example.com"}},
202+
"paths": map[string]interface{}{
203+
"/ping": map[string]interface{}{
204+
"get": map[string]interface{}{
205+
"operationId": "ping",
206+
"summary": "Ping",
207+
"responses": map[string]interface{}{
208+
"200": map[string]interface{}{
209+
"content": map[string]interface{}{
210+
"application/json": map[string]interface{}{
211+
"schema": map[string]interface{}{
212+
"type": "object",
213+
},
214+
},
215+
},
216+
},
217+
},
218+
},
219+
},
220+
},
221+
}
222+
c := NewConverter(spec, "", "")
223+
manual := c.Convert()
224+
if manual.Version != "1.0" {
225+
t.Fatalf("unexpected version: %s", manual.Version)
226+
}
227+
if len(manual.Tools) != 1 || manual.Tools[0].Name != "ping" {
228+
t.Fatalf("unexpected tools: %+v", manual.Tools)
229+
}
230+
}

src/openapi/open_api_converter_test.go

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,3 @@ func TestOptionalString(t *testing.T) {
1313
t.Fatalf("unexpected value: %v", val)
1414
}
1515
}
16-
17-
func TestConvert_Basic(t *testing.T) {
18-
spec := map[string]interface{}{
19-
"info": map[string]interface{}{"title": "Test API"},
20-
"servers": []interface{}{map[string]interface{}{"url": "https://api.example.com"}},
21-
"paths": map[string]interface{}{
22-
"/ping": map[string]interface{}{
23-
"get": map[string]interface{}{
24-
"operationId": "ping",
25-
"summary": "Ping",
26-
"responses": map[string]interface{}{
27-
"200": map[string]interface{}{
28-
"content": map[string]interface{}{
29-
"application/json": map[string]interface{}{
30-
"schema": map[string]interface{}{
31-
"type": "object",
32-
},
33-
},
34-
},
35-
},
36-
},
37-
},
38-
},
39-
},
40-
}
41-
c := NewConverter(spec, "", "")
42-
manual := c.Convert()
43-
if manual.Version != "1.0" {
44-
t.Fatalf("unexpected version: %s", manual.Version)
45-
}
46-
if len(manual.Tools) != 1 || manual.Tools[0].Name != "ping" {
47-
t.Fatalf("unexpected tools: %+v", manual.Tools)
48-
}
49-
}

utcp_client.go

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212
"sync"
1313

14+
"github.com/universal-tool-calling-protocol/go-utcp/src/openapi"
1415
. "github.com/universal-tool-calling-protocol/go-utcp/src/providers/helpers"
1516
. "github.com/universal-tool-calling-protocol/go-utcp/src/repository"
1617
. "github.com/universal-tool-calling-protocol/go-utcp/src/tag"
@@ -275,6 +276,7 @@ func (c *UtcpClient) setProviderName(prov Provider, name string) {
275276
}
276277
}
277278

279+
// RegisterToolProvider applies variable substitution, picks the right transport, and registers tools.
278280
// RegisterToolProvider applies variable substitution, picks the right transport, and registers tools.
279281
func (c *UtcpClient) RegisterToolProvider(
280282
ctx context.Context,
@@ -320,10 +322,48 @@ func (c *UtcpClient) RegisterToolProvider(
320322
return nil, fmt.Errorf("unsupported provider type: %s", prov.Type())
321323
}
322324

323-
tools, err := tr.RegisterToolProvider(ctx, prov)
324-
if err != nil {
325-
return nil, err
325+
var tools []Tool
326+
var err error
327+
328+
// Special-case: if this is an HTTP provider and its URL points to an OpenAPI spec, try using the converter.
329+
if prov.Type() == ProviderHTTP {
330+
if httpProv, ok := prov.(*HttpProvider); ok {
331+
converter, convErr := openapi.NewConverterFromURL(httpProv.URL, "")
332+
if convErr != nil {
333+
fmt.Printf("OpenAPI converter instantiation error for %s: %v\n", httpProv.URL, convErr)
334+
// fallback
335+
tools, err = tr.RegisterToolProvider(ctx, prov)
336+
if err != nil {
337+
return nil, err
338+
}
339+
} else {
340+
manual := converter.Convert()
341+
fmt.Printf("Converter produced %d tools from %s\n", len(manual.Tools), httpProv.URL)
342+
if len(manual.Tools) == 0 {
343+
// dump some context for why zero
344+
345+
// fallback attempt
346+
tools, err = tr.RegisterToolProvider(ctx, prov)
347+
if err != nil {
348+
return nil, err
349+
}
350+
} else {
351+
tools = manual.Tools
352+
}
353+
}
354+
} else {
355+
tools, err = tr.RegisterToolProvider(ctx, prov)
356+
if err != nil {
357+
return nil, err
358+
}
359+
}
360+
} else {
361+
tools, err = tr.RegisterToolProvider(ctx, prov)
362+
if err != nil {
363+
return nil, err
364+
}
326365
}
366+
327367
// Prefix tool names with provider name if not already prefixed
328368
for i := range tools {
329369
if !strings.HasPrefix(tools[i].Name, name+".") {

0 commit comments

Comments
 (0)