feat: add htsy CLI tool for Haitun private domain API
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user