From 8d061ea3bd1336ff2fb97dcd4d90cc8f851ce133 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 16:54:35 +0800 Subject: [PATCH] feat: add apps observability helpers --- shortcuts/apps/apps_observability_common.go | 166 ++++++++++++++++++ .../apps/apps_observability_common_test.go | 132 ++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 shortcuts/apps/apps_observability_common.go create mode 100644 shortcuts/apps/apps_observability_common_test.go diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go new file mode 100644 index 000000000..612000fa4 --- /dev/null +++ b/shortcuts/apps/apps_observability_common.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/validate" +) + +const ( + defaultAppsPageSize = 50 + maxAppsPageSize = 100 +) + +func appScopedPath(appID, suffix string) string { + base := apiBasePath + "/apps/" + validate.EncodePathSegment(strings.TrimSpace(appID)) + suffix = strings.TrimLeft(strings.TrimSpace(suffix), "/") + if suffix == "" { + return base + } + return base + "/" + suffix +} + +func validateObservabilityEnv(env string) error { + switch strings.TrimSpace(env) { + case "", "online": + return nil + default: + return appsValidationParamError("--env", "observability commands only support --env online (got %q)", env) + } +} + +func validateEnvVarEnv(env string) error { + switch strings.TrimSpace(env) { + case "dev", "online": + return nil + default: + return appsValidationParamError("--env", "env var commands only support --env dev or --env online (got %q)", env) + } +} + +func validateAppsPageSize(n int) error { + if n < 1 || n > maxAppsPageSize { + return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxAppsPageSize) + } + return nil +} + +func cleanRepeatedStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func parseAppsTimeRange(sinceName, sinceRaw, untilName, untilRaw string) (time.Time, time.Time, bool, bool, error) { + var since, until time.Time + var hasSince, hasUntil bool + now := time.Now() + if strings.TrimSpace(sinceRaw) != "" { + parsed, err := parseAppsTimeFlag(sinceName, sinceRaw, now) + if err != nil { + return time.Time{}, time.Time{}, false, false, err + } + since = parsed + hasSince = true + } + if strings.TrimSpace(untilRaw) != "" { + parsed, err := parseAppsTimeFlag(untilName, untilRaw, now) + if err != nil { + return since, time.Time{}, hasSince, false, err + } + until = parsed + hasUntil = true + } + if hasSince && hasUntil && since.After(until) { + return since, until, true, true, appsValidationParamError(untilName, "%s must be greater than or equal to %s", untilName, sinceName) + } + return since, until, hasSince, hasUntil, nil +} + +func parseAppsTimeFlag(param, raw string, now time.Time) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, appsValidationParamError(param, "%s is required", param) + } + if d, ok := parseAppsRelativeDuration(raw); ok { + return now.Add(-d), nil + } + if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { + return t, nil + } + for _, layout := range []string{ + "2006-01-02", + "2006-01-02T15:04:05", + "2006-01-02T15:04:05.000", + } { + if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { + return t, nil + } + } + return time.Time{}, appsValidationParamError(param, "invalid %s %q: expected relative duration (30s, 5m, 2h, 3d, 1w), YYYY-MM-DD, local YYYY-MM-DDTHH:mm:ss(.SSS), or RFC3339", param, raw) +} + +func parseAppsRelativeDuration(s string) (time.Duration, bool) { + s = strings.TrimSpace(s) + if len(s) < 2 { + return 0, false + } + unit := s[len(s)-1] + number := s[:len(s)-1] + for i := 0; i < len(number); i++ { + if number[i] < '0' || number[i] > '9' { + return 0, false + } + } + n, err := strconv.ParseInt(number, 10, 64) + if err != nil || n <= 0 { + return 0, false + } + var unitDuration time.Duration + switch unit { + case 's': + unitDuration = time.Second + case 'm': + unitDuration = time.Minute + case 'h': + unitDuration = time.Hour + case 'd': + unitDuration = 24 * time.Hour + case 'w': + unitDuration = 7 * 24 * time.Hour + default: + return 0, false + } + const maxDuration = time.Duration(1<<63 - 1) + if n > int64(maxDuration)/int64(unitDuration) { + return 0, false + } + return time.Duration(n) * unitDuration, true +} + +func nsString(t time.Time) string { + return strconv.FormatInt(t.UnixNano(), 10) +} + +func secString(t time.Time) string { + return strconv.FormatInt(t.Unix(), 10) +} diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go new file mode 100644 index 000000000..291d9d1b4 --- /dev/null +++ b/shortcuts/apps/apps_observability_common_test.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "testing" + "time" + + "github.com/larksuite/cli/errs" +) + +func requireAppsValidationParam(t *testing.T, err error, want string) *errs.Problem { + t.Helper() + p := requireAppsValidationProblem(t, err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error with param %q, got %T: %v", want, err, err) + } + if validationErr.Param != want { + t.Fatalf("param = %q, want %s", validationErr.Param, want) + } + return p +} + +func TestAppsObservabilityValidateEnvOnlyOnline(t *testing.T) { + if err := validateObservabilityEnv(""); err != nil { + t.Fatalf("empty env should default/pass as online: %v", err) + } + if err := validateObservabilityEnv("online"); err != nil { + t.Fatalf("online should pass: %v", err) + } + err := validateObservabilityEnv("dev") + p := requireAppsValidationParam(t, err, "--env") + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %#v, want invalid_argument param --env", p) + } +} + +func TestAppsObservabilityPageSizeRange(t *testing.T) { + for _, n := range []int{1, 50, 100} { + if err := validateAppsPageSize(n); err != nil { + t.Fatalf("page size %d should pass: %v", n, err) + } + } + for _, n := range []int{0, 101} { + err := validateAppsPageSize(n) + requireAppsValidationParam(t, err, "--page-size") + } +} + +func TestAppsObservabilityCommonHelpers(t *testing.T) { + if got := appScopedPath("app/x", "observability/logs"); got != "/open-apis/spark/v1/apps/app%2Fx/observability/logs" { + t.Fatalf("appScopedPath = %q", got) + } + for _, env := range []string{"dev", "online"} { + if err := validateEnvVarEnv(env); err != nil { + t.Fatalf("validateEnvVarEnv(%q) err=%v", env, err) + } + } + requireAppsValidationParam(t, validateEnvVarEnv(""), "--env") + requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--env") + got := cleanRepeatedStrings([]string{" a ", "b", "a", "", "b", "c"}) + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("cleanRepeatedStrings len=%d, want %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("cleanRepeatedStrings[%d]=%q, want %q", i, got[i], want[i]) + } + } + ts := time.Date(2026, 6, 23, 10, 11, 12, 123456789, time.UTC) + if got := nsString(ts); got != "1782209472123456789" { + t.Fatalf("nsString = %q", got) + } + if got := secString(ts); got != "1782209472" { + t.Fatalf("secString = %q", got) + } +} + +func TestParseAppsTimeAcceptsSupportedInputs(t *testing.T) { + now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.Local) + cases := []struct { + raw string + want time.Time + wantOffset *int + }{ + {raw: "30s", want: now.Add(-30 * time.Second)}, + {raw: "5m", want: now.Add(-5 * time.Minute)}, + {raw: "2h", want: now.Add(-2 * time.Hour)}, + {raw: "3d", want: now.Add(-72 * time.Hour)}, + {raw: "1w", want: now.Add(-7 * 24 * time.Hour)}, + {raw: "2026-06-23", want: time.Date(2026, 6, 23, 0, 0, 0, 0, time.Local)}, + {raw: "2026-06-23T10:11:12", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.Local)}, + {raw: "2026-06-23T10:11:12.123", want: time.Date(2026, 6, 23, 10, 11, 12, 123000000, time.Local)}, + {raw: "2026-06-23T10:11:12Z", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.UTC), wantOffset: ptrInt(0)}, + {raw: "2026-06-23T10:11:12+08:00", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.FixedZone("", 8*60*60)), wantOffset: ptrInt(8 * 60 * 60)}, + } + for _, tc := range cases { + got, err := parseAppsTimeFlag("--since", tc.raw, now) + if err != nil { + t.Fatalf("parseAppsTimeFlag(%q) err=%v", tc.raw, err) + } + if !got.Equal(tc.want) { + t.Fatalf("parseAppsTimeFlag(%q)=%s, want %s", tc.raw, got.Format(time.RFC3339Nano), tc.want.Format(time.RFC3339Nano)) + } + if tc.wantOffset != nil { + _, offset := got.Zone() + if offset != *tc.wantOffset { + t.Fatalf("parseAppsTimeFlag(%q) zone offset=%d, want %d", tc.raw, offset, *tc.wantOffset) + } + } + } +} + +func TestParseAppsTimeRejectsUnsupportedInputs(t *testing.T) { + for _, in := range []string{"2026/06/23", "yesterday", "2026-06-23 10:11:12", "999999999999999999w", "2147483647w"} { + _, _, _, _, err := parseAppsTimeRange("--since", in, "--until", "") + requireAppsValidationParam(t, err, "--since") + } +} + +func TestParseAppsTimeRangeRejectsSinceAfterUntil(t *testing.T) { + _, _, _, _, err := parseAppsTimeRange("--since", "2026-06-24", "--until", "2026-06-23") + requireAppsValidationParam(t, err, "--until") +} + +func ptrInt(n int) *int { + return &n +}