| Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 9h ago | 1 | package cmd |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/url" |
| 7 | "os" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/spf13/cobra" |
| 12 | ) |
| 13 | |
| 14 | var ( |
| 15 | apiFields []string |
| 16 | apiRawFields []string |
| 17 | ) |
| 18 | |
| 19 | func init() { |
| 20 | apiCmd := &cobra.Command{ |
| 21 | Use: "api <method> <path>", |
| 22 | Short: "Make an authenticated request to an arbitrary API endpoint", |
| 23 | Long: `Low-level escape hatch, like "gh api". METHOD is GET, POST, PATCH, PUT, DELETE. |
| 24 | PATH is relative to /api/v1 (a leading /api/v1 or / is optional). |
| 25 | |
| 26 | Fields (--field/-f) are added as query parameters for GET/HEAD and as a JSON |
| 27 | body otherwise. --field values are type-inferred (true/false/null/numbers); |
| 28 | use --raw-field/-F to force a string. The JSON response is printed to stdout. |
| 29 | |
| 30 | Examples: |
| 31 | rickub api GET /user |
| 32 | rickub api GET search/repos -f q=api |
| 33 | rickub api POST /repos -f name=demo -f visibility=public |
| 34 | rickub api PATCH repos/me/demo -F description="hello world"`, |
| 35 | Args: cobra.ExactArgs(2), |
| 36 | RunE: runAPI, |
| 37 | } |
| 38 | apiCmd.Flags().StringArrayVarP(&apiFields, "field", "f", nil, "typed field key=value (repeatable)") |
| 39 | apiCmd.Flags().StringArrayVarP(&apiRawFields, "raw-field", "F", nil, "string field key=value (repeatable)") |
| 40 | rootCmd.AddCommand(apiCmd) |
| 41 | } |
| 42 | |
| 43 | func runAPI(cmd *cobra.Command, args []string) error { |
| 44 | client, err := newClient() |
| 45 | if err != nil { |
| 46 | return err |
| 47 | } |
| 48 | method := strings.ToUpper(args[0]) |
| 49 | path := normalizeAPIPath(args[1]) |
| 50 | |
| 51 | fields := map[string]any{} |
| 52 | for _, f := range apiRawFields { |
| 53 | k, v, ok := splitKV(f) |
| 54 | if !ok { |
| 55 | return fmt.Errorf("invalid --raw-field %q (want key=value)", f) |
| 56 | } |
| 57 | fields[k] = v |
| 58 | } |
| 59 | for _, f := range apiFields { |
| 60 | k, v, ok := splitKV(f) |
| 61 | if !ok { |
| 62 | return fmt.Errorf("invalid --field %q (want key=value)", f) |
| 63 | } |
| 64 | fields[k] = inferType(v) |
| 65 | } |
| 66 | |
| 67 | var query url.Values |
| 68 | var body any |
| 69 | isRead := method == "GET" || method == "HEAD" |
| 70 | if len(fields) > 0 { |
| 71 | if isRead { |
| 72 | query = url.Values{} |
| 73 | for k, v := range fields { |
| 74 | query.Set(k, fmt.Sprintf("%v", v)) |
| 75 | } |
| 76 | } else { |
| 77 | body = fields |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | data, _, err := client.RawJSON(cmd.Context(), method, path, query, body) |
| 82 | if err != nil { |
| 83 | return err |
| 84 | } |
| 85 | // Pretty-print JSON when possible, else emit raw bytes. |
| 86 | var pretty any |
| 87 | if len(strings.TrimSpace(string(data))) > 0 && json.Unmarshal(data, &pretty) == nil { |
| 88 | return printJSON(cmd.OutOrStdout(), pretty) |
| 89 | } |
| 90 | _, err = os.Stdout.Write(data) |
| 91 | return err |
| 92 | } |
| 93 | |
| 94 | func normalizeAPIPath(p string) string { |
| 95 | p = strings.TrimPrefix(p, "/api/v1") |
| 96 | if !strings.HasPrefix(p, "/") { |
| 97 | p = "/" + p |
| 98 | } |
| 99 | return p |
| 100 | } |
| 101 | |
| 102 | func splitKV(s string) (string, string, bool) { |
| 103 | i := strings.IndexByte(s, '=') |
| 104 | if i < 0 { |
| 105 | return "", "", false |
| 106 | } |
| 107 | return s[:i], s[i+1:], true |
| 108 | } |
| 109 | |
| 110 | // inferType coerces common scalar spellings so JSON bodies get proper types. |
| 111 | func inferType(v string) any { |
| 112 | switch v { |
| 113 | case "true": |
| 114 | return true |
| 115 | case "false": |
| 116 | return false |
| 117 | case "null": |
| 118 | return nil |
| 119 | } |
| 120 | if n, err := strconv.Atoi(v); err == nil { |
| 121 | return n |
| 122 | } |
| 123 | if f, err := strconv.ParseFloat(v, 64); err == nil { |
| 124 | return f |
| 125 | } |
| 126 | return v |
| 127 | } |