Files
godoxy/cmd/cli/cli.go
yusing 2305eca90b feat(cli): add CLI application with automatic command generation from swagger
Add a new CLI application (`cmd/cli/`) that generates command-line interface commands from the API swagger specification. Includes:
- Main CLI entry point with command parsing and execution
- Code generator that reads swagger.json and generates typed command handlers
- Makefile targets (`gen-cli`, `build-cli`) for generating and building the CLI
- GitHub Actions workflow to build cross-platform CLI binaries (linux/amd64, linux/arm64)
2026-02-22 19:51:49 +08:00

731 lines
16 KiB
Go
Executable File

package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/yusing/goutils/env"
)
type config struct {
Addr string
}
type stringSliceFlag struct {
set bool
v []string
}
func (s *stringSliceFlag) String() string {
return strings.Join(s.v, ",")
}
func (s *stringSliceFlag) Set(value string) error {
s.set = true
if value == "" {
s.v = nil
return nil
}
s.v = strings.Split(value, ",")
return nil
}
func run(args []string) error {
cfg, rest, err := parseGlobal(args)
if err != nil {
return err
}
if len(rest) == 0 {
printHelp()
return nil
}
if rest[0] == "help" {
printHelp()
return nil
}
ep, matchedLen := findEndpoint(rest)
if ep == nil {
ep, matchedLen = findEndpointAlias(rest)
}
if ep == nil {
return unknownCommandError(rest)
}
cmdArgs := rest[matchedLen:]
return executeEndpoint(cfg.Addr, *ep, cmdArgs)
}
func parseGlobal(args []string) (config, []string, error) {
var cfg config
fs := flag.NewFlagSet("godoxy", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&cfg.Addr, "addr", "", "API address, e.g. 127.0.0.1:8888 or http://127.0.0.1:8888")
if err := fs.Parse(args); err != nil {
return cfg, nil, err
}
return cfg, fs.Args(), nil
}
func resolveBaseURL(addrFlag string) (string, error) {
if addrFlag != "" {
return normalizeURL(addrFlag), nil
}
_, _, _, fullURL := env.GetAddrEnv("LOCAL_API_ADDR", "", "http")
if fullURL == "" {
return "", errors.New("missing LOCAL_API_ADDR (or GODOXY_LOCAL_API_ADDR). set env var or pass --addr")
}
return normalizeURL(fullURL), nil
}
func normalizeURL(addr string) string {
a := strings.TrimSpace(addr)
if strings.Contains(a, "://") {
return strings.TrimRight(a, "/")
}
return "http://" + strings.TrimRight(a, "/")
}
func findEndpoint(args []string) (*Endpoint, int) {
var best *Endpoint
bestLen := -1
for i := range generatedEndpoints {
ep := &generatedEndpoints[i]
if len(ep.CommandPath) > len(args) {
continue
}
ok := true
for j, tok := range ep.CommandPath {
if args[j] != tok {
ok = false
break
}
}
if ok && len(ep.CommandPath) > bestLen {
best = ep
bestLen = len(ep.CommandPath)
}
}
return best, bestLen
}
func executeEndpoint(addrFlag string, ep Endpoint, args []string) error {
fs := flag.NewFlagSet(strings.Join(ep.CommandPath, "-"), flag.ContinueOnError)
fs.SetOutput(io.Discard)
useWS := false
if ep.IsWebSocket {
fs.BoolVar(&useWS, "ws", false, "use websocket")
}
typedValues := make(map[string]any, len(ep.Params))
isSet := make(map[string]bool, len(ep.Params))
for _, p := range ep.Params {
switch p.Type {
case "integer":
v := new(int)
fs.IntVar(v, p.FlagName, 0, p.Description)
typedValues[p.FlagName] = v
case "number":
v := new(float64)
fs.Float64Var(v, p.FlagName, 0, p.Description)
typedValues[p.FlagName] = v
case "boolean":
v := new(bool)
fs.BoolVar(v, p.FlagName, false, p.Description)
typedValues[p.FlagName] = v
case "array":
v := &stringSliceFlag{}
fs.Var(v, p.FlagName, p.Description+" (comma-separated)")
typedValues[p.FlagName] = v
default:
v := new(string)
fs.StringVar(v, p.FlagName, "", p.Description)
typedValues[p.FlagName] = v
}
}
if err := fs.Parse(args); err != nil {
return fmt.Errorf("%w\n\n%s", err, formatEndpointHelp(ep))
}
if len(fs.Args()) > 0 {
return fmt.Errorf("unexpected args: %s\n\n%s", strings.Join(fs.Args(), " "), formatEndpointHelp(ep))
}
fs.Visit(func(f *flag.Flag) {
isSet[f.Name] = true
})
for _, p := range ep.Params {
if !p.Required {
continue
}
if !isSet[p.FlagName] {
return fmt.Errorf("missing required flag --%s\n\n%s", p.FlagName, formatEndpointHelp(ep))
}
}
baseURL, err := resolveBaseURL(addrFlag)
if err != nil {
return err
}
reqURL, body, err := buildRequest(ep, baseURL, typedValues, isSet)
if err != nil {
return err
}
if useWS {
if !ep.IsWebSocket {
return errors.New("--ws is only supported for websocket endpoints")
}
return execWebsocket(ep, reqURL)
}
return execHTTP(ep, reqURL, body)
}
func buildRequest(ep Endpoint, baseURL string, typedValues map[string]any, isSet map[string]bool) (string, []byte, error) {
path := ep.Path
for _, p := range ep.Params {
if p.In != "path" {
continue
}
raw, err := paramValueString(p, typedValues[p.FlagName], isSet[p.FlagName])
if err != nil {
return "", nil, err
}
if raw == "" {
continue
}
esc := url.PathEscape(raw)
path = strings.ReplaceAll(path, "{"+p.Name+"}", esc)
path = strings.ReplaceAll(path, ":"+p.Name, esc)
}
u, err := url.Parse(baseURL)
if err != nil {
return "", nil, fmt.Errorf("invalid base url: %w", err)
}
u.Path = strings.TrimRight(u.Path, "/") + path
q := u.Query()
for _, p := range ep.Params {
if p.In != "query" || !isSet[p.FlagName] {
continue
}
val, err := paramQueryValues(p, typedValues[p.FlagName])
if err != nil {
return "", nil, err
}
for _, v := range val {
q.Add(p.Name, v)
}
}
u.RawQuery = q.Encode()
bodyMap := map[string]any{}
rawBody := ""
for _, p := range ep.Params {
if p.In != "body" || !isSet[p.FlagName] {
continue
}
if p.Name == "file" {
s, err := paramValueString(p, typedValues[p.FlagName], true)
if err != nil {
return "", nil, err
}
rawBody = s
continue
}
v, err := paramBodyValue(p, typedValues[p.FlagName])
if err != nil {
return "", nil, err
}
bodyMap[p.Name] = v
}
if rawBody != "" {
return u.String(), []byte(rawBody), nil
}
if len(bodyMap) == 0 {
return u.String(), nil, nil
}
data, err := json.Marshal(bodyMap)
if err != nil {
return "", nil, fmt.Errorf("marshal body: %w", err)
}
return u.String(), data, nil
}
func paramValueString(p Param, raw any, wasSet bool) (string, error) {
if !wasSet {
return "", nil
}
switch v := raw.(type) {
case *string:
return *v, nil
case *int:
return strconv.Itoa(*v), nil
case *float64:
return strconv.FormatFloat(*v, 'f', -1, 64), nil
case *bool:
if *v {
return "true", nil
}
return "false", nil
case *stringSliceFlag:
return strings.Join(v.v, ","), nil
default:
return "", fmt.Errorf("unsupported flag value for %s", p.FlagName)
}
}
func paramQueryValues(p Param, raw any) ([]string, error) {
switch v := raw.(type) {
case *string:
return []string{*v}, nil
case *int:
return []string{strconv.Itoa(*v)}, nil
case *float64:
return []string{strconv.FormatFloat(*v, 'f', -1, 64)}, nil
case *bool:
if *v {
return []string{"true"}, nil
}
return []string{"false"}, nil
case *stringSliceFlag:
if len(v.v) == 0 {
return nil, nil
}
return v.v, nil
default:
return nil, fmt.Errorf("unsupported query flag type for %s", p.FlagName)
}
}
func paramBodyValue(p Param, raw any) (any, error) {
switch v := raw.(type) {
case *string:
if p.Type == "object" || p.Type == "array" {
var decoded any
if err := json.Unmarshal([]byte(*v), &decoded); err != nil {
return nil, fmt.Errorf("invalid JSON for --%s: %w", p.FlagName, err)
}
return decoded, nil
}
return *v, nil
case *int:
return *v, nil
case *float64:
return *v, nil
case *bool:
return *v, nil
case *stringSliceFlag:
return v.v, nil
default:
return nil, fmt.Errorf("unsupported body flag type for %s", p.FlagName)
}
}
func execHTTP(ep Endpoint, reqURL string, body []byte) error {
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(ep.Method, reqURL, r)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if len(payload) == 0 {
return fmt.Errorf("%s %s failed: %s", ep.Method, ep.Path, resp.Status)
}
return fmt.Errorf("%s %s failed: %s: %s", ep.Method, ep.Path, resp.Status, strings.TrimSpace(string(payload)))
}
printJSON(payload)
return nil
}
func execWebsocket(ep Endpoint, reqURL string) error {
wsURL := strings.Replace(reqURL, "http://", "ws://", 1)
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
if strings.ToUpper(ep.Method) != http.MethodGet {
return fmt.Errorf("--ws requires GET endpoint, got %s", ep.Method)
}
c, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return err
}
defer c.Close()
stopPing := make(chan struct{})
defer close(stopPing)
go func() {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-stopPing:
return
case <-ticker.C:
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil {
return
}
}
}
}()
for {
_, msg, err := c.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) || strings.Contains(err.Error(), "close") {
return nil
}
return err
}
if string(msg) == "pong" {
continue
}
fmt.Println(string(msg))
}
}
func printJSON(payload []byte) {
if len(payload) == 0 {
fmt.Println("null")
return
}
var v any
if err := json.Unmarshal(payload, &v); err != nil {
fmt.Println(strings.TrimSpace(string(payload)))
return
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
func printHelp() {
fmt.Println("godoxy [--addr ADDR] <command>")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" godoxy version")
fmt.Println(" godoxy route list")
fmt.Println(" godoxy route route --which whoami")
fmt.Println()
printGroupedCommands()
}
func printGroupedCommands() {
grouped := map[string][]Endpoint{}
groupOrder := make([]string, 0)
seen := map[string]bool{}
for _, ep := range generatedEndpoints {
group := "root"
if len(ep.CommandPath) > 1 {
group = ep.CommandPath[0]
}
grouped[group] = append(grouped[group], ep)
if !seen[group] {
seen[group] = true
groupOrder = append(groupOrder, group)
}
}
sort.Strings(groupOrder)
for _, group := range groupOrder {
fmt.Printf("Commands (%s):\n", group)
sort.Slice(grouped[group], func(i, j int) bool {
li := strings.Join(grouped[group][i].CommandPath, " ")
lj := strings.Join(grouped[group][j].CommandPath, " ")
return li < lj
})
maxCmdWidth := 0
for _, ep := range grouped[group] {
cmd := strings.Join(ep.CommandPath, " ")
if len(cmd) > maxCmdWidth {
maxCmdWidth = len(cmd)
}
}
for _, ep := range grouped[group] {
cmd := strings.Join(ep.CommandPath, " ")
fmt.Printf(" %-*s %s\n", maxCmdWidth, cmd, ep.Summary)
}
fmt.Println()
}
}
func unknownCommandError(rest []string) error {
cmd := strings.Join(rest, " ")
var b strings.Builder
b.WriteString("unknown command: ")
b.WriteString(cmd)
if len(rest) > 0 && hasGroup(rest[0]) {
if len(rest) > 1 {
if hint := nearestForGroup(rest[0], rest[1]); hint != "" {
b.WriteString("\nDo you mean ")
b.WriteString(hint)
b.WriteString("?")
}
}
b.WriteString("\n\n")
b.WriteString(formatGroupHelp(rest[0]))
return errors.New(b.String())
}
if hint := nearestCommand(cmd); hint != "" {
b.WriteString("\nDo you mean ")
b.WriteString(hint)
b.WriteString("?")
}
b.WriteString("\n\n")
b.WriteString("Run `godoxy help` for available commands.")
return errors.New(b.String())
}
func findEndpointAlias(args []string) (*Endpoint, int) {
var best *Endpoint
bestLen := -1
for i := range generatedEndpoints {
alias := aliasCommandPath(generatedEndpoints[i])
if len(alias) == 0 || len(alias) > len(args) {
continue
}
ok := true
for j, tok := range alias {
if args[j] != tok {
ok = false
break
}
}
if ok && len(alias) > bestLen {
best = &generatedEndpoints[i]
bestLen = len(alias)
}
}
return best, bestLen
}
func aliasCommandPath(ep Endpoint) []string {
rawPath := strings.TrimPrefix(ep.Path, "/api/v1/")
rawPath = strings.Trim(rawPath, "/")
if rawPath == "" {
return nil
}
parts := strings.Split(rawPath, "/")
if len(parts) == 1 {
if isPathParam(parts[0]) {
return nil
}
return []string{toKebabToken(parts[0])}
}
if isPathParam(parts[0]) || isPathParam(parts[1]) {
return nil
}
return []string{toKebabToken(parts[0]), toKebabToken(parts[1])}
}
func isPathParam(s string) bool {
return strings.HasPrefix(s, "{") || strings.HasPrefix(s, ":")
}
func toKebabToken(s string) string {
s = strings.ReplaceAll(s, "_", "-")
return strings.ToLower(strings.Trim(s, "-"))
}
func hasGroup(group string) bool {
for _, ep := range generatedEndpoints {
if len(ep.CommandPath) > 1 && ep.CommandPath[0] == group {
return true
}
}
return false
}
func nearestCommand(input string) string {
commands := make([]string, 0, len(generatedEndpoints))
for _, ep := range generatedEndpoints {
commands = append(commands, strings.Join(ep.CommandPath, " "))
}
return nearestByDistance(input, commands)
}
func nearestForGroup(group, input string) string {
choiceSet := map[string]struct{}{}
for _, ep := range generatedEndpoints {
if len(ep.CommandPath) < 2 || ep.CommandPath[0] != group {
continue
}
choiceSet[ep.CommandPath[1]] = struct{}{}
alias := aliasCommandPath(ep)
if len(alias) == 2 && alias[0] == group {
choiceSet[alias[1]] = struct{}{}
}
}
choices := make([]string, 0, len(choiceSet))
for choice := range choiceSet {
choices = append(choices, choice)
}
if len(choices) == 0 {
return ""
}
return group + " " + nearestByDistance(input, choices)
}
func formatGroupHelp(group string) string {
commands := make([]Endpoint, 0)
for _, ep := range generatedEndpoints {
if len(ep.CommandPath) > 1 && ep.CommandPath[0] == group {
commands = append(commands, ep)
}
}
sort.Slice(commands, func(i, j int) bool {
return strings.Join(commands[i].CommandPath, " ") < strings.Join(commands[j].CommandPath, " ")
})
maxWidth := 0
for _, ep := range commands {
cmd := strings.Join(ep.CommandPath, " ")
if len(cmd) > maxWidth {
maxWidth = len(cmd)
}
}
var b strings.Builder
fmt.Fprintf(&b, "Available subcommands for %s:\n", group)
for _, ep := range commands {
cmd := strings.Join(ep.CommandPath, " ")
fmt.Fprintf(&b, " %-*s %s\n", maxWidth, cmd, ep.Summary)
}
return strings.TrimRight(b.String(), "\n")
}
func formatEndpointHelp(ep Endpoint) string {
cmd := "godoxy " + strings.Join(ep.CommandPath, " ")
var b strings.Builder
fmt.Fprintf(&b, "Usage: %s [flags]\n", cmd)
if ep.Summary != "" {
fmt.Fprintf(&b, "Summary: %s\n", ep.Summary)
}
if alias := aliasCommandPath(ep); len(alias) > 0 && strings.Join(alias, " ") != strings.Join(ep.CommandPath, " ") {
fmt.Fprintf(&b, "Alias: godoxy %s\n", strings.Join(alias, " "))
}
params := make([]Param, 0, len(ep.Params))
params = append(params, ep.Params...)
if ep.IsWebSocket {
params = append(params, Param{
FlagName: "ws",
Type: "boolean",
Description: "use websocket",
Required: false,
})
}
if len(params) == 0 {
return strings.TrimRight(b.String(), "\n")
}
b.WriteString("Flags:\n")
maxWidth := 0
flagNames := make([]string, 0, len(params))
for _, p := range params {
name := "--" + p.FlagName
if p.Required {
name += " (required)"
}
flagNames = append(flagNames, name)
if len(name) > maxWidth {
maxWidth = len(name)
}
}
for i, p := range params {
desc := p.Description
if desc == "" {
desc = p.In + " " + p.Type
}
fmt.Fprintf(&b, " %-*s %s\n", maxWidth, flagNames[i], desc)
}
return strings.TrimRight(b.String(), "\n")
}
func nearestByDistance(input string, choices []string) string {
if len(choices) == 0 {
return ""
}
nearest := choices[0]
minDistance := levenshteinDistance(input, nearest)
for _, choice := range choices[1:] {
d := levenshteinDistance(input, choice)
if d < minDistance {
minDistance = d
nearest = choice
}
}
return nearest
}
//nolint:intrange
func levenshteinDistance(a, b string) int {
if a == b {
return 0
}
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
v0 := make([]int, len(b)+1)
v1 := make([]int, len(b)+1)
for i := 0; i <= len(b); i++ {
v0[i] = i
}
for i := 0; i < len(a); i++ {
v1[0] = i + 1
for j := 0; j < len(b); j++ {
cost := 0
if a[i] != b[j] {
cost = 1
}
v1[j+1] = min3(v1[j]+1, v0[j+1]+1, v0[j]+cost)
}
for j := 0; j <= len(b); j++ {
v0[j] = v1[j]
}
}
return v1[len(b)]
}
func min3(a, b, c int) int {
if a < b && a < c {
return a
}
if b < a && b < c {
return b
}
return c
}