Files
backend/internal/skill/openapi.go

276 lines
7.4 KiB
Go

package skill
import (
"encoding/json"
"fmt"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// CandidateTool is the intermediate representation produced by an importer
// (OpenAPI, manual, ...) before the LLM synthesis pass refines it into a
// finished Tool. Importers aim to fill as many fields as the source provides.
type CandidateTool struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters jsonObject `json:"parameters"` // JSON Schema
Endpoint Endpoint `json:"endpoint"`
}
// ParseOpenAPI accepts JSON or YAML OpenAPI 3.x (and best-effort Swagger 2.0)
// text and returns one CandidateTool per operation under paths. This is a
// deterministic, no-LLM pass: it just translates the spec into our endpoint
// shape. The synthesis pass later decides which candidates to keep and how to
// phrase descriptions.
func ParseOpenAPI(raw []byte, baseURL string) ([]CandidateTool, error) {
spec, err := unmarshalSpec(raw)
if err != nil {
return nil, err
}
if baseURL == "" {
baseURL = spec.baseURL()
}
var out []CandidateTool
// Stable iteration order so generated skills are deterministic across runs.
paths := make([]string, 0, len(spec.Paths))
for p := range spec.Paths {
paths = append(paths, p)
}
sort.Strings(paths)
for _, p := range paths {
item := spec.Paths[p]
for _, method := range []string{"get", "post", "put", "patch", "delete"} {
op := item.operation(method)
if op == nil {
continue
}
ct, err := buildCandidate(method, p, op, baseURL)
if err != nil {
// Skip a malformed operation rather than failing the whole spec.
continue
}
out = append(out, ct)
}
}
return out, nil
}
// --- intermediate openapi structures ---------------------------------------
//
// Only the fields we actually consume are modelled. Anything else is ignored.
// Both JSON and YAML unmarshal into these via the json tags (yaml.v3 reads the
// same lower-cased keys).
type oaSpec struct {
OpenAPI string `json:"openapi"`
Info oaInfo `json:"info"`
Servers []oaServer `json:"servers"`
Host string `json:"host"` // swagger 2.0
BasePath string `json:"basePath"` // swagger 2.0
Paths map[string]*oaPathItem `json:"paths"`
}
type oaInfo struct {
Title string `json:"title"`
Description string `json:"description"`
}
type oaServer struct {
URL string `json:"url"`
}
type oaPathItem struct {
Get *oaOperation `json:"get"`
Post *oaOperation `json:"post"`
Put *oaOperation `json:"put"`
Patch *oaOperation `json:"patch"`
Delete *oaOperation `json:"delete"`
}
type oaOperation struct {
Summary string `json:"summary"`
Description string `json:"description"`
OperationID string `json:"operationId"`
Parameters []oaParameter `json:"parameters"`
}
type oaParameter struct {
Name string `json:"name"`
In string `json:"in"` // query | path | header
Required bool `json:"required"`
Schema *oaSchemaRef `json:"schema"`
Description string `json:"description"`
}
type oaSchemaRef struct {
Type string `json:"type"`
}
func (s *oaSpec) baseURL() string {
if len(s.Servers) > 0 && s.Servers[0].URL != "" {
return strings.TrimRight(s.Servers[0].URL, "/")
}
if s.Host != "" {
bp := strings.Trim(s.BasePath, "/")
scheme := "https"
if strings.HasPrefix(s.Host, "localhost") || strings.HasPrefix(s.Host, "127.") {
scheme = "http"
}
if bp != "" {
return fmt.Sprintf("%s://%s/%s", scheme, s.Host, bp)
}
return fmt.Sprintf("%s://%s", scheme, s.Host)
}
return ""
}
func (p *oaPathItem) operation(method string) *oaOperation {
switch method {
case "get":
return p.Get
case "post":
return p.Post
case "put":
return p.Put
case "patch":
return p.Patch
case "delete":
return p.Delete
}
return nil
}
// buildCandidate converts one OpenAPI operation into a CandidateTool.
func buildCandidate(method, p string, op *oaOperation, baseURL string) (CandidateTool, error) {
name := op.OperationID
if name == "" {
// operationId absent: synthesise from method + path.
name = method + strings.ReplaceAll(strings.ReplaceAll(p, "/", "_"), "{", "")
name = strings.ReplaceAll(name, "}", "")
name = strings.Trim(name, "_")
}
if name == "" {
return CandidateTool{}, fmt.Errorf("cannot derive name")
}
desc := strings.TrimSpace(op.Description)
if desc == "" {
desc = strings.TrimSpace(op.Summary)
}
if desc == "" {
desc = name
}
// Split parameters into query / path / header and build a flat JSON Schema
// of the arguments the LLM needs to supply.
schemaProps := map[string]any{}
var required []string
var query, hdr map[string]string
pathParams := map[string]string{}
for _, param := range op.Parameters {
if param.Name == "" {
continue
}
t := "string"
if param.Schema != nil && param.Schema.Type != "" {
t = param.Schema.Type
}
entry := map[string]any{"type": t}
if param.Description != "" {
entry["description"] = param.Description
}
schemaProps[param.Name] = entry
if param.Required {
required = append(required, param.Name)
}
switch param.In {
case "query":
if query == nil {
query = map[string]string{}
}
query[param.Name] = "{{" + param.Name + "}}"
case "path":
pathParams[param.Name] = "{{" + param.Name + "}}"
case "header":
if hdr == nil {
hdr = map[string]string{}
}
hdr[param.Name] = "{{" + param.Name + "}}"
}
}
schema := map[string]any{
"type": "object",
"properties": schemaProps,
}
if len(required) > 0 {
sort.Strings(required)
schema["required"] = required
}
paramsJSON, _ := json.Marshal(schema)
// Render the full URL: combine baseURL + path. Path params stay as
// {name}; the agent executor substitutes them at call time. We avoid
// url.Parse here because it would percent-encode the braces.
fullURL := joinURL(baseURL, p)
return CandidateTool{
Name: name,
Description: desc,
Parameters: jsonObject(paramsJSON),
Endpoint: Endpoint{
Method: strings.ToUpper(method),
URL: fullURL,
Path: pathParams,
Query: query,
Header: hdr,
},
}, nil
}
// joinURL merges a base URL with an OpenAPI path. We deliberately do NOT use
// net/url for the join: it would percent-encode the "{name}" path-parameter
// placeholders into "%7Bname%7D", which the executor's literal replace would
// then fail to match. String concatenation keeps the braces intact.
func joinURL(base, p string) string {
base = strings.TrimRight(strings.TrimSpace(base), "/")
p = strings.TrimSpace(p)
if base == "" {
return p
}
if p == "" {
return base
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return base + p
}
// unmarshalSpec accepts either JSON or YAML (which is a JSON superset) by
// routing through yaml.v3, then re-marshals to JSON so the typed oaSpec can
// decode cleanly with consistent key casing.
func unmarshalSpec(raw []byte) (*oaSpec, error) {
var generic map[string]any
if err := yaml.Unmarshal(raw, &generic); err != nil {
return nil, fmt.Errorf("parse spec (yaml/json): %w", err)
}
// OpenAPI paths keys can be "/foo": {...}; keep them as-is.
jb, err := json.Marshal(generic)
if err != nil {
return nil, fmt.Errorf("re-marshal spec: %w", err)
}
var spec oaSpec
if err := json.Unmarshal(jb, &spec); err != nil {
return nil, fmt.Errorf("decode spec: %w", err)
}
if spec.Paths == nil {
return nil, fmt.Errorf("spec has no paths")
}
return &spec, nil
}