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, "'", "'\\''") + "'" }