package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) type requestOptions struct { BaseURL string Path string Body []byte Token string NoAuth bool Timeout time.Duration } type responseData struct { StatusCode int Body []byte } func normalizeBaseURL(raw string) (string, error) { if raw == "" { raw = defaultBaseURL } parsed, err := url.Parse(raw) if err != nil { return "", fmt.Errorf("parse base url: %w", err) } if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", fmt.Errorf("base url must start with http:// or https://") } if parsed.Host == "" { return "", fmt.Errorf("base url must include a host") } return strings.TrimRight(parsed.String(), "/"), nil } func endpointURL(baseURL, path string) (string, error) { base, err := normalizeBaseURL(baseURL) if err != nil { return "", err } if path == "" { return "", fmt.Errorf("endpoint path is required") } if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path, nil } if !strings.HasPrefix(path, "/") { path = "/" + path } return base + path, nil } func postJSON(ctx context.Context, opts requestOptions) (responseData, error) { target, err := endpointURL(opts.BaseURL, opts.Path) if err != nil { return responseData{}, err } if len(opts.Body) == 0 { opts.Body = []byte("{}") } req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(opts.Body)) if err != nil { return responseData{}, fmt.Errorf("create request: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") if !opts.NoAuth && opts.Token != "" { req.Header.Set("Authorization", "Bearer "+opts.Token) } timeout := opts.Timeout if timeout <= 0 { timeout = 30 * time.Second } client := &http.Client{Timeout: timeout} resp, err := client.Do(req) if err != nil { return responseData{}, fmt.Errorf("http post %s: %w", target, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return responseData{}, fmt.Errorf("read response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return responseData{StatusCode: resp.StatusCode, Body: body}, httpStatusError{StatusCode: resp.StatusCode, Body: body} } return responseData{StatusCode: resp.StatusCode, Body: body}, nil } type httpStatusError struct { StatusCode int Body []byte } func (e httpStatusError) Error() string { body := strings.TrimSpace(string(e.Body)) if body == "" { return fmt.Sprintf("request failed with status %d", e.StatusCode) } return fmt.Sprintf("request failed with status %d: %s", e.StatusCode, body) } func readJSONData(value string) ([]byte, error) { if value == "" { return []byte("{}"), nil } var data []byte var err error switch { case value == "-": data, err = io.ReadAll(os.Stdin) case strings.HasPrefix(value, "@"): path := strings.TrimPrefix(value, "@") if path == "" { return nil, fmt.Errorf("missing file after @") } cleanPath, err := filepath.Abs(path) if err != nil { return nil, fmt.Errorf("resolve json file: %w", err) } data, err = os.ReadFile(cleanPath) default: data = []byte(value) } if err != nil { return nil, fmt.Errorf("read json body: %w", err) } data = bytes.TrimSpace(data) if len(data) == 0 { data = []byte("{}") } if !json.Valid(data) { return nil, fmt.Errorf("request body must be valid JSON") } return data, nil } func writeJSONResponse(w io.Writer, body []byte, raw bool) error { body = bytes.TrimSpace(body) if len(body) == 0 { _, err := fmt.Fprintln(w, "{}") return err } if raw { _, err := w.Write(append(body, '\n')) return err } var out bytes.Buffer if err := json.Indent(&out, body, "", " "); err != nil { _, writeErr := w.Write(append(body, '\n')) return writeErr } out.WriteByte('\n') _, err := w.Write(out.Bytes()) return err } func tokenFromResponse(body []byte) (string, error) { var decoded map[string]any if err := json.Unmarshal(body, &decoded); err != nil { return "", fmt.Errorf("login response is not JSON: %w", err) } if token, ok := decoded["token"].(string); ok && token != "" { return token, nil } if data, ok := decoded["data"].(map[string]any); ok { if token, ok := data["token"].(string); ok && token != "" { return token, nil } } return "", fmt.Errorf("login response does not contain token") }