Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/feichibridge/client.go

212 lines
6.0 KiB
Go

package feichibridge
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var ErrUnauthorized = errors.New("feichi API session unauthorized")
type APIClient struct {
baseURL *url.URL
headers http.Header
client *http.Client
login *loginSession
}
func NewAPIClient(baseURL string, headers map[string]string, timeout time.Duration) (*APIClient, error) {
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/")
if err != nil {
return nil, fmt.Errorf("parse FEICHI_BASE_URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("FEICHI_BASE_URL must use http or https")
}
if timeout <= 0 {
timeout = 15 * time.Second
}
out := &APIClient{
baseURL: parsed,
headers: make(http.Header),
client: &http.Client{Timeout: timeout},
}
for name, value := range headers {
if strings.TrimSpace(name) != "" && strings.TrimSpace(value) != "" {
out.headers.Set(name, value)
}
}
return out, nil
}
func NewAuthenticatedAPIClient(baseURL string, credentials LoginCredentials, solver CaptchaSolver, timeout time.Duration) (*APIClient, error) {
client, err := NewAPIClient(baseURL, nil, timeout)
if err != nil {
return nil, err
}
login, err := newLoginSession(credentials, solver)
if err != nil {
return nil, err
}
client.login = login
return client, nil
}
func (c *APIClient) Vehicles(ctx context.Context) ([]Vehicle, error) {
payload := map[string]any{
"conditions": []map[string]string{
{"name": "iccid", "value": ""},
{"name": "powerMode", "value": ""},
},
"sort": []map[string]string{{"name": "updateTime", "order": "desc"}},
"start": 0,
"limit": 100,
}
var envelope collectionEnvelope[Vehicle]
if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/vehicleRealStatuss", payload, &envelope); err != nil {
return nil, err
}
if !apiCodeOK(envelope.Code) {
return nil, apiCodeError(envelope.Code)
}
return envelope.Data, nil
}
func (c *APIClient) Snapshot(ctx context.Context, vehicleID string) (Snapshot, error) {
path := "api/v1/sys/vehicleRealStatussByVId/" + url.PathEscape(vehicleID) + "/-1"
var envelope objectEnvelope[Snapshot]
if err := c.doJSON(ctx, http.MethodGet, path, nil, &envelope); err != nil {
return Snapshot{}, err
}
if !apiCodeOK(envelope.Code) {
return Snapshot{}, apiCodeError(envelope.Code)
}
return envelope.Data, nil
}
func (c *APIClient) History(ctx context.Context, vin string, begin, end time.Time) ([]Record, error) {
payload := map[string]any{
"conditions": []map[string]string{
{"name": "queryType", "value": "0"},
{"name": "queryContent", "value": vin},
{"name": "beginTime", "value": begin.In(shanghai).Format("2006-01-02 15:04:05")},
{"name": "endTime", "value": end.In(shanghai).Format("2006-01-02 15:04:05")},
{"name": "hisdataType", "value": "0"},
},
"start": 0,
"limit": 100,
}
var envelope collectionEnvelope[historySegment]
if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/hisdataQuerys", payload, &envelope); err != nil {
return nil, err
}
if !apiCodeOK(envelope.Code) {
return nil, apiCodeError(envelope.Code)
}
var records []Record
for _, segment := range envelope.Data {
records = append(records, segment.Subdata...)
}
return records, nil
}
func (c *APIClient) doJSON(ctx context.Context, method, path string, body any, output any) error {
if c.login != nil {
if err := c.login.ensure(ctx, c); err != nil {
return err
}
}
for attempt := 0; attempt < 2; attempt++ {
_, err := c.rawJSON(ctx, method, path, body, output)
if !errors.Is(err, ErrUnauthorized) || c.login == nil || attempt == 1 {
return err
}
c.login.invalidate()
if err := c.login.ensure(ctx, c); err != nil {
return err
}
}
return ErrUnauthorized
}
func (c *APIClient) rawJSON(ctx context.Context, method, path string, body any, output any) (http.Header, error) {
endpoint, err := c.baseURL.Parse(path)
if err != nil {
return nil, err
}
var reader io.Reader
if body != nil {
encoded, marshalErr := json.Marshal(body)
if marshalErr != nil {
return nil, marshalErr
}
reader = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
for name, values := range c.headers {
for _, value := range values {
request.Header.Add(name, value)
}
}
if c.login != nil {
if cookie := c.login.cookieHeader(); cookie != "" {
request.Header.Set("Cookie", cookie)
}
if csrf := c.login.csrfHeader(); csrf != "" {
request.Header.Set("x-api-csrf", csrf)
}
}
response, err := c.client.Do(request)
if err != nil {
return nil, fmt.Errorf("feichi %s %s: %w", method, path, err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return response.Header, ErrUnauthorized
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
snippet, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
return response.Header, fmt.Errorf("feichi %s %s: HTTP %d: %s", method, path, response.StatusCode, strings.TrimSpace(string(snippet)))
}
encoded, err := io.ReadAll(io.LimitReader(response.Body, 16<<20))
if err != nil {
return response.Header, fmt.Errorf("read feichi %s: %w", path, err)
}
var status struct {
Code any `json:"code"`
}
if err := json.Unmarshal(encoded, &status); err != nil {
return response.Header, fmt.Errorf("decode feichi %s: %w", path, err)
}
if fmt.Sprint(status.Code) == "401" || fmt.Sprint(status.Code) == "403" {
return response.Header, ErrUnauthorized
}
if output != nil {
if err := json.Unmarshal(encoded, output); err != nil {
return response.Header, fmt.Errorf("decode feichi %s: %w", path, err)
}
}
return response.Header, nil
}
func apiCodeError(code any) error {
if fmt.Sprint(code) == "401" || fmt.Sprint(code) == "403" {
return ErrUnauthorized
}
return fmt.Errorf("feichi API returned code %v", code)
}