mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
feat: merge automation workflow updates
This commit is contained in:
@@ -11,9 +11,15 @@ import (
|
||||
"ant-chrome/backend/internal/automation"
|
||||
)
|
||||
|
||||
const (
|
||||
automationMinTimeoutMs = 1000
|
||||
automationMaxTimeoutMs = 30 * 60 * 1000
|
||||
)
|
||||
|
||||
type automationScriptRunAPIRequest struct {
|
||||
ScriptID string `json:"scriptId"`
|
||||
Selector json.RawMessage `json:"selector"`
|
||||
TargetInput json.RawMessage `json:"targetInput"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
UseScriptSelector *bool `json:"useScriptSelector"`
|
||||
UseScriptParams *bool `json:"useScriptParams"`
|
||||
@@ -21,19 +27,20 @@ type automationScriptRunAPIRequest struct {
|
||||
}
|
||||
|
||||
type automationScriptSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
EntryFile string `json:"entryFile"`
|
||||
Tags []string `json:"tags"`
|
||||
Selector map[string]interface{} `json:"selector"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Notes string `json:"notes"`
|
||||
TargetConfig automation.ScriptTargetConfig `json:"targetConfig"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
EntryFile string `json:"entryFile"`
|
||||
Tags []string `json:"tags"`
|
||||
Selector map[string]interface{} `json:"selector"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Notes string `json:"notes"`
|
||||
TargetConfig automation.ScriptTargetConfig `json:"targetConfig"`
|
||||
PublicAPI automation.ScriptPublicAPIConfig `json:"publicAPI"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type automationScriptDetail struct {
|
||||
@@ -45,28 +52,19 @@ type automationScriptDetail struct {
|
||||
|
||||
func (s *LaunchServer) handleAutomationScripts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "method not allowed",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
|
||||
return
|
||||
}
|
||||
|
||||
lister, ok := s.starter.(AutomationScriptLister)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "automation script api is unavailable",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := lister.AutomationScriptList()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,37 +73,27 @@ func (s *LaunchServer) handleAutomationScripts(w http.ResponseWriter, r *http.Re
|
||||
result = append(result, summarizeAutomationScript(item))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"count": len(result),
|
||||
"items": result,
|
||||
writeAutomationAPISuccess(w, http.StatusOK, "", automationAPIListData[automationScriptSummary]{
|
||||
Items: result,
|
||||
Count: len(result),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LaunchServer) handleAutomationScriptByID(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "method not allowed",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
|
||||
return
|
||||
}
|
||||
|
||||
scriptID, ok := parseAutomationScriptPathID(r.URL.Path)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "script not found",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "script not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
getter, ok := s.starter.(AutomationScriptGetter)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "automation script api is unavailable",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -113,47 +101,31 @@ func (s *LaunchServer) handleAutomationScriptByID(w http.ResponseWriter, r *http
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(err.Error())
|
||||
if os.IsNotExist(err) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "script not found",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "script not found", "")
|
||||
return
|
||||
}
|
||||
if strings.Contains(strings.ToLower(message), "script id is invalid") || strings.Contains(strings.ToLower(message), "script id is required") {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": message,
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", message, "scriptId")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": message,
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", message, "")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"item": detailAutomationScript(*item),
|
||||
writeAutomationAPISuccess(w, http.StatusOK, "", automationAPIItemData[automationScriptDetail]{
|
||||
Item: detailAutomationScript(*item),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LaunchServer) handleAutomationScriptRun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "method not allowed",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
|
||||
return
|
||||
}
|
||||
|
||||
runner, ok := s.starter.(AutomationScriptRunner)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "automation script api is unavailable",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -161,52 +133,41 @@ func (s *LaunchServer) handleAutomationScriptRun(w http.ResponseWriter, r *http.
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "invalid request body",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
input, err := normalizeAutomationRunRequest(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", err.Error(), automationRequestErrorField(err))
|
||||
return
|
||||
}
|
||||
|
||||
run, err := runner.AutomationScriptRunWithOptions(input)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"run": run,
|
||||
})
|
||||
data := automationAPIRunData{
|
||||
Run: run,
|
||||
Summary: run.Summary,
|
||||
}
|
||||
if result := decodeAutomationRunResult(run.ResultText); result != nil {
|
||||
data.Result = result
|
||||
}
|
||||
writeAutomationAPISuccess(w, http.StatusOK, run.Summary, data)
|
||||
}
|
||||
|
||||
func (s *LaunchServer) handleAutomationScriptRuns(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "method not allowed",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
|
||||
return
|
||||
}
|
||||
|
||||
lister, ok := s.starter.(AutomationScriptRunLister)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "automation script api is unavailable",
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -225,18 +186,14 @@ func (s *LaunchServer) handleAutomationScriptRuns(w http.ResponseWriter, r *http
|
||||
|
||||
items, err := lister.AutomationScriptRunList(limit)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"count": len(items),
|
||||
"limit": limit,
|
||||
"items": items,
|
||||
writeAutomationAPISuccess(w, http.StatusOK, "", automationAPIListData[automation.ScriptRunRecord]{
|
||||
Items: items,
|
||||
Count: len(items),
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -253,6 +210,7 @@ func summarizeAutomationScript(record automation.ScriptRecord) automationScriptS
|
||||
Params: parseJSONObjectText(record.ParamsText),
|
||||
Notes: strings.TrimSpace(record.Notes),
|
||||
TargetConfig: record.TargetConfig,
|
||||
PublicAPI: record.PublicAPI,
|
||||
CreatedAt: strings.TrimSpace(record.CreatedAt),
|
||||
UpdatedAt: strings.TrimSpace(record.UpdatedAt),
|
||||
}
|
||||
@@ -287,6 +245,10 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio
|
||||
if err != nil {
|
||||
return automation.ScriptRunRequest{}, err
|
||||
}
|
||||
targetInput, hasTargetInput, err := decodeJSONObjectRaw(req.TargetInput, "targetInput")
|
||||
if err != nil {
|
||||
return automation.ScriptRunRequest{}, err
|
||||
}
|
||||
params, hasParams, err := decodeJSONObjectRaw(req.Params, "params")
|
||||
if err != nil {
|
||||
return automation.ScriptRunRequest{}, err
|
||||
@@ -300,6 +262,9 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio
|
||||
if err != nil {
|
||||
return automation.ScriptRunRequest{}, err
|
||||
}
|
||||
if err := validateAutomationTimeoutMs(req.TimeoutMs); err != nil {
|
||||
return automation.ScriptRunRequest{}, err
|
||||
}
|
||||
|
||||
selectorText := ""
|
||||
if !useScriptSelector {
|
||||
@@ -322,13 +287,24 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio
|
||||
return automation.ScriptRunRequest{
|
||||
ScriptID: scriptID,
|
||||
SelectorText: selectorText,
|
||||
TargetInput: targetInput,
|
||||
ParamsText: paramsText,
|
||||
UseScriptSelector: useScriptSelector,
|
||||
UseScriptSelector: useScriptSelector && !hasTargetInput,
|
||||
UseScriptParams: useScriptParams,
|
||||
TimeoutMs: req.TimeoutMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateAutomationTimeoutMs(timeoutMs int) error {
|
||||
if timeoutMs == 0 {
|
||||
return nil
|
||||
}
|
||||
if timeoutMs < automationMinTimeoutMs || timeoutMs > automationMaxTimeoutMs {
|
||||
return badAutomationRequest("timeoutMs must be between 1000 and 1800000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveUseScriptField(name string, explicit *bool, hasObject bool) (bool, error) {
|
||||
if explicit == nil {
|
||||
return !hasObject, nil
|
||||
@@ -360,6 +336,14 @@ func decodeJSONObjectRaw(raw json.RawMessage, fieldName string) (map[string]inte
|
||||
return obj, true, nil
|
||||
}
|
||||
|
||||
func decodeAutomationRunResult(raw string) interface{} {
|
||||
_, result, ok := decodeAutomationRunPayloadValue(raw)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseJSONObjectText(text string) map[string]interface{} {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
@@ -390,6 +374,16 @@ func badAutomationRequest(message string) error {
|
||||
return automationRequestError(strings.TrimSpace(message))
|
||||
}
|
||||
|
||||
func automationRequestErrorField(err error) string {
|
||||
message := strings.TrimSpace(err.Error())
|
||||
for _, field := range []string{"scriptId", "selector", "targetInput", "params"} {
|
||||
if strings.Contains(message, field) {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type automationRequestError string
|
||||
|
||||
func (e automationRequestError) Error() string {
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
package launchcode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ant-chrome/backend/internal/automation"
|
||||
)
|
||||
|
||||
const automationPublicHookRoutePrefix = "/api/automation/hooks/"
|
||||
|
||||
func (s *LaunchServer) handleAutomationPublicHook(w http.ResponseWriter, r *http.Request) {
|
||||
hookPath, ok := parseAutomationPublicHookPath(r.URL.Path)
|
||||
if !ok {
|
||||
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := s.findAutomationPublicHookScript(hookPath)
|
||||
if err != nil {
|
||||
if err == errAutomationHookServiceUnavailable {
|
||||
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
|
||||
return
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "")
|
||||
return
|
||||
}
|
||||
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
if !record.PublicAPI.Enabled {
|
||||
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != record.PublicAPI.Method {
|
||||
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
|
||||
return
|
||||
}
|
||||
|
||||
runner, ok := s.starter.(AutomationScriptRunner)
|
||||
if !ok {
|
||||
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
input, err := buildAutomationPublicHookRunRequest(*record, r, body)
|
||||
if err != nil {
|
||||
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", err.Error(), automationRequestErrorField(err))
|
||||
return
|
||||
}
|
||||
|
||||
run, err := runner.AutomationScriptRunWithOptions(input)
|
||||
if err != nil {
|
||||
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
writeAutomationPublicHookResponse(w, *record, run)
|
||||
}
|
||||
|
||||
var errAutomationHookServiceUnavailable = automationRequestError("automation hook service unavailable")
|
||||
|
||||
func (s *LaunchServer) findAutomationPublicHookScript(hookPath string) (*automation.ScriptRecord, error) {
|
||||
lister, ok := s.starter.(AutomationScriptLister)
|
||||
if !ok {
|
||||
return nil, errAutomationHookServiceUnavailable
|
||||
}
|
||||
|
||||
items, err := lister.AutomationScriptList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if normalizeAutomationPublicHookPath(item.PublicAPI.Path) != hookPath {
|
||||
continue
|
||||
}
|
||||
record := item
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func parseAutomationPublicHookPath(urlPath string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(urlPath)
|
||||
if !strings.HasPrefix(trimmed, automationPublicHookRoutePrefix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
trimmed = strings.TrimPrefix(trimmed, automationPublicHookRoutePrefix)
|
||||
trimmed = normalizeAutomationPublicHookPath(trimmed)
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
return trimmed, true
|
||||
}
|
||||
|
||||
func normalizeAutomationPublicHookPath(value string) string {
|
||||
value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
cleaned := strings.Trim(path.Clean("/"+value), "/")
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(cleaned)
|
||||
}
|
||||
|
||||
func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http.Request, body []byte) (automation.ScriptRunRequest, error) {
|
||||
if shouldApplyAutomationPublicHookVariables(record) {
|
||||
resolvedBody, err := resolveAutomationPublicHookRequestBody(record, body)
|
||||
if err != nil {
|
||||
return automation.ScriptRunRequest{}, err
|
||||
}
|
||||
body = resolvedBody
|
||||
}
|
||||
|
||||
input, err := decodeAutomationPublicHookRequestBody(body)
|
||||
if err != nil {
|
||||
return automation.ScriptRunRequest{}, err
|
||||
}
|
||||
selectorText := ""
|
||||
useScriptSelector := true
|
||||
if strings.TrimSpace(input.Code) != "" {
|
||||
encodedSelectorText, err := encodeAutomationPublicHookJSONObject(map[string]interface{}{"code": strings.TrimSpace(input.Code)})
|
||||
if err != nil {
|
||||
return automation.ScriptRunRequest{}, badAutomationRequest("code is invalid")
|
||||
}
|
||||
selectorText = encodedSelectorText
|
||||
useScriptSelector = false
|
||||
}
|
||||
if err := validateAutomationTimeoutMs(input.TimeoutMs); err != nil {
|
||||
return automation.ScriptRunRequest{}, err
|
||||
}
|
||||
params := mergeAutomationPublicHookDefaultParamsObject(record, input.Params)
|
||||
paramsText, err := encodeAutomationPublicHookJSONObject(params)
|
||||
if err != nil {
|
||||
return automation.ScriptRunRequest{}, badAutomationRequest("params must be a JSON object")
|
||||
}
|
||||
|
||||
return automation.ScriptRunRequest{
|
||||
ScriptID: record.ID,
|
||||
SelectorText: selectorText,
|
||||
ParamsText: paramsText,
|
||||
UseScriptSelector: useScriptSelector,
|
||||
UseScriptParams: false,
|
||||
TimeoutMs: resolveAutomationPublicHookTimeout(r, input.TimeoutMs, record.PublicAPI.TimeoutMs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type automationPublicHookRequestBody struct {
|
||||
Code string `json:"code"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
func decodeAutomationPublicHookRequestBody(body []byte) (automationPublicHookRequestBody, error) {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return automationPublicHookRequestBody{}, nil
|
||||
}
|
||||
|
||||
var input automationPublicHookRequestBody
|
||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&input); err != nil {
|
||||
return automationPublicHookRequestBody{}, badAutomationRequest("invalid request body")
|
||||
}
|
||||
if input.Params == nil {
|
||||
input.Params = map[string]interface{}{}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func encodeAutomationPublicHookJSONObject(obj map[string]interface{}) (string, error) {
|
||||
encoded, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func shouldApplyAutomationPublicHookVariables(record automation.ScriptRecord) bool {
|
||||
if strings.TrimSpace(record.PublicAPI.RequestBodyText) == "" {
|
||||
return false
|
||||
}
|
||||
for _, variable := range record.PublicAPI.Variables {
|
||||
name := strings.TrimSpace(variable.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(record.PublicAPI.RequestBodyText, "{{"+name+"}}") || strings.Contains(record.PublicAPI.RequestBodyText, "${"+name+"}") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func replaceAutomationPublicHookPlaceholderValue(bodyText string, name string, rawValue interface{}) string {
|
||||
value := strings.TrimSpace(formatAutomationPublicHookVariableValue(rawValue))
|
||||
escapedValue := escapeAutomationPublicHookJSONString(value)
|
||||
for _, placeholder := range []string{"{{" + name + "}}", "${" + name + "}"} {
|
||||
bodyText = strings.ReplaceAll(bodyText, placeholder, escapedValue)
|
||||
}
|
||||
return bodyText
|
||||
}
|
||||
|
||||
func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body []byte) ([]byte, error) {
|
||||
config := record.PublicAPI
|
||||
input, err := decodeAutomationPublicHookRequestBody(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := input.Params
|
||||
|
||||
bodyText := replaceAutomationPublicHookPlaceholderValue(config.RequestBodyText, "code", input.Code)
|
||||
for _, variable := range config.Variables {
|
||||
name := strings.TrimSpace(variable.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
placeholders := []string{"{{" + name + "}}", "${" + name + "}"}
|
||||
used := false
|
||||
for _, placeholder := range placeholders {
|
||||
if strings.Contains(bodyText, placeholder) {
|
||||
used = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !used {
|
||||
continue
|
||||
}
|
||||
|
||||
rawValue := interface{}(variable.DefaultValue)
|
||||
if incomingValue, ok := values[name]; ok {
|
||||
rawValue = incomingValue
|
||||
}
|
||||
value := strings.TrimSpace(formatAutomationPublicHookVariableValue(rawValue))
|
||||
if variable.Required && value == "" {
|
||||
return nil, badAutomationRequest("missing required variable: " + name)
|
||||
}
|
||||
escapedValue := escapeAutomationPublicHookJSONString(value)
|
||||
for _, placeholder := range placeholders {
|
||||
bodyText = strings.ReplaceAll(bodyText, placeholder, escapedValue)
|
||||
}
|
||||
}
|
||||
|
||||
var decoded interface{}
|
||||
if err := json.Unmarshal([]byte(bodyText), &decoded); err != nil {
|
||||
return nil, badAutomationRequest("resolved request body must be a JSON object")
|
||||
}
|
||||
decodedBody, ok := decoded.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, badAutomationRequest("resolved request body must be a JSON object")
|
||||
}
|
||||
mergedBody := mergeAutomationPublicHookDefaultParams(record, decodedBody)
|
||||
encoded, err := json.Marshal(mergedBody)
|
||||
if err != nil {
|
||||
return nil, badAutomationRequest("resolved request body must be a JSON object")
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func mergeAutomationPublicHookDefaultParams(record automation.ScriptRecord, body map[string]interface{}) map[string]interface{} {
|
||||
defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText)
|
||||
if !ok || len(defaultParams) == 0 {
|
||||
return body
|
||||
}
|
||||
|
||||
if record.PublicAPI.RequestMode == "params-only" {
|
||||
return mergeAutomationPublicHookJSONObjects(defaultParams, body)
|
||||
}
|
||||
|
||||
rawParams, ok := body["params"].(map[string]interface{})
|
||||
if !ok {
|
||||
return body
|
||||
}
|
||||
|
||||
nextBody := make(map[string]interface{}, len(body))
|
||||
for key, value := range body {
|
||||
nextBody[key] = value
|
||||
}
|
||||
nextBody["params"] = mergeAutomationPublicHookJSONObjects(defaultParams, rawParams)
|
||||
return nextBody
|
||||
}
|
||||
|
||||
func mergeAutomationPublicHookDefaultParamsObject(record automation.ScriptRecord, param map[string]interface{}) map[string]interface{} {
|
||||
if param == nil {
|
||||
param = map[string]interface{}{}
|
||||
}
|
||||
defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText)
|
||||
if !ok || len(defaultParams) == 0 {
|
||||
return param
|
||||
}
|
||||
return mergeAutomationPublicHookJSONObjects(defaultParams, param)
|
||||
}
|
||||
|
||||
func parseAutomationPublicHookJSONObject(text string) (map[string]interface{}, bool) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return nil, false
|
||||
}
|
||||
var value interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &value); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
object, ok := value.(map[string]interface{})
|
||||
return object, ok
|
||||
}
|
||||
|
||||
func mergeAutomationPublicHookJSONObjects(base map[string]interface{}, patch map[string]interface{}) map[string]interface{} {
|
||||
merged := make(map[string]interface{}, len(base)+len(patch))
|
||||
for key, value := range base {
|
||||
merged[key] = value
|
||||
}
|
||||
for key, value := range patch {
|
||||
baseObject, baseOK := merged[key].(map[string]interface{})
|
||||
patchObject, patchOK := value.(map[string]interface{})
|
||||
if baseOK && patchOK {
|
||||
merged[key] = mergeAutomationPublicHookJSONObjects(baseObject, patchObject)
|
||||
continue
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func formatAutomationPublicHookVariableValue(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return typed
|
||||
case float64, bool, int, int64, json.Number:
|
||||
return strings.TrimSpace(strings.Trim(fmt.Sprint(typed), "\""))
|
||||
default:
|
||||
encoded, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return fmt.Sprint(typed)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func escapeAutomationPublicHookJSONString(value string) string {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
text := string(encoded)
|
||||
if len(text) >= 2 {
|
||||
return text[1 : len(text)-1]
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func decodeAutomationRunAPIRequestBody(body []byte) (automationScriptRunAPIRequest, error) {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
return automationScriptRunAPIRequest{}, nil
|
||||
}
|
||||
|
||||
var req automationScriptRunAPIRequest
|
||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return automationScriptRunAPIRequest{}, badAutomationRequest("invalid request body")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func decodeJSONObjectBody(body []byte, fieldName string) (map[string]interface{}, bool, error) {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
if err := json.Unmarshal(trimmed, &value); err != nil {
|
||||
return nil, false, badAutomationRequest(fieldName + " must be a JSON object")
|
||||
}
|
||||
|
||||
obj, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false, badAutomationRequest(fieldName + " must be a JSON object")
|
||||
}
|
||||
return obj, true, nil
|
||||
}
|
||||
|
||||
func resolveAutomationPublicHookTimeout(r *http.Request, requestTimeout int, fallback int) int {
|
||||
if requestTimeout > 0 {
|
||||
return requestTimeout
|
||||
}
|
||||
|
||||
if r != nil {
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("timeoutMs")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func writeAutomationPublicHookResponse(w http.ResponseWriter, record automation.ScriptRecord, run *automation.ScriptRunRecord) {
|
||||
_ = record
|
||||
parsedPayload, resultPayload, hasResult := decodeAutomationRunPayloadValue(run.ResultText)
|
||||
if run.Status != "success" {
|
||||
writeJSON(w, http.StatusOK, compactAutomationPublicHookFailure(run))
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"ok": true,
|
||||
"status": run.Status,
|
||||
"summary": run.Summary,
|
||||
"message": run.Summary,
|
||||
"data": map[string]interface{}{},
|
||||
"result": map[string]interface{}{},
|
||||
}
|
||||
|
||||
if hasResult {
|
||||
data := compactAutomationPublicHookData(resultPayload, run)
|
||||
response["data"] = data
|
||||
response["result"] = data
|
||||
} else if parsedPayload != nil {
|
||||
data := compactAutomationPublicHookData(parsedPayload, run)
|
||||
response["data"] = data
|
||||
response["result"] = data
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func compactAutomationPublicHookFailure(run *automation.ScriptRunRecord) map[string]interface{} {
|
||||
response := map[string]interface{}{
|
||||
"ok": false,
|
||||
"status": run.Status,
|
||||
"summary": run.Summary,
|
||||
"message": run.Summary,
|
||||
"data": map[string]interface{}{},
|
||||
"result": map[string]interface{}{},
|
||||
}
|
||||
if strings.TrimSpace(run.Error) != "" {
|
||||
response["error"] = run.Error
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func compactAutomationPublicHookData(payload interface{}, run *automation.ScriptRunRecord) interface{} {
|
||||
data := compactAutomationPublicHookResult(payload, run)
|
||||
delete(data, "ok")
|
||||
delete(data, "summary")
|
||||
return data
|
||||
}
|
||||
|
||||
func compactAutomationPublicHookResult(payload interface{}, run *automation.ScriptRunRecord) map[string]interface{} {
|
||||
obj, ok := payload.(map[string]interface{})
|
||||
if !ok {
|
||||
result := map[string]interface{}{"ok": true}
|
||||
if strings.TrimSpace(run.Summary) != "" {
|
||||
result["summary"] = run.Summary
|
||||
}
|
||||
if payload != nil {
|
||||
result["result"] = payload
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if !hasAutomationPublicHookDownloadField(obj) {
|
||||
result := make(map[string]interface{}, len(obj)+1)
|
||||
result["ok"] = true
|
||||
for key, value := range obj {
|
||||
if key != "ok" && value != nil {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
if _, exists := result["summary"]; !exists && strings.TrimSpace(run.Summary) != "" {
|
||||
result["summary"] = run.Summary
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
result := map[string]interface{}{"ok": true}
|
||||
|
||||
for _, key := range []string{
|
||||
"downloadAddress",
|
||||
"downloadPath",
|
||||
"outputPath",
|
||||
"sourceImageUrl",
|
||||
"sourceDownloadUrl",
|
||||
"screenshotPath",
|
||||
"pageScreenshotPath",
|
||||
"contentType",
|
||||
"imageWidth",
|
||||
"imageHeight",
|
||||
"status",
|
||||
"summary",
|
||||
"error",
|
||||
} {
|
||||
if value, exists := obj[key]; exists && value != nil {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasAutomationPublicHookDownloadField(obj map[string]interface{}) bool {
|
||||
for _, key := range []string{"downloadAddress", "downloadPath", "outputPath"} {
|
||||
if value, exists := obj[key]; exists && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decodeAutomationRunPayloadValue(raw string) (interface{}, interface{}, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
var payload interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
if obj, ok := payload.(map[string]interface{}); ok {
|
||||
result, exists := obj["result"]
|
||||
return payload, result, exists
|
||||
}
|
||||
return payload, nil, false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package launchcode
|
||||
|
||||
import "net/http"
|
||||
|
||||
type automationAPIResponse[T any] struct {
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error *automationAPIError `json:"error,omitempty"`
|
||||
Data T `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type automationAPIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Field string `json:"field,omitempty"`
|
||||
}
|
||||
|
||||
type automationAPIListData[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
Count int `json:"count"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type automationAPIItemData[T any] struct {
|
||||
Item T `json:"item"`
|
||||
}
|
||||
|
||||
type automationAPIRunData struct {
|
||||
Run interface{} `json:"run,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
func writeAutomationAPIError(w http.ResponseWriter, status int, code string, message string, field string) {
|
||||
writeJSON(w, status, automationAPIResponse[struct{}]{
|
||||
OK: false,
|
||||
Status: "failed",
|
||||
Error: &automationAPIError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Field: field,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func writeAutomationAPISuccess[T any](w http.ResponseWriter, status int, message string, data T) {
|
||||
writeJSON(w, status, automationAPIResponse[T]{
|
||||
OK: true,
|
||||
Status: "success",
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ func (s *LaunchServer) buildMux() *http.ServeMux {
|
||||
mux.HandleFunc("/api/automation/scripts/", s.handleAutomationScriptByID)
|
||||
mux.HandleFunc("/api/automation/scripts/run", s.handleAutomationScriptRun)
|
||||
mux.HandleFunc("/api/automation/scripts/runs", s.handleAutomationScriptRuns)
|
||||
mux.HandleFunc("/api/automation/hooks/", s.handleAutomationPublicHook)
|
||||
mux.HandleFunc("/api/profiles", s.handleProfiles)
|
||||
mux.HandleFunc("/api/profiles/", s.handleProfileByID)
|
||||
mux.HandleFunc("/api/runtime/active", s.handleRuntimeActive)
|
||||
|
||||
Reference in New Issue
Block a user