mirror of
https://codeberg.org/scip/esctl.git
synced 2026-08-24 14:14:23 +02:00
569 lines
12 KiB
Go
569 lines
12 KiB
Go
/*
|
|
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 <http://www.gnu.org/licenses/>.
|
|
*/
|
|
package es
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"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.`
|
|
)
|
|
|
|
// 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: %s", 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/<Ret> [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))
|
|
}
|
|
|
|
pageJsonOutput(conf, raw)
|
|
}
|
|
|
|
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
|
|
tr := &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
}
|
|
|
|
client := &http.Client{Transport: tr}
|
|
|
|
if conf.DebugHTTP {
|
|
client = &http.Client{
|
|
Transport: &cfg.DebugTransport{
|
|
Transport: tr}}
|
|
}
|
|
|
|
req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Read and print response
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %s", 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 {
|
|
return "", fmt.Errorf("json parse error: %s", err)
|
|
}
|
|
|
|
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 := ""
|
|
|
|
if input != "" {
|
|
data = input
|
|
} else {
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
|
|
if line == "" {
|
|
break
|
|
}
|
|
|
|
data += line
|
|
}
|
|
}
|
|
|
|
if data == "" {
|
|
return data, nil
|
|
}
|
|
|
|
// validate
|
|
check := map[string]any{}
|
|
err := json.Unmarshal([]byte(data), &check)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error: input data is not proper JSON: %s", err)
|
|
|
|
}
|
|
|
|
return data, 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 := []string{}
|
|
|
|
for path := range assets.OpenAPI.Spec().Paths.Paths {
|
|
paths = append(paths, path)
|
|
}
|
|
|
|
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)
|
|
var paragraph = lipgloss.NewStyle().
|
|
MarginBottom(1).
|
|
MarginLeft(cfg.DefaultMargin)
|
|
var boldparagraph = lipgloss.NewStyle().
|
|
MarginBottom(1).
|
|
MarginLeft(cfg.DefaultMargin).
|
|
Bold(true)
|
|
var 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(indentparagraph.Render(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
|
|
}
|