feat: add htsy CLI tool for Haitun private domain API

This commit is contained in:
dcsunny
2026-07-01 10:11:06 +08:00
commit 9e9ae38858
8 changed files with 1142 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
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")
}