/* Copyright © 2026 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package es import ( "bufio" "bytes" "context" "crypto/tls" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log" "log/slog" "net/http" "os" "os/exec" "regexp" "slices" "strings" "codeberg.org/scip/esctl/assets" "codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/printer" markdown "github.com/MichaelMure/go-term-markdown" "github.com/charmbracelet/lipgloss" "github.com/chzyer/readline" "github.com/go-openapi/spec" ) const ( intro = `# Input format: verb path [data]" # # Example: # # post /yourindex/_ccr/pause_follow # put /yourindex/_settings {"number_of_replicas": 1} # # You can also put multiline JSON after the path like: # # put /yourindex/_settings # { # "number_of_replicas": 1 # } # # If you do NOT supply a JSON in the first line, you need to hit ENTER # twice to complete. # # Supply the flag --human-readable-cat, -H to view /_cat API calls in # human readable form.` ) // holds an API operation via go-openapi/spec type Op struct { Verb string Op *spec.Operation } type Param struct { Description, Param string } // API operation parameter type type Params struct { Path []Param Query []Param } func ApiRepl(conf *cfg.Config) error { verbs := []string{"post", "get", "put", "delete"} fmt.Println(intro) fmt.Println() reader, err := readline.NewEx(&readline.Config{ Prompt: "> ", HistoryFile: os.Getenv("HOME") + "/.config/esctl/history", HistoryLimit: 500, InterruptPrompt: "^C", EOFPrompt: "exit", HistorySearchFold: true, }) if err != nil { return fmt.Errorf("failed to initialize readline lib: %w", err) } for { text, err := reader.Readline() if err != nil { break } text = strings.TrimSpace(text) if text == "" { continue } parts := strings.SplitN(strings.TrimSpace(text), " ", 3) if len(parts) < 2 { fmt.Println("error: you need to input a verb, uri [and post data]") continue } if !slices.Contains(verbs, strings.ToLower(parts[0])) { fmt.Println("error: verb must be one of " + strings.Join(verbs, ",")) continue } if !strings.HasPrefix(parts[1], "/") { parts[1] = "/" + parts[1] } json := "" if len(parts) == 3 { // put /uri {json} json = parts[2] } // put /uri/ [json] data, err := readJSON(json) if err != nil { fmt.Println(err) } raw, err := CallAPI(conf, parts[0], parts[1], data) if err != nil { fmt.Printf("failed to call API: %s\n", esErrorString(err)) } if conf.HumanCat && strings.HasPrefix(parts[1], "/_cat") { fmt.Println(string(raw)) } else { pageJsonOutput(conf, raw) } } //nolint:nilerr return nil } func pageJsonOutput(conf *cfg.Config, raw []byte) { tmpconf := &cfg.Config{HaveJQ: conf.HaveJQ} if conf.Pager != "" { tmpconf.HaveJQ = false } output, err := prettyfiJson(tmpconf, raw) if err != nil { fmt.Println(err) } lines := len(strings.Split(output, "\n")) height := cfg.GetTermHeight() if lines > height { if conf.Pager != "" { cmd := strings.Split(conf.Pager, " ") pager := exec.Command(cmd[0], cmd[1:]...) var buf bytes.Buffer buf.WriteString(output) pager.Stdout = os.Stdout pager.Stdin = &buf pager.Stderr = os.Stderr err := pager.Run() if err != nil { fmt.Printf("failed to execute pager '%s': %s", conf.Pager, err) } } else { printer.Pager("json output", output) } return } fmt.Println(output) } func encodeAuth(username, password string) string { return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) } func CallAPI(conf *cfg.Config, verb, path, data string) ([]byte, error) { verb = strings.ToUpper(verb) // we're using port-forwards anyway noVerifyTransport := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } client := &http.Client{Transport: noVerifyTransport} if conf.DebugHTTP { client = &http.Client{ Transport: &cfg.DebugTransport{ Transport: noVerifyTransport}} } req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data))) if err != nil { return nil, err } if !conf.HumanCat || (conf.HumanCat && !strings.HasPrefix(path, "/_cat")) { req.Header.Add("Content-Type", "application/json") req.Header.Add("Accept", "application/json") } // make sure we have got all we need if err := conf.DefaultCluster.CheckAuth(); err != nil { return nil, err } if conf.DefaultCluster.Token != "" { req.Header.Add("Authorization", "APIKey "+conf.DefaultCluster.Token) } else { req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass)) } // actually execute the request resp, err := client.Do(req) if err != nil { return nil, err } defer func() { if err := resp.Body.Close(); err != nil { log.Fatal(err) } }() // Read and print response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return body, nil } func prettyfiJson(conf *cfg.Config, raw []byte) (string, error) { if conf.HaveJQ { cmd := exec.CommandContext(context.Background(), "jq", "-C") cmd.Stdin = bytes.NewReader(raw) var out bytes.Buffer cmd.Stdout = &out err := cmd.Run() if err != nil { return "", err } return out.String(), nil } var pretty bytes.Buffer err := json.Indent(&pretty, raw, "", "\t") if err != nil { //nolint:nilerr return string(raw), nil } return pretty.String(), nil } // interactively read arbitrary JSON data from STDIN, which is // virtually a repl inside the primary repl func readJSON(input string) (string, error) { data := strings.Builder{} if input != "" { data.WriteString(input) } else { scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { break } data.WriteString(line) } } if data.Len() == 0 { return "", nil } // validate check := map[string]any{} err := json.Unmarshal([]byte(data.String()), &check) if err != nil { return "", fmt.Errorf("error: input data is not proper JSON: %w", err) } return data.String(), nil } func ApiList(conf *cfg.Config, pattern string) error { assets.LoadAssetOpenApi() filter := regexp.MustCompile(pattern) table := printer.NewTable(conf, 4, 0) table.Addheaders("path", "http verb", "tag", "description") for path, item := range assets.OpenAPI.Spec().Paths.Paths { if pattern != "" { if !filter.MatchString(path) { continue } } ops := findOperation(item.PathItemProps) for _, op := range ops { tags := findTags(op) if conf.Tag != "" { if !slices.Contains(tags, conf.Tag) { continue } } table.AddRow( path, op.Verb, strings.Join(tags, ","), strings.TrimSpace(op.Op.Summary), ) } } table.Sort() return table.Print() } // for completion func ApiPathNames() []string { assets.LoadAssetOpenApi() paths := make([]string, len(assets.OpenAPI.Spec().Paths.Paths)) idx := 0 for path := range assets.OpenAPI.Spec().Paths.Paths { paths[idx] = path idx++ } return paths } func ApiShow(conf *cfg.Config, showpath, verb string) error { assets.LoadAssetOpenApi() out := printer.Builder{} op, err := matchOperation(showpath, verb) if err != nil { return err } slog.Debug("api operation", "op", op) cleanMarkup := regexp.MustCompile(`<[^<>]+>`) width := cfg.GetTermWidth() params := getApiParameters(op, showpath, width) sample := getApiExample(op) description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, cfg.DefaultMargin) var ( bold = lipgloss.NewStyle(). Bold(true) paragraph = lipgloss.NewStyle(). MarginBottom(1). MarginLeft(cfg.DefaultMargin) boldparagraph = lipgloss.NewStyle(). MarginBottom(1). MarginLeft(cfg.DefaultMargin). Bold(true) indentparagraph = lipgloss.NewStyle(). MarginBottom(1). MarginLeft(2) ) out.WriteStringLine(bold.Render("ID: " + op.Op.ID)) out.WriteStringLine(paragraph.Render(fmt.Sprintf("%s %s", op.Verb, showpath))) out.WriteStringLine(bold.Render("Tags")) out.WriteStringLine(paragraph.Render(strings.Join(op.Op.Tags, ", "))) out.WriteStringLine(bold.Render("Summary")) out.WriteStringLine(paragraph.Render(strings.TrimSpace(op.Op.Summary))) out.WriteStringLine(bold.Render("Description")) out.WriteStringLine(string(description)) // already indented if len(params.Path) > 0 { out.WriteStringLine(bold.Render("Path Parameters")) for _, param := range params.Path { out.WriteStringLine(boldparagraph.Render(param.Param)) if param.Description != "" { out.WriteStringLine(param.Description) } } } if len(params.Query) > 0 { out.WriteStringLine(bold.Render("Query Parameters")) for _, param := range params.Query { out.WriteStringLine(boldparagraph.Render(param.Param)) if param.Description != "" { out.WriteStringLine(indentparagraph.Render(param.Description)) } } } if sample != "" { out.WriteStringLine(bold.Render("Example")) out.WriteStringLine(paragraph.Render(sample)) } printer.Pager(showpath, out.String()) return nil } // find all tags associated with op func findTags(op *Op) []string { return op.Op.Tags } // Get API parameters func getApiParameters(op *Op, path string, width int) Params { params := Params{} pathParams := []string{} pathParamsReg := regexp.MustCompile(`{([a-z_]+)}`) // find params in url path for _, match := range pathParamsReg.FindAllStringSubmatch(path, -1) { if len(match) == 2 { pathParams = append(pathParams, match[1]) } } empty := spec.ParamProps{} for _, param := range op.Op.Parameters { if param.ParamProps == empty { for _, token := range param.Refable.Ref.Ref.GetPointer().DecodedTokens() { parts := strings.Split(token, "-") if len(parts) == 2 { param := parts[1] if slices.Contains(pathParams, param) { params.Path = append(params.Path, Param{Param: param}) } else { params.Query = append(params.Query, Param{Param: param}) } } } } else { par := Param{ Description: string(markdown.Render(param.Description, width, cfg.DefaultMargin)), Param: param.Name, } if param.In == "query" { params.Query = append(params.Query, par) } else { params.Path = append(params.Path, par) } } } return params } // Get console example func getApiExample(op *Op) string { sample := "" samples, sampleexist := op.Op.Extensions["x-codeSamples"] if sampleexist { for _, itemany := range samples.([]any) { item := itemany.(map[string]any) lang, haslang := item["lang"] if haslang { if lang == "Console" { source, hassource := item["source"] if hassource { sample = strings.TrimSpace(source.(string)) } } } } } return sample } // Find an API operation which matches given path and verb. // If no verb is given and only 1 op exists, return this one, // otherwise showpath+verb have to match precisely. func matchOperation(showpath, verb string) (*Op, error) { ops := []*Op{} op := &Op{} var found bool for path, item := range assets.OpenAPI.Spec().Paths.Paths { if path == showpath { ops = findOperation(item.PathItemProps) break } } if len(ops) == 0 { return nil, errors.New("no matching API call found") } if len(ops) == 1 && verb == "" { return ops[0], nil } for _, item := range ops { if strings.ToLower(item.Verb) == verb { op = item found = true break } } if !found { return nil, errors.New("no matching API call found for given path+verb") } return op, nil } func findOperation(item spec.PathItemProps) []*Op { ops := []*Op{} if item.Post != nil { ops = append(ops, &Op{"POST", item.Post}) } if item.Get != nil { ops = append(ops, &Op{"GET", item.Get}) } if item.Delete != nil { ops = append(ops, &Op{"DELETE", item.Delete}) } if item.Put != nil { ops = append(ops, &Op{"PUT", item.Put}) } if item.Patch != nil { ops = append(ops, &Op{"PATCH", item.Patch}) } return ops }