Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ To call the API with an OAuth access token instead, use `NewClientWithAccessToke
client, err := flashduty.NewClientWithAccessToken(accessToken)
```

## Public and dedicated integrations

Use `client.Integrations` for public integrations managed in the integration center. They can be connected to channels through subscriptions or routing and require integration permissions. Use `client.DedicatedIntegrations` for integrations owned by one channel; they require channel permissions, including access to that specific channel, rather than integration-center permissions.

```go
req := &flashduty.ChannelIntegrationListRequest{ChannelID: 20001}
integrations, _, err := client.DedicatedIntegrations.ReadList(ctx, req)
```

Choose the API from the integration's ownership. An integration ID alone does not identify its scope. The SDK does not switch API groups on a permission error. Both groups authenticate with the management App Key; the returned `integration_key` authenticates inbound events. List/info include that key and masked settings, so avoid logging complete responses.

Dedicated updates preserve omitted fields. Set `Description` to a pointer to `""` to clear it; nil keeps it. A nil `Settings` map preserves configuration; a non-nil empty map submits `{}` as a replacement, subject to the type's validation and secret-preservation rules.

## Design

- **Thin and typed.** Every method maps to exactly one HTTP call and returns `(*T, *Response, error)`. No hidden cross-endpoint enrichment.
Expand Down
13 changes: 13 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ func main() {
client, err := flashduty.NewClientWithAccessToken(accessToken)
```

## 公共集成与专属集成

集成中心管理的公共集成使用 `client.Integrations`,可通过订阅或路由接入协作空间,需要集成中心权限。属于某个协作空间的专属集成使用 `client.DedicatedIntegrations`,需要协作空间权限以及对目标空间的访问或管理权限,不需要集成中心权限。

```go
req := &flashduty.ChannelIntegrationListRequest{ChannelID: 20001}
integrations, _, err := client.DedicatedIntegrations.ReadList(ctx, req)
```

按集成的实际归属选择接口,不能仅凭集成 ID 判断类型。权限不足时 SDK 不会自动切换接口。两类接口均使用管理用的 App Key 认证;响应里的 `integration_key` 用于事件上报。列表和详情包含完整的集成 Key 及脱敏配置,避免记录完整响应。

专属集成更新时,省略字段会保留原值。`Description` 为 nil 时保留原值,指向空字符串时清空描述;`Settings` 为 nil 时保留配置,非 nil 的空 map 会提交 `{}` 作为替换配置,仍受该集成类型的校验和敏感字段保留规则约束。

## 设计

- **薄且强类型。** 每个方法只对应一次 HTTP 调用,返回 `(*T, *Response, error)`,不会在背后跨接口补数据。
Expand Down
105 changes: 105 additions & 0 deletions dedicated_integrations.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 151 additions & 0 deletions dedicated_integrations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package flashduty

import (
"context"
"encoding/json"
"net/http"
"reflect"
"testing"
)

func TestDedicatedIntegrationRequests(t *testing.T) {
for _, tc := range []struct {
name string
body string
call func(*Client) error
}{
{"list", `{"channel_id":20001,"p":2,"limit":10,"name":"Primary","plugin_type":"standard.alert","status":"enabled"}`, func(c *Client) error {
_, _, err := c.DedicatedIntegrations.ReadList(context.Background(), &ChannelIntegrationListRequest{ListOptions: ListOptions{Page: 2, Limit: 10}, ChannelID: 20001, Name: "Primary", PluginType: "standard.alert", Status: "enabled"})
return err
}},
{"info", `{"integration_id":10001}`, func(c *Client) error {
_, _, err := c.DedicatedIntegrations.ReadInfo(context.Background(), &ChannelIntegrationIDRequest{IntegrationID: 10001})
return err
}},
{"create", `{"channel_id":20001,"plugin_type":"standard.alert","name":"Primary","integration_key":"0123456789abcdef0123456789abcdef100","settings":{"headers":{}}}`, func(c *Client) error {
_, _, err := c.DedicatedIntegrations.WriteCreate(context.Background(), &ChannelIntegrationCreateRequest{ChannelID: 20001, PluginType: "standard.alert", Name: "Primary", IntegrationKey: "0123456789abcdef0123456789abcdef100", Settings: map[string]any{"headers": map[string]any{}}})
return err
}},
{"update", `{"integration_id":10001,"description":""}`, func(c *Client) error {
empty := ""
_, _, err := c.DedicatedIntegrations.WriteUpdate(context.Background(), &ChannelIntegrationUpdateRequest{IntegrationID: 10001, Description: &empty})
return err
}},
{"delete", `{"integration_id":10001}`, func(c *Client) error {
_, err := c.DedicatedIntegrations.WriteDelete(context.Background(), &ChannelIntegrationIDRequest{IntegrationID: 10001})
return err
}},
{"enable", `{"integration_id":10001}`, func(c *Client) error {
_, err := c.DedicatedIntegrations.WriteEnable(context.Background(), &ChannelIntegrationIDRequest{IntegrationID: 10001})
return err
}},
{"disable", `{"integration_id":10001}`, func(c *Client) error {
_, err := c.DedicatedIntegrations.WriteDisable(context.Background(), &ChannelIntegrationIDRequest{IntegrationID: 10001})
return err
}},
{"key/rotate", `{"integration_id":10001}`, func(c *Client) error {
_, _, err := c.DedicatedIntegrations.WriteRotateKey(context.Background(), &ChannelIntegrationIDRequest{IntegrationID: 10001})
return err
}},
} {
t.Run(tc.name, func(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/channel/integration/"+tc.name || r.URL.Query().Get("app_key") != "KEY" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL)
}
var want, got map[string]any
err := json.Unmarshal([]byte(tc.body), &want)
if err != nil {
t.Error(err)
}
err = json.NewDecoder(r.Body).Decode(&got)
if err != nil {
t.Error(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("body = %#v, want %#v", got, want)
}
_, _ = w.Write([]byte(`{"data":{}}`))
})
err := tc.call(c)
if err != nil {
t.Fatal(err)
}
})
}
}

func TestDedicatedIntegrationUpdatePreservesEmptySettings(t *testing.T) {
for _, tc := range []struct {
name string
settings map[string]any
present bool
}{
{"omitted", nil, false},
{"empty replacement", map[string]any{}, true},
{"masked secret", map[string]any{"password": "******"}, true},
} {
t.Run(tc.name, func(t *testing.T) {
body, err := json.Marshal(&ChannelIntegrationUpdateRequest{IntegrationID: 10001, Settings: tc.settings})
if err != nil {
t.Fatal(err)
}
var got map[string]any
_ = json.Unmarshal(body, &got)
_, present := got["settings"]
if present != tc.present {
t.Fatalf("settings presence = %v, want %v: %s", present, tc.present, body)
}
if present && !reflect.DeepEqual(got["settings"], tc.settings) {
t.Fatalf("settings changed: %s", body)
}
})
}
}

func TestDedicatedIntegrationResponsePreservesOwnershipAndConfiguration(t *testing.T) {
const item = `{"integration_id":10001,"channel_id":20001,"integration_key":"fixture-key","exclusive_data_source_id":0,"plugin_id":9,"plugin_type":"standard.alert","plugin_type_name":"Standard","category":"event.alert","name":"Primary","description":"","status":"enabled","created_at":1700000000,"updated_at":1700000001,"last_time":0,"last_source_time":0,"settings":{"password":"******"}}`
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":{"items":[` + item + `],"total":1,"p":1,"limit":100}}`))
})
out, _, err := c.DedicatedIntegrations.ReadList(context.Background(), &ChannelIntegrationListRequest{ChannelID: 20001})
if err != nil {
t.Fatal(err)
}
if out.Total != 1 || len(out.Items) != 1 {
t.Fatalf("unexpected page: %#v", out)
}
encoded, err := json.Marshal(out.Items[0])
if err != nil {
t.Fatal(err)
}
var want, got map[string]any
_ = json.Unmarshal([]byte(item), &want)
_ = json.Unmarshal(encoded, &got)
if out.Items[0].CreatedAt.Unix() != 1700000000 || out.Items[0].UpdatedAt.Unix() != 1700000001 {
t.Fatalf("timestamps changed: %#v", out.Items[0])
}
for _, field := range []string{"created_at", "updated_at"} {
delete(want, field)
delete(got, field)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("response fields changed: got %s, want %s", encoded, item)
}
}

func TestDedicatedIntegrationForbiddenDoesNotCallPublicAPI(t *testing.T) {
calls := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/channel/integration/info" {
t.Errorf("unexpected fallback: %s", r.URL.Path)
}
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":{"code":"AccessDenied","message":"Channel access denied"}}`))
})
_, _, err := c.DedicatedIntegrations.ReadInfo(context.Background(), &ChannelIntegrationIDRequest{IntegrationID: 10001})
if ErrorCodeOf(err) != ErrorCodeAccessDenied || calls != 1 {
t.Fatalf("error = %v, calls = %d", err, calls)
}
}
16 changes: 8 additions & 8 deletions integrations.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 9 additions & 6 deletions internal/cmd/gen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -875,8 +875,8 @@ func (g *Gen) emitStruct(name string, s map[string]any) string {
case inReq && needsPointer && (isNullable(pv) || preserveAbsence):
jsonTag = k + ",omitempty"
toonTag = k + ",omitempty"
case inReq && !required[k] && preserveAbsence && strings.HasPrefix(gt, "[]"):
// Preserve nil (omitted) versus an explicit empty slice (clear).
case inReq && !required[k] && ((preserveAbsence && strings.HasPrefix(gt, "[]")) || (isNullable(pv) && strings.HasPrefix(gt, "map["))):
// Preserve nil (omitted) versus an explicit empty container (clear).
// omitempty would drop both and silently keep the stored value.
jsonTag = k + ",omitzero"
toonTag = k + ",omitempty"
Expand Down Expand Up @@ -925,11 +925,14 @@ func (g *Gen) goTypeOf(s map[string]any, hint string) string {
}
return goName(ref)
}
// A oneOf/anyOf is a polymorphic value (e.g. bool | string | string[]); its
// only safe Go type is any. Returning map[string]any here would be wrong for
// non-object members.
// Scalar alternatives can refine an explicit type (e.g. empty or a minimum
// string length). Other unions remain polymorphic, so their safe type is any.
if len(asSlice(s["oneOf"])) > 0 || len(asSlice(s["anyOf"])) > 0 {
return "any"
switch typeStr(s) {
case "string", "integer", "number", "boolean":
default:
return "any"
}
}
if len(asSlice(s["allOf"])) > 0 {
return g.queue(hint, g.mergeAllOf(s))
Expand Down
Loading
Loading