mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-24 17:28:31 +02:00
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)
This commit is contained in:
60
.github/workflows/cli-binary.yml
vendored
Executable file
60
.github/workflows/cli-binary.yml
vendored
Executable file
@@ -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 }}
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -40,4 +40,7 @@ CLAUDE.md
|
|||||||
!.trunk/configs
|
!.trunk/configs
|
||||||
|
|
||||||
# minified files
|
# minified files
|
||||||
**/*-min.*
|
**/*-min.*
|
||||||
|
|
||||||
|
# generated CLI commands
|
||||||
|
cmd/cli/generated_commands.go
|
||||||
10
Makefile
10
Makefile
@@ -58,6 +58,7 @@ endif
|
|||||||
|
|
||||||
BUILD_FLAGS += -tags '$(GO_TAGS)' -ldflags='$(LDFLAGS)'
|
BUILD_FLAGS += -tags '$(GO_TAGS)' -ldflags='$(LDFLAGS)'
|
||||||
BIN_PATH := $(shell pwd)/bin/${NAME}
|
BIN_PATH := $(shell pwd)/bin/${NAME}
|
||||||
|
CLI_BIN_PATH ?= $(shell pwd)/bin/godoxy-cli
|
||||||
|
|
||||||
export NAME
|
export NAME
|
||||||
export CGO_ENABLED
|
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 \
|
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
|
--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:
|
update-wiki:
|
||||||
DOCS_DIR=${DOCS_DIR} REPO_URL=${REPO_URL} bun --bun scripts/update-wiki/main.ts
|
DOCS_DIR=${DOCS_DIR} REPO_URL=${REPO_URL} bun --bun scripts/update-wiki/main.ts
|
||||||
|
|||||||
730
cmd/cli/cli.go
Executable file
730
cmd/cli/cli.go
Executable file
@@ -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] <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
|
||||||
|
}
|
||||||
366
cmd/cli/gen/main.go
Executable file
366
cmd/cli/gen/main.go
Executable file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
cmd/cli/go.mod
Normal file
10
cmd/cli/go.mod
Normal file
@@ -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
|
||||||
10
cmd/cli/go.sum
Executable file
10
cmd/cli/go.sum
Executable file
@@ -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=
|
||||||
13
cmd/cli/main.go
Normal file
13
cmd/cli/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
cmd/cli/types.go
Executable file
19
cmd/cli/types.go
Executable file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user