mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-23 09:18:51 +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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user