Skip to content

Commit 22ebdb7

Browse files
cpunionclaude
andcommitted
feat(openapitoolset): add OpenAPI specification toolset
Add a new toolset that automatically generates tools from OpenAPI/Swagger specifications, enabling agents to interact with REST APIs. Key components: - OpenAPI spec parser supporting v3.0/v3.1 with JSON Schema conversion - RestApiTool for executing HTTP requests with path/query/body parameters - Toolset for loading specs from file/URL and generating tools - OAuth2 authentication support with credential request flow - Example demonstrating GitHub API integration with device auth flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent ad30aff commit 22ebdb7

File tree

8 files changed

+2451
-0
lines changed

8 files changed

+2451
-0
lines changed

examples/openapi/main.go

Lines changed: 462 additions & 0 deletions
Large diffs are not rendered by default.

examples/openapi/oauth2handler/handler.go

Lines changed: 447 additions & 0 deletions
Large diffs are not rendered by default.

tool/openapitoolset/parser.go

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package openapitoolset
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
)
21+
22+
// ParsedOperation represents a parsed OpenAPI operation.
23+
type ParsedOperation struct {
24+
// Name is the operation ID or generated name.
25+
Name string
26+
// Description is the operation description.
27+
Description string
28+
// Method is the HTTP method (GET, POST, etc.).
29+
Method string
30+
// Path is the URL path with parameter placeholders.
31+
Path string
32+
// BaseURL is the server base URL.
33+
BaseURL string
34+
// Parameters are the operation parameters.
35+
Parameters []Parameter
36+
// RequestBody describes the request body if present.
37+
RequestBody *RequestBody
38+
// Responses describes the expected responses.
39+
Responses map[string]*Response
40+
}
41+
42+
// Parameter represents an API parameter.
43+
type Parameter struct {
44+
Name string
45+
In string // "path", "query", "header", "cookie"
46+
Description string
47+
Required bool
48+
Schema map[string]any
49+
}
50+
51+
// RequestBody represents a request body specification.
52+
type RequestBody struct {
53+
Description string
54+
Required bool
55+
Content map[string]MediaType
56+
}
57+
58+
// MediaType represents a media type specification.
59+
type MediaType struct {
60+
Schema map[string]any
61+
}
62+
63+
// Response represents an API response.
64+
type Response struct {
65+
Description string
66+
Content map[string]MediaType
67+
}
68+
69+
// parseOpenAPISpec parses an OpenAPI specification into RestApiTools.
70+
func parseOpenAPISpec(spec map[string]any) ([]*RestApiTool, error) {
71+
var tools []*RestApiTool
72+
73+
// Extract base URL from servers
74+
baseURL := ""
75+
if servers, ok := spec["servers"].([]any); ok && len(servers) > 0 {
76+
if server, ok := servers[0].(map[string]any); ok {
77+
if url, ok := server["url"].(string); ok {
78+
baseURL = strings.TrimSuffix(url, "/")
79+
}
80+
}
81+
}
82+
83+
// Parse paths
84+
paths, ok := spec["paths"].(map[string]any)
85+
if !ok {
86+
return nil, fmt.Errorf("no paths found in OpenAPI spec")
87+
}
88+
89+
for path, pathItem := range paths {
90+
pathItemMap, ok := pathItem.(map[string]any)
91+
if !ok {
92+
continue
93+
}
94+
95+
// Parse each HTTP method
96+
for method, operation := range pathItemMap {
97+
// Skip non-operation fields
98+
if method == "parameters" || method == "servers" || method == "$ref" {
99+
continue
100+
}
101+
102+
op, ok := operation.(map[string]any)
103+
if !ok {
104+
continue
105+
}
106+
107+
parsed, err := parseOperation(path, method, op, baseURL, pathItemMap)
108+
if err != nil {
109+
continue // Skip invalid operations
110+
}
111+
112+
tool := newRestApiToolFromParsed(parsed)
113+
tools = append(tools, tool)
114+
}
115+
}
116+
117+
return tools, nil
118+
}
119+
120+
// parseOperation parses a single OpenAPI operation.
121+
func parseOperation(path, method string, op map[string]any, baseURL string, pathItem map[string]any) (*ParsedOperation, error) {
122+
parsed := &ParsedOperation{
123+
Path: path,
124+
Method: strings.ToUpper(method),
125+
BaseURL: baseURL,
126+
}
127+
128+
// Get operation ID or generate name
129+
if opID, ok := op["operationId"].(string); ok {
130+
parsed.Name = opID
131+
} else {
132+
// Generate name from method and path
133+
parsed.Name = generateOperationName(method, path)
134+
}
135+
136+
// Get description
137+
if desc, ok := op["description"].(string); ok {
138+
parsed.Description = desc
139+
} else if summary, ok := op["summary"].(string); ok {
140+
parsed.Description = summary
141+
}
142+
143+
// Parse parameters
144+
parsed.Parameters = parseParameters(op, pathItem)
145+
146+
// Parse request body
147+
if reqBody, ok := op["requestBody"].(map[string]any); ok {
148+
parsed.RequestBody = parseRequestBody(reqBody)
149+
}
150+
151+
// Parse responses
152+
if responses, ok := op["responses"].(map[string]any); ok {
153+
parsed.Responses = parseResponses(responses)
154+
}
155+
156+
return parsed, nil
157+
}
158+
159+
// generateOperationName generates an operation name from method and path.
160+
func generateOperationName(method, path string) string {
161+
// Convert path to snake_case name
162+
name := strings.ReplaceAll(path, "/", "_")
163+
name = strings.ReplaceAll(name, "{", "")
164+
name = strings.ReplaceAll(name, "}", "")
165+
name = strings.ReplaceAll(name, "-", "_")
166+
name = strings.Trim(name, "_")
167+
return strings.ToLower(method) + "_" + name
168+
}
169+
170+
// parseParameters parses operation and path-level parameters.
171+
func parseParameters(op map[string]any, pathItem map[string]any) []Parameter {
172+
var params []Parameter
173+
174+
// Parse path-level parameters
175+
if pathParams, ok := pathItem["parameters"].([]any); ok {
176+
params = append(params, parseParameterList(pathParams)...)
177+
}
178+
179+
// Parse operation-level parameters (override path-level)
180+
if opParams, ok := op["parameters"].([]any); ok {
181+
params = append(params, parseParameterList(opParams)...)
182+
}
183+
184+
return params
185+
}
186+
187+
// parseParameterList parses a list of parameters.
188+
func parseParameterList(paramList []any) []Parameter {
189+
var params []Parameter
190+
for _, p := range paramList {
191+
pm, ok := p.(map[string]any)
192+
if !ok {
193+
continue
194+
}
195+
196+
param := Parameter{
197+
Name: getString(pm, "name"),
198+
In: getString(pm, "in"),
199+
}
200+
if desc, ok := pm["description"].(string); ok {
201+
param.Description = desc
202+
}
203+
if required, ok := pm["required"].(bool); ok {
204+
param.Required = required
205+
}
206+
if schema, ok := pm["schema"].(map[string]any); ok {
207+
param.Schema = schema
208+
}
209+
params = append(params, param)
210+
}
211+
return params
212+
}
213+
214+
// parseRequestBody parses a request body specification.
215+
func parseRequestBody(reqBody map[string]any) *RequestBody {
216+
rb := &RequestBody{}
217+
if desc, ok := reqBody["description"].(string); ok {
218+
rb.Description = desc
219+
}
220+
if required, ok := reqBody["required"].(bool); ok {
221+
rb.Required = required
222+
}
223+
if content, ok := reqBody["content"].(map[string]any); ok {
224+
rb.Content = make(map[string]MediaType)
225+
for mediaType, mtSpec := range content {
226+
mt := MediaType{}
227+
if mtMap, ok := mtSpec.(map[string]any); ok {
228+
if schema, ok := mtMap["schema"].(map[string]any); ok {
229+
mt.Schema = schema
230+
}
231+
}
232+
rb.Content[mediaType] = mt
233+
}
234+
}
235+
return rb
236+
}
237+
238+
// parseResponses parses response specifications.
239+
func parseResponses(responses map[string]any) map[string]*Response {
240+
result := make(map[string]*Response)
241+
for code, resp := range responses {
242+
respMap, ok := resp.(map[string]any)
243+
if !ok {
244+
continue
245+
}
246+
r := &Response{}
247+
if desc, ok := respMap["description"].(string); ok {
248+
r.Description = desc
249+
}
250+
if content, ok := respMap["content"].(map[string]any); ok {
251+
r.Content = make(map[string]MediaType)
252+
for mediaType, mtSpec := range content {
253+
mt := MediaType{}
254+
if mtMap, ok := mtSpec.(map[string]any); ok {
255+
if schema, ok := mtMap["schema"].(map[string]any); ok {
256+
mt.Schema = schema
257+
}
258+
}
259+
r.Content[mediaType] = mt
260+
}
261+
}
262+
result[code] = r
263+
}
264+
return result
265+
}
266+
267+
// getString safely gets a string from a map.
268+
func getString(m map[string]any, key string) string {
269+
if v, ok := m[key].(string); ok {
270+
return v
271+
}
272+
return ""
273+
}

0 commit comments

Comments
 (0)