Files
larksuite__cli/shortcuts/common/extract.go
木杉 4488da0b14 feat(apps): add +user-id-convert shortcut for Miaoda↔Feishu ID conversion (#2270)
* feat(apps): add +user-id-convert shortcut for Miaoda↔Feishu ID conversion

Wrap the platform id_convert OpenAPI as a read-only shortcut that maps
Miaoda user_id ↔ Feishu open platform IDs (open_id / union_id / Feishu
user_id). It does one thing — conversion — with no local mapping table,
caching, permission pre-check, or direction guessing.

- --convert-type enum → server id_convert_type (10/11/20/21/40)
- --ids: csv / @file / stdin, 1-100 per call, not de-duped, input order
- reconstructs data.missed by diffing input positions against returned
  source_ids (server silently drops unresolved IDs), keyed by 0-based index
- meta counters (total/hit_count/missed_count) via pointer fields on
  output.Meta so an explicit missed_count: 0 survives omitempty

* test(apps): address review feedback on +user-id-convert

- reject empty --ids CSV entries (e.g. "a,,b") with a typed validation
  error instead of silently dropping them, since a dropped entry shifts
  every later result's 0-based index and breaks the position-keyed
  items/missed contract; add an interior-empty-element test
- reuse common.GetSlice / common.GetString for response projection
  (house convention) instead of local asSlice/asString helpers
- requireConvertValidation now asserts CategoryValidation +
  SubtypeInvalidArgument via errs.ProblemOf, keeping ValidationError.Param
- table-drive TestResolveConvertType over all five directions so every
  --convert-type → id_convert_type mapping (10/11/20/21/40) is protected

* fix(apps): split newline-delimited --ids for +user-id-convert @file/stdin

@file and - (stdin) input arrives verbatim from the framework as
one-ID-per-line text, but parseConvertIDs only split on commas, so such a
block was sent as a single malformed request ID. Treat a newline as
equivalent to a comma, tolerating a file's trailing newline while still
rejecting interior empty entries so position-keyed result indices stay
aligned. Add @file and stdin tests asserting the request body's ids are
split into discrete IDs.

* fix(apps): stringify numeric JSON IDs in +user-id-convert results

Responses decode with json.Number (client.ParseJSONResponse uses
dec.UseNumber()), so a server that emits source_id/target_id as bare
numbers — plausible for the numeric Miaoda user_id form — was silently
coerced to "" by buildConvertResult's strict string assertion: the
source_id got dropped (false not_found) and the target_id blanked
(false success).

Add common.GetStringLoose, which stringifies string/json.Number/int64/
float64 via literal text (large integer IDs keep full precision, never
routed through a lossy float64), and use it for both id reads. Cover it
with a package-level table test plus an end-to-end regression asserting a
numeric-JSON response yields intact, non-blank ids and no false miss.

Also exercise resolveConvertType's non-empty "not a valid direction"
branch directly, since the runner's enum gate preempts it in normal flow.

* test(common): tighten GetStringLoose numeric coverage

Add an int-branch case (was only covering int64) and swap the float64
fixture from 42 — which no formatter would render in exponent form — to
1e-7, whose fixed-point rendering "0.0000001" fails under the 'g' verb.
This turns the "no scientific notation" case into a real guard for the
'f' verb choice, per CodeRabbit review on c64cca39.
2026-08-11 19:14:52 +08:00

156 lines
3.8 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"encoding/json"
"strconv"
"github.com/larksuite/cli/internal/util"
)
// GetString safely extracts a string from a nested map path.
// Usage: GetString(data, "user", "name") is equivalent to
// data["user"].(map[string]interface{})["name"].(string)
func GetString(m map[string]interface{}, keys ...string) string {
if len(keys) == 0 {
return ""
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return ""
}
s, _ := v[keys[len(keys)-1]].(string)
return s
}
// GetStringLoose extracts a string, tolerating a numeric JSON value. Responses
// decode with json.Number (see client.ParseJSONResponse's dec.UseNumber()), so a
// field the server sometimes quotes and sometimes emits bare — e.g. a numeric
// Miaoda user_id — would read as "" under GetString's strict string assertion.
// A json.Number is stringified via its literal text, so large integer IDs keep
// full precision and are never routed through a lossy float64.
func GetStringLoose(m map[string]interface{}, keys ...string) string {
if len(keys) == 0 {
return ""
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return ""
}
switch n := v[keys[len(keys)-1]].(type) {
case string:
return n
case json.Number:
return n.String()
case int:
return strconv.Itoa(n)
case int64:
return strconv.FormatInt(n, 10)
case float64:
return strconv.FormatFloat(n, 'f', -1, 64)
}
return ""
}
// GetFloat safely extracts a float64 (the default JSON number type).
func GetFloat(m map[string]interface{}, keys ...string) float64 {
f, _ := GetFloatOK(m, keys...)
return f
}
// GetFloatOK extracts a float64 and reports whether the field was present and
// numeric. Use it for protocol discriminators where silently turning malformed
// input into zero could misclassify a response as successful.
func GetFloatOK(m map[string]interface{}, keys ...string) (float64, bool) {
if len(keys) == 0 {
return 0, false
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return 0, false
}
f, ok := util.ToFloat64(v[keys[len(keys)-1]])
return f, ok
}
// GetInt safely extracts an int, accepting both in-memory ints and JSON-style float64 values.
func GetInt(m map[string]interface{}, keys ...string) int {
if len(keys) == 0 {
return 0
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return 0
}
switch n := v[keys[len(keys)-1]].(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
}
return 0
}
// GetBool safely extracts a bool.
func GetBool(m map[string]interface{}, keys ...string) bool {
if len(keys) == 0 {
return false
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return false
}
b, _ := v[keys[len(keys)-1]].(bool)
return b
}
// GetMap safely extracts a nested map.
func GetMap(m map[string]interface{}, keys ...string) map[string]interface{} {
if len(keys) == 0 {
return m
}
return navigate(m, keys)
}
// GetSlice safely extracts a []interface{}.
func GetSlice(m map[string]interface{}, keys ...string) []interface{} {
if len(keys) == 0 {
return nil
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return nil
}
s, _ := v[keys[len(keys)-1]].([]interface{})
return s
}
// EachMap iterates over map elements in a slice, skipping non-map items.
func EachMap(items []interface{}, fn func(m map[string]interface{})) {
if fn == nil {
return
}
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
fn(m)
}
}
}
// navigate walks a map along the given keys, returning nil if any step fails.
func navigate(m map[string]interface{}, keys []string) map[string]interface{} {
cur := m
for _, k := range keys {
next, ok := cur[k].(map[string]interface{})
if !ok {
return nil
}
cur = next
}
return cur
}