Files
esctl/pkg/es/api.go

520 lines
11 KiB
Go
Raw Normal View History

/*
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"
"golang.org/x/term"
)
const (
DefaultMargin = 4
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))
}
if err := prettyfiJson(conf, raw); err != nil {
fmt.Println(err)
}
}
return nil
}
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}
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")
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) 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
}
fmt.Println(out.String())
return nil
}
var pretty bytes.Buffer
err := json.Indent(&pretty, raw, "", "\t")
if err != nil {
return fmt.Errorf("json parse error: %s", err)
}
fmt.Println(pretty.String())
return 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()
op, err := matchOperation(showpath, verb)
if err != nil {
return err
}
slog.Debug("api operation", "op", op)
cleanMarkup := regexp.MustCompile(`<[^<>]+>`)
width := getTermWidth()
params := getApiParameters(op, showpath, width)
sample := getApiExample(op)
description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, DefaultMargin)
var bold = lipgloss.NewStyle().
Bold(true)
var paragraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(DefaultMargin)
var boldparagraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(DefaultMargin).
Bold(true)
var indentparagraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(2)
fmt.Println(bold.Render("ID: " + op.Op.ID))
fmt.Println(paragraph.Render(fmt.Sprintf("%s %s", op.Verb, showpath)))
fmt.Println(bold.Render("Tags"))
fmt.Println(paragraph.Render(strings.Join(op.Op.Tags, ", ")))
fmt.Println(bold.Render("Summary"))
fmt.Println(paragraph.Render(strings.TrimSpace(op.Op.Summary)))
fmt.Println(bold.Render("Description"))
fmt.Println(string(description)) // already indented
if len(params.Path) > 0 {
fmt.Println(bold.Render("Path Parameters"))
for _, param := range params.Path {
fmt.Println(boldparagraph.Render(param.Param))
fmt.Println(indentparagraph.Render(param.Description))
}
}
if len(params.Query) > 0 {
fmt.Println(bold.Render("Query Parameters"))
for _, param := range params.Query {
fmt.Println(boldparagraph.Render(param.Param))
fmt.Println(indentparagraph.Render(param.Description))
}
}
if sample != "" {
fmt.Println(bold.Render("Example"))
fmt.Println(paragraph.Render(sample))
}
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, 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{}
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")
}
2026-06-17 14:16:26 +02:00
if len(ops) == 1 && verb == "" {
return ops[0], nil
}
for _, item := range ops {
if strings.ToLower(item.Verb) == verb {
op = item
}
}
if op == nil {
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
}
func getTermWidth() int {
if term.IsTerminal(int(os.Stdout.Fd())) {
2026-06-17 14:16:26 +02:00
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err == nil {
return width - DefaultMargin
}
}
2026-06-17 14:16:26 +02:00
return 80
}