Initial commit: CarrotAssistant backend (Go + Gin + SQLite, OpenAI-compatible agent runtime)
This commit is contained in:
275
internal/skill/openapi.go
Normal file
275
internal/skill/openapi.go
Normal file
@@ -0,0 +1,275 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user