diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go new file mode 100644 index 00000000..03479337 --- /dev/null +++ b/internal/apiclient/client.go @@ -0,0 +1,226 @@ +// 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 provides a lightweight HTTP client for calling DingTalk +// OpenAPI (https://api.dingtalk.com) directly, bypassing the MCP JSON-RPC +// transport. It is used exclusively by the `dws api` command. +package apiclient + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + // DefaultBaseURL is the DingTalk new-style OpenAPI base URL. + DefaultBaseURL = "https://api.dingtalk.com" + + // LegacyBaseURL is the DingTalk legacy (oapi) API base URL. + LegacyBaseURL = "https://oapi.dingtalk.com" + + // AuthHeader is the new-style OpenAPI authentication header. + AuthHeader = "x-acs-dingtalk-access-token" + + // LegacyAuthParam is the query parameter used for legacy API authentication. + LegacyAuthParam = "access_token" +) + +// AllowedMethods is the set of HTTP methods permitted for raw API calls. +var AllowedMethods = map[string]bool{ + "GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true, +} + +// RawAPIRequest describes a raw API request to DingTalk OpenAPI. +type RawAPIRequest struct { + Method string // GET, POST, PUT, PATCH, DELETE + Path string // /v1.0/calendar/events or full URL + Params map[string]any // query parameters + Data any // request body (JSON), nil for GET +} + +// RawAPIResponse encapsulates the raw HTTP response. +type RawAPIResponse struct { + StatusCode int + Header http.Header + Body []byte +} + +// APIClient wraps an HTTP client for DingTalk OpenAPI calls. +type APIClient struct { + BaseURL string + HTTPClient *http.Client + Token string +} + +// NewClient creates an APIClient with sensible defaults. +func NewClient(token, baseURL string) *APIClient { + if strings.TrimSpace(baseURL) == "" { + baseURL = DefaultBaseURL + } + return &APIClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + HTTPClient: &http.Client{ + Transport: defaultTransport(), + Timeout: 30 * time.Second, + }, + } +} + +// Do sends a raw API request and returns the response. +func (c *APIClient) Do(ctx context.Context, req RawAPIRequest) (*RawAPIResponse, error) { + method := strings.ToUpper(strings.TrimSpace(req.Method)) + if !AllowedMethods[method] { + return nil, fmt.Errorf("unsupported HTTP method: %s (allowed: GET, POST, PUT, PATCH, DELETE)", req.Method) + } + + fullURL, err := c.buildURL(req.Path, req.Params) + if err != nil { + return nil, fmt.Errorf("building request URL: %w", err) + } + + // Security: verify target host before sending token. + if err := ValidateTargetHost(fullURL); err != nil { + return nil, err + } + + var bodyReader io.Reader + if req.Data != nil && method != "GET" { + data, marshalErr := json.Marshal(req.Data) + if marshalErr != nil { + return nil, fmt.Errorf("marshaling request body: %w", marshalErr) + } + bodyReader = bytes.NewReader(data) + } + + httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("creating HTTP request: %w", err) + } + + // Set headers and auth based on API style. + if IsLegacyAPI(fullURL) { + // Legacy API: token goes in query parameter. + parsed, _ := url.Parse(fullURL) + q := parsed.Query() + q.Set(LegacyAuthParam, c.Token) + parsed.RawQuery = q.Encode() + httpReq.URL = parsed + } else { + // New API: token goes in header. + httpReq.Header.Set(AuthHeader, c.Token) + } + if bodyReader != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + httpReq.Header.Set("User-Agent", "dws-cli/raw-api") + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("executing HTTP request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + return &RawAPIResponse{ + StatusCode: resp.StatusCode, + Header: resp.Header, + Body: body, + }, nil +} + +// buildURL constructs the full request URL from path and query params. +func (c *APIClient) buildURL(path string, params map[string]any) (string, error) { + normalised := NormalisePath(path, c.BaseURL) + parsed, err := url.Parse(normalised) + if err != nil { + return "", fmt.Errorf("parsing URL %q: %w", normalised, err) + } + + if len(params) > 0 { + q := parsed.Query() + for k, v := range params { + q.Set(k, fmt.Sprintf("%v", v)) + } + parsed.RawQuery = q.Encode() + } + + return parsed.String(), nil +} + +// IsLegacyAPI returns true if the URL targets the legacy oapi.dingtalk.com endpoint. +// Legacy APIs use query-parameter authentication instead of header-based auth. +func IsLegacyAPI(urlStr string) bool { + lower := strings.ToLower(urlStr) + return strings.Contains(lower, "oapi.dingtalk.com") || + strings.HasPrefix(lower, LegacyBaseURL) +} + +// NormalisePath normalises an API path: +// - Full URLs are accepted as-is (after stripping query/fragment) +// - Relative paths are prefixed with the base URL +// - Query strings and fragments are stripped (must use --params) +func NormalisePath(path, baseURL string) string { + path = strings.TrimSpace(path) + + // Strip query and fragment to force --params usage. + if idx := strings.IndexAny(path, "?#"); idx >= 0 { + path = path[:idx] + } + + // Full URL: extract the path portion relative to the base. + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + + // Ensure leading slash. + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + if strings.TrimSpace(baseURL) == "" { + baseURL = DefaultBaseURL + } + return strings.TrimRight(baseURL, "/") + path +} + +// defaultTransport returns a tuned http.Transport matching the project conventions. +func defaultTransport() *http.Transport { + return &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 3 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 20 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + ForceAttemptHTTP2: true, + } +} diff --git a/internal/apiclient/client_test.go b/internal/apiclient/client_test.go new file mode 100644 index 00000000..dd58cc99 --- /dev/null +++ b/internal/apiclient/client_test.go @@ -0,0 +1,242 @@ +// 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" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestNewClient_DefaultBaseURL(t *testing.T) { + c := NewClient("tok", "") + if c.BaseURL != DefaultBaseURL { + t.Errorf("expected %q, got %q", DefaultBaseURL, c.BaseURL) + } +} + +func TestNewClient_CustomBaseURL(t *testing.T) { + c := NewClient("tok", "https://custom.api.com/") + if c.BaseURL != "https://custom.api.com" { + t.Errorf("expected trailing slash stripped, got %q", c.BaseURL) + } +} + +func TestNormalisePath(t *testing.T) { + tests := []struct { + path, base, want string + }{ + {"/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"}, + {"v1.0/users", "", "https://api.dingtalk.com/v1.0/users"}, + {"https://api.dingtalk.com/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"}, + {"/v1.0/users?foo=bar#frag", "", "https://api.dingtalk.com/v1.0/users"}, + {"/v1.0/users", "https://custom.example.com", "https://custom.example.com/v1.0/users"}, + } + for _, tt := range tests { + got := NormalisePath(tt.path, tt.base) + if got != tt.want { + t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want) + } + } +} + +func TestDo_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(AuthHeader) != "test-token" { + t.Errorf("expected auth header %q, got %q", "test-token", r.Header.Get(AuthHeader)) + } + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]string{"name": "test"}) + })) + defer srv.Close() + + c := NewClient("test-token", srv.URL) + resp, err := c.Do(context.Background(), RawAPIRequest{ + Method: "GET", + Path: "/v1.0/test", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} + +func TestDo_PostWithBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected JSON content type") + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["key"] != "value" { + t.Errorf("expected body key=value, got %v", body) + } + w.WriteHeader(200) + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + c := NewClient("tok", srv.URL) + resp, err := c.Do(context.Background(), RawAPIRequest{ + Method: "POST", + Path: "/v1.0/test", + Data: map[string]string{"key": "value"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} + +func TestDo_InvalidMethod(t *testing.T) { + c := NewClient("tok", "") + _, err := c.Do(context.Background(), RawAPIRequest{ + Method: "INVALID", + Path: "/test", + }) + if err == nil { + t.Error("expected error for invalid method") + } +} + +func TestDo_QueryParams(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("pageSize") != "10" { + t.Errorf("expected pageSize=10, got %v", r.URL.Query()) + } + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := NewClient("tok", srv.URL) + _, err := c.Do(context.Background(), RawAPIRequest{ + Method: "GET", + Path: "/v1.0/test", + Params: map[string]any{"pageSize": 10}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestIsLegacyAPI(t *testing.T) { + tests := []struct { + url string + want bool + }{ + {"https://api.dingtalk.com/v1.0/users", false}, + {"https://oapi.dingtalk.com/topapi/v2/user/get", true}, + {"https://OAPI.DINGTALK.COM/topapi/v2/user/get", true}, + {"https://custom.example.com/api", false}, + {"", false}, + } + for _, tt := range tests { + got := IsLegacyAPI(tt.url) + if got != tt.want { + t.Errorf("IsLegacyAPI(%q) = %v, want %v", tt.url, got, tt.want) + } + } +} + +func TestDo_LegacyAPI_TokenInQueryParam(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Legacy API: token should be in query param. + if r.URL.Query().Get(LegacyAuthParam) != "legacy-token" { + t.Errorf("expected access_token=legacy-token in query, got %v", r.URL.Query()) + } + // Should NOT have the new-style auth header. + if r.Header.Get(AuthHeader) != "" { + t.Errorf("expected no auth header for legacy API, got %q", r.Header.Get(AuthHeader)) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{"errcode":0,"errmsg":"ok","result":{"userid":"user1"}}`)) + })) + defer srv.Close() + + // Use full URL with oapi.dingtalk.com in the path, but redirect to test server. + // Since we can't DNS-resolve oapi.dingtalk.com, we use the test server URL + // and pass the full oapi URL as Path so that NormalisePath preserves it. + // Then we override the resolved URL in the client to point to our test server. + // + // Best approach: directly verify that buildURL + IsLegacyAPI routing works + // by testing buildURL output and calling Do with a custom transport that + // redirects oapi.dingtalk.com to our test server. + c := NewClient("legacy-token", "") + // Replace the transport to redirect oapi.dingtalk.com to test server. + c.HTTPClient.Transport = &legacyTestTransport{targetURL: srv.URL} + + resp, err := c.Do(context.Background(), RawAPIRequest{ + Method: "POST", + Path: "https://oapi.dingtalk.com/topapi/v2/user/get", + Data: map[string]string{"userid": "user1"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} + +// legacyTestTransport redirects requests from oapi.dingtalk.com to a local test server. +type legacyTestTransport struct { + targetURL string +} + +func (t *legacyTestTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Rewrite the host to point to our test server, preserving path and query. + newURL := t.targetURL + req.URL.Path + if req.URL.RawQuery != "" { + newURL += "?" + req.URL.RawQuery + } + parsed, _ := url.Parse(newURL) + req.URL = parsed + req.Host = parsed.Host + return http.DefaultTransport.RoundTrip(req) +} + +func TestNormalisePath_Legacy(t *testing.T) { + tests := []struct { + path, base, want string + }{ + // Legacy full URL preserved. + {"https://oapi.dingtalk.com/topapi/v2/user/get", "", "https://oapi.dingtalk.com/topapi/v2/user/get"}, + // Relative path with legacy base URL. + {"/topapi/v2/user/get", LegacyBaseURL, "https://oapi.dingtalk.com/topapi/v2/user/get"}, + // Strip query from legacy URL. + {"https://oapi.dingtalk.com/topapi/v2/user/get?access_token=xxx", "", "https://oapi.dingtalk.com/topapi/v2/user/get"}, + } + for _, tt := range tests { + got := NormalisePath(tt.path, tt.base) + if got != tt.want { + t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want) + } + } +} diff --git a/internal/apiclient/dryrun.go b/internal/apiclient/dryrun.go new file mode 100644 index 00000000..6942259d --- /dev/null +++ b/internal/apiclient/dryrun.go @@ -0,0 +1,63 @@ +// 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 ( + "encoding/json" + "fmt" + "io" + "strings" +) + +// MaskToken returns a masked version of a token for display in dry-run +// and log output. Shows the first 4 characters followed by "****". +func MaskToken(token string) string { + if len(token) <= 4 { + return "****" + } + return token[:4] + "****" +} + +// PrintDryRun outputs a dry-run preview of the API request that would be sent. +func PrintDryRun(w io.Writer, req RawAPIRequest, baseURL, token string) error { + fullURL := NormalisePath(req.Path, baseURL) + + fmt.Fprintln(w, "=== Dry Run ===") + fmt.Fprintf(w, "%-12s%s\n", "Method:", strings.ToUpper(req.Method)) + fmt.Fprintf(w, "%-12s%s\n", "URL:", fullURL) + + if len(req.Params) > 0 { + paramsJSON, err := json.MarshalIndent(req.Params, " ", " ") + if err == nil { + fmt.Fprintf(w, "%-12s%s\n", "Params:", string(paramsJSON)) + } + } + + if req.Data != nil { + dataJSON, err := json.MarshalIndent(req.Data, " ", " ") + if err == nil { + fmt.Fprintf(w, "%-12s%s\n", "Body:", string(dataJSON)) + } + } + + if IsLegacyAPI(fullURL) { + fmt.Fprintf(w, "%-12s%s=%s\n", "Auth:", LegacyAuthParam, MaskToken(token)) + fmt.Fprintf(w, "%-12s%s\n", "Style:", "旧版 (oapi.dingtalk.com)") + } else { + fmt.Fprintf(w, "%-12s%s: %s\n", "Auth:", AuthHeader, MaskToken(token)) + fmt.Fprintf(w, "%-12s%s\n", "Style:", "新版 (api.dingtalk.com)") + } + fmt.Fprintln(w, "===============") + return nil +} diff --git a/internal/apiclient/pagination.go b/internal/apiclient/pagination.go new file mode 100644 index 00000000..abb6a083 --- /dev/null +++ b/internal/apiclient/pagination.go @@ -0,0 +1,185 @@ +// 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" + "time" +) + +const ( + // DefaultPageLimit is the maximum number of pages fetched with --page-all. + DefaultPageLimit = 10 + + // DefaultPageDelay is the delay between paginated requests in milliseconds. + DefaultPageDelay = 200 +) + +// PaginationOptions controls automatic pagination behavior. +type PaginationOptions struct { + PageLimit int // Maximum pages (0 = unlimited) + PageDelay int // Delay between pages in milliseconds +} + +// 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) { + if opts.PageLimit == 0 { + opts.PageLimit = DefaultPageLimit + } + if opts.PageDelay <= 0 { + opts.PageDelay = DefaultPageDelay + } + + var allResults []any + pageCount := 0 + + for { + pageCount++ + if opts.PageLimit > 0 && pageCount > opts.PageLimit { + break + } + + 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 + } + return allResults, nil + } + + allResults = append(allResults, result) + + if !hasMore || nextToken == "" { + 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 +} + +// 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) +} diff --git a/internal/apiclient/parse.go b/internal/apiclient/parse.go new file mode 100644 index 00000000..bb118ba0 --- /dev/null +++ b/internal/apiclient/parse.go @@ -0,0 +1,102 @@ +// 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 ( + "encoding/json" + "fmt" + "io" + "strings" +) + +// ParseJSONMap parses a --params flag value into a map[string]any. +// Supports: +// - JSON string: '{"key":"value"}' +// - "-" to read from stdin +// - Empty string returns nil (no params) +func ParseJSONMap(raw, flagName string, stdin io.Reader) (map[string]any, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + if raw == "-" { + data, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("从 stdin 读取 %s 失败: %w", flagName, err) + } + raw = strings.TrimSpace(string(data)) + if raw == "" { + return nil, nil + } + } + + // Strip wrapping single quotes (common shell escaping). + raw = stripSingleQuotes(raw) + + var result map[string]any + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return nil, fmt.Errorf("解析 %s JSON 失败: %w\n输入: %s", flagName, err, truncate(raw, 200)) + } + return result, nil +} + +// ParseOptionalBody parses a --data flag value into a request body. +// Returns nil for empty input. GET requests are not allowed to have a body. +func ParseOptionalBody(method, raw string, stdin io.Reader) (any, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + if strings.ToUpper(method) == "GET" && raw != "" { + return nil, fmt.Errorf("GET 请求不允许使用 --data 参数") + } + + if raw == "-" { + data, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("从 stdin 读取 --data 失败: %w", err) + } + raw = strings.TrimSpace(string(data)) + if raw == "" { + return nil, nil + } + } + + // Strip wrapping single quotes. + raw = stripSingleQuotes(raw) + + var result any + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return nil, fmt.Errorf("解析 --data JSON 失败: %w\n输入: %s", err, truncate(raw, 200)) + } + return result, nil +} + +// stripSingleQuotes removes a leading and trailing single quote pair. +func stripSingleQuotes(s string) string { + if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' { + return s[1 : len(s)-1] + } + return s +} + +// truncate returns at most n characters of s, appending "..." if truncated. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/apiclient/parse_test.go b/internal/apiclient/parse_test.go new file mode 100644 index 00000000..0329b4f7 --- /dev/null +++ b/internal/apiclient/parse_test.go @@ -0,0 +1,123 @@ +// 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 ( + "strings" + "testing" +) + +func TestParseJSONMap_Empty(t *testing.T) { + result, err := ParseJSONMap("", "--params", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestParseJSONMap_ValidJSON(t *testing.T) { + result, err := ParseJSONMap(`{"key":"value","num":42}`, "--params", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["key"] != "value" { + t.Errorf("expected key=value, got %v", result["key"]) + } +} + +func TestParseJSONMap_SingleQuotes(t *testing.T) { + result, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["key"] != "value" { + t.Errorf("expected key=value, got %v", result["key"]) + } +} + +func TestParseJSONMap_Stdin(t *testing.T) { + stdin := strings.NewReader(`{"from":"stdin"}`) + result, err := ParseJSONMap("-", "--params", stdin) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["from"] != "stdin" { + t.Errorf("expected from=stdin, got %v", result["from"]) + } +} + +func TestParseJSONMap_InvalidJSON(t *testing.T) { + _, err := ParseJSONMap("not json", "--params", nil) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestParseOptionalBody_Empty(t *testing.T) { + result, err := ParseOptionalBody("POST", "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestParseOptionalBody_GETNotAllowed(t *testing.T) { + _, err := ParseOptionalBody("GET", `{"data":true}`, nil) + if err == nil { + t.Error("expected error for GET with body") + } +} + +func TestParseOptionalBody_ValidPOST(t *testing.T) { + result, err := ParseOptionalBody("POST", `{"key":"value"}`, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", result) + } + if m["key"] != "value" { + t.Errorf("expected key=value, got %v", m["key"]) + } +} + +func TestStripSingleQuotes(t *testing.T) { + tests := []struct{ in, want string }{ + {`'hello'`, `hello`}, + {`"hello"`, `"hello"`}, + {`hello`, `hello`}, + {`''`, ``}, + {`'`, `'`}, + } + for _, tt := range tests { + got := stripSingleQuotes(tt.in) + if got != tt.want { + t.Errorf("stripSingleQuotes(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("hello", 10); got != "hello" { + t.Errorf("expected hello, got %q", got) + } + if got := truncate("hello world", 5); got != "hello..." { + t.Errorf("expected hello..., got %q", got) + } +} diff --git a/internal/apiclient/response.go b/internal/apiclient/response.go new file mode 100644 index 00000000..6f4896eb --- /dev/null +++ b/internal/apiclient/response.go @@ -0,0 +1,179 @@ +// 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 ( + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" +) + +// ResponseOptions controls how an API response is processed. +type ResponseOptions struct { + OutputPath string // --output file path for binary responses + Format output.Format // output format (json|table|raw) + JqExpr string // --jq expression + Fields string // --fields comma-separated field names + Out io.Writer // stdout + ErrOut io.Writer // stderr +} + +// HandleResponse routes response processing based on Content-Type and status code. +func HandleResponse(resp *RawAPIResponse, opts ResponseOptions) error { + contentType := resp.Header.Get("Content-Type") + isJSON := isJSONContentType(contentType) + + // HTTP error with non-JSON body: print as plain text error. + if resp.StatusCode >= 400 && !isJSON { + return fmt.Errorf("API 请求失败 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(resp.Body))) + } + + // JSON response + if isJSON { + return handleJSONResponse(resp, opts) + } + + // Binary response + return handleBinaryResponse(resp, opts) +} + +// handleJSONResponse parses the JSON body, checks for DingTalk business errors, +// and writes the output using the configured format and filters. +func handleJSONResponse(resp *RawAPIResponse, opts ResponseOptions) error { + if len(resp.Body) == 0 { + return fmt.Errorf("API 返回空响应体 (HTTP %d),如需下载文件请使用 --output 参数", resp.StatusCode) + } + + var payload any + if err := json.Unmarshal(resp.Body, &payload); err != nil { + return fmt.Errorf("解析 JSON 响应失败: %w", err) + } + + // Check for DingTalk business error: {"errcode": xxx, "errmsg": "xxx"} + if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil { + return apiErr + } + + return output.WriteFiltered(opts.Out, opts.Format, payload, opts.Fields, opts.JqExpr) +} + +// checkDingTalkError inspects a parsed JSON response for DingTalk error codes. +// Returns nil if no error is detected. +func checkDingTalkError(payload any, statusCode int) error { + obj, ok := payload.(map[string]any) + if !ok { + return nil + } + + // Check for errcode != 0 + if errcode, hasCode := obj["errcode"]; hasCode { + code := toFloat64(errcode) + if code != 0 { + errmsg, _ := obj["errmsg"].(string) + if errmsg == "" { + errmsg = "unknown error" + } + return fmt.Errorf("API 业务错误 (errcode: %.0f, HTTP %d): %s", code, statusCode, errmsg) + } + } + + // Also check HTTP error status even if no errcode field + if statusCode >= 400 { + errmsg, _ := obj["errmsg"].(string) + if errmsg == "" { + errmsg, _ = obj["message"].(string) + } + if errmsg == "" { + errmsg, _ = obj["error"].(string) + } + if errmsg != "" { + return fmt.Errorf("API 请求失败 (HTTP %d): %s", statusCode, errmsg) + } + return fmt.Errorf("API 请求失败 (HTTP %d)", statusCode) + } + + return nil +} + +// handleBinaryResponse saves the response body to a file. +func handleBinaryResponse(resp *RawAPIResponse, opts ResponseOptions) error { + outputPath := strings.TrimSpace(opts.OutputPath) + + if outputPath == "" { + // Try to infer filename from Content-Disposition header. + outputPath = inferFilename(resp.Header) + if outputPath == "" { + return fmt.Errorf("响应为非 JSON 格式 (Content-Type: %s),请使用 --output 指定保存路径", + resp.Header.Get("Content-Type")) + } + } + + dir := filepath.Dir(outputPath) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("创建输出目录失败: %w", err) + } + } + + if err := os.WriteFile(outputPath, resp.Body, 0o644); err != nil { + return fmt.Errorf("写入文件失败: %w", err) + } + + fmt.Fprintf(opts.ErrOut, "已保存到: %s (%d 字节)\n", outputPath, len(resp.Body)) + return nil +} + +// inferFilename tries to extract a filename from the Content-Disposition header. +func inferFilename(header http.Header) string { + cd := header.Get("Content-Disposition") + if cd == "" { + return "" + } + _, params, err := mime.ParseMediaType(cd) + if err != nil { + return "" + } + return strings.TrimSpace(params["filename"]) +} + +// isJSONContentType returns true if the Content-Type indicates JSON. +func isJSONContentType(ct string) bool { + ct = strings.TrimSpace(strings.ToLower(ct)) + return strings.HasPrefix(ct, "application/json") || + strings.HasPrefix(ct, "text/json") || + strings.Contains(ct, "+json") +} + +// toFloat64 attempts to convert a JSON number to float64. +func toFloat64(v any) float64 { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + case int64: + return float64(n) + case json.Number: + f, _ := n.Float64() + return f + } + return 0 +} diff --git a/internal/apiclient/validate.go b/internal/apiclient/validate.go new file mode 100644 index 00000000..e9667bf9 --- /dev/null +++ b/internal/apiclient/validate.go @@ -0,0 +1,152 @@ +// 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 ( + "fmt" + "net/url" + "strings" + "unicode" +) + +// AllowedHosts is the set of trusted DingTalk API hosts. +// Only these hosts may receive access tokens to prevent token leakage. +var AllowedHosts = map[string]bool{ + "api.dingtalk.com": true, + "oapi.dingtalk.com": true, +} + +// ValidateTargetHost checks that the resolved request URL targets a trusted +// DingTalk host. This prevents access-token leakage to arbitrary domains. +func ValidateTargetHost(fullURL string) error { + parsed, err := url.Parse(fullURL) + if err != nil { + return fmt.Errorf("无法解析请求 URL: %w", err) + } + host := strings.ToLower(parsed.Hostname()) + if !AllowedHosts[host] { + return fmt.Errorf( + "安全限制: 目标域名 %q 不在允许列表中。\n"+ + "dws api 仅允许向以下域名发起请求:\n"+ + " - api.dingtalk.com (新版 API)\n"+ + " - oapi.dingtalk.com (旧版 API)\n"+ + "请检查 URL 或 --base-url 参数是否正确。", + host, + ) + } + return nil +} + +// ValidateMethod checks that the HTTP method is one of the five allowed methods. +func ValidateMethod(method string) (string, error) { + upper := strings.ToUpper(strings.TrimSpace(method)) + if !AllowedMethods[upper] { + return "", fmt.Errorf("不支持的 HTTP 方法: %s (允许: GET, POST, PUT, PATCH, DELETE)", method) + } + return upper, nil +} + +// ValidatePath checks the API path for injection attacks and dangerous characters. +func ValidatePath(path string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("API 路径不能为空") + } + if err := rejectDangerousChars(path, "path"); err != nil { + return err + } + // Reject path traversal + if strings.Contains(path, "..") { + return fmt.Errorf("API 路径不能包含 '..' (路径遍历)") + } + return nil +} + +// ValidateUserInput checks a user-provided string for control characters and +// dangerous Unicode codepoints that could enable injection attacks. +func ValidateUserInput(value, fieldName string) error { + return rejectDangerousChars(value, fieldName) +} + +// rejectDangerousChars rejects C0 control characters (except \t and \n), +// DEL (0x7F), and dangerous Unicode codepoints in a string. +func rejectDangerousChars(s, fieldName string) error { + for i, r := range s { + // Allow tab and newline + if r == '\t' || r == '\n' { + continue + } + // Reject C0 control chars (0x00-0x1F) and DEL (0x7F) + if r < 0x20 || r == 0x7F { + return fmt.Errorf("%s 包含非法控制字符 (位置 %d, U+%04X)", fieldName, i, r) + } + // Reject dangerous Unicode + if isDangerousUnicode(r) { + return fmt.Errorf("%s 包含危险 Unicode 字符 (位置 %d, U+%04X)", fieldName, i, r) + } + } + return nil +} + +// isDangerousUnicode returns true for Unicode codepoints that can be used +// for visual spoofing or terminal injection attacks. +func isDangerousUnicode(r rune) bool { + switch { + // Zero-width characters + case r >= 0x200B && r <= 0x200D: + return true + // BOM + case r == 0xFEFF: + return true + // Bidi override characters + case r >= 0x202A && r <= 0x202E: + return true + // Line/paragraph separator + case r == 0x2028 || r == 0x2029: + return true + // Bidi isolate characters + case r >= 0x2066 && r <= 0x2069: + return true + // Additional Bidi controls + case r == 0x061C: + return true + // Object replacement / annotation chars + case r == 0xFFFC || r == 0xFFFD: + return false // replacement char is OK + // Non-characters + case r >= 0xFDD0 && r <= 0xFDEF: + return true + // Check for non-printable, non-space category (but allow CJK etc.) + case !unicode.IsPrint(r) && !unicode.IsSpace(r) && r > 0x7F: + // Only block explicit dangerous ranges above; allow CJK and other + // legitimate non-ASCII characters through. + return false + } + return false +} + +// ValidateStdinExclusion checks that --params and --data don't both read from stdin. +func ValidateStdinExclusion(params, data string) error { + if strings.TrimSpace(params) == "-" && strings.TrimSpace(data) == "-" { + return fmt.Errorf("--params 和 --data 不能同时从 stdin 读取 (-)") + } + return nil +} + +// ValidateFlagExclusion checks mutual exclusion between flags. +func ValidateFlagExclusion(outputPath string, pageAll bool) error { + if strings.TrimSpace(outputPath) != "" && pageAll { + return fmt.Errorf("--output 和 --page-all 不能同时使用") + } + return nil +} diff --git a/internal/apiclient/validate_test.go b/internal/apiclient/validate_test.go new file mode 100644 index 00000000..34d0b479 --- /dev/null +++ b/internal/apiclient/validate_test.go @@ -0,0 +1,155 @@ +// 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 ( + "strings" + "testing" +) + +func TestValidateMethod(t *testing.T) { + valid := []string{"GET", "get", "Post", "put", "PATCH", "delete"} + for _, m := range valid { + got, err := ValidateMethod(m) + if err != nil { + t.Errorf("ValidateMethod(%q) unexpected error: %v", m, err) + } + if got != strings.ToUpper(m) { + t.Errorf("ValidateMethod(%q) = %q, want %q", m, got, strings.ToUpper(m)) + } + } + + invalid := []string{"HEAD", "OPTIONS", "TRACE", "CONNECT", "INVALID", ""} + for _, m := range invalid { + _, err := ValidateMethod(m) + if err == nil { + t.Errorf("ValidateMethod(%q) expected error, got nil", m) + } + } +} + +func TestValidatePath(t *testing.T) { + // Valid paths + for _, p := range []string{"/v1.0/users", "/v2.0/calendar/events", "v1.0/contact/users/me"} { + if err := ValidatePath(p); err != nil { + t.Errorf("ValidatePath(%q) unexpected error: %v", p, err) + } + } + + // Empty path + if err := ValidatePath(""); err == nil { + t.Error("ValidatePath(\"\") expected error") + } + + // Path traversal + if err := ValidatePath("/v1.0/../secret"); err == nil { + t.Error("ValidatePath with .. expected error") + } + + // Control character + if err := ValidatePath("/v1.0/\x00test"); err == nil { + t.Error("ValidatePath with null byte expected error") + } +} + +func TestRejectDangerousUnicode(t *testing.T) { + // Zero-width space + if err := ValidateUserInput("hello\u200Bworld", "test"); err == nil { + t.Error("expected error for zero-width space") + } + // BOM + if err := ValidateUserInput("\uFEFFhello", "test"); err == nil { + t.Error("expected error for BOM") + } + // Bidi override + if err := ValidateUserInput("hello\u202Aworld", "test"); err == nil { + t.Error("expected error for bidi override") + } + // Normal string should pass + if err := ValidateUserInput("hello world 你好", "test"); err != nil { + t.Errorf("unexpected error for normal string: %v", err) + } +} + +func TestValidateStdinExclusion(t *testing.T) { + if err := ValidateStdinExclusion("-", "-"); err == nil { + t.Error("expected error when both params and data read from stdin") + } + if err := ValidateStdinExclusion("-", "{}"); err != nil { + t.Errorf("unexpected error: %v", err) + } + if err := ValidateStdinExclusion("{}", "-"); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateFlagExclusion(t *testing.T) { + if err := ValidateFlagExclusion("output.json", true); err == nil { + t.Error("expected error when --output and --page-all both set") + } + if err := ValidateFlagExclusion("output.json", false); err != nil { + t.Errorf("unexpected error: %v", err) + } + if err := ValidateFlagExclusion("", true); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestMaskToken(t *testing.T) { + tests := []struct { + in, want string + }{ + {"", "****"}, + {"abc", "****"}, + {"abcd", "****"}, + {"abcde", "abcd****"}, + {"abcdefghij", "abcd****"}, + } + for _, tt := range tests { + got := MaskToken(tt.in) + if got != tt.want { + t.Errorf("MaskToken(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestValidateTargetHost(t *testing.T) { + // Allowed hosts + allowed := []string{ + "https://api.dingtalk.com/v1.0/contact/users/me", + "https://oapi.dingtalk.com/topapi/v2/user/get", + "https://API.DINGTALK.COM/v1.0/test", + "https://OAPI.DINGTALK.COM/topapi/test", + } + for _, u := range allowed { + if err := ValidateTargetHost(u); err != nil { + t.Errorf("ValidateTargetHost(%q) unexpected error: %v", u, err) + } + } + + // Blocked hosts + blocked := []string{ + "https://oapi.dingtalk.fakedomain.com/topapi/v2/user/get", + "https://fake.com/v1.0/test", + "https://api.dingtalk.com.evil.com/v1.0/test", + "https://evil.com/redirect?url=https://api.dingtalk.com", + "http://localhost:8080/v1.0/test", + "https://dingtalk.com/v1.0/test", + } + for _, u := range blocked { + if err := ValidateTargetHost(u); err == nil { + t.Errorf("ValidateTargetHost(%q) expected error, got nil", u) + } + } +} diff --git a/internal/app/api_command.go b/internal/app/api_command.go new file mode 100644 index 00000000..6298460a --- /dev/null +++ b/internal/app/api_command.go @@ -0,0 +1,277 @@ +// 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 app + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/spf13/cobra" +) + +// apiFlags holds the flags specific to the `dws api` command. +type apiFlags struct { + params string + data string + pageAll bool + pageLimit int + pageDelay int + baseURL string +} + +// newAPICommand creates the `dws api` subcommand for raw DingTalk OpenAPI calls. +func newAPICommand(flags *GlobalFlags) *cobra.Command { + af := &apiFlags{} + + cmd := &cobra.Command{ + Use: "api [flags]", + Short: "调用钉钉 OpenAPI (Raw HTTP)", + Long: `直接调用钉钉 OpenAPI,支持新版和旧版两种 API 形态。 + +新版 API (api.dingtalk.com): + Token 通过 HTTP Header (x-acs-dingtalk-access-token) 传递。 + 路径格式: /v1.0/xxx 或 /v2.0/xxx + +旧版 API (oapi.dingtalk.com): + Token 通过 URL 查询参数 (access_token) 传递。 + 路径格式: /topapi/v2/xxx 或完整 URL https://oapi.dingtalk.com/topapi/... + +仅限使用自有应用凭证(--client-id/--client-secret)登录后使用。 +通过 MCP 默认凭证登录获取的加密 token 不支持 raw API 调用。 + +示例: + # === 新版 API (api.dingtalk.com) === + + # 获取当前用户信息 + dws api GET /v1.0/contact/users/me + + # 搜索用户 (POST + JSON body) + dws api POST /v1.0/contact/users/search \ + --data '{"queryWord":"张三","offset":0,"size":10}' + + # 创建日历事件 + dws api POST /v1.0/calendar/users/me/calendars/primary/events \ + --data '{"summary":"Team Meeting","start":{"dateTime":"2026-01-01T10:00:00+08:00"}}' + + # === 旧版 API (oapi.dingtalk.com) === + + # 获取用户详情 (旧版,使用 --base-url) + dws api POST /topapi/v2/user/get \ + --base-url https://oapi.dingtalk.com \ + --data '{"userid":"manager123"}' + + # 也可以直接使用完整 URL + dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \ + --data '{"userid":"manager123"}' + + # === 通用功能 === + + # 分页获取所有结果 + dws api GET /v1.0/attendance/groups --page-all --page-limit 5 + + # Dry-run 预览请求 + dws api GET /v1.0/contact/users/me --dry-run + + # 使用 jq 过滤输出 + dws api GET /v1.0/contact/users/me --jq '.nick'`, + Args: cobra.ExactArgs(2), + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPI(cmd, args, flags, af) + }, + } + + cmd.Flags().StringVar(&af.params, "params", "", "查询参数 JSON (支持 - 从 stdin 读取)") + cmd.Flags().StringVar(&af.data, "data", "", "请求体 JSON (支持 - 从 stdin 读取)") + cmd.Flags().BoolVar(&af.pageAll, "page-all", false, "自动遍历所有分页") + cmd.Flags().IntVar(&af.pageLimit, "page-limit", apiclient.DefaultPageLimit, "最大分页数 (0 = 无限)") + cmd.Flags().IntVar(&af.pageDelay, "page-delay", apiclient.DefaultPageDelay, "分页间隔毫秒") + cmd.Flags().StringVar(&af.baseURL, "base-url", "", "覆盖 API 基础 URL (默认 https://api.dingtalk.com)") + + return cmd +} + +// runAPI is the main execution logic for `dws api`. +func runAPI(cmd *cobra.Command, args []string, gf *GlobalFlags, af *apiFlags) error { + ctx := cmd.Context() + method := args[0] + path := args[1] + + // 1. Validate HTTP method. + method, err := apiclient.ValidateMethod(method) + if err != nil { + return apperrors.NewValidation(err.Error()) + } + + // 2. Validate API path. + if err := apiclient.ValidatePath(path); err != nil { + return apperrors.NewValidation(err.Error()) + } + + // 3. Validate input safety for params and data. + if err := apiclient.ValidateUserInput(af.params, "--params"); err != nil { + return apperrors.NewValidation(err.Error()) + } + if err := apiclient.ValidateUserInput(af.data, "--data"); err != nil { + return apperrors.NewValidation(err.Error()) + } + + // 4. Validate mutual exclusion. + if err := apiclient.ValidateStdinExclusion(af.params, af.data); err != nil { + return apperrors.NewValidation(err.Error()) + } + if err := apiclient.ValidateFlagExclusion(gf.Output, af.pageAll); err != nil { + return apperrors.NewValidation(err.Error()) + } + + // 5. Parse --params. + params, err := apiclient.ParseJSONMap(af.params, "--params", os.Stdin) + if err != nil { + return apperrors.NewValidation(err.Error()) + } + + // 6. Parse --data. + body, err := apiclient.ParseOptionalBody(method, af.data, os.Stdin) + if err != nil { + return apperrors.NewValidation(err.Error()) + } + + // 7. Determine API style (new vs legacy) for token resolution. + fullURL := apiclient.NormalisePath(path, af.baseURL) + isLegacy := apiclient.IsLegacyAPI(fullURL) + + // 7b. Security: validate target host is a trusted DingTalk domain. + if err := apiclient.ValidateTargetHost(fullURL); err != nil { + return apperrors.NewValidation(err.Error()) + } + + // 8. Resolve app-level token based on API style (with timeout). + tokenCtx, tokenCancel := context.WithTimeout(ctx, 15*time.Second) + defer tokenCancel() + token, err := resolveRawAPIToken(tokenCtx, gf.Token, isLegacy) + if err != nil { + return err + } + + // 9. Build request. + req := apiclient.RawAPIRequest{ + Method: method, + Path: path, + Params: params, + Data: body, + } + + baseURL := af.baseURL + + // 10. Dry-run mode. + if gf.DryRun { + return apiclient.PrintDryRun(cmd.OutOrStdout(), req, baseURL, token) + } + + // 11. Create client with timeout. + client := apiclient.NewClient(token, baseURL) + if gf.Timeout > 0 { + client.HTTPClient.Timeout = time.Duration(gf.Timeout) * time.Second + } + + // 12. Execute request (with or without pagination). + format := output.Format(gf.Format) + respOpts := apiclient.ResponseOptions{ + OutputPath: gf.Output, + Format: format, + JqExpr: gf.JQ, + Fields: gf.Fields, + Out: cmd.OutOrStdout(), + ErrOut: cmd.ErrOrStderr(), + } + + if af.pageAll { + return runPaginated(ctx, client, req, af, respOpts) + } + + resp, err := client.Do(ctx, req) + if err != nil { + return apperrors.NewAPI(fmt.Sprintf("API 请求失败: %v", err)) + } + return apiclient.HandleResponse(resp, respOpts) +} + +// runPaginated executes a paginated API request and outputs all results. +func runPaginated(ctx context.Context, client *apiclient.APIClient, req apiclient.RawAPIRequest, af *apiFlags, opts apiclient.ResponseOptions) error { + pages, err := client.PaginateAll(ctx, req, apiclient.PaginationOptions{ + PageLimit: af.pageLimit, + PageDelay: af.pageDelay, + }) + if err != nil && len(pages) == 0 { + return apperrors.NewAPI(fmt.Sprintf("分页请求失败: %v", err)) + } + + // Output all pages as a JSON array. + return output.WriteFiltered(opts.Out, opts.Format, pages, opts.Fields, opts.JqExpr) +} + +// resolveRawAPIToken resolves an app-level access token for raw API calls. +// It uses AppTokenProvider to obtain the correct token type based on API style: +// - isLegacy=false: fetches from POST api.dingtalk.com/v1.0/oauth2/accessToken +// - isLegacy=true: fetches from GET oapi.dingtalk.com/gettoken +// +// Tokens are cached in keychain and auto-refreshed when expired. +func resolveRawAPIToken(ctx context.Context, explicitToken string, isLegacy bool) (string, error) { + // Explicit --token flag takes priority (user knows what they're doing). + if t := strings.TrimSpace(explicitToken); t != "" { + return t, nil + } + + // Resolve app credentials (clientID/clientSecret). + appKey := authpkg.ClientID() + appSecret := authpkg.ClientSecret() + + if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") { + return "", apperrors.NewAuth( + "缺少应用凭证。dws api 需要使用自有应用的 AppKey/AppSecret 获取 accessToken。\n\n" + + "解决方法:\n" + + " 1. 使用自有应用凭证登录:\n" + + " dws auth login --client-id --client-secret \n\n" + + " 2. 或通过环境变量设置:\n" + + " export DWS_CLIENT_ID=\n" + + " export DWS_CLIENT_SECRET=\n" + + " dws auth login\n\n" + + "说明: 通过 MCP 默认凭证登录的加密 token 无法用于 raw API 调用。", + ) + } + + // Use AppTokenProvider for automatic caching and refresh. + configDir := defaultConfigDir() + provider := &authpkg.AppTokenProvider{ + ConfigDir: configDir, + AppKey: appKey, + AppSecret: appSecret, + } + token, err := provider.GetToken(ctx, isLegacy) + if err != nil { + return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err)) + } + if strings.TrimSpace(token) == "" { + return "", apperrors.NewAuth("应用级访问令牌为空,请检查应用凭证是否正确") + } + + return strings.TrimSpace(token), nil +} diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index b417ee46..2ccb0bb1 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -198,9 +198,14 @@ func newAuthLogoutCommand() *cobra.Command { if err := authpkg.DeleteTokenData(configDir); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err)) } - // Clean up associated client secret from keychain + // Clean up associated client secret and app token from keychain if storedClientID != "" { _ = authpkg.DeleteClientSecret(storedClientID) + _ = authpkg.DeleteAppTokenData(storedClientID) + } + // Also try cleaning app token using appKey from app config + if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID { + _ = authpkg.DeleteAppTokenData(appKey) } // Clean up app credentials (app.json + keychain secret) _ = authpkg.DeleteAppConfig(configDir) diff --git a/internal/app/root.go b/internal/app/root.go index d7cf3b8a..956f113f 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -290,6 +290,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) utilityCommands := []*cobra.Command{ newAuthCommand(), + newAPICommand(flags), newSkillCommand(), newCacheCommand(), newConfigCommand(), @@ -662,6 +663,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) { } staticCommands := map[string]bool{ "auth": true, + "api": true, "cache": true, "config": true, "doctor": true, @@ -693,7 +695,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) { // not override. This protects core CLI functionality from being hijacked // by a malicious or misconfigured plugin. var reservedCommands = map[string]bool{ - "auth": true, "login": true, "logout": true, + "auth": true, "api": true, "login": true, "logout": true, "plugin": true, "skill": true, "cache": true, "config": true, "doctor": true, "completion": true, "recovery": true, "upgrade": true, "version": true, diff --git a/internal/auth/app_token.go b/internal/auth/app_token.go new file mode 100644 index 00000000..0dede6b0 --- /dev/null +++ b/internal/auth/app_token.go @@ -0,0 +1,304 @@ +// 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 auth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +const ( + // appTokenPrefix is the keychain account prefix for app-level tokens. + appTokenPrefix = "app-token:" + + // tokenExpiryBuffer is the buffer time before actual expiry to consider + // the token as expired (same as user token: 5 minutes). + tokenExpiryBuffer = 5 * time.Minute +) + +// AppTokenData stores app-level access tokens (not user-level OAuth tokens). +// New-style and legacy API tokens are stored separately because they are not interchangeable. +type AppTokenData struct { + // New API token (api.dingtalk.com) + NewAccessToken string `json:"new_access_token,omitempty"` + NewExpiresAt time.Time `json:"new_expires_at,omitempty"` + + // Legacy API token (oapi.dingtalk.com) + LegacyAccessToken string `json:"legacy_access_token,omitempty"` + LegacyExpiresAt time.Time `json:"legacy_expires_at,omitempty"` + + // Associated app credentials + ClientID string `json:"client_id"` + UpdatedAt time.Time `json:"updated_at"` +} + +// IsNewTokenValid returns true if the new-style API token has not expired. +func (d *AppTokenData) IsNewTokenValid() bool { + if d == nil || d.NewAccessToken == "" { + return false + } + return time.Now().Before(d.NewExpiresAt.Add(-tokenExpiryBuffer)) +} + +// IsLegacyTokenValid returns true if the legacy API token has not expired. +func (d *AppTokenData) IsLegacyTokenValid() bool { + if d == nil || d.LegacyAccessToken == "" { + return false + } + return time.Now().Before(d.LegacyExpiresAt.Add(-tokenExpiryBuffer)) +} + +// SaveAppTokenData persists AppTokenData to keychain, keyed by clientID. +func SaveAppTokenData(data *AppTokenData) error { + if data.ClientID == "" { + return fmt.Errorf("clientID is required for saving app token data") + } + data.UpdatedAt = time.Now() + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshal app token data: %w", err) + } + defer func() { + for i := range jsonData { + jsonData[i] = 0 + } + }() + + account := appTokenPrefix + data.ClientID + if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil { + return fmt.Errorf("save app token to keychain: %w", err) + } + return nil +} + +// LoadAppTokenData loads AppTokenData from keychain for the given clientID. +// Returns nil, nil if no data exists. +func LoadAppTokenData(clientID string) (*AppTokenData, error) { + if clientID == "" { + return nil, fmt.Errorf("clientID is required for loading app token data") + } + account := appTokenPrefix + clientID + jsonStr, err := keychain.Get(keychain.Service, account) + if err != nil { + return nil, nil // Not found is not an error + } + if jsonStr == "" { + return nil, nil + } + + var data AppTokenData + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + return nil, fmt.Errorf("parse app token data: %w", err) + } + return &data, nil +} + +// DeleteAppTokenData removes AppTokenData from keychain for the given clientID. +func DeleteAppTokenData(clientID string) error { + if clientID == "" { + return nil + } + account := appTokenPrefix + clientID + return keychain.Remove(keychain.Service, account) +} + +// --- Token Fetch Functions --- + +// FetchNewAPIToken obtains a new-style app access token from: +// +// POST https://api.dingtalk.com/v1.0/oauth2/accessToken +// Body: {"appKey":"X","appSecret":"X"} +// Response: {"accessToken":"xxx","expireIn":7200} +func FetchNewAPIToken(ctx context.Context, appKey, appSecret string) (token string, expiresIn int64, err error) { + body := map[string]string{ + "appKey": appKey, + "appSecret": appSecret, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return "", 0, fmt.Errorf("marshal request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, AppAccessTokenURL, bytes.NewReader(bodyBytes)) + if err != nil { + return "", 0, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := appTokenHTTPClient.Do(req) + if err != nil { + return "", 0, fmt.Errorf("fetching new API token: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return "", 0, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("获取新版 API token 失败 (HTTP %d): %s", resp.StatusCode, truncateStr(string(respBody), 200)) + } + + var result struct { + AccessToken string `json:"accessToken"` + ExpireIn int64 `json:"expireIn"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", 0, fmt.Errorf("parsing new API token response: %w", err) + } + if result.AccessToken == "" { + return "", 0, fmt.Errorf("新版 API token 响应缺少 accessToken 字段") + } + if result.ExpireIn <= 0 { + result.ExpireIn = config.DefaultAccessTokenExpiry + } + return result.AccessToken, result.ExpireIn, nil +} + +// FetchLegacyAPIToken obtains a legacy app access token from: +// +// GET https://oapi.dingtalk.com/gettoken?appkey=X&appsecret=X +// Response: {"errcode":0,"access_token":"xxx","errmsg":"ok","expires_in":7200} +func FetchLegacyAPIToken(ctx context.Context, appKey, appSecret string) (token string, expiresIn int64, err error) { + u := LegacyGetTokenURL + "?appkey=" + appKey + "&appsecret=" + appSecret + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", 0, fmt.Errorf("creating request: %w", err) + } + + resp, err := appTokenHTTPClient.Do(req) + if err != nil { + return "", 0, fmt.Errorf("fetching legacy API token: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return "", 0, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("获取旧版 API token 失败 (HTTP %d): %s", resp.StatusCode, truncateStr(string(respBody), 200)) + } + + var result struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", 0, fmt.Errorf("parsing legacy API token response: %w", err) + } + if result.ErrCode != 0 { + return "", 0, fmt.Errorf("获取旧版 API token 业务错误 (errcode: %d): %s", result.ErrCode, result.ErrMsg) + } + if result.AccessToken == "" { + return "", 0, fmt.Errorf("旧版 API token 响应缺少 access_token 字段") + } + if result.ExpiresIn <= 0 { + result.ExpiresIn = config.DefaultAccessTokenExpiry + } + return result.AccessToken, result.ExpiresIn, nil +} + +// --- AppTokenProvider --- + +// AppTokenProvider manages app-level token acquisition, caching and auto-refresh. +type AppTokenProvider struct { + ConfigDir string + AppKey string + AppSecret string + HTTPClient *http.Client // injectable for testing; nil uses default +} + +// GetToken returns a valid app-level access token for the given API style. +// isLegacy=true returns a legacy (oapi) token; false returns a new-style (api) token. +// Tokens are cached in keychain and auto-refreshed when expired (with 5-min buffer). +func (p *AppTokenProvider) GetToken(ctx context.Context, isLegacy bool) (string, error) { + if p.AppKey == "" || p.AppSecret == "" { + return "", fmt.Errorf("缺少应用凭证 (appKey/appSecret),请通过 --client-id/--client-secret 指定或先执行 dws auth login") + } + + // Load cached token data. + data, err := LoadAppTokenData(p.AppKey) + if err != nil { + data = nil // Treat load errors as cache miss + } + + // Fast path: cached token is still valid. + if data != nil { + if isLegacy && data.IsLegacyTokenValid() { + return data.LegacyAccessToken, nil + } + if !isLegacy && data.IsNewTokenValid() { + return data.NewAccessToken, nil + } + } + + // Slow path: fetch a new token. + if data == nil { + data = &AppTokenData{ClientID: p.AppKey} + } + + now := time.Now() + if isLegacy { + token, expiresIn, fetchErr := FetchLegacyAPIToken(ctx, p.AppKey, p.AppSecret) + if fetchErr != nil { + return "", fetchErr + } + data.LegacyAccessToken = token + data.LegacyExpiresAt = now.Add(time.Duration(expiresIn) * time.Second) + } else { + token, expiresIn, fetchErr := FetchNewAPIToken(ctx, p.AppKey, p.AppSecret) + if fetchErr != nil { + return "", fetchErr + } + data.NewAccessToken = token + data.NewExpiresAt = now.Add(time.Duration(expiresIn) * time.Second) + } + + // Persist updated token data. + if saveErr := SaveAppTokenData(data); saveErr != nil { + // Log but don't fail — token is still usable this time. + fmt.Printf("Warning: 无法缓存 app token: %v\n", saveErr) + } + + if isLegacy { + return data.LegacyAccessToken, nil + } + return data.NewAccessToken, nil +} + +// truncateStr truncates a string to maxLen characters. +func truncateStr(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// appTokenHTTPClient is the default HTTP client for app token operations. +var appTokenHTTPClient = &http.Client{ + Timeout: 15 * time.Second, +} diff --git a/internal/auth/app_token_test.go b/internal/auth/app_token_test.go new file mode 100644 index 00000000..d0969a59 --- /dev/null +++ b/internal/auth/app_token_test.go @@ -0,0 +1,223 @@ +// 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 auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestAppTokenData_IsNewTokenValid(t *testing.T) { + tests := []struct { + name string + data *AppTokenData + want bool + }{ + {"nil data", nil, false}, + {"empty token", &AppTokenData{}, false}, + {"expired", &AppTokenData{ + NewAccessToken: "tok", + NewExpiresAt: time.Now().Add(-1 * time.Minute), + }, false}, + {"within buffer", &AppTokenData{ + NewAccessToken: "tok", + NewExpiresAt: time.Now().Add(3 * time.Minute), // 3 min < 5 min buffer + }, false}, + {"valid", &AppTokenData{ + NewAccessToken: "tok", + NewExpiresAt: time.Now().Add(10 * time.Minute), + }, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.data.IsNewTokenValid(); got != tt.want { + t.Errorf("IsNewTokenValid() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppTokenData_IsLegacyTokenValid(t *testing.T) { + tests := []struct { + name string + data *AppTokenData + want bool + }{ + {"nil data", nil, false}, + {"empty token", &AppTokenData{}, false}, + {"expired", &AppTokenData{ + LegacyAccessToken: "tok", + LegacyExpiresAt: time.Now().Add(-1 * time.Minute), + }, false}, + {"valid", &AppTokenData{ + LegacyAccessToken: "tok", + LegacyExpiresAt: time.Now().Add(10 * time.Minute), + }, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.data.IsLegacyTokenValid(); got != tt.want { + t.Errorf("IsLegacyTokenValid() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppTokenData_JSONRoundTrip(t *testing.T) { + original := &AppTokenData{ + NewAccessToken: "new-tok-abc", + NewExpiresAt: time.Now().Add(2 * time.Hour).Truncate(time.Second), + LegacyAccessToken: "legacy-tok-xyz", + LegacyExpiresAt: time.Now().Add(2 * time.Hour).Truncate(time.Second), + ClientID: "my-app-key", + UpdatedAt: time.Now().Truncate(time.Second), + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var decoded AppTokenData + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if decoded.NewAccessToken != original.NewAccessToken { + t.Errorf("NewAccessToken = %q, want %q", decoded.NewAccessToken, original.NewAccessToken) + } + if decoded.LegacyAccessToken != original.LegacyAccessToken { + t.Errorf("LegacyAccessToken = %q, want %q", decoded.LegacyAccessToken, original.LegacyAccessToken) + } + if decoded.ClientID != original.ClientID { + t.Errorf("ClientID = %q, want %q", decoded.ClientID, original.ClientID) + } +} + +func TestFetchNewAPIToken_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]any{ + "accessToken": "new-tok-123", + "expireIn": 7200, + }) + })) + defer srv.Close() + + resp, err := srv.Client().Post(srv.URL, "application/json", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var result struct { + AccessToken string `json:"accessToken"` + ExpireIn int64 `json:"expireIn"` + } + json.NewDecoder(resp.Body).Decode(&result) + if result.AccessToken != "new-tok-123" { + t.Errorf("got token %q, want new-tok-123", result.AccessToken) + } + if result.ExpireIn != 7200 { + t.Errorf("got expireIn %d, want 7200", result.ExpireIn) + } +} + +func TestFetchLegacyAPIToken_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]any{ + "errcode": 0, + "errmsg": "ok", + "access_token": "legacy-tok-456", + "expires_in": 7200, + }) + })) + defer srv.Close() + + resp, err := srv.Client().Get(srv.URL + "?appkey=mykey&appsecret=mysecret") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var result struct { + ErrCode int `json:"errcode"` + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + } + json.NewDecoder(resp.Body).Decode(&result) + if result.ErrCode != 0 { + t.Errorf("got errcode %d, want 0", result.ErrCode) + } + if result.AccessToken != "legacy-tok-456" { + t.Errorf("got token %q, want legacy-tok-456", result.AccessToken) + } + if result.ExpiresIn != 7200 { + t.Errorf("got expires_in %d, want 7200", result.ExpiresIn) + } +} + +func TestFetchLegacyAPIToken_BusinessError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]any{ + "errcode": 40014, + "errmsg": "invalid appkey", + "access_token": "", + "expires_in": 0, + }) + })) + defer srv.Close() + + resp, err := srv.Client().Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var result struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + json.NewDecoder(resp.Body).Decode(&result) + if result.ErrCode != 40014 { + t.Errorf("got errcode %d, want 40014", result.ErrCode) + } +} + +func TestAppTokenProvider_GetToken_MissingCredentials(t *testing.T) { + provider := &AppTokenProvider{ + ConfigDir: t.TempDir(), + AppKey: "", + AppSecret: "", + } + _, err := provider.GetToken(context.Background(), false) + if err == nil { + t.Error("expected error for missing credentials") + } +} + +func TestTruncateStr(t *testing.T) { + if got := truncateStr("hello", 10); got != "hello" { + t.Errorf("got %q, want hello", got) + } + if got := truncateStr("hello world", 5); got != "hello..." { + t.Errorf("got %q, want hello...", got) + } +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 6c8f9147..5b56a079 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -271,16 +271,19 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err) } + // Persist app credentials (with secret) if using custom client credentials. + // MUST run BEFORE os.Setenv below to avoid env-matching short circuit. + oauthProvider.persistAppConfigIfNeeded() + // Always persist clientId to app.json so future process startups // can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env. if p.clientID != "" { _ = os.Setenv("DWS_CLIENT_ID", p.clientID) - _ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID}) + if !HasAppConfig(p.configDir) { + _ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID}) + } } - // Persist app credentials if using custom client credentials - oauthProvider.persistAppConfigIfNeeded() - return tokenData, nil } diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 7837ab4c..50ddd6a3 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -106,6 +106,16 @@ const ( MCPOAuthTokenPath = "/oauth2/getToken" MCPRefreshTokenPath = "/oauth2/refreshToken" MCPRevokeTokenPath = "/oauth2/revokeToken" + + // App-level access token endpoints (for dws api raw calls). + + // AppAccessTokenURL is the new-style app token endpoint. + // POST with {"appKey":"X","appSecret":"X"} → {"accessToken":"...","expireIn":7200} + AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken" + + // LegacyGetTokenURL is the legacy app token endpoint. + // GET ?appkey=X&appsecret=X → {"errcode":0,"access_token":"...","expires_in":7200} + LegacyGetTokenURL = "https://oapi.dingtalk.com/gettoken" ) // GetTerminalBaseURL returns the terminal base URL with priority: diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 8faa65a7..d7034ee4 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -485,16 +485,19 @@ continueLogin: return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err) } + // Persist app credentials (with secret) if using custom client credentials. + // MUST run BEFORE os.Setenv below to avoid env-matching short circuit. + p.persistAppConfigIfNeeded() + // Always persist clientId to app.json so future process startups // can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env. if p.clientID != "" { _ = os.Setenv("DWS_CLIENT_ID", p.clientID) - _ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID}) + if !HasAppConfig(p.configDir) { + _ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID}) + } } - // Persist app credentials if using custom client credentials - p.persistAppConfigIfNeeded() - return tokenData, nil } @@ -616,9 +619,8 @@ func (p *OAuthProvider) persistAppConfigIfNeeded() { return } - // Only persist if they differ from environment/default values - envID := getEnvClientID() - if clientID == envID || clientID == DefaultClientID { + // Skip if using default placeholder credentials + if clientID == DefaultClientID { return }