423 lines
13 KiB
Go
423 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
stdhttp "net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"runtime/debug"
|
|
"strings"
|
|
"time"
|
|
|
|
"apigo.cc/go/cast"
|
|
gohttp "apigo.cc/go/http"
|
|
"apigo.cc/go/safe"
|
|
)
|
|
|
|
// Call invokes an Action and returns the common API result envelope.
|
|
func Call(action Action, options ...*CallOptions) (*Result, error) {
|
|
if action == nil {
|
|
return nil, errors.New("action is required")
|
|
}
|
|
opts := firstOptions(options)
|
|
started := time.Now()
|
|
result := &Result{Headers: map[string]string{}}
|
|
finishTiming := func() {}
|
|
if opts.Timing {
|
|
result.Timing = map[string]any{"unit": "ms"}
|
|
finishTiming = func() { result.Timing["total"] = time.Since(started).Milliseconds() }
|
|
defer finishTiming()
|
|
}
|
|
actionConfig, _ := GetActionConfig(action.ActionName())
|
|
if ca, ok := action.(ConfigurableAction); ok {
|
|
MergeMap(actionConfig, ca.Config())
|
|
}
|
|
applyActionTraits(action, actionConfig)
|
|
if enabled, exists := actionConfig["enabled"]; exists && !cast.Bool(enabled) {
|
|
result.Error = "API Action is disabled"
|
|
return result, errors.New(result.Error)
|
|
}
|
|
if cast.Bool(actionConfig["abstract"]) {
|
|
result.Error = "abstract API Action cannot be called"
|
|
return result, errors.New(result.Error)
|
|
}
|
|
if err := verifyToken(actionConfig, opts.Token); err != nil {
|
|
result.Error = err.Error()
|
|
logCall(opts, actionConfig, action.ActionName(), "", "", nil, result, started)
|
|
return result, err
|
|
}
|
|
if err := mergeOverrides(actionConfig, opts.Config); err != nil {
|
|
result.Error = err.Error()
|
|
logCall(opts, actionConfig, action.ActionName(), "", "", nil, result, started)
|
|
return result, err
|
|
}
|
|
|
|
var openedSecrets []*safe.SecretPlaintext
|
|
preprocessCallSecrets(actionConfig, &openedSecrets)
|
|
defer func() {
|
|
for _, secret := range openedSecrets {
|
|
secret.Close()
|
|
}
|
|
}()
|
|
if va, ok := action.(ValidatableAction); ok {
|
|
if err := va.Validate(); err != nil {
|
|
result.Error = fmt.Sprintf("action validation failed: %v", err)
|
|
return result, errors.New(result.Error)
|
|
}
|
|
}
|
|
fill(action, actionConfig)
|
|
method := strings.ToUpper(cast.String(actionConfig["method"]))
|
|
if method == "" {
|
|
method = "POST"
|
|
}
|
|
requestURL, err := buildURL(actionConfig)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return result, err
|
|
}
|
|
httpReq := &HttpRequest{Url: requestURL, Method: method, Payload: action, Context: opts.Context}
|
|
defer httpReq.Close()
|
|
if ga, ok := action.(*GenericAction); ok {
|
|
httpReq.Payload = ga.payload
|
|
}
|
|
if headers, ok := actionConfig["headers"].(map[string]any); ok {
|
|
for k, v := range headers {
|
|
httpReq.SetHeader(k, v)
|
|
}
|
|
}
|
|
filters := newFilterPipeline(actionConfig, "")
|
|
if filtered, filterErr := filters.apply(opts.Context, "request", map[string]any{"action": action.ActionName(), "request": requestMap(httpReq, actionConfig)}); filterErr != nil {
|
|
result.Error = filterErr.Error()
|
|
return result, filterErr
|
|
} else if request, ok := filtered["request"].(map[string]any); ok {
|
|
if filteredConfig := applyRequestMap(httpReq, request); filteredConfig != nil {
|
|
actionConfig = filteredConfig
|
|
}
|
|
}
|
|
if cast.Bool(actionConfig["llm"]) {
|
|
sanitizeLLMPayload(httpReq.Payload)
|
|
}
|
|
if err := sign(cast.String(actionConfig["signer"]), httpReq, actionConfig); err != nil {
|
|
result.Error = "sign failed: " + err.Error()
|
|
return result, errors.New(result.Error)
|
|
}
|
|
|
|
payload := formattedPayload(httpReq.Payload, actionConfig)
|
|
timeout := opts.Timeout
|
|
if timeout <= 0 {
|
|
timeout = cast.Duration(actionConfig["timeout"])
|
|
}
|
|
client := gohttp.NewClient(timeout)
|
|
defer client.Destroy()
|
|
if opts.Stream != nil {
|
|
err = callStream(client, httpReq, payload, opts.Stream, filters, action.ActionName(), opts.Context, result, started)
|
|
} else {
|
|
err = callBuffered(client, httpReq, payload, result)
|
|
if err == nil {
|
|
applyResponseRules(actionConfig, result)
|
|
if filtered, filterErr := filters.apply(opts.Context, "result", map[string]any{"action": action.ActionName(), "result": resultMap(result)}); filterErr != nil {
|
|
err = filterErr
|
|
result.Error = filterErr.Error()
|
|
} else if value, ok := filtered["result"].(map[string]any); ok {
|
|
applyResultMap(result, value)
|
|
}
|
|
}
|
|
}
|
|
if err == nil && opts.Stream != nil {
|
|
applyResponseRules(actionConfig, result)
|
|
if filtered, filterErr := filters.apply(opts.Context, "done", map[string]any{"action": action.ActionName(), "result": resultMap(result)}); filterErr != nil {
|
|
err = filterErr
|
|
result.Error = filterErr.Error()
|
|
} else if value, ok := filtered["result"].(map[string]any); ok {
|
|
applyResultMap(result, value)
|
|
}
|
|
}
|
|
if err == nil && opts.Stream != nil && opts.Stream.OnDone != nil {
|
|
finishTiming()
|
|
err = opts.Stream.OnDone(resultMap(result))
|
|
if err != nil {
|
|
result.Ok = false
|
|
result.Error = err.Error()
|
|
}
|
|
}
|
|
logCall(opts, actionConfig, action.ActionName(), httpReq.Method, httpReq.Url, httpReq.Payload, result, started)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func sanitizeLLMPayload(payload any) {
|
|
values, ok := payload.(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
for _, field := range []string{"name", "title", "extends", "abstract", "creation", "providerProtocol", "url", "host", "baseUrl", "path", "method", "format", "signer", "key", "filters", "requestSchema", "responseSchema", "test", "models", "defaultModel", "reasoningSupported"} {
|
|
delete(values, field)
|
|
}
|
|
}
|
|
|
|
func applyActionTraits(action Action, config map[string]any) {
|
|
if value, ok := action.(URLAction); ok && value.GetURL() != "" {
|
|
config["url"] = value.GetURL()
|
|
}
|
|
if value, ok := action.(MethodAction); ok && value.GetMethod() != "" {
|
|
config["method"] = value.GetMethod()
|
|
}
|
|
if value, ok := action.(SignerAction); ok && value.SignerName() != "" {
|
|
config["signer"] = value.SignerName()
|
|
}
|
|
if value, ok := action.(FormatAction); ok && value.GetFormat() != "" {
|
|
config["format"] = value.GetFormat()
|
|
}
|
|
}
|
|
|
|
// CallBy invokes a registered dynamic or Go Action.
|
|
func CallBy(name string, payload any, options ...*CallOptions) (*Result, error) {
|
|
actionRegistryMutex.RLock()
|
|
tmpl, ok := actionRegistry[name]
|
|
actionRegistryMutex.RUnlock()
|
|
if !ok {
|
|
err := fmt.Errorf("action not found: %s", name)
|
|
return &Result{Headers: map[string]string{}, Error: err.Error()}, err
|
|
}
|
|
var action Action
|
|
if t, ok := tmpl.(reflect.Type); ok {
|
|
inst := reflect.New(t).Interface()
|
|
if payload != nil {
|
|
cast.Convert(inst, payload)
|
|
}
|
|
action = inst.(Action)
|
|
} else if ga, ok := tmpl.(*GenericAction); ok {
|
|
config, resolveErr := resolveGenericActionConfig(name, map[string]bool{})
|
|
if resolveErr != nil {
|
|
return &Result{Headers: map[string]string{}, Error: resolveErr.Error()}, resolveErr
|
|
}
|
|
copyAction := &GenericAction{name: ga.name, config: config, payload: cloneMap(ga.payload)}
|
|
if payload != nil {
|
|
if m, ok := payload.(map[string]any); ok {
|
|
MergeMap(copyAction.payload, m)
|
|
} else {
|
|
cast.Convert(©Action.payload, payload)
|
|
}
|
|
}
|
|
action = copyAction
|
|
}
|
|
return Call(action, options...)
|
|
}
|
|
|
|
func resolveGenericActionConfig(name string, visiting map[string]bool) (map[string]any, error) {
|
|
if visiting[name] {
|
|
return nil, fmt.Errorf("circular Action inheritance: %s", name)
|
|
}
|
|
actionRegistryMutex.RLock()
|
|
template, exists := actionRegistry[name]
|
|
actionRegistryMutex.RUnlock()
|
|
generic, ok := template.(*GenericAction)
|
|
if !exists || !ok {
|
|
return nil, fmt.Errorf("inherited Action not found: %s", name)
|
|
}
|
|
visiting[name] = true
|
|
defer delete(visiting, name)
|
|
config := map[string]any{}
|
|
if parent := cast.String(generic.config["extends"]); parent != "" {
|
|
parentConfig, err := resolveGenericActionConfig(parent, visiting)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// abstract describes whether this definition itself is callable. Children
|
|
// inherit its configuration contract, but become concrete by definition.
|
|
delete(parentConfig, "abstract")
|
|
MergeMap(config, parentConfig)
|
|
}
|
|
MergeMap(config, generic.config)
|
|
return config, nil
|
|
}
|
|
|
|
func formattedPayload(payload any, config map[string]any) any {
|
|
switch strings.ToLower(cast.String(config["format"])) {
|
|
case "form":
|
|
var form gohttp.Form
|
|
cast.Convert(&form, payload)
|
|
return form
|
|
case "multipart":
|
|
var multipart gohttp.Multipart
|
|
cast.Convert(&multipart, payload)
|
|
return multipart
|
|
default:
|
|
return payload
|
|
}
|
|
}
|
|
|
|
func callBuffered(client *gohttp.Client, req *HttpRequest, payload any, result *Result) error {
|
|
res := client.Do(req.Method, req.Url, payload, headerSlice(req)...)
|
|
if res.Error != nil {
|
|
result.Error = res.Error.Error()
|
|
return res.Error
|
|
}
|
|
copyHTTPMeta(res.Response, result)
|
|
if res.Response != nil && res.Response.ContentLength > 0 {
|
|
result.receivedBytes = res.Response.ContentLength
|
|
}
|
|
var data any
|
|
if err := res.To(&data); err != nil {
|
|
result.Error = err.Error()
|
|
return err
|
|
}
|
|
result.Data = data
|
|
return nil
|
|
}
|
|
|
|
func callStream(client *gohttp.Client, req *HttpRequest, payload any, stream *StreamOptions, filters *filterPipeline, action string, ctx context.Context, result *Result, started time.Time) error {
|
|
res := client.ManualDo(req.Method, req.Url, payload, headerSlice(req)...)
|
|
if res.Error != nil {
|
|
result.Error = res.Error.Error()
|
|
return res.Error
|
|
}
|
|
if res.Response == nil {
|
|
result.Error = "empty HTTP response"
|
|
return errors.New(result.Error)
|
|
}
|
|
defer res.Response.Body.Close()
|
|
copyHTTPMeta(res.Response, result)
|
|
if filtered, err := filters.apply(ctx, "headers", map[string]any{"action": action, "result": resultMap(result)}); err != nil {
|
|
result.Error = err.Error()
|
|
return err
|
|
} else if value, ok := filtered["result"].(map[string]any); ok {
|
|
applyResultMap(result, value)
|
|
}
|
|
if stream.OnHeaders != nil {
|
|
if err := invokeStreamCallback(func() error { return stream.OnHeaders(resultMap(result)) }); err != nil {
|
|
result.Error = err.Error()
|
|
return err
|
|
}
|
|
}
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, readErr := res.Response.Body.Read(buf)
|
|
if n > 0 {
|
|
if result.Timing != nil {
|
|
if _, exists := result.Timing["firstToken"]; !exists {
|
|
result.Timing["firstToken"] = time.Since(started).Milliseconds()
|
|
}
|
|
}
|
|
result.receivedBytes += int64(n)
|
|
if stream.OnData != nil {
|
|
filtered, filterErr := filters.apply(ctx, "chunk", map[string]any{"action": action, "chunk": append([]byte(nil), buf[:n]...), "drop": false})
|
|
if filterErr != nil {
|
|
result.Error = filterErr.Error()
|
|
return filterErr
|
|
}
|
|
chunk := filteredChunk(filtered["chunk"])
|
|
if !cast.Bool(filtered["drop"]) && len(chunk) > 0 {
|
|
if err := invokeStreamCallback(func() error { return stream.OnData(chunk) }); err != nil {
|
|
result.Error = err.Error()
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if readErr == io.EOF {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
result.Error = readErr.Error()
|
|
return readErr
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resultMap(result *Result) map[string]any {
|
|
if result == nil {
|
|
return map[string]any{"ok": false, "statusCode": 0, "headers": map[string]string{}, "data": nil, "code": "", "error": "empty API result"}
|
|
}
|
|
output := map[string]any{
|
|
"ok": result.Ok, "statusCode": result.StatusCode, "headers": result.Headers,
|
|
"data": result.Data, "code": result.Code, "error": result.Error,
|
|
}
|
|
if result.Timing != nil {
|
|
output["timing"] = result.Timing
|
|
}
|
|
return output
|
|
}
|
|
|
|
func invokeStreamCallback(callback func() error) (err error) {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
err = fmt.Errorf("stream callback panic: %v\n%s", recovered, debug.Stack())
|
|
}
|
|
}()
|
|
return callback()
|
|
}
|
|
|
|
func copyHTTPMeta(response *stdhttp.Response, result *Result) {
|
|
if response == nil {
|
|
return
|
|
}
|
|
result.StatusCode = response.StatusCode
|
|
for key, values := range response.Header {
|
|
result.Headers[key] = strings.Join(values, ", ")
|
|
}
|
|
}
|
|
|
|
func buildURL(config map[string]any) (string, error) {
|
|
raw := cast.String(config["url"])
|
|
path := cast.String(config["path"])
|
|
if raw == "" {
|
|
raw = cast.String(config["baseUrl"])
|
|
if raw == "" {
|
|
raw = cast.String(config["host"])
|
|
if raw != "" && !strings.Contains(raw, "://") {
|
|
raw = "https://" + raw
|
|
}
|
|
}
|
|
if path != "" {
|
|
raw = strings.TrimRight(raw, "/") + "/" + strings.TrimLeft(path, "/")
|
|
}
|
|
}
|
|
if raw == "" {
|
|
return "", errors.New("API URL is required")
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if cast.String(config["url"]) != "" && path != "" {
|
|
u.Path = "/" + strings.TrimLeft(path, "/")
|
|
u.RawPath = ""
|
|
raw = u.String()
|
|
}
|
|
if values, ok := config["query"].(map[string]any); ok && len(values) > 0 {
|
|
query := u.Query()
|
|
for key, value := range values {
|
|
query.Set(key, secretString(value))
|
|
}
|
|
u.RawQuery = query.Encode()
|
|
return u.String(), nil
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func preprocessCallSecrets(m map[string]any, opened *[]*safe.SecretPlaintext) {
|
|
for k, v := range m {
|
|
if sb, ok := v.(*safe.SafeBuf); ok {
|
|
secret := sb.Open()
|
|
m[k] = secret
|
|
*opened = append(*opened, secret)
|
|
} else if subMap, ok := v.(map[string]any); ok {
|
|
preprocessCallSecrets(subMap, opened)
|
|
}
|
|
}
|
|
}
|
|
|
|
func secretString(value any) string {
|
|
if secret, ok := value.(*safe.SecretPlaintext); ok && secret != nil {
|
|
return secret.String()
|
|
}
|
|
return cast.String(value)
|
|
}
|