From 2305eca90ba934179d0622a5f5f4f3dc78c287d5 Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 22 Feb 2026 19:51:49 +0800 Subject: [PATCH] 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) --- .github/workflows/cli-binary.yml | 60 +++ .gitignore | 5 +- Makefile | 10 +- cmd/cli/cli.go | 730 +++++++++++++++++++++++++++++++ cmd/cli/gen/main.go | 366 ++++++++++++++++ cmd/cli/go.mod | 10 + cmd/cli/go.sum | 10 + cmd/cli/main.go | 13 + cmd/cli/types.go | 19 + 9 files changed, 1221 insertions(+), 2 deletions(-) create mode 100755 .github/workflows/cli-binary.yml create mode 100755 cmd/cli/cli.go create mode 100755 cmd/cli/gen/main.go create mode 100644 cmd/cli/go.mod create mode 100755 cmd/cli/go.sum create mode 100644 cmd/cli/main.go create mode 100755 cmd/cli/types.go diff --git a/.github/workflows/cli-binary.yml b/.github/workflows/cli-binary.yml new file mode 100755 index 00000000..4fae5725 --- /dev/null +++ b/.github/workflows/cli-binary.yml @@ -0,0 +1,60 @@ +name: GoDoxy CLI Binary + +on: + pull_request: + paths: + - "cmd/cli/**" + - "internal/api/v1/docs/swagger.json" + - "Makefile" + - ".github/workflows/cli-binary.yml" + push: + branches: + - main + paths: + - "cmd/cli/**" + - "internal/api/v1/docs/swagger.json" + - "Makefile" + - ".github/workflows/cli-binary.yml" + tags-ignore: + - "**" + workflow_dispatch: + +jobs: + build: + strategy: + matrix: + include: + - runner: ubuntu-latest + platform: linux/amd64 + binary_name: godoxy-cli-linux-amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + binary_name: godoxy-cli-linux-arm64 + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: "recursive" + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Verify dependencies + run: go mod verify + + - name: Build CLI + run: | + make CLI_BIN_PATH=bin/${{ matrix.binary_name }} build-cli + + - name: Check binary + run: | + file bin/${{ matrix.binary_name }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.binary_name }} + path: bin/${{ matrix.binary_name }} diff --git a/.gitignore b/.gitignore index f8ac4ac3..faea39d4 100755 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ CLAUDE.md !.trunk/configs # minified files -**/*-min.* \ No newline at end of file +**/*-min.* + +# generated CLI commands +cmd/cli/generated_commands.go \ No newline at end of file diff --git a/Makefile b/Makefile index 43d8df75..335621fb 100755 --- a/Makefile +++ b/Makefile @@ -58,6 +58,7 @@ endif BUILD_FLAGS += -tags '$(GO_TAGS)' -ldflags='$(LDFLAGS)' BIN_PATH := $(shell pwd)/bin/${NAME} +CLI_BIN_PATH ?= $(shell pwd)/bin/godoxy-cli export NAME export CGO_ENABLED @@ -185,6 +186,13 @@ gen-api-types: gen-swagger bunx --bun swagger-typescript-api generate --sort-types --generate-union-enums --axios --add-readonly --route-types \ --responses -o ${WEBUI_DIR}/src/lib -n api.ts -p internal/api/v1/docs/swagger.json -.PHONY: update-wiki +gen-cli: + cd cmd/cli && go run ./gen + +build-cli: gen-cli + mkdir -p $(shell dirname ${CLI_BIN_PATH}) + go build -C cmd/cli -o ${CLI_BIN_PATH} . + +.PHONY: gen-cli build-cli update-wiki update-wiki: DOCS_DIR=${DOCS_DIR} REPO_URL=${REPO_URL} bun --bun scripts/update-wiki/main.ts diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go new file mode 100755 index 00000000..decbb56d --- /dev/null +++ b/cmd/cli/cli.go @@ -0,0 +1,730 @@ +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] ") + 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 +} diff --git a/cmd/cli/gen/main.go b/cmd/cli/gen/main.go new file mode 100755 index 00000000..253b2896 --- /dev/null +++ b/cmd/cli/gen/main.go @@ -0,0 +1,366 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "os" + "path/filepath" + "sort" + "strings" + "unicode" +) + +type swaggerSpec struct { + BasePath string `json:"basePath"` + Paths map[string]map[string]operation `json:"paths"` + Definitions map[string]definition `json:"definitions"` +} + +type operation struct { + OperationID string `json:"operationId"` + Summary string `json:"summary"` + Tags []string `json:"tags"` + Parameters []parameter `json:"parameters"` +} + +type parameter struct { + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Type string `json:"type"` + Description string `json:"description"` + Schema *schemaRef `json:"schema"` +} + +type schemaRef struct { + Ref string `json:"$ref"` +} + +type definition struct { + Type string `json:"type"` + Required []string `json:"required"` + Properties map[string]definition `json:"properties"` + Items *definition `json:"items"` +} + +type endpoint struct { + CommandPath []string + Method string + Path string + Summary string + IsWebSocket bool + Params []param +} + +type param struct { + FlagName string + Name string + In string + Type string + Required bool + Description string +} + +func main() { + root := filepath.Join("..", "..") + inPath := filepath.Join(root, "internal", "api", "v1", "docs", "swagger.json") + outPath := "generated_commands.go" + + raw, err := os.ReadFile(inPath) + must(err) + + var spec swaggerSpec + must(json.Unmarshal(raw, &spec)) + + eps := buildEndpoints(spec) + must(writeGenerated(outPath, eps)) +} + +func buildEndpoints(spec swaggerSpec) []endpoint { + byCommand := map[string]endpoint{} + + pathKeys := make([]string, 0, len(spec.Paths)) + for p := range spec.Paths { + pathKeys = append(pathKeys, p) + } + sort.Strings(pathKeys) + + for _, p := range pathKeys { + methodMap := spec.Paths[p] + methods := make([]string, 0, len(methodMap)) + for m := range methodMap { + methods = append(methods, strings.ToUpper(m)) + } + sort.Strings(methods) + + for _, method := range methods { + op := methodMap[strings.ToLower(method)] + if op.OperationID == "" { + continue + } + ep := endpoint{ + CommandPath: commandPathFromOp(p, op.OperationID), + Method: method, + Path: ensureSlash(spec.BasePath) + normalizePath(p), + Summary: op.Summary, + IsWebSocket: hasTag(op.Tags, "websocket"), + Params: collectParams(spec, op), + } + key := strings.Join(ep.CommandPath, " ") + if existing, ok := byCommand[key]; ok { + if betterEndpoint(ep, existing) { + byCommand[key] = ep + } + continue + } + byCommand[key] = ep + } + } + + out := make([]endpoint, 0, len(byCommand)) + for _, ep := range byCommand { + out = append(out, ep) + } + sort.Slice(out, func(i, j int) bool { + ai := strings.Join(out[i].CommandPath, " ") + aj := strings.Join(out[j].CommandPath, " ") + return ai < aj + }) + return out +} + +func commandPathFromOp(path, opID string) []string { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 0 { + return []string{toKebab(opID)} + } + if len(parts) == 1 { + return []string{toKebab(parts[0])} + } + group := toKebab(parts[0]) + name := toKebab(opID) + if name == group { + name = "get" + } + if group == "v1" { + return []string{name} + } + return []string{group, name} +} + +func collectParams(spec swaggerSpec, op operation) []param { + params := make([]param, 0) + for _, p := range op.Parameters { + switch p.In { + case "body": + if p.Schema != nil && p.Schema.Ref != "" { + defName := strings.TrimPrefix(p.Schema.Ref, "#/definitions/") + params = append(params, bodyParamsFromDef(spec.Definitions[defName])...) + continue + } + params = append(params, param{ + FlagName: toKebab(p.Name), + Name: p.Name, + In: "body", + Type: defaultType(p.Type), + Required: p.Required, + Description: p.Description, + }) + default: + params = append(params, param{ + FlagName: toKebab(p.Name), + Name: p.Name, + In: p.In, + Type: defaultType(p.Type), + Required: p.Required, + Description: p.Description, + }) + } + } + + // Deduplicate by flag name, prefer required entries. + byFlag := map[string]param{} + for _, p := range params { + if cur, ok := byFlag[p.FlagName]; ok { + if !cur.Required && p.Required { + byFlag[p.FlagName] = p + } + continue + } + byFlag[p.FlagName] = p + } + out := make([]param, 0, len(byFlag)) + for _, p := range byFlag { + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { + if out[i].In != out[j].In { + return out[i].In < out[j].In + } + return out[i].FlagName < out[j].FlagName + }) + return out +} + +func bodyParamsFromDef(def definition) []param { + if def.Type != "object" { + return nil + } + requiredSet := map[string]struct{}{} + for _, name := range def.Required { + requiredSet[name] = struct{}{} + } + keys := make([]string, 0, len(def.Properties)) + for k := range def.Properties { + keys = append(keys, k) + } + sort.Strings(keys) + out := make([]param, 0, len(keys)) + for _, k := range keys { + prop := def.Properties[k] + _, required := requiredSet[k] + t := defaultType(prop.Type) + if prop.Type == "array" { + t = "array" + } + if prop.Type == "object" { + t = "object" + } + out = append(out, param{ + FlagName: toKebab(k), + Name: k, + In: "body", + Type: t, + Required: required, + }) + } + return out +} + +func betterEndpoint(a, b endpoint) bool { + // Prefer GET, then fewer path params, then shorter path. + if a.Method == "GET" && b.Method != "GET" { + return true + } + if a.Method != "GET" && b.Method == "GET" { + return false + } + ac := countPathParams(a.Path) + bc := countPathParams(b.Path) + if ac != bc { + return ac < bc + } + return len(a.Path) < len(b.Path) +} + +func countPathParams(path string) int { + count := 0 + for _, seg := range strings.Split(path, "/") { + if strings.HasPrefix(seg, "{") || strings.HasPrefix(seg, ":") { + count++ + } + } + return count +} + +func normalizePath(p string) string { + parts := strings.Split(p, "/") + for i, part := range parts { + if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { + name := strings.TrimSuffix(strings.TrimPrefix(part, "{"), "}") + parts[i] = "{" + name + "}" + } + } + return strings.Join(parts, "/") +} + +func hasTag(tags []string, want string) bool { + for _, t := range tags { + if strings.EqualFold(t, want) { + return true + } + } + return false +} + +func writeGenerated(outPath string, eps []endpoint) error { + var b bytes.Buffer + b.WriteString("// Code generated by cmd/cli/gen. DO NOT EDIT.\n") + b.WriteString("package main\n\n") + b.WriteString("var generatedEndpoints = []Endpoint{\n") + for _, ep := range eps { + b.WriteString("\t{\n") + fmt.Fprintf(&b, "\t\tCommandPath: %#v,\n", ep.CommandPath) + fmt.Fprintf(&b, "\t\tMethod: %q,\n", ep.Method) + fmt.Fprintf(&b, "\t\tPath: %q,\n", ep.Path) + fmt.Fprintf(&b, "\t\tSummary: %q,\n", ep.Summary) + fmt.Fprintf(&b, "\t\tIsWebSocket: %t,\n", ep.IsWebSocket) + b.WriteString("\t\tParams: []Param{\n") + for _, p := range ep.Params { + b.WriteString("\t\t\t{\n") + fmt.Fprintf(&b, "\t\t\t\tFlagName: %q,\n", p.FlagName) + fmt.Fprintf(&b, "\t\t\t\tName: %q,\n", p.Name) + fmt.Fprintf(&b, "\t\t\t\tIn: %q,\n", p.In) + fmt.Fprintf(&b, "\t\t\t\tType: %q,\n", p.Type) + fmt.Fprintf(&b, "\t\t\t\tRequired: %t,\n", p.Required) + fmt.Fprintf(&b, "\t\t\t\tDescription: %q,\n", p.Description) + b.WriteString("\t\t\t},\n") + } + b.WriteString("\t\t},\n") + b.WriteString("\t},\n") + } + b.WriteString("}\n") + formatted, err := format.Source(b.Bytes()) + if err != nil { + return fmt.Errorf("format generated source: %w", err) + } + return os.WriteFile(outPath, formatted, 0o644) +} + +func ensureSlash(s string) string { + if strings.HasPrefix(s, "/") { + return s + } + return "/" + s +} + +func defaultType(t string) string { + switch t { + case "integer", "number", "boolean", "array", "object", "string": + return t + default: + return "string" + } +} + +func toKebab(s string) string { + if s == "" { + return s + } + s = strings.ReplaceAll(s, "_", "-") + s = strings.ReplaceAll(s, ".", "-") + var out []rune + for i, r := range s { + if unicode.IsUpper(r) { + if i > 0 && out[len(out)-1] != '-' { + out = append(out, '-') + } + out = append(out, unicode.ToLower(r)) + continue + } + out = append(out, unicode.ToLower(r)) + } + res := strings.Trim(string(out), "-") + for strings.Contains(res, "--") { + res = strings.ReplaceAll(res, "--", "-") + } + return res +} + +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/cmd/cli/go.mod b/cmd/cli/go.mod new file mode 100644 index 00000000..63ca68d2 --- /dev/null +++ b/cmd/cli/go.mod @@ -0,0 +1,10 @@ +module github.com/yusing/godoxy/cli + +go 1.26.0 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/yusing/goutils v0.7.0 +) + +replace github.com/yusing/goutils => ../../goutils diff --git a/cmd/cli/go.sum b/cmd/cli/go.sum new file mode 100755 index 00000000..3b2e5149 --- /dev/null +++ b/cmd/cli/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 00000000..cd79acbc --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/cmd/cli/types.go b/cmd/cli/types.go new file mode 100755 index 00000000..63767aa5 --- /dev/null +++ b/cmd/cli/types.go @@ -0,0 +1,19 @@ +package main + +type Param struct { + FlagName string + Name string + In string + Type string + Required bool + Description string +} + +type Endpoint struct { + CommandPath []string + Method string + Path string + Summary string + IsWebSocket bool + Params []Param +}