Files
im_server/loadtest/im-pressure/httpapi.go
T

309 lines
10 KiB
Go

package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync/atomic"
"time"
)
type apiClient struct {
baseURL string
apiKey string
tenant string
client *http.Client
seq atomic.Uint64
}
type apiEnvelope struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
Reason string `json:"reason"`
Message string `json:"message"`
RequestID string `json:"request_id"`
}
type apiError struct {
Status int
Code int
Reason string
Message string
}
func (e *apiError) Error() string {
return fmt.Sprintf("HTTP %d code=%d reason=%s message=%s", e.Status, e.Code, e.Reason, e.Message)
}
func newAPIClient(cfg *Config, apiKey string) *apiClient {
transport := http.DefaultTransport.(*http.Transport).Clone()
if cfg.InsecureTLS {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402: explicit load-test option.
}
return &apiClient{
baseURL: strings.TrimRight(cfg.HTTPBase, "/"), apiKey: apiKey, tenant: cfg.Tenant,
client: &http.Client{Timeout: 2 * time.Minute, Transport: transport},
}
}
func (a *apiClient) do(ctx context.Context, method, path, token, idempotencyKey string, body any, output any) error {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, a.baseURL+path, reader)
if err != nil {
return err
}
req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-Tenant-Code", a.tenant)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", fmt.Sprintf("loadtest-%d-%d", time.Now().UnixNano(), a.seq.Add(1)))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return err
}
var envelope apiEnvelope
if err = json.Unmarshal(raw, &envelope); err != nil {
return fmt.Errorf("decode HTTP %d response: %w", resp.StatusCode, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != 0 {
return &apiError{Status: resp.StatusCode, Code: envelope.Code, Reason: envelope.Reason, Message: envelope.Message}
}
if output != nil && len(envelope.Data) > 0 {
if err = json.Unmarshal(envelope.Data, output); err != nil {
return fmt.Errorf("decode response data: %w", err)
}
}
return nil
}
type privacyResponse struct {
FriendRequestVerification bool `json:"friend_request_verification"`
GroupInviteVerification bool `json:"group_invite_verification"`
Version int `json:"version"`
}
type registrationCredential struct {
ChallengeID string
VerificationToken string
}
func (a *apiClient) createRegistrationCredential(ctx context.Context, account AccountRecord, code string) (*registrationCredential, error) {
if account.Phone == "" {
return nil, nil
}
var challenge struct {
ChallengeID string `json:"challenge_id"`
}
key := fmt.Sprintf("loadtest-verification-%s-%d", account.Username, time.Now().UnixNano())
body := map[string]any{"scene": "register", "registration_type": 0, "tenant_code": a.tenant, "phone": account.Phone}
if err := a.do(ctx, http.MethodPost, "/v1/im/auth/verification-codes", "", key, body, &challenge); err != nil {
return nil, fmt.Errorf("create verification for %s: %w", account.Username, err)
}
if challenge.ChallengeID == "" {
return nil, fmt.Errorf("create verification for %s: missing challenge_id", account.Username)
}
var verified struct {
VerificationToken string `json:"verification_token"`
}
key = fmt.Sprintf("loadtest-verify-%s-%d", account.Username, time.Now().UnixNano())
path := "/v1/im/auth/verification-codes/" + url.PathEscape(challenge.ChallengeID) + "/verify"
if err := a.do(ctx, http.MethodPost, path, "", key, map[string]any{"code": code}, &verified); err != nil {
return nil, fmt.Errorf("verify code for %s: %w", account.Username, err)
}
if verified.VerificationToken == "" {
return nil, fmt.Errorf("verify code for %s: missing verification_token", account.Username)
}
return &registrationCredential{ChallengeID: challenge.ChallengeID, VerificationToken: verified.VerificationToken}, nil
}
func (a *apiClient) disableGroupInviteVerification(ctx context.Context, account AccountRecord) error {
var privacy privacyResponse
if err := a.do(ctx, http.MethodGet, "/v1/im/me/privacy", account.Token, "", nil, &privacy); err != nil {
return err
}
if !privacy.GroupInviteVerification {
return nil
}
body := map[string]any{"group_invite_verification": false, "version": privacy.Version}
return a.do(ctx, http.MethodPatch, "/v1/im/me/privacy", account.Token,
"loadtest-privacy-"+account.Username, body, &privacy)
}
type friendRequestResponse struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
Topic string `json:"topic"`
}
type friendRequestListResponse struct {
Items []struct {
RequestID string `json:"request_id"`
ApplicantID string `json:"applicant_id"`
Status string `json:"status"`
} `json:"items"`
NextCursor string `json:"next_cursor"`
}
func (a *apiClient) findPendingFriendRequest(ctx context.Context, target AccountRecord, applicantID string) (string, error) {
cursor := ""
for {
path := "/v1/im/friend-requests?direction=inbound&status=pending&limit=100"
if cursor != "" {
path += "&cursor=" + url.QueryEscape(cursor)
}
var page friendRequestListResponse
if err := a.do(ctx, http.MethodGet, path, target.Token, "", nil, &page); err != nil {
return "", err
}
for _, item := range page.Items {
if item.ApplicantID == applicantID && item.Status == "pending" {
return item.RequestID, nil
}
}
if page.NextCursor == "" || page.NextCursor == cursor {
return "", nil
}
cursor = page.NextCursor
}
}
func (a *apiClient) createFriendRequest(ctx context.Context, source, target AccountRecord) (*friendRequestResponse, error) {
var result friendRequestResponse
body := map[string]any{"target_user_id": target.UserID, "message": "loadtest", "source": "search", "source_topic": ""}
idempotencyKey := fmt.Sprintf("loadtest-friend-%d-%s-%04d", time.Now().UnixNano(), source.Machine, source.Index)
err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests", source.Token,
idempotencyKey, body, &result)
return &result, err
}
func (a *apiClient) acceptFriendRequest(ctx context.Context, target AccountRecord, requestID string) (*friendRequestResponse, error) {
var result friendRequestResponse
idempotencyKey := fmt.Sprintf("loadtest-friend-accept-%d", time.Now().UnixNano())
err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests/"+url.PathEscape(requestID)+"/accept", target.Token,
idempotencyKey, map[string]any{}, &result)
return &result, err
}
type p2pResponse struct {
Topic string `json:"topic"`
Relationship string `json:"relationship"`
}
func (a *apiClient) ensureP2P(ctx context.Context, source, target AccountRecord) (*p2pResponse, error) {
var result p2pResponse
// EnsureP2P must execute on every preflight so it can repair subscriptions
// and refresh a loaded topic. Use a fresh key instead of replaying an old
// successful response from a previous load-test run.
idempotencyKey := fmt.Sprintf("loadtest-p2p-%d-%s-%04d", time.Now().UnixNano(), source.Machine, source.Index)
err := a.do(ctx, http.MethodPost, "/v1/im/conversations/p2p", source.Token,
idempotencyKey,
map[string]any{"peer_user_id": target.UserID}, &result)
return &result, err
}
type groupCreateResponse struct {
Topic string `json:"topic"`
MemberCount int `json:"member_count"`
}
func (a *apiClient) createGroup(ctx context.Context, owner AccountRecord) (*groupCreateResponse, error) {
var result groupCreateResponse
body := map[string]any{"name": "IM 006 Load Test", "description": "im-pressure", "member_user_ids": []string{}}
err := a.do(ctx, http.MethodPost, "/v1/im/groups", owner.Token, "loadtest-group-create", body, &result)
return &result, err
}
type groupInvitationItem struct {
UserID string `json:"user_id"`
Status string `json:"status"`
Reason string `json:"reason"`
}
type groupInvitationResponse struct {
Topic string `json:"topic"`
Items []groupInvitationItem `json:"items"`
}
func (a *apiClient) inviteGroup(ctx context.Context, owner AccountRecord, topic string, batch int, userIDs []string) (*groupInvitationResponse, error) {
var result groupInvitationResponse
membersHash := topicHash(strings.Join(userIDs, ","))
err := a.do(ctx, http.MethodPost, "/v1/im/groups/"+url.PathEscape(topic)+"/invitations", owner.Token,
fmt.Sprintf("loadtest-group-%s-invite-%03d-%s", topicHash(topic), batch, membersHash),
map[string]any{"user_ids": userIDs, "message": "loadtest"}, &result)
return &result, err
}
type groupMembersResponse struct {
Items []struct {
UserID string `json:"user_id"`
} `json:"items"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
}
func (a *apiClient) countGroupMembers(ctx context.Context, account AccountRecord, topic string) (int, error) {
members, err := a.groupMemberIDs(ctx, account, topic)
return len(members), err
}
func (a *apiClient) groupMemberIDs(ctx context.Context, account AccountRecord, topic string) (map[string]bool, error) {
cursor := ""
seen := make(map[string]bool)
for {
path := "/v1/im/groups/" + url.PathEscape(topic) + "/members?limit=100"
if cursor != "" {
path += "&cursor=" + url.QueryEscape(cursor)
}
var page groupMembersResponse
if err := a.do(ctx, http.MethodGet, path, account.Token, "", nil, &page); err != nil {
return nil, err
}
for _, item := range page.Items {
seen[item.UserID] = true
}
if page.NextCursor == "" || (!page.HasMore && len(page.Items) < 100) {
break
}
if page.NextCursor == cursor {
return nil, fmt.Errorf("group member cursor did not advance: %s", cursor)
}
cursor = page.NextCursor
}
return seen, nil
}
func queryInt(value string, fallback int) int {
n, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return n
}