From 39448c14b7296962ecd6571d6656fa0a661ab330 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 12 Aug 2026 02:42:53 -0700 Subject: [PATCH] fix: let callers send meaningful zero values on request fields --- flashduty_test.go | 165 ++++++++++++++++++++++++++++++++++++++++ models_gen.go | 30 ++++---- openapi/openapi.en.json | 48 ++++++++---- openapi/openapi.zh.json | 48 ++++++++---- 4 files changed, 246 insertions(+), 45 deletions(-) diff --git a/flashduty_test.go b/flashduty_test.go index 2ef86e2..93c1574 100644 --- a/flashduty_test.go +++ b/flashduty_test.go @@ -426,3 +426,168 @@ func TestDoReturnsRateLimitErrorOn429(t *testing.T) { t.Fatalf("Response.RateLimit not populated: %+v", resp.RateLimit) } } + +// lookup walks a decoded JSON body along path and reports the value at the end +// of it, plus whether every segment existed. +func lookup(payload map[string]any, path ...string) (any, bool) { + var cur any = payload + for _, key := range path { + obj, ok := cur.(map[string]any) + if !ok { + return nil, false + } + cur, ok = obj[key] + if !ok { + return nil, false + } + } + return cur, true +} + +// Fields whose zero value carries meaning are generated as pointers so the +// caller can send it. Sending the zero value must put the key on the wire. +func TestNullableRequestFieldsSendExplicitZero(t *testing.T) { + c, _ := NewClient("KEY", WithBaseURL("https://api.flashcat.cloud"), WithLogger(noopLogger{})) + + tests := []struct { + name string + path string + body any + at []string + want any + }{ + { + name: "rum application update clears is_private", + path: "/rum/application/update", + body: &RUMApplicationUpdateRequest{ApplicationID: "app-1", IsPrivate: Bool(false)}, + at: []string{"is_private"}, + want: false, + }, + { + name: "rum application update re-enables geo inference", + path: "/rum/application/update", + body: &RUMApplicationUpdateRequest{ApplicationID: "app-1", NoGeo: Bool(false)}, + at: []string{"no_geo"}, + want: false, + }, + { + name: "rum application update re-enables ip collection", + path: "/rum/application/update", + body: &RUMApplicationUpdateRequest{ApplicationID: "app-1", NoIP: Bool(false)}, + at: []string{"no_ip"}, + want: false, + }, + { + // A minimal alerting override is entirely zero-valued apart from + // Enabled; the pointer keeps the omitzero container on the wire. + name: "rum application update disables alerting", + path: "/rum/application/update", + body: &RUMApplicationUpdateRequest{ApplicationID: "app-1", Alerting: RUMApplicationAlerting{Enabled: Bool(false)}}, + at: []string{"alerting", "enabled"}, + want: false, + }, + { + name: "rum field list selects non-facet fields", + path: "/rum/field/list", + body: &RUMFieldListRequest{IsFacet: Bool(false)}, + at: []string{"is_facet"}, + want: false, + }, + { + name: "schedule notifies exactly at shift start", + path: "/schedule/create", + body: &ScheduleUpsertRequest{Notify: ScheduleNotify{AdvanceInTime: Int64(0)}}, + at: []string{"notify", "advance_in_time"}, + want: float64(0), + }, + { + name: "template update turns the feishu card table off", + path: "/template/update", + body: &TemplateUpdateRequest{TemplateID: "t-1", TemplateName: "t", FeishuAppCardV2TableEnabled: Bool(false)}, + at: []string{"feishu_app_card_v2_table_enabled"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := c.newRequest(context.Background(), http.MethodPost, tt.path, tt.body) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + got, ok := lookup(payload, tt.at...) + if !ok { + t.Fatalf("%s missing from request body: %s", strings.Join(tt.at, "."), body) + } + if got != tt.want { + t.Fatalf("%s = %#v, want %#v, body = %s", strings.Join(tt.at, "."), got, tt.want, body) + } + }) + } +} + +// A nil pointer must stay off the wire so the server leaves the field alone. +func TestNullableRequestFieldsOmitUnsetValues(t *testing.T) { + c, _ := NewClient("KEY", WithBaseURL("https://api.flashcat.cloud"), WithLogger(noopLogger{})) + + tests := []struct { + name string + path string + body any + absent []string + present []string + }{ + { + name: "rum application update leaves privacy toggles alone", + path: "/rum/application/update", + body: &RUMApplicationUpdateRequest{ApplicationID: "app-1", ApplicationName: "renamed"}, + absent: []string{`"is_private"`, `"no_geo"`, `"no_ip"`, `"alerting"`}, + present: []string{`"application_name":"renamed"`}, + }, + { + name: "rum field list returns every field", + path: "/rum/field/list", + body: &RUMFieldListRequest{Scopes: []string{"session"}}, + absent: []string{`"is_facet"`}, + present: []string{`"scopes"`}, + }, + { + name: "template update leaves the feishu card table alone", + path: "/template/update", + body: &TemplateUpdateRequest{TemplateID: "t-1", TemplateName: "t"}, + absent: []string{`"feishu_app_card_v2_table_enabled"`}, + present: []string{`"template_id":"t-1"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := c.newRequest(context.Background(), http.MethodPost, tt.path, tt.body) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + for _, key := range tt.absent { + if strings.Contains(string(body), key) { + t.Fatalf("unset %s must be omitted from the wire, got body = %s", key, body) + } + } + for _, key := range tt.present { + if !strings.Contains(string(body), key) { + t.Fatalf("%s missing from request body: %s", key, body) + } + } + }) + } +} diff --git a/models_gen.go b/models_gen.go index 72c94ad..63d2cd3 100644 --- a/models_gen.go +++ b/models_gen.go @@ -6259,7 +6259,7 @@ type RUMApplicationAlerting struct { // Channel IDs to send alerts to. ChannelIDs []int64 `json:"channel_ids,omitempty" toon:"channel_ids,omitempty"` // Whether alerting is enabled. - Enabled bool `json:"enabled,omitempty" toon:"enabled,omitempty"` + Enabled *bool `json:"enabled,omitempty" toon:"enabled,omitempty"` // Associated on-call integration ID (read-only, auto-assigned). IntegrationID int64 `json:"integration_id,omitempty" toon:"integration_id,omitempty"` } @@ -6414,14 +6414,14 @@ type RUMApplicationUpdateRequest struct { ApplicationID string `json:"application_id" toon:"application_id"` // New application name, 1–40 characters. Omit to leave unchanged. ApplicationName string `json:"application_name,omitempty" toon:"application_name,omitempty"` - // Restrict access to members of the owning team. Omit to leave unchanged. - IsPrivate bool `json:"is_private,omitempty" toon:"is_private,omitempty"` + // Restrict access to members of the owning team; `false` explicitly makes the application public. Omit to leave unchanged. + IsPrivate *bool `json:"is_private,omitempty" toon:"is_private,omitempty"` // External-link integration configuration. Omit to leave unchanged. Links RUMApplicationLinks `json:"links,omitzero" toon:"links,omitempty"` - // When `true`, stop inferring geographic location from IP. Omit to leave unchanged. - NoGeo bool `json:"no_geo,omitempty" toon:"no_geo,omitempty"` - // When `true`, stop collecting user IP addresses. Omit to leave unchanged. - NoIP bool `json:"no_ip,omitempty" toon:"no_ip,omitempty"` + // When `true`, stop inferring geographic location from IP; when `false`, resume inferring it. Omit to leave unchanged. + NoGeo *bool `json:"no_geo,omitempty" toon:"no_geo,omitempty"` + // When `true`, stop collecting user IP addresses; when `false`, resume collecting them. Omit to leave unchanged. + NoIP *bool `json:"no_ip,omitempty" toon:"no_ip,omitempty"` // Owning team ID. Get team IDs via `POST /team/list`. Omit to leave unchanged. TeamID int64 `json:"team_id,omitempty" toon:"team_id,omitempty"` // APM tracing integration configuration. Omit to leave unchanged. @@ -6732,8 +6732,8 @@ type RUMFieldItem struct { // RUMFieldListRequest is generated from the Flashduty OpenAPI schema. type RUMFieldListRequest struct { - // When true, return only facet-enabled fields. When false or omitted, return all fields. - IsFacet bool `json:"is_facet,omitempty" toon:"is_facet,omitempty"` + // When omitted or `null`, return all fields. When `true`, return only facet-enabled fields. When `false`, return only fields that are not facet-enabled. + IsFacet *bool `json:"is_facet,omitempty" toon:"is_facet,omitempty"` // Filter by RUM data scopes. Valid values: `session`, `view`, `action`, `error`, `resource`, `long_task`, `vital`, `issue`, `sourcemap`. Scopes []string `json:"scopes,omitempty" toon:"scopes,omitempty"` } @@ -7462,8 +7462,8 @@ type ScheduleMember struct { // ScheduleNotify is generated from the Flashduty OpenAPI schema. type ScheduleNotify struct { - // Advance notification lead time (seconds). - AdvanceInTime int64 `json:"advance_in_time,omitempty" toon:"advance_in_time,omitempty"` + // Advance notification lead time in seconds. `0` notifies exactly at shift start; omitting disables advance notification. + AdvanceInTime *int64 `json:"advance_in_time,omitempty" toon:"advance_in_time,omitempty"` By ScheduleNotifyBy `json:"by" toon:"by"` FixedTime ScheduleFixedTimeNotifyInfo `json:"fixed_time" toon:"fixed_time"` // Legacy IM-type to token map. @@ -9094,7 +9094,7 @@ type TemplateCreateRequest struct { // Feishu app message template source. FeishuApp string `json:"feishu_app,omitempty" toon:"feishu_app,omitempty"` // Render alert labels as a table in Feishu app cards. - FeishuAppCardTableEnabled bool `json:"feishu_app_card_table_enabled,omitempty" toon:"feishu_app_card_table_enabled,omitempty"` + FeishuAppCardV2TableEnabled bool `json:"feishu_app_card_v2_table_enabled,omitempty" toon:"feishu_app_card_v2_table_enabled,omitempty"` // Incident card fields hidden per IM app type. IncidentCardHiddenFields IncidentCardHiddenFields `json:"incident_card_hidden_fields,omitempty" toon:"incident_card_hidden_fields,omitempty"` // Slack robot message template source. @@ -9158,8 +9158,8 @@ type TemplateItem struct { // Feishu app message template source. FeishuApp string `json:"feishu_app" toon:"feishu_app"` // Whether alert labels use table rendering in Feishu app cards. - FeishuAppCardTableEnabled bool `json:"feishu_app_card_table_enabled" toon:"feishu_app_card_table_enabled"` - IncidentCardHiddenFields IncidentCardHiddenFields `json:"incident_card_hidden_fields" toon:"incident_card_hidden_fields"` + FeishuAppCardV2TableEnabled bool `json:"feishu_app_card_v2_table_enabled" toon:"feishu_app_card_v2_table_enabled"` + IncidentCardHiddenFields IncidentCardHiddenFields `json:"incident_card_hidden_fields" toon:"incident_card_hidden_fields"` // Slack robot message template source. Slack string `json:"slack" toon:"slack"` // Slack app message template source. @@ -9233,7 +9233,7 @@ type TemplateUpdateRequest struct { // Feishu app message template source. FeishuApp string `json:"feishu_app,omitempty" toon:"feishu_app,omitempty"` // When set, enable or disable table rendering for alert labels in Feishu app cards. Omit to keep the existing setting. - FeishuAppCardTableEnabled *bool `json:"feishu_app_card_table_enabled,omitempty" toon:"feishu_app_card_table_enabled,omitempty"` + FeishuAppCardV2TableEnabled *bool `json:"feishu_app_card_v2_table_enabled,omitempty" toon:"feishu_app_card_v2_table_enabled,omitempty"` // Incident card fields hidden per IM app type. IncidentCardHiddenFields IncidentCardHiddenFields `json:"incident_card_hidden_fields,omitempty" toon:"incident_card_hidden_fields,omitempty"` // Slack robot message template source. diff --git a/openapi/openapi.en.json b/openapi/openapi.en.json index 2426221..e005676 100644 --- a/openapi/openapi.en.json +++ b/openapi/openapi.en.json @@ -38514,9 +38514,12 @@ ], "properties": { "advance_in_time": { - "type": "integer", + "type": [ + "integer", + "null" + ], "format": "int64", - "description": "Advance notification lead time (seconds)." + "description": "Advance notification lead time in seconds. `0` notifies exactly at shift start; omitting disables advance notification." }, "fixed_time": { "$ref": "#/components/schemas/ScheduleFixedTimeNotifyInfo" @@ -39462,7 +39465,7 @@ "wecom", "feishu", "feishu_app", - "feishu_app_card_table_enabled", + "feishu_app_card_v2_table_enabled", "dingtalk_app", "wecom_app", "slack_app", @@ -39529,7 +39532,7 @@ "type": "string", "description": "Feishu app message template source." }, - "feishu_app_card_table_enabled": { + "feishu_app_card_v2_table_enabled": { "type": "boolean", "description": "Whether alert labels use table rendering in Feishu app cards." }, @@ -39754,7 +39757,7 @@ "type": "string", "description": "Feishu app message template source." }, - "feishu_app_card_table_enabled": { + "feishu_app_card_v2_table_enabled": { "type": "boolean", "default": false, "description": "Render alert labels as a table in Feishu app cards." @@ -39872,7 +39875,7 @@ "type": "string", "description": "Feishu app message template source." }, - "feishu_app_card_table_enabled": { + "feishu_app_card_v2_table_enabled": { "type": [ "boolean", "null" @@ -44520,7 +44523,10 @@ "description": "Alert settings for the application.", "properties": { "enabled": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "description": "Whether alerting is enabled." }, "channel_ids": { @@ -44901,16 +44907,25 @@ "description": "Owning team ID. Get team IDs via `POST /team/list`. Omit to leave unchanged." }, "is_private": { - "type": "boolean", - "description": "Restrict access to members of the owning team. Omit to leave unchanged." + "type": [ + "boolean", + "null" + ], + "description": "Restrict access to members of the owning team; `false` explicitly makes the application public. Omit to leave unchanged." }, "no_ip": { - "type": "boolean", - "description": "When `true`, stop collecting user IP addresses. Omit to leave unchanged." + "type": [ + "boolean", + "null" + ], + "description": "When `true`, stop collecting user IP addresses; when `false`, resume collecting them. Omit to leave unchanged." }, "no_geo": { - "type": "boolean", - "description": "When `true`, stop inferring geographic location from IP. Omit to leave unchanged." + "type": [ + "boolean", + "null" + ], + "description": "When `true`, stop inferring geographic location from IP; when `false`, resume inferring it. Omit to leave unchanged." }, "alerting": { "$ref": "#/components/schemas/RumApplicationAlerting", @@ -49781,8 +49796,11 @@ "description": "Filter by RUM data scopes. Valid values: `session`, `view`, `action`, `error`, `resource`, `long_task`, `vital`, `issue`, `sourcemap`." }, "is_facet": { - "type": "boolean", - "description": "When true, return only facet-enabled fields. When false or omitted, return all fields." + "type": [ + "boolean", + "null" + ], + "description": "When omitted or `null`, return all fields. When `true`, return only facet-enabled fields. When `false`, return only fields that are not facet-enabled." } } }, diff --git a/openapi/openapi.zh.json b/openapi/openapi.zh.json index 9a14f0b..b6cfa2c 100644 --- a/openapi/openapi.zh.json +++ b/openapi/openapi.zh.json @@ -38505,9 +38505,12 @@ ], "properties": { "advance_in_time": { - "type": "integer", + "type": [ + "integer", + "null" + ], "format": "int64", - "description": "提前通知时间(秒)。" + "description": "提前通知时间(秒);为 `0` 时在排班开始时通知;不传则不发送提前通知。" }, "fixed_time": { "$ref": "#/components/schemas/ScheduleFixedTimeNotifyInfo" @@ -39453,7 +39456,7 @@ "wecom", "feishu", "feishu_app", - "feishu_app_card_table_enabled", + "feishu_app_card_v2_table_enabled", "dingtalk_app", "wecom_app", "slack_app", @@ -39520,7 +39523,7 @@ "type": "string", "description": "飞书应用消息模板源。" }, - "feishu_app_card_table_enabled": { + "feishu_app_card_v2_table_enabled": { "type": "boolean", "description": "是否在飞书应用卡片中以表格渲染告警标签。" }, @@ -39745,7 +39748,7 @@ "type": "string", "description": "飞书应用消息模板源。" }, - "feishu_app_card_table_enabled": { + "feishu_app_card_v2_table_enabled": { "type": "boolean", "default": false, "description": "是否在飞书应用卡片中以表格渲染告警标签,默认关闭。" @@ -39863,7 +39866,7 @@ "type": "string", "description": "飞书应用消息模板源。" }, - "feishu_app_card_table_enabled": { + "feishu_app_card_v2_table_enabled": { "type": [ "boolean", "null" @@ -44511,7 +44514,10 @@ "description": "应用的告警配置。", "properties": { "enabled": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "description": "是否启用告警。" }, "channel_ids": { @@ -44892,16 +44898,25 @@ "description": "应用所属团队 ID,可通过 `POST /team/list` 获取;不传则保持不变。" }, "is_private": { - "type": "boolean", - "description": "是否仅限所属团队成员访问;不传则保持不变。" + "type": [ + "boolean", + "null" + ], + "description": "是否仅限所属团队成员访问;`false` 显式改为公开访问;不传则保持不变。" }, "no_ip": { - "type": "boolean", - "description": "为 `true` 时停止采集用户 IP 地址;不传则保持不变。" + "type": [ + "boolean", + "null" + ], + "description": "为 `true` 时停止采集用户 IP 地址,为 `false` 时恢复采集;不传则保持不变。" }, "no_geo": { - "type": "boolean", - "description": "为 `true` 时不再基于 IP 推断地理位置;不传则保持不变。" + "type": [ + "boolean", + "null" + ], + "description": "为 `true` 时不再基于 IP 推断地理位置,为 `false` 时恢复推断;不传则保持不变。" }, "alerting": { "$ref": "#/components/schemas/RumApplicationAlerting", @@ -49772,8 +49787,11 @@ "description": "按 RUM 数据 scope 过滤。合法值:`session`、`view`、`action`、`error`、`resource`、`long_task`、`vital`、`issue`、`sourcemap`。" }, "is_facet": { - "type": "boolean", - "description": "为 true 时只返回支持分面查询的字段;为 false 或不传时返回所有字段。" + "type": [ + "boolean", + "null" + ], + "description": "不传或为 `null` 时返回所有字段;为 `true` 时只返回支持分面查询的字段;为 `false` 时只返回不支持分面查询的字段。" } } },