package api import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" "greq/internal/storage" ) // SavedToken is the most-recently captured bearer token. // It is populated automatically from any response that contains a // top-level "token", "access_token", "accessToken", "jwt", or "id_token" field. var SavedToken string // Response holds the parsed result of an HTTP call. type Response struct { StatusCode int Status string Body string // pretty-printed if JSON, raw otherwise Headers http.Header IsJSON bool Duration time.Duration Size int // raw body bytes } // ResolveVariables replaces every {{key}} placeholder in input. // // Resolution order: // 1. Values from env.yaml (envs map) // 2. The captured bearer token ({{token}} and {{access_token}}) // // Missing keys are left as-is (e.g. "{{unknown}}" stays in the string) // so the caller can detect unresolved variables by scanning for "{{". // ResolveVariables replaces {{key}} placeholders in the following order: // 1. env.yaml values (envs) // 2. Auto-chained vars from the last response (ChainedVars, e.g. {{last_id}}) // 3. Captured bearer token ({{token}}, {{access_token}}) // // Unknown keys are left as-is so callers can detect them by scanning for "{{". func ResolveVariables(input string, envs map[string]string) string { for k, v := range envs { input = strings.ReplaceAll(input, "{{"+k+"}}", v) } for k, v := range ChainedVars { input = strings.ReplaceAll(input, "{{"+k+"}}", v) } if SavedToken != "" { input = strings.ReplaceAll(input, "{{token}}", SavedToken) input = strings.ReplaceAll(input, "{{access_token}}", SavedToken) } return input } // Do executes the request described by rf with variable substitution applied. func Do(rf storage.RequestFile, envs map[string]string) (*Response, error) { finalURL := ResolveVariables(rf.URL, envs) finalBody := ResolveVariables(rf.Body, envs) req, err := http.NewRequest(rf.Method, finalURL, bytes.NewBufferString(finalBody)) if err != nil { return nil, fmt.Errorf("build request: %w", err) } // Headers from request file (variables resolved) for k, v := range rf.Headers { req.Header.Set(k, ResolveVariables(v, envs)) } // Auto-inject token when present and header not already set if SavedToken != "" && req.Header.Get("Authorization") == "" { req.Header.Set("Authorization", "Bearer "+SavedToken) } // Default Content-Type for non-empty bodies if finalBody != "" && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } client := &http.Client{Timeout: 15 * time.Second} start := time.Now() resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) elapsed := time.Since(start) if err != nil { return nil, fmt.Errorf("read body: %w", err) } // Extract token and chain variables from the response body extractToken(raw) ExtractChained(raw) // Pretty-print JSON bodies body := string(raw) isJSON := false var pretty bytes.Buffer if json.Indent(&pretty, raw, "", " ") == nil && pretty.Len() > 0 { body = pretty.String() isJSON = true } return &Response{ StatusCode: resp.StatusCode, Status: resp.Status, Body: body, Headers: resp.Header, IsJSON: isJSON, Duration: elapsed, Size: len(raw), }, nil } // extractToken scans the top level of a JSON object for common token field names // and stores the first match in SavedToken. func extractToken(body []byte) { var data map[string]interface{} if err := json.Unmarshal(body, &data); err != nil { return } for _, key := range []string{"token", "access_token", "accessToken", "jwt", "id_token"} { if v, ok := data[key]; ok { if s, ok := v.(string); ok && s != "" { SavedToken = s return } } } }