mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).
Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success
Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.
Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.
All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.
Drive-by: signature field is now omitempty (mostly empty in practice).
Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
primary array correctly
Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0
187 lines
5.1 KiB
Go
187 lines
5.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package httpmock
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// Stub defines a preset HTTP response.
|
|
type Stub struct {
|
|
Method string // empty = match any method
|
|
URL string // substring match on URL
|
|
Status int // default 200
|
|
Body interface{} // auto JSON-serialized
|
|
RawBody []byte // raw bytes (takes precedence over Body when non-nil)
|
|
ContentType string // override Content-Type header (default: application/json)
|
|
Headers http.Header // optional full response headers (takes precedence over ContentType)
|
|
matched bool
|
|
|
|
// BodyFilter (optional): match only when the captured request body satisfies
|
|
// this predicate. Used to disambiguate multiple stubs that share a URL.
|
|
BodyFilter func([]byte) bool
|
|
|
|
// OnMatch (optional): runs synchronously after the stub matches but before
|
|
// the response is composed. Used in tests to inject panics or count
|
|
// in-flight goroutines.
|
|
OnMatch func(req *http.Request)
|
|
|
|
// Reusable (optional): when true, the stub stays available for further
|
|
// matches after the first hit. Each match appends to CapturedBodies.
|
|
Reusable bool
|
|
|
|
// CapturedHeaders records the request headers of the matched request.
|
|
// Populated after RoundTrip matches this stub.
|
|
CapturedHeaders http.Header
|
|
CapturedBody []byte
|
|
// CapturedBodies records every captured request body when Reusable is set.
|
|
// (CapturedBody continues to record the most recent capture for back-compat.)
|
|
CapturedBodies [][]byte
|
|
}
|
|
|
|
// Registry records stubs and implements http.RoundTripper.
|
|
type Registry struct {
|
|
mu sync.Mutex
|
|
stubs []*Stub
|
|
}
|
|
|
|
// Register adds a stub to the registry.
|
|
func (r *Registry) Register(s *Stub) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if s.Status == 0 {
|
|
s.Status = 200
|
|
}
|
|
r.stubs = append(r.stubs, s)
|
|
}
|
|
|
|
// RoundTrip implements http.RoundTripper.
|
|
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
urlStr := req.URL.String()
|
|
|
|
// Read body once up-front so BodyFilter can inspect it without consuming
|
|
// the original reader; restore for downstream consumers afterwards.
|
|
// http.RoundTripper requires us to close the original body.
|
|
var capturedBody []byte
|
|
if req.Body != nil {
|
|
var err error
|
|
capturedBody, err = io.ReadAll(req.Body)
|
|
_ = req.Body.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("httpmock: read request body: %w", err)
|
|
}
|
|
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
|
|
}
|
|
|
|
matched := r.match(req, urlStr, capturedBody)
|
|
|
|
if matched != nil {
|
|
// Restore body again in case OnMatch wants to read it.
|
|
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
|
|
if matched.OnMatch != nil {
|
|
matched.OnMatch(req)
|
|
}
|
|
resp, err := stubResponse(matched)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
|
|
}
|
|
|
|
// match selects the first stub whose Method/URL/BodyFilter all match the
|
|
// request, mutates its capture state, and returns it. defer-Unlock guarantees
|
|
// a panicking user-supplied BodyFilter cannot leak the mutex.
|
|
func (r *Registry) match(req *http.Request, urlStr string, capturedBody []byte) *Stub {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for _, s := range r.stubs {
|
|
if s.matched {
|
|
continue
|
|
}
|
|
if s.Method != "" && s.Method != req.Method {
|
|
continue
|
|
}
|
|
if s.URL != "" && !strings.Contains(urlStr, s.URL) {
|
|
continue
|
|
}
|
|
if s.BodyFilter != nil && !s.BodyFilter(capturedBody) {
|
|
continue
|
|
}
|
|
if !s.Reusable {
|
|
s.matched = true
|
|
}
|
|
s.CapturedHeaders = req.Header.Clone()
|
|
s.CapturedBody = capturedBody
|
|
s.CapturedBodies = append(s.CapturedBodies, capturedBody)
|
|
return s
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Verify asserts all stubs were matched.
|
|
func (r *Registry) Verify(t testing.TB) {
|
|
t.Helper()
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for _, s := range r.stubs {
|
|
if s.matched {
|
|
continue
|
|
}
|
|
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
|
if s.Reusable && len(s.CapturedBodies) > 0 {
|
|
continue
|
|
}
|
|
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
|
|
}
|
|
}
|
|
|
|
func stubResponse(s *Stub) (*http.Response, error) {
|
|
ct := s.ContentType
|
|
if ct == "" {
|
|
ct = "application/json"
|
|
}
|
|
|
|
var body io.ReadCloser
|
|
if s.RawBody != nil {
|
|
body = io.NopCloser(bytes.NewReader(s.RawBody))
|
|
} else {
|
|
switch v := s.Body.(type) {
|
|
case string:
|
|
body = io.NopCloser(strings.NewReader(v))
|
|
case []byte:
|
|
body = io.NopCloser(bytes.NewReader(v))
|
|
default:
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal body: %w", err)
|
|
}
|
|
body = io.NopCloser(bytes.NewReader(b))
|
|
}
|
|
}
|
|
return &http.Response{
|
|
StatusCode: s.Status,
|
|
Header: func() http.Header {
|
|
if s.Headers != nil {
|
|
return s.Headers.Clone()
|
|
}
|
|
return http.Header{"Content-Type": []string{ct}}
|
|
}(),
|
|
Body: body,
|
|
}, nil
|
|
}
|
|
|
|
// NewClient returns an http.Client that uses the Registry as its transport.
|
|
func NewClient(reg *Registry) *http.Client {
|
|
return &http.Client{Transport: reg}
|
|
}
|