Files
NetworkAuth/services/request/resty_client.go

528 lines
15 KiB
Go

package request
import (
"bytes"
"compress/flate"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"github.com/andybalholm/brotli"
"github.com/go-resty/resty/v2"
req "github.com/imroc/req/v3"
"github.com/skycheung803/go-bypasser"
)
type RestyClient struct {
client *resty.Client
reqClient *req.Client
ctx context.Context
baseURL string
defaultHeaders map[string]string
proxyStr string
timeout time.Duration
}
func (request *RestyClient) Resty() *resty.Client {
return request.client
}
// NewClient 创建一个基于 go-bypasser(req/v3) 的客户端。
// 对外继续保留 Resty 风格接口,但底层请求不再走 resty.Transport。
func NewClient(baseURL string, proxyStr string, persistCookies bool, timeout int) *RestyClient {
if timeout <= 0 {
timeout = 60
}
timeoutDuration := time.Duration(timeout) * time.Second
defaultHeaders := map[string]string{
"accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
"connection": "keep-alive",
"pragma": "no-cache",
"priority": "u=1,i",
"sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"macOS\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
}
stateClient := resty.New().
SetTimeout(timeoutDuration).
SetHeaders(defaultHeaders)
if baseURL != "" {
stateClient.SetBaseURL(baseURL)
}
var sharedJar http.CookieJar
if persistCookies {
jar, _ := cookiejar.New(nil)
sharedJar = jar
stateClient.SetCookieJar(sharedJar)
}
baseReqClient := mustNewReqClient(proxyStr, timeoutDuration, baseURL, defaultHeaders, sharedJar)
return &RestyClient{
client: stateClient,
reqClient: baseReqClient,
ctx: context.Background(),
baseURL: baseURL,
defaultHeaders: defaultHeaders,
proxyStr: proxyStr,
timeout: timeoutDuration,
}
}
func (request *RestyClient) WithContext(ctx context.Context) *RestyClient {
if ctx == nil {
ctx = context.Background()
}
return &RestyClient{
client: request.client,
reqClient: request.reqClient,
ctx: ctx,
baseURL: request.baseURL,
defaultHeaders: request.defaultHeaders,
proxyStr: request.proxyStr,
timeout: request.timeout,
}
}
// SetPersistentHeader 设置持久化 Header。
// 除 Cookie 外,其余 Header 会同步到 req 客户端的 common headers。
func (request *RestyClient) SetPersistentHeader(key string, value string) {
if request.defaultHeaders == nil {
request.defaultHeaders = make(map[string]string)
}
lowerKey := strings.ToLower(key)
request.defaultHeaders[lowerKey] = value
if request.client != nil {
request.client.SetHeader(key, value)
}
if request.reqClient != nil && lowerKey != "cookie" {
request.reqClient.SetCommonHeader(key, value)
}
}
func mustNewReqClient(proxyStr string, timeout time.Duration, baseURL string, defaultHeaders map[string]string, jar http.CookieJar) *req.Client {
opts := []bypasser.BypasserOption{
bypasser.WithInsecureSkipVerify(true),
}
if proxyStr != "" {
opts = append(opts, bypasser.WithProxy(proxyStr))
}
bypass, err := bypasser.NewBypasser(opts...)
if err != nil {
panic(err)
}
rt, ok := bypass.Transport.(*bypasser.StandardRoundTripper)
if !ok || rt.Client == nil {
panic("go-bypasser did not return a StandardRoundTripper client")
}
client := rt.Client
client.SetTimeout(timeout)
client.SetRedirectPolicy(req.DefaultRedirectPolicy())
if baseURL != "" {
client.SetBaseURL(baseURL)
}
for k, v := range defaultHeaders {
if strings.ToLower(k) == "cookie" {
continue
}
client.SetCommonHeader(k, v)
}
if jar != nil {
client.SetCookieJar(jar)
}
return client
}
func (request *RestyClient) newRequestClient(redirectCount int) *req.Client {
client := request.reqClient.Clone()
if request.baseURL != "" {
client.SetBaseURL(request.baseURL)
}
client.SetTimeout(request.timeout)
switch {
case redirectCount == 0:
client.SetRedirectPolicy(req.NoRedirectPolicy())
case redirectCount > 0:
client.SetRedirectPolicy(req.MaxRedirectPolicy(redirectCount))
default:
client.SetRedirectPolicy(req.DefaultRedirectPolicy())
}
return client
}
func (request *RestyClient) resolveRequestURL(path string) *url.URL {
if path == "" {
return nil
}
parsedURL, err := url.Parse(path)
if err != nil {
return nil
}
if parsedURL.IsAbs() {
return parsedURL
}
if request.baseURL == "" {
return parsedURL
}
baseURL, err := url.Parse(request.baseURL)
if err != nil {
return parsedURL
}
return baseURL.ResolveReference(parsedURL)
}
func cloneCookie(cookie *http.Cookie) *http.Cookie {
if cookie == nil {
return nil
}
copied := *cookie
return &copied
}
func isSafeHTTPCookieValue(value string) bool {
if value == "" {
return true
}
for _, r := range value {
if r < 0x21 || r > 0x7e {
return false
}
switch r {
case '"', ';', '\\', ',':
return false
}
}
return true
}
func parseRawCookieHeader(raw string) []*http.Cookie {
if raw == "" {
return nil
}
var cookies []*http.Cookie
for _, part := range strings.Split(raw, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
name, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
name = strings.TrimSpace(name)
value = strings.TrimSpace(value)
if name == "" {
continue
}
cookies = append(cookies, &http.Cookie{Name: name, Value: value})
}
return cookies
}
func buildCookieHeader(cookies []*http.Cookie) string {
if len(cookies) == 0 {
return ""
}
parts := make([]string, 0, len(cookies))
for _, cookie := range cookies {
if cookie == nil || cookie.Name == "" {
continue
}
parts = append(parts, fmt.Sprintf("%s=%s", cookie.Name, cookie.Value))
}
return strings.Join(parts, "; ")
}
// decodeCompressedBody 按响应头解压正文,兼容项目里依赖明文 HTML/JSON 的解析逻辑。
func decodeCompressedBody(body []byte, contentEncoding string) ([]byte, error) {
encoding := strings.ToLower(strings.TrimSpace(contentEncoding))
switch {
case encoding == "", encoding == "identity":
return body, nil
case strings.Contains(encoding, "gzip"):
reader, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
case strings.Contains(encoding, "deflate"):
reader := flate.NewReader(bytes.NewReader(body))
defer reader.Close()
return io.ReadAll(reader)
case strings.Contains(encoding, "br"):
reader := brotli.NewReader(bytes.NewReader(body))
return io.ReadAll(reader)
default:
return body, nil
}
}
func (request *RestyClient) prepareCookies(path string, requestCookies []*http.Cookie) ([]*http.Cookie, string) {
cookieMap := make(map[string]*http.Cookie)
order := make([]string, 0)
rawCookieNames := make(map[string]struct{})
appendCookie := func(cookie *http.Cookie) {
if cookie == nil || cookie.Name == "" {
return
}
if _, exists := cookieMap[cookie.Name]; !exists {
order = append(order, cookie.Name)
}
cloned := cloneCookie(cookie)
cookieMap[cookie.Name] = cloned
if cloned != nil && !isSafeHTTPCookieValue(cloned.Value) {
rawCookieNames[cloned.Name] = struct{}{}
}
}
parsedURL := request.resolveRequestURL(path)
if request.client != nil && request.client.GetClient() != nil && request.client.GetClient().Jar != nil && parsedURL != nil {
for _, cookie := range request.client.GetClient().Jar.Cookies(parsedURL) {
appendCookie(cookie)
}
}
if request.client != nil {
for _, cookie := range request.client.Cookies {
appendCookie(cookie)
}
}
for _, cookie := range requestCookies {
appendCookie(cookie)
}
rawCookies := parseRawCookieHeader(request.defaultHeaders["cookie"])
for _, cookie := range rawCookies {
if cookie == nil || cookie.Name == "" {
continue
}
rawCookieNames[cookie.Name] = struct{}{}
if _, exists := cookieMap[cookie.Name]; !exists {
order = append(order, cookie.Name)
}
cookieMap[cookie.Name] = cookie
}
mergedCookies := make([]*http.Cookie, 0, len(order))
for _, name := range order {
if cookie := cookieMap[name]; cookie != nil {
mergedCookies = append(mergedCookies, cloneCookie(cookie))
}
}
if len(rawCookieNames) == 0 {
return mergedCookies, ""
}
return mergedCookies, buildCookieHeader(mergedCookies)
}
func (request *RestyClient) buildReqRequest(client *req.Client, path string, headers map[string]string, cookies []*http.Cookie) *req.Request {
r := client.R().SetContext(request.ctx)
if len(headers) > 0 {
r.SetHeaders(headers)
}
mergedCookies, rawCookieHeader := request.prepareCookies(path, cookies)
if rawCookieHeader != "" {
r.SetHeader("Cookie", rawCookieHeader)
} else if len(mergedCookies) > 0 {
r.SetCookies(mergedCookies...)
}
return r
}
func (request *RestyClient) adaptReqResponse(path string, method string, data any, headers map[string]string, cookies []*http.Cookie, resp *req.Response) (*resty.Response, error) {
if resp == nil {
return nil, nil
}
body, err := resp.ToBytes()
if err != nil && resp.Response == nil {
return nil, err
}
rawResponse := resp.Response
if rawResponse != nil {
decodedBody, decodeErr := decodeCompressedBody(body, rawResponse.Header.Get("Content-Encoding"))
if decodeErr != nil {
return nil, decodeErr
}
body = decodedBody
rawResponse.Body = io.NopCloser(bytes.NewReader(body))
rawResponse.Header.Del("Content-Encoding")
rawResponse.ContentLength = int64(len(body))
}
restyReq := request.client.R()
restyReq.Method = method
restyReq.URL = path
restyReq.Body = data
restyReq.Header = make(http.Header)
for k, v := range headers {
restyReq.Header.Set(k, v)
}
mergedCookies, rawCookieHeader := request.prepareCookies(path, cookies)
if rawCookieHeader != "" {
restyReq.Header.Set("Cookie", rawCookieHeader)
} else {
restyReq.Cookies = mergedCookies
}
restyResp := &resty.Response{
Request: restyReq,
RawResponse: rawResponse,
}
restyResp.SetBody(body)
return restyResp, err
}
func (request *RestyClient) decodeResponse(resp *resty.Response, result any) error {
if resp == nil || result == nil {
return nil
}
body := resp.Body()
if len(body) == 0 {
return nil
}
ct := strings.ToLower(resp.Header().Get("Content-Type"))
if strings.Contains(ct, "application/json") || json.Valid(body) {
if err := json.Unmarshal(body, result); err != nil {
return err
}
}
return nil
}
func (request *RestyClient) execute(method string, path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
client := request.newRequestClient(redirectCount)
doRequest := func(extraHeaders map[string]string) (*resty.Response, error) {
r := request.buildReqRequest(client, path, extraHeaders, cookies)
if data != nil {
r.SetBody(data)
}
var (
resp *req.Response
err error
)
switch method {
case http.MethodGet:
resp, err = r.Get(path)
case http.MethodPost:
resp, err = r.Post(path)
case http.MethodPut:
resp, err = r.Put(path)
case http.MethodPatch:
resp, err = r.Patch(path)
case http.MethodDelete:
resp, err = r.Delete(path)
case http.MethodHead:
resp, err = r.Head(path)
case http.MethodOptions:
resp, err = r.Options(path)
default:
return nil, fmt.Errorf("unsupported method: %s", method)
}
restyResp, adaptErr := request.adaptReqResponse(path, method, data, extraHeaders, cookies, resp)
if err != nil && errors.Is(err, http.ErrUseLastResponse) {
err = nil
}
if adaptErr != nil && err == nil {
err = adaptErr
}
return restyResp, err
}
resp, err := doRequest(headers)
if err == nil && resp != nil && strings.Contains(strings.ToLower(resp.Header().Get("Content-Encoding")), "zstd") {
err = fmt.Errorf("zstd body requires identity fallback")
}
if err == nil {
if decodeErr := request.decodeResponse(resp, result); decodeErr != nil {
return nil, decodeErr
}
return resp, nil
}
errStr := err.Error()
if strings.Contains(errStr, "gzip") || strings.Contains(errStr, "magic number mismatch") || strings.Contains(errStr, "zstd") || strings.Contains(errStr, "brotli") || strings.Contains(errStr, "flate") {
h2 := map[string]string{}
for k, v := range headers {
if strings.ToLower(k) != "accept-encoding" {
h2[k] = v
}
}
h2["Accept-Encoding"] = "identity"
resp2, err2 := doRequest(h2)
if err2 == nil {
if decodeErr := request.decodeResponse(resp2, result); decodeErr != nil {
return nil, decodeErr
}
return resp2, nil
}
if resp2 != nil {
return resp2, err2
}
}
if resp != nil {
return resp, err
}
return nil, err
}
func (request *RestyClient) RestyGet(path string, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
return request.execute(http.MethodGet, path, nil, result, headers, cookies, redirectCount)
}
func (request *RestyClient) RestyPost(path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
return request.execute(http.MethodPost, path, data, result, headers, cookies, redirectCount)
}
func (request *RestyClient) RestyPut(path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
return request.execute(http.MethodPut, path, data, result, headers, cookies, redirectCount)
}
func (request *RestyClient) RestyPatch(path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
return request.execute(http.MethodPatch, path, data, result, headers, cookies, redirectCount)
}
func (request *RestyClient) RestyDelete(path string, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
return request.execute(http.MethodDelete, path, nil, result, headers, cookies, redirectCount)
}
func (request *RestyClient) RestyHead(path string, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
return request.execute(http.MethodHead, path, nil, nil, headers, cookies, redirectCount)
}
func (request *RestyClient) RestyOptions(path string, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
return request.execute(http.MethodOptions, path, nil, nil, headers, cookies, redirectCount)
}