212 lines
6.0 KiB
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)
|
|
}
|