219 lines
5.2 KiB
Go
219 lines
5.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"greq/internal/storage"
|
|
)
|
|
|
|
// ImportCURL parses a curl command string into a RequestFile.
|
|
// Handles -X, -H/--header, -d/--data/--data-raw, -u/--user, -b/--cookie.
|
|
// Unknown placeholder variables ({{…}}) are left as-is.
|
|
func ImportCURL(raw string) (storage.RequestFile, error) {
|
|
tokens := tokenizeCURL(raw)
|
|
if len(tokens) == 0 {
|
|
return storage.RequestFile{}, fmt.Errorf("empty input")
|
|
}
|
|
if !strings.EqualFold(tokens[0], "curl") {
|
|
return storage.RequestFile{}, fmt.Errorf("does not start with 'curl'")
|
|
}
|
|
|
|
rf := storage.RequestFile{
|
|
Method: "GET",
|
|
Headers: make(map[string]string),
|
|
}
|
|
|
|
i := 1
|
|
for i < len(tokens) {
|
|
tok := tokens[i]
|
|
switch tok {
|
|
case "-X", "--request":
|
|
i++
|
|
if i < len(tokens) {
|
|
rf.Method = strings.ToUpper(tokens[i])
|
|
}
|
|
case "-H", "--header":
|
|
i++
|
|
if i < len(tokens) {
|
|
if k, v, ok := splitHeader(tokens[i]); ok {
|
|
rf.Headers[k] = v
|
|
}
|
|
}
|
|
case "-d", "--data", "--data-raw", "--data-ascii", "--data-urlencode":
|
|
i++
|
|
if i < len(tokens) {
|
|
if rf.Body != "" {
|
|
rf.Body += "&"
|
|
}
|
|
rf.Body += tokens[i]
|
|
if rf.Method == "GET" {
|
|
rf.Method = "POST"
|
|
}
|
|
}
|
|
case "--data-binary":
|
|
i++
|
|
if i < len(tokens) && !strings.HasPrefix(tokens[i], "@") {
|
|
rf.Body = tokens[i]
|
|
if rf.Method == "GET" {
|
|
rf.Method = "POST"
|
|
}
|
|
}
|
|
case "-u", "--user":
|
|
i++
|
|
if i < len(tokens) {
|
|
enc := base64.StdEncoding.EncodeToString([]byte(tokens[i]))
|
|
rf.Headers["Authorization"] = "Basic " + enc
|
|
}
|
|
case "-b", "--cookie":
|
|
i++
|
|
if i < len(tokens) {
|
|
rf.Headers["Cookie"] = tokens[i]
|
|
}
|
|
case "-A", "--user-agent":
|
|
i++
|
|
if i < len(tokens) {
|
|
rf.Headers["User-Agent"] = tokens[i]
|
|
}
|
|
// Flags to silently ignore (no value)
|
|
case "--compressed", "--silent", "-s", "--insecure", "-k",
|
|
"-L", "--location", "-v", "--verbose", "-i", "--include",
|
|
"--no-keepalive", "--http2", "--http1.1", "--http1.0":
|
|
// Short flags with values that we don't handle — skip value
|
|
default:
|
|
if !strings.HasPrefix(tok, "-") {
|
|
if rf.URL == "" {
|
|
rf.URL = tok
|
|
}
|
|
} else if len(tok) == 2 {
|
|
// Unknown short flag: skip its value
|
|
i++
|
|
}
|
|
// Unknown long flags: skip
|
|
}
|
|
i++
|
|
}
|
|
|
|
if rf.URL == "" {
|
|
return rf, fmt.Errorf("no URL found in curl command")
|
|
}
|
|
if len(rf.Headers) == 0 {
|
|
rf.Headers = nil
|
|
}
|
|
rf.Name = inferName(rf.Method, rf.URL)
|
|
return rf, nil
|
|
}
|
|
|
|
// ExportCURL builds a shell-safe curl command from a RequestFile.
|
|
// Variables are resolved before embedding in the command string.
|
|
func ExportCURL(rf storage.RequestFile, envs map[string]string) string {
|
|
var parts []string
|
|
parts = append(parts, "curl")
|
|
parts = append(parts, shellQuote(ResolveVariables(rf.URL, envs)))
|
|
|
|
if rf.Method != "GET" || rf.Body != "" {
|
|
parts = append(parts, "-X", rf.Method)
|
|
}
|
|
|
|
// Deterministic header order
|
|
keys := make([]string, 0, len(rf.Headers))
|
|
for k := range rf.Headers {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
v := ResolveVariables(rf.Headers[k], envs)
|
|
parts = append(parts, "-H", shellQuote(k+": "+v))
|
|
}
|
|
|
|
if rf.Body != "" {
|
|
parts = append(parts, "--data-raw", shellQuote(ResolveVariables(rf.Body, envs)))
|
|
}
|
|
|
|
return strings.Join(parts, " \\\n ")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internals
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func tokenizeCURL(s string) []string {
|
|
// Join backslash-newline continuations
|
|
s = strings.ReplaceAll(s, "\\\n", " ")
|
|
s = strings.ReplaceAll(s, "\\\r\n", " ")
|
|
s = strings.TrimSpace(s)
|
|
|
|
var tokens []string
|
|
var cur strings.Builder
|
|
inSingle := false
|
|
inDouble := false
|
|
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
switch {
|
|
case c == '\'' && !inDouble:
|
|
inSingle = !inSingle
|
|
case c == '"' && !inSingle:
|
|
inDouble = !inDouble
|
|
case c == '\\' && !inSingle:
|
|
i++
|
|
if i < len(s) {
|
|
cur.WriteByte(s[i])
|
|
}
|
|
case (c == ' ' || c == '\t' || c == '\n') && !inSingle && !inDouble:
|
|
if cur.Len() > 0 {
|
|
tokens = append(tokens, cur.String())
|
|
cur.Reset()
|
|
}
|
|
default:
|
|
cur.WriteByte(c)
|
|
}
|
|
}
|
|
if cur.Len() > 0 {
|
|
tokens = append(tokens, cur.String())
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
func splitHeader(s string) (key, val string, ok bool) {
|
|
idx := strings.Index(s, ": ")
|
|
if idx == -1 {
|
|
idx = strings.Index(s, ":")
|
|
if idx == -1 {
|
|
return "", "", false
|
|
}
|
|
}
|
|
return strings.TrimSpace(s[:idx]), strings.TrimSpace(s[idx+1:]), true
|
|
}
|
|
|
|
func inferName(method, rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil || u.Path == "" || u.Path == "/" {
|
|
return method + " Request"
|
|
}
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
last := parts[len(parts)-1]
|
|
if last == "" || last == "{" {
|
|
return method + " Request"
|
|
}
|
|
// Title-case the last path segment
|
|
words := strings.FieldsFunc(strings.ReplaceAll(last, "_", " "), func(r rune) bool {
|
|
return r == '-' || unicode.IsSpace(r)
|
|
})
|
|
for i, w := range words {
|
|
if len(w) > 0 {
|
|
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
|
}
|
|
}
|
|
return strings.Join(words, " ")
|
|
}
|
|
|
|
// shellQuote wraps s in single quotes, escaping any embedded single quotes.
|
|
func shellQuote(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
|
}
|