// 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" "encoding/json" "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 } type appTokenGetter interface { GetToken(context.Context) (string, error) } var newAppTokenProvider = func(configDir, appKey, appSecret string) appTokenGetter { return &authpkg.AppTokenProvider{ConfigDir: configDir, AppKey: appKey, AppSecret: appSecret} } var ( apiClientID = authpkg.ClientID apiClientSecret = authpkg.ClientSecret ) // 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.dingtalk.com 和 oapi.dingtalk.com 两个域名。 api.dingtalk.com: Token 通过 HTTP Header (x-acs-dingtalk-access-token) 传递。 路径格式: /v1.0/xxx 或 /v2.0/xxx 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.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"}}' # === 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=不限, 默认10, 硬上限500)") 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] // 0. Reject path with inline query string — must use --params instead. if idx := strings.IndexByte(path, '?'); idx >= 0 { cleanPath := path[:idx] // Parse query string to generate the exact --params JSON for the user. paramsJSON := parseQueryStringToJSON(path[idx+1:]) return apperrors.NewValidation( "API 路径中不允许直接拼接查询参数(?key=value),该写法会导致参数在解析时被静默丢弃。\n\n"+ "命令格式可参考:\n\n"+ " dws api "+method+" "+cleanPath+" --params '"+paramsJSON+"'", apperrors.WithHint("查询参数必须通过 --params 传递,形如 --params '{\"key\":\"value\"}'"), ) } // 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. Normalise and validate target URL. fullURL := apiclient.NormalisePath(path, af.baseURL) // 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 (with timeout). tokenCtx, tokenCancel := context.WithTimeout(ctx, 15*time.Second) defer tokenCancel() token, err := resolveRawAPIToken(tokenCtx, gf.Token) 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, LogWriter: opts.ErrOut, }) 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) } // parseQueryStringToJSON parses a raw URL query string into a JSON object string. // Uses simple & and = splitting (no URL decoding) to preserve values as-is. func parseQueryStringToJSON(rawQuery string) string { rawQuery = strings.TrimSpace(rawQuery) if rawQuery == "" { return "{}" } paramsMap := make(map[string]any) for _, pair := range strings.Split(rawQuery, "&") { kv := strings.SplitN(pair, "=", 2) key := strings.TrimSpace(kv[0]) if key == "" { continue } var val string if len(kv) == 2 { val = strings.TrimSpace(kv[1]) } if val == "" { continue // skip empty values like nextToken= } paramsMap[key] = val } if len(paramsMap) == 0 { return "{}" } data, _ := json.Marshal(paramsMap) return string(data) } // resolveRawAPIToken resolves an app-level access token for raw API calls. // It uses AppTokenProvider to fetch from the unified POST /v1.0/oauth2/accessToken // endpoint. The same token works for both api.dingtalk.com and oapi.dingtalk.com. // Tokens are cached in keychain and auto-refreshed when expired. func resolveRawAPIToken(ctx context.Context, explicitToken string) (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 := apiClientID() appSecret := apiClientSecret() 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 := newAppTokenProvider(configDir, appKey, appSecret) token, err := provider.GetToken(ctx) if err != nil { return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err)) } if strings.TrimSpace(token) == "" { return "", apperrors.NewAuth("应用级访问令牌为空,请检查应用凭证是否正确") } return strings.TrimSpace(token), nil }