Files

227 lines
6.9 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package apiclient
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
)
const (
// DefaultPageLimit is the maximum number of pages fetched with --page-all
// when --page-limit is not explicitly set.
DefaultPageLimit = 10
// MaxPageLimit is the hard safety cap to prevent infinite loops when an
// API endpoint has a bug that causes has_more to never become false.
// Use --page-limit 0 to hit this cap; any explicit positive value is
// honoured up to this ceiling.
MaxPageLimit = 500
// DefaultPageDelay is the delay between paginated requests in milliseconds.
DefaultPageDelay = 200
)
// PaginationOptions controls automatic pagination behaviour.
type PaginationOptions struct {
PageLimit int // Maximum pages (0 = unlimited, capped at MaxPageLimit)
PageDelay int // Delay between pages in milliseconds
LogWriter io.Writer // Optional: progress log output (typically stderr)
}
// PaginateAll fetches all pages of a paginated API and merges the results.
// DingTalk APIs use two pagination patterns:
// - cursor/next_cursor/has_more (in response body)
// - next_token (in response body)
//
// The function auto-detects which pattern the API uses.
func (c *APIClient) PaginateAll(ctx context.Context, req RawAPIRequest, opts PaginationOptions) ([]any, error) {
limit := resolvePageLimit(opts.PageLimit)
if opts.PageDelay <= 0 {
opts.PageDelay = DefaultPageDelay
}
var allResults []any
pageCount := 0
for {
pageCount++
// Safety cap — only break if a carry is active (pageCount > 1).
if limit > 0 && pageCount > limit {
logf(opts.LogWriter, "[pagination] ⚠ 已达安全上限 %d 页,停止翻页。数据可能不完整,请检查 API 是否异常。\n", limit)
break
}
logf(opts.LogWriter, "[pagination] 第 %d 页 请求中...\n", pageCount)
resp, err := c.Do(ctx, req)
if err != nil {
if pageCount == 1 {
return nil, err
}
// Non-first page error: return what we have so far.
return allResults, fmt.Errorf("分页第 %d 页请求失败 (已获取 %d 页结果): %w", pageCount, pageCount-1, err)
}
result, hasMore, nextToken, parseErr := parsePaginatedResponse(resp)
if parseErr != nil {
if pageCount == 1 {
return nil, parseErr
}
// Non-first page parse failure: warn the caller so users aren't
// silently left with incomplete data.
logf(opts.LogWriter, "[pagination] ⚠ 第 %d 页解析失败,停止翻页并返回已获取的 %d 页数据: %v\n", pageCount, pageCount-1, parseErr)
return allResults, nil
}
allResults = append(allResults, result)
if !hasMore || nextToken == "" {
logf(opts.LogWriter, "[pagination] 数据获取完成 (共 %d 页)\n", pageCount)
break
}
// Inject the next page token into the request.
req = injectPageToken(req, nextToken)
// Delay between pages to prevent API throttling.
select {
case <-ctx.Done():
return allResults, ctx.Err()
case <-time.After(time.Duration(opts.PageDelay) * time.Millisecond):
}
}
return allResults, nil
}
// resolvePageLimit translates the user-facing value into an internal limit:
//
// 0 → MaxPageLimit (user wants unlimited; safety cap applies)
// positive N → min(N, MaxPageLimit) (explicit page limit, still capped)
// negative → DefaultPageLimit (invalid input treated as default)
func resolvePageLimit(raw int) int {
if raw == 0 {
return MaxPageLimit
}
if raw < 0 {
return DefaultPageLimit
}
if raw > MaxPageLimit {
return MaxPageLimit
}
return raw
}
func logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format, args...)
}
// parsePaginatedResponse extracts the response payload and pagination info.
// It auto-detects DingTalk's two pagination patterns.
func parsePaginatedResponse(resp *RawAPIResponse) (result any, hasMore bool, nextToken string, err error) {
contentType := resp.Header.Get("Content-Type")
if !isJSONContentType(contentType) {
return nil, false, "", fmt.Errorf("分页响应非 JSON 格式 (Content-Type: %s)", contentType)
}
if len(resp.Body) == 0 {
return nil, false, "", fmt.Errorf("分页响应体为空 (HTTP %d)", resp.StatusCode)
}
var payload map[string]any
if unmarshalErr := jsonUnmarshal(resp.Body, &payload); unmarshalErr != nil {
return nil, false, "", fmt.Errorf("解析分页 JSON 响应失败: %w", unmarshalErr)
}
// Check for DingTalk errors first.
if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil {
return nil, false, "", apiErr
}
// Pattern 1: cursor/next_cursor/has_more (often nested in "result" or top-level)
if resultObj, ok := payload["result"]; ok {
if resultMap, isMap := resultObj.(map[string]any); isMap {
hasMore, _ = resultMap["has_more"].(bool)
if nc, ok := resultMap["next_cursor"].(float64); ok && nc > 0 {
nextToken = fmt.Sprintf("%.0f", nc)
}
return payload, hasMore, nextToken, nil
}
}
// Top-level has_more / next_cursor
if hm, ok := payload["has_more"]; ok {
hasMore, _ = hm.(bool)
}
if nc, ok := payload["next_cursor"]; ok {
if ncf, isFloat := nc.(float64); isFloat && ncf > 0 {
nextToken = fmt.Sprintf("%.0f", ncf)
}
}
// Pattern 2: next_token
if nt, ok := payload["next_token"]; ok {
if nts, isStr := nt.(string); isStr && nts != "" {
nextToken = nts
hasMore = true
}
}
return payload, hasMore, nextToken, nil
}
// injectPageToken injects the pagination token into the next request.
// For GET requests, it's added as a query param; for POST, it's in the body.
func injectPageToken(req RawAPIRequest, token string) RawAPIRequest {
method := req.Method
if method == "GET" {
if req.Params == nil {
req.Params = make(map[string]any)
}
// Try to detect which param name the API uses
if _, ok := req.Params["cursor"]; ok {
req.Params["cursor"] = token
} else if _, ok := req.Params["next_token"]; ok {
req.Params["next_token"] = token
} else {
// Default to next_token for GET requests
req.Params["next_token"] = token
}
} else {
// For POST/PUT requests, inject into the body
if bodyMap, ok := req.Data.(map[string]any); ok {
if _, hasCursor := bodyMap["cursor"]; hasCursor {
bodyMap["cursor"] = token
} else {
bodyMap["next_token"] = token
}
req.Data = bodyMap
}
}
return req
}
// jsonUnmarshal is a helper for JSON unmarshaling.
func jsonUnmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}