diff --git a/account.go b/account.go index f29f780..ee513ac 100644 --- a/account.go +++ b/account.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" ) // AccountInfo contains account details returned by the account info API. @@ -20,33 +19,15 @@ type AccountInfo struct { CreatedAt int64 `json:"created_at"` } -type accountInfoResponse struct { - Error *DutyError `json:"error,omitempty"` - Data *AccountInfo `json:"data,omitempty"` -} - // GetAccountInfo retrieves the account information for the authenticated app key. func (c *Client) GetAccountInfo(ctx context.Context) (*AccountInfo, error) { - resp, err := c.makeRequest(ctx, "POST", "/account/info", map[string]any{}) + data, err := postOptionalData[AccountInfo](c, ctx, "/account/info", map[string]any{}, "unable to get account info") if err != nil { - return nil, fmt.Errorf("unable to get account info: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result accountInfoResponse - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil { + if data == nil { return nil, fmt.Errorf("empty account info in response") } - return result.Data, nil + return data, nil } diff --git a/alerts.go b/alerts.go index 10bf7e1..1dc1167 100644 --- a/alerts.go +++ b/alerts.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" "strings" ) @@ -39,32 +38,16 @@ func (c *Client) ListAlertEvents(ctx context.Context, input *ListAlertEventsInpu "alert_id": input.AlertID, } - resp, err := c.makeRequest(ctx, "POST", "/alert/event/list", requestBody) + result, err := postData[struct { + Items []AlertEvent `json:"items"` + }](c, ctx, "/alert/event/list", requestBody, "failed to list alert events") if err != nil { - return nil, fmt.Errorf("failed to list alert events: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []AlertEvent `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } events := []AlertEvent{} - if result.Data != nil { - events = result.Data.Items + if result != nil { + events = result.Items } return &ListAlertEventsOutput{ @@ -154,41 +137,25 @@ func (c *Client) ListAlerts(ctx context.Context, input *ListAlertsInput) (*ListA requestBody["search_after_ctx"] = input.SearchAfterCtx } - resp, err := c.makeRequest(ctx, "POST", "/alert/list", requestBody) + result, err := postData[struct { + Items []Alert `json:"items"` + Total int `json:"total"` + HasNextPage bool `json:"has_next_page"` + SearchAfterCtx string `json:"search_after_ctx,omitempty"` + }](c, ctx, "/alert/list", requestBody, "failed to list alerts") if err != nil { - return nil, fmt.Errorf("failed to list alerts: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []Alert `json:"items"` - Total int `json:"total"` - HasNextPage bool `json:"has_next_page"` - SearchAfterCtx string `json:"search_after_ctx,omitempty"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } alerts := []Alert{} total := 0 hasNextPage := false searchAfterCtx := "" - if result.Data != nil { - alerts = result.Data.Items - total = result.Data.Total - hasNextPage = result.Data.HasNextPage - searchAfterCtx = result.Data.SearchAfterCtx + if result != nil { + alerts = result.Items + total = result.Total + hasNextPage = result.HasNextPage + searchAfterCtx = result.SearchAfterCtx } return &ListAlertsOutput{ @@ -219,32 +186,16 @@ func (c *Client) GetAlertDetail(ctx context.Context, input *GetAlertDetailInput) "alert_id": input.AlertID, } - resp, err := c.makeRequest(ctx, "POST", "/alert/info", requestBody) + alert, err := postOptionalData[Alert](c, ctx, "/alert/info", requestBody, "failed to get alert detail") if err != nil { - return nil, fmt.Errorf("failed to get alert detail: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *Alert `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil { + if alert == nil { return nil, fmt.Errorf("alert not found: %s", input.AlertID) } - return &GetAlertDetailOutput{Alert: *result.Data}, nil + return &GetAlertDetailOutput{Alert: *alert}, nil } // ListAlertsByIDs fetches alerts by their IDs @@ -253,32 +204,16 @@ func (c *Client) ListAlertsByIDs(ctx context.Context, alertIDs []string) (*ListA "alert_ids": alertIDs, } - resp, err := c.makeRequest(ctx, "POST", "/alert/list-by-ids", requestBody) + result, err := postData[struct { + Items []Alert `json:"items"` + }](c, ctx, "/alert/list-by-ids", requestBody, "failed to list alerts by IDs") if err != nil { - return nil, fmt.Errorf("failed to list alerts by IDs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []Alert `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } alerts := []Alert{} - if result.Data != nil { - alerts = result.Data.Items + if result != nil { + alerts = result.Items } return &ListAlertsOutput{ @@ -312,25 +247,7 @@ func (c *Client) MergeAlertsToIncident(ctx context.Context, input *MergeAlertsIn requestBody["title"] = input.Title } - resp, err := c.makeRequest(ctx, "POST", "/alert/merge", requestBody) - if err != nil { - return fmt.Errorf("failed to merge alerts to incident: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + return postEmpty(c, ctx, "/alert/merge", requestBody, "failed to merge alerts to incident") } // GetAlertFeedInput contains parameters for getting alert feed/timeline @@ -373,31 +290,15 @@ func (c *Client) GetAlertFeed(ctx context.Context, input *GetAlertFeedInput) (*G requestBody["types"] = input.Types } - resp, err := c.makeRequest(ctx, "POST", "/alert/feed", requestBody) + result, err := postData[struct { + Items []RawTimelineItem `json:"items"` + HasNextPage bool `json:"has_next_page"` + }](c, ctx, "/alert/feed", requestBody, "failed to get alert feed") if err != nil { - return nil, fmt.Errorf("failed to get alert feed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawTimelineItem `json:"items"` - HasNextPage bool `json:"has_next_page"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil || len(result.Data.Items) == 0 { + if result == nil || len(result.Items) == 0 { return &GetAlertFeedOutput{ Items: []TimelineEvent{}, HasNextPage: false, @@ -405,17 +306,17 @@ func (c *Client) GetAlertFeed(ctx context.Context, input *GetAlertFeedInput) (*G } // Enrich with person names - personIDs := collectTimelinePersonIDs(result.Data.Items) + personIDs := collectTimelinePersonIDs(result.Items) personMap, err := c.fetchPersonInfos(ctx, personIDs) if err != nil { return nil, fmt.Errorf("failed to load person details for feed: %w", err) } - enrichedItems := enrichTimelineItems(result.Data.Items, personMap) + enrichedItems := enrichTimelineItems(result.Items, personMap) return &GetAlertFeedOutput{ Items: enrichedItems, - HasNextPage: result.Data.HasNextPage, + HasNextPage: result.HasNextPage, }, nil } @@ -481,41 +382,25 @@ func (c *Client) ListAlertEventsGlobal(ctx context.Context, input *ListAlertEven requestBody["search_after_ctx"] = input.SearchAfterCtx } - resp, err := c.makeRequest(ctx, "POST", "/alert-event/list", requestBody) + result, err := postData[struct { + Items []AlertEvent `json:"items"` + Total int `json:"total"` + HasNextPage bool `json:"has_next_page"` + SearchAfterCtx string `json:"search_after_ctx,omitempty"` + }](c, ctx, "/alert-event/list", requestBody, "failed to list alert events") if err != nil { - return nil, fmt.Errorf("failed to list alert events: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []AlertEvent `json:"items"` - Total int `json:"total"` - HasNextPage bool `json:"has_next_page"` - SearchAfterCtx string `json:"search_after_ctx,omitempty"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } events := []AlertEvent{} total := 0 hasNextPage := false searchAfterCtx := "" - if result.Data != nil { - events = result.Data.Items - total = result.Data.Total - hasNextPage = result.Data.HasNextPage - searchAfterCtx = result.Data.SearchAfterCtx + if result != nil { + events = result.Items + total = result.Total + hasNextPage = result.HasNextPage + searchAfterCtx = result.SearchAfterCtx } return &ListAlertEventsGlobalOutput{ diff --git a/audit.go b/audit.go index 2bc5107..174cc5c 100644 --- a/audit.go +++ b/audit.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" ) // SearchAuditLogsInput contains parameters for searching audit logs @@ -102,38 +101,22 @@ func (c *Client) SearchAuditLogs(ctx context.Context, input *SearchAuditLogsInpu requestBody["is_write"] = *input.IsWrite } - resp, err := c.makeRequest(ctx, "POST", "/audit/search", requestBody) + result, err := postData[struct { + Docs []AuditLogRecord `json:"docs"` + Total int64 `json:"total"` + SearchAfterCtx string `json:"search_after_ctx"` + }](c, ctx, "/audit/search", requestBody, "failed to search audit logs") if err != nil { - return nil, fmt.Errorf("failed to search audit logs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Docs []AuditLogRecord `json:"docs"` - Total int64 `json:"total"` - SearchAfterCtx string `json:"search_after_ctx"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } logs := []AuditLogRecord{} total := int64(0) searchAfterCtx := "" - if result.Data != nil { - logs = result.Data.Docs - total = result.Data.Total - searchAfterCtx = result.Data.SearchAfterCtx + if result != nil { + logs = result.Docs + total = result.Total + searchAfterCtx = result.SearchAfterCtx } return &SearchAuditLogsOutput{ diff --git a/changes.go b/changes.go index 743b1ef..4302c2c 100644 --- a/changes.go +++ b/changes.go @@ -2,8 +2,6 @@ package flashduty import ( "context" - "fmt" - "net/http" "golang.org/x/sync/errgroup" ) @@ -60,42 +58,26 @@ func (c *Client) ListChanges(ctx context.Context, input *ListChangesInput) (*Lis requestBody["type"] = input.Type } - resp, err := c.makeRequest(ctx, "POST", "/change/list", requestBody) + result, err := postData[struct { + Items []struct { + ChangeID string `json:"change_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + CreatorID int64 `json:"creator_id,omitempty"` + StartTime int64 `json:"start_time,omitempty"` + EndTime int64 `json:"end_time,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + } `json:"items"` + Total int `json:"total"` + }](c, ctx, "/change/list", requestBody, "failed to query changes") if err != nil { - return nil, fmt.Errorf("failed to query changes: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - ChangeID string `json:"change_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` - Status string `json:"status,omitempty"` - ChannelID int64 `json:"channel_id,omitempty"` - CreatorID int64 `json:"creator_id,omitempty"` - StartTime int64 `json:"start_time,omitempty"` - EndTime int64 `json:"end_time,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - } `json:"items"` - Total int `json:"total"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil || len(result.Data.Items) == 0 { + if len(result.Items) == 0 { return &ListChangesOutput{ Changes: []Change{}, Total: 0, @@ -105,7 +87,7 @@ func (c *Client) ListChanges(ctx context.Context, input *ListChangesInput) (*Lis // Collect IDs for enrichment channelIDs := make([]int64, 0) personIDs := make([]int64, 0) - for _, item := range result.Data.Items { + for _, item := range result.Items { if item.ChannelID != 0 { channelIDs = append(channelIDs, item.ChannelID) } @@ -132,8 +114,8 @@ func (c *Client) ListChanges(ctx context.Context, input *ListChangesInput) (*Lis _ = g.Wait() // Build enriched changes - changes := make([]Change, 0, len(result.Data.Items)) - for _, item := range result.Data.Items { + changes := make([]Change, 0, len(result.Items)) + for _, item := range result.Items { change := Change{ ChangeID: item.ChangeID, Title: item.Title, @@ -163,6 +145,6 @@ func (c *Client) ListChanges(ctx context.Context, input *ListChangesInput) (*Lis return &ListChangesOutput{ Changes: changes, - Total: result.Data.Total, + Total: result.Total, }, nil } diff --git a/channels.go b/channels.go index 11c77d4..735715a 100644 --- a/channels.go +++ b/channels.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" "strings" ) @@ -45,48 +44,30 @@ func (c *Client) ListChannels(ctx context.Context, input *ListChannelsInput) (*L } // List all channels - resp, err := c.makeRequest(ctx, "POST", "/channel/list", map[string]any{}) + result, err := postData[struct { + Items []struct { + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + TeamID int64 `json:"team_id,omitempty"` + CreatorID int64 `json:"creator_id,omitempty"` + } `json:"items"` + }](c, ctx, "/channel/list", map[string]any{}, "unable to list channels") if err != nil { - return nil, fmt.Errorf("unable to list channels: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - ChannelID int64 `json:"channel_id"` - ChannelName string `json:"channel_name"` - TeamID int64 `json:"team_id,omitempty"` - CreatorID int64 `json:"creator_id,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } channels := []ChannelInfo{} - if result.Data != nil { - for _, ch := range result.Data.Items { - // Filter by name if provided (case-insensitive substring match) - if input.Name != "" && !strings.Contains(strings.ToLower(ch.ChannelName), strings.ToLower(input.Name)) { - continue - } - channels = append(channels, ChannelInfo{ - ChannelID: ch.ChannelID, - ChannelName: ch.ChannelName, - TeamID: ch.TeamID, - CreatorID: ch.CreatorID, - }) + for _, ch := range result.Items { + // Filter by name if provided (case-insensitive substring match) + if input.Name != "" && !strings.Contains(strings.ToLower(ch.ChannelName), strings.ToLower(input.Name)) { + continue } + channels = append(channels, ChannelInfo{ + ChannelID: ch.ChannelID, + ChannelName: ch.ChannelName, + TeamID: ch.TeamID, + CreatorID: ch.CreatorID, + }) } enrichedChannels, err := c.enrichChannels(ctx, channels) diff --git a/common.go b/common.go new file mode 100644 index 0000000..7ec2ad1 --- /dev/null +++ b/common.go @@ -0,0 +1,112 @@ +package flashduty + +import ( + "context" + "fmt" + "net/http" +) + +type dataEnvelope[T any] struct { + Error *DutyError `json:"error,omitempty"` + Data T `json:"data,omitempty"` +} + +type optionalDataEnvelope[T any] struct { + Error *DutyError `json:"error,omitempty"` + Data *T `json:"data,omitempty"` +} + +type emptyEnvelope struct { + Error *DutyError `json:"error,omitempty"` +} + +func getData[T any](c *Client, ctx context.Context, path string, errPrefix string) (*T, error) { + resp, err := c.makeRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errPrefix, err) + } + defer func() { _ = resp.Body.Close() }() + + return parseData[T](c, resp) +} + +func postData[T any](c *Client, ctx context.Context, path string, body any, errPrefix string) (*T, error) { + resp, err := c.makeRequest(ctx, http.MethodPost, path, body) + if err != nil { + return nil, fmt.Errorf("%s: %w", errPrefix, err) + } + defer func() { _ = resp.Body.Close() }() + + return parseData[T](c, resp) +} + +func getOptionalData[T any](c *Client, ctx context.Context, path string, errPrefix string) (*T, error) { + resp, err := c.makeRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", errPrefix, err) + } + defer func() { _ = resp.Body.Close() }() + + return parseOptionalData[T](c, resp) +} + +func postOptionalData[T any](c *Client, ctx context.Context, path string, body any, errPrefix string) (*T, error) { + resp, err := c.makeRequest(ctx, http.MethodPost, path, body) + if err != nil { + return nil, fmt.Errorf("%s: %w", errPrefix, err) + } + defer func() { _ = resp.Body.Close() }() + + return parseOptionalData[T](c, resp) +} + +func postEmpty(c *Client, ctx context.Context, path string, body any, errPrefix string) error { + resp, err := c.makeRequest(ctx, http.MethodPost, path, body) + if err != nil { + return fmt.Errorf("%s: %w", errPrefix, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleAPIError(c.logger, resp) + } + + var result emptyEnvelope + if err := parseResponse(c.logger, resp, &result); err != nil { + return err + } + if result.Error != nil { + return result.Error + } + return nil +} + +func parseData[T any](c *Client, resp *http.Response) (*T, error) { + if resp.StatusCode != http.StatusOK { + return nil, handleAPIError(c.logger, resp) + } + + var result dataEnvelope[T] + if err := parseResponse(c.logger, resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, result.Error + } + return &result.Data, nil +} + +func parseOptionalData[T any](c *Client, resp *http.Response) (*T, error) { + if resp.StatusCode != http.StatusOK { + return nil, handleAPIError(c.logger, resp) + } + + var result optionalDataEnvelope[T] + if err := parseResponse(c.logger, resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, result.Error + } + return result.Data, nil +} diff --git a/common_test.go b/common_test.go new file mode 100644 index 0000000..775fd7a --- /dev/null +++ b/common_test.go @@ -0,0 +1,36 @@ +package flashduty + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestGetDataDecodesEnvelope(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/common/test" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "name": "ok", + }, + }) + }) + + out, err := getData[struct { + Name string `json:"name"` + }](client, context.Background(), "/common/test", "failed to get test data") + if err != nil { + t.Fatalf("getData returned error: %v", err) + } + if out.Name != "ok" { + t.Fatalf("name = %q, want ok", out.Name) + } +} diff --git a/datasources.go b/datasources.go new file mode 100644 index 0000000..a156e79 --- /dev/null +++ b/datasources.go @@ -0,0 +1,22 @@ +package flashduty + +import "context" + +// DataSourceIntegration represents a configured Flashduty integration data source. +type DataSourceIntegration struct { + DataSourceID int64 `json:"data_source_id" toon:"data_source_id"` + Name string `json:"name,omitempty" toon:"name,omitempty"` + PluginType string `json:"plugin_type,omitempty" toon:"plugin_type,omitempty"` + Category string `json:"category,omitempty" toon:"category,omitempty"` + Settings map[string]any `json:"settings,omitempty" toon:"settings,omitempty"` +} + +// ListWarRoomEnabledDataSourcesOutput contains IM integrations that have war-room enabled. +type ListWarRoomEnabledDataSourcesOutput struct { + Items []DataSourceIntegration `json:"items" toon:"items"` +} + +// ListWarRoomEnabledDataSources lists IM integrations with war-room creation enabled. +func (c *Client) ListWarRoomEnabledDataSources(ctx context.Context) (*ListWarRoomEnabledDataSourcesOutput, error) { + return postData[ListWarRoomEnabledDataSourcesOutput](c, ctx, "/datasource/im/war-room-enabled/list", map[string]any{}, "failed to list war-room enabled data sources") +} diff --git a/enrichment.go b/enrichment.go index 0babe35..fb1adc3 100644 --- a/enrichment.go +++ b/enrichment.go @@ -2,8 +2,6 @@ package flashduty import ( "context" - "fmt" - "net/http" "golang.org/x/sync/errgroup" ) @@ -16,34 +14,17 @@ func (c *Client) fetchIncidentTimeline(ctx context.Context, incidentID string) ( "asc": true, } - resp, err := c.makeRequest(ctx, "POST", "/incident/feed", requestBody) + result, err := postData[struct { + Items []RawTimelineItem `json:"items"` + }](c, ctx, "/incident/feed", requestBody, "unable to fetch timeline") if err != nil { - return nil, fmt.Errorf("unable to fetch timeline: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawTimelineItem `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - - if result.Data == nil { + if result == nil { return nil, nil } - return result.Data.Items, nil + return result.Items, nil } // fetchIncidentAlerts fetches alerts for a single incident @@ -54,43 +35,26 @@ func (c *Client) fetchIncidentAlerts(ctx context.Context, incidentID string, lim "limit": limit, } - resp, err := c.makeRequest(ctx, "POST", "/incident/alert/list", requestBody) + result, err := postData[struct { + Total int `json:"total"` + Items []struct { + AlertID string `json:"alert_id"` + Title string `json:"title"` + Severity string `json:"severity"` + Status string `json:"status"` + TriggerTime int64 `json:"trigger_time"` + Labels map[string]string `json:"labels,omitempty"` + } `json:"items"` + }](c, ctx, "/incident/alert/list", requestBody, "unable to fetch alerts") if err != nil { - return nil, 0, fmt.Errorf("unable to fetch alerts: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, 0, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Total int `json:"total"` - Items []struct { - AlertID string `json:"alert_id"` - Title string `json:"title"` - Severity string `json:"severity"` - Status string `json:"status"` - TriggerTime int64 `json:"trigger_time"` - Labels map[string]string `json:"labels,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, 0, err } - if result.Error != nil { - return nil, 0, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - - if result.Data == nil { + if result == nil { return nil, 0, nil } - alerts := make([]AlertPreview, 0, len(result.Data.Items)) - for _, item := range result.Data.Items { + alerts := make([]AlertPreview, 0, len(result.Items)) + for _, item := range result.Items { alerts = append(alerts, AlertPreview{ AlertID: item.AlertID, Title: item.Title, @@ -100,7 +64,7 @@ func (c *Client) fetchIncidentAlerts(ctx context.Context, incidentID string, lim Labels: item.Labels, }) } - return alerts, result.Data.Total, nil + return alerts, result.Total, nil } // fetchPersonInfos fetches person information by IDs @@ -129,38 +93,22 @@ func (c *Client) fetchPersonInfos(ctx context.Context, personIDs []int64) (map[i "person_ids": uniqueIDs, } - resp, err := c.makeRequest(ctx, "POST", "/person/infos", requestBody) + result, err := postData[struct { + Items []struct { + PersonID int64 `json:"person_id"` + PersonName string `json:"person_name"` + Email string `json:"email,omitempty"` + Avatar string `json:"avatar,omitempty"` + As string `json:"as,omitempty"` + } `json:"items"` + }](c, ctx, "/person/infos", requestBody, "unable to fetch person information") if err != nil { - return nil, fmt.Errorf("unable to fetch person information: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - PersonID int64 `json:"person_id"` - PersonName string `json:"person_name"` - Email string `json:"email,omitempty"` - Avatar string `json:"avatar,omitempty"` - As string `json:"as,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } personMap := make(map[int64]PersonInfo) - if result.Data != nil { - for _, item := range result.Data.Items { + if result != nil { + for _, item := range result.Items { personMap[item.PersonID] = PersonInfo{ PersonID: item.PersonID, PersonName: item.PersonName, @@ -199,35 +147,19 @@ func (c *Client) fetchTeamInfos(ctx context.Context, teamIDs []int64) (map[int64 "team_ids": uniqueIDs, } - resp, err := c.makeRequest(ctx, "POST", "/team/infos", requestBody) + result, err := postData[struct { + Items []struct { + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name"` + } `json:"items"` + }](c, ctx, "/team/infos", requestBody, "unable to fetch team information") if err != nil { - return nil, fmt.Errorf("unable to fetch team information: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - TeamID int64 `json:"team_id"` - TeamName string `json:"team_name"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } teamMap := make(map[int64]TeamInfo) - if result.Data != nil { - for _, item := range result.Data.Items { + if result != nil { + for _, item := range result.Items { teamMap[item.TeamID] = TeamInfo{ TeamID: item.TeamID, TeamName: item.TeamName, @@ -263,35 +195,19 @@ func (c *Client) fetchScheduleInfos(ctx context.Context, scheduleIDs []int64) (m "schedule_ids": uniqueIDs, } - resp, err := c.makeRequest(ctx, "POST", "/schedule/infos", requestBody) + result, err := postData[struct { + Items []struct { + ID *int64 `json:"id"` + Name *string `json:"name"` + } `json:"items"` + }](c, ctx, "/schedule/infos", requestBody, "unable to fetch schedule information") if err != nil { - return nil, fmt.Errorf("unable to fetch schedule information: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - ID *int64 `json:"id"` - Name *string `json:"name"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } scheduleMap := make(map[int64]ScheduleInfo) - if result.Data != nil { - for _, item := range result.Data.Items { + if result != nil { + for _, item := range result.Items { if item.ID != nil { info := ScheduleInfo{ScheduleID: *item.ID} if item.Name != nil { @@ -330,37 +246,21 @@ func (c *Client) fetchChannelInfos(ctx context.Context, channelIDs []int64) (map "channel_ids": uniqueIDs, } - resp, err := c.makeRequest(ctx, "POST", "/channel/infos", requestBody) + result, err := postData[struct { + Items []struct { + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + TeamID int64 `json:"team_id,omitempty"` + CreatorID int64 `json:"creator_id,omitempty"` + } `json:"items"` + }](c, ctx, "/channel/infos", requestBody, "unable to fetch channel information") if err != nil { - return nil, fmt.Errorf("unable to fetch channel information: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - ChannelID int64 `json:"channel_id"` - ChannelName string `json:"channel_name"` - TeamID int64 `json:"team_id,omitempty"` - CreatorID int64 `json:"creator_id,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } channelMap := make(map[int64]ChannelInfo) - if result.Data != nil { - for _, item := range result.Data.Items { + if result != nil { + for _, item := range result.Items { channelMap[item.ChannelID] = ChannelInfo{ ChannelID: item.ChannelID, ChannelName: item.ChannelName, diff --git a/escalation_rules.go b/escalation_rules.go index 4d07845..12f62ca 100644 --- a/escalation_rules.go +++ b/escalation_rules.go @@ -2,9 +2,7 @@ package flashduty import ( "context" - "fmt" "log/slog" - "net/http" "golang.org/x/sync/errgroup" ) @@ -65,31 +63,15 @@ func (c *Client) ListEscalationRules(ctx context.Context, channelID int64) (*Lis "channel_id": channelID, } - resp, err := c.makeRequest(ctx, "POST", "/channel/escalate/rule/list", requestBody) + result, err := postData[struct { + Items []rawEscalationRule `json:"items"` + }](c, ctx, "/channel/escalate/rule/list", requestBody, "unable to query escalation rules") if err != nil { - return nil, fmt.Errorf("unable to query escalation rules: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []rawEscalationRule `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } rules := []EscalationRule{} - if result.Data == nil || len(result.Data.Items) == 0 { + if len(result.Items) == 0 { return &ListEscalationRulesOutput{ Rules: rules, Total: 0, @@ -101,7 +83,7 @@ func (c *Client) ListEscalationRules(ctx context.Context, channelID int64) (*Lis teamIDs := make([]int64, 0) scheduleIDs := make([]int64, 0) - for _, r := range result.Data.Items { + for _, r := range result.Items { for _, l := range r.Layers { if l.Target == nil { continue @@ -172,7 +154,7 @@ func (c *Client) ListEscalationRules(ctx context.Context, channelID int64) (*Lis _ = g.Wait() // Build enriched rules - for _, r := range result.Data.Items { + for _, r := range result.Items { rule := EscalationRule{ RuleID: r.RuleID, RuleName: r.RuleName, diff --git a/fields.go b/fields.go index 377d1de..63557f1 100644 --- a/fields.go +++ b/fields.go @@ -2,8 +2,6 @@ package flashduty import ( "context" - "fmt" - "net/http" ) // ListFieldsInput contains parameters for listing custom fields @@ -20,36 +18,20 @@ type ListFieldsOutput struct { // ListFields queries custom field definitions func (c *Client) ListFields(ctx context.Context, input *ListFieldsInput) (*ListFieldsOutput, error) { - resp, err := c.makeRequest(ctx, "POST", "/field/list", map[string]any{}) + result, err := postData[struct { + Items []struct { + FieldID string `json:"field_id"` + FieldName string `json:"field_name"` + DisplayName string `json:"display_name"` + FieldType string `json:"field_type"` + ValueType string `json:"value_type"` + Options []string `json:"options,omitempty"` + DefaultValue any `json:"default_value,omitempty"` + } `json:"items"` + }](c, ctx, "/field/list", map[string]any{}, "failed to list fields") if err != nil { - return nil, fmt.Errorf("failed to list fields: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - FieldID string `json:"field_id"` - FieldName string `json:"field_name"` - DisplayName string `json:"display_name"` - FieldType string `json:"field_type"` - ValueType string `json:"value_type"` - Options []string `json:"options,omitempty"` - DefaultValue any `json:"default_value,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } // Build filter ID set filterIDSet := make(map[string]struct{}) @@ -58,30 +40,28 @@ func (c *Client) ListFields(ctx context.Context, input *ListFieldsInput) (*ListF } fields := []FieldInfo{} - if result.Data != nil { - for _, f := range result.Data.Items { - // Filter by ID if provided - if len(filterIDSet) > 0 { - if _, ok := filterIDSet[f.FieldID]; !ok { - continue - } - } - - // Filter by name if provided - if input.FieldName != "" && f.FieldName != input.FieldName { + for _, f := range result.Items { + // Filter by ID if provided + if len(filterIDSet) > 0 { + if _, ok := filterIDSet[f.FieldID]; !ok { continue } + } - fields = append(fields, FieldInfo{ - FieldID: f.FieldID, - FieldName: f.FieldName, - DisplayName: f.DisplayName, - FieldType: f.FieldType, - ValueType: f.ValueType, - Options: f.Options, - DefaultValue: f.DefaultValue, - }) + // Filter by name if provided + if input.FieldName != "" && f.FieldName != input.FieldName { + continue } + + fields = append(fields, FieldInfo{ + FieldID: f.FieldID, + FieldName: f.FieldName, + DisplayName: f.DisplayName, + FieldType: f.FieldType, + ValueType: f.ValueType, + Options: f.Options, + DefaultValue: f.DefaultValue, + }) } return &ListFieldsOutput{ diff --git a/incident_lifecycle.go b/incident_lifecycle.go new file mode 100644 index 0000000..31c78b4 --- /dev/null +++ b/incident_lifecycle.go @@ -0,0 +1,253 @@ +package flashduty + +import ( + "context" + "fmt" +) + +// IncidentNotifyInput contains optional notification controls for incident write operations. +type IncidentNotifyInput struct { + FollowPreference bool + PersonalChannels []string + TemplateID string +} + +// IncidentCommentInput contains parameters for adding a comment to one or more incidents. +type IncidentCommentInput struct { + IncidentIDs []string + Comment string + MuteReply bool + Notify *IncidentNotifyInput +} + +// IncidentAddResponderInput contains parameters for adding responders to an incident. +type IncidentAddResponderInput struct { + IncidentID string + PersonIDs []int64 + Notify *IncidentNotifyInput +} + +// IncidentWarRoomCreateInput contains parameters for creating an incident war room. +type IncidentWarRoomCreateInput struct { + IncidentID string + IntegrationID int64 + MemberIDs []int64 + AddObservers bool +} + +// IncidentWarRoomListInput contains parameters for listing war rooms on an incident. +type IncidentWarRoomListInput struct { + IncidentID string + IntegrationID int64 +} + +// IncidentWarRoomDetailInput contains parameters for fetching a war room detail. +type IncidentWarRoomDetailInput struct { + IntegrationID int64 + ChatID string +} + +// IncidentWarRoomDeleteInput contains parameters for deleting an incident war room. +type IncidentWarRoomDeleteInput struct { + IncidentID string + IntegrationID int64 +} + +// IncidentWarRoomAddMemberInput contains parameters for adding members to a war room. +type IncidentWarRoomAddMemberInput struct { + IntegrationID int64 + ChatID string + MemberIDs []int64 +} + +// IncidentWarRoom represents an IM war room. +type IncidentWarRoom struct { + AccountID int64 `json:"account_id,omitempty" toon:"account_id,omitempty"` + IntegrationID int64 `json:"integration_id,omitempty" toon:"integration_id,omitempty"` + ChatID string `json:"chat_id" toon:"chat_id"` + ChatName string `json:"chat_name,omitempty" toon:"chat_name,omitempty"` + ShareLink string `json:"share_link,omitempty" toon:"share_link,omitempty"` + IncidentID string `json:"incident_id,omitempty" toon:"incident_id,omitempty"` + CreatedBy int64 `json:"created_by,omitempty" toon:"created_by,omitempty"` + CreatedAt int64 `json:"created_at,omitempty" toon:"created_at,omitempty"` + PluginType string `json:"plugin_type,omitempty" toon:"plugin_type,omitempty"` + Status string `json:"status,omitempty" toon:"status,omitempty"` +} + +// IncidentWarRoomItem represents a war room list item. +type IncidentWarRoomItem = IncidentWarRoom + +// IncidentWarRoomListOutput contains war rooms for an incident. +type IncidentWarRoomListOutput struct { + Items []IncidentWarRoomItem `json:"items" toon:"items"` +} + +// IncidentWarRoomObserver represents a default observer candidate for war room invitation. +type IncidentWarRoomObserver struct { + PersonID int64 `json:"person_id" toon:"person_id"` + PersonName string `json:"person_name,omitempty" toon:"person_name,omitempty"` + Nickname string `json:"nickname,omitempty" toon:"nickname,omitempty"` + Name string `json:"name,omitempty" toon:"name,omitempty"` + Email string `json:"email,omitempty" toon:"email,omitempty"` + Phone string `json:"phone,omitempty" toon:"phone,omitempty"` + Status string `json:"status,omitempty" toon:"status,omitempty"` + AssignedAt int64 `json:"assigned_at,omitempty" toon:"assigned_at,omitempty"` +} + +// DisplayName returns the best available human-readable observer name. +func (o IncidentWarRoomObserver) DisplayName() string { + if o.PersonName != "" { + return o.PersonName + } + if o.Nickname != "" { + return o.Nickname + } + return o.Name +} + +// UnackIncidents cancels acknowledgement for one or more incidents. +func (c *Client) UnackIncidents(ctx context.Context, incidentIDs []string) error { + return postEmpty(c, ctx, "/incident/unack", map[string]any{"incident_ids": incidentIDs}, "failed to unack incidents") +} + +// WakeIncidents wakes one or more incidents from snooze. +func (c *Client) WakeIncidents(ctx context.Context, incidentIDs []string) error { + return postEmpty(c, ctx, "/incident/wake", map[string]any{"incident_ids": incidentIDs}, "failed to wake incidents") +} + +// RemoveIncidents removes one or more incidents. +func (c *Client) RemoveIncidents(ctx context.Context, incidentIDs []string) error { + return postEmpty(c, ctx, "/incident/remove", map[string]any{"incident_ids": incidentIDs}, "failed to remove incidents") +} + +// DisableIncidentMerge disables merge for one or more incidents. +func (c *Client) DisableIncidentMerge(ctx context.Context, incidentIDs []string) error { + return postEmpty(c, ctx, "/incident/disable-merge", map[string]any{"incident_ids": incidentIDs}, "failed to disable incident merge") +} + +// CommentIncidents adds a comment to one or more incidents. +func (c *Client) CommentIncidents(ctx context.Context, input *IncidentCommentInput) error { + if input == nil { + return fmt.Errorf("incident comment input is required") + } + body := map[string]any{ + "incident_ids": input.IncidentIDs, + "comment": input.Comment, + } + if input.MuteReply { + body["mute_reply"] = true + } + if notify := buildIncidentNotifyBody(input.Notify); len(notify) > 0 { + body["notify"] = notify + } + return postEmpty(c, ctx, "/incident/comment", body, "failed to comment incidents") +} + +// AddIncidentResponders adds responders to an incident. +func (c *Client) AddIncidentResponders(ctx context.Context, input *IncidentAddResponderInput) error { + if input == nil { + return fmt.Errorf("incident add responder input is required") + } + body := map[string]any{ + "incident_id": input.IncidentID, + "person_ids": input.PersonIDs, + } + if notify := buildIncidentNotifyBody(input.Notify); len(notify) > 0 { + body["notify"] = notify + } + return postEmpty(c, ctx, "/incident/responder/add", body, "failed to add incident responders") +} + +// CreateIncidentWarRoom creates an IM war room for an incident. +func (c *Client) CreateIncidentWarRoom(ctx context.Context, input *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) { + if input == nil { + return nil, fmt.Errorf("incident war-room create input is required") + } + body := map[string]any{ + "incident_id": input.IncidentID, + "integration_id": input.IntegrationID, + } + if len(input.MemberIDs) > 0 { + body["member_ids"] = input.MemberIDs + } + if input.AddObservers { + body["add_observers"] = true + } + return postData[IncidentWarRoom](c, ctx, "/incident/war-room/create", body, "failed to create incident war room") +} + +// ListIncidentWarRooms lists war rooms for an incident. +func (c *Client) ListIncidentWarRooms(ctx context.Context, input *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) { + if input == nil { + return nil, fmt.Errorf("incident war-room list input is required") + } + body := map[string]any{"incident_id": input.IncidentID} + if input.IntegrationID > 0 { + body["integration_id"] = input.IntegrationID + } + return postData[IncidentWarRoomListOutput](c, ctx, "/incident/war-room/list", body, "failed to list incident war rooms") +} + +// GetIncidentWarRoom fetches war room details from the IM provider. +func (c *Client) GetIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) { + if input == nil { + return nil, fmt.Errorf("incident war-room detail input is required") + } + return postData[IncidentWarRoom](c, ctx, "/incident/war-room/detail", map[string]any{ + "integration_id": input.IntegrationID, + "chat_id": input.ChatID, + }, "failed to get incident war room") +} + +// DeleteIncidentWarRoom deletes an incident war room. +func (c *Client) DeleteIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDeleteInput) error { + if input == nil { + return fmt.Errorf("incident war-room delete input is required") + } + return postEmpty(c, ctx, "/incident/war-room/delete", map[string]any{ + "incident_id": input.IncidentID, + "integration_id": input.IntegrationID, + }, "failed to delete incident war room") +} + +// AddIncidentWarRoomMembers adds members to an existing incident war room. +func (c *Client) AddIncidentWarRoomMembers(ctx context.Context, input *IncidentWarRoomAddMemberInput) error { + if input == nil { + return fmt.Errorf("incident war-room add-member input is required") + } + return postEmpty(c, ctx, "/incident/war-room/add-member", map[string]any{ + "integration_id": input.IntegrationID, + "chat_id": input.ChatID, + "member_ids": input.MemberIDs, + }, "failed to add incident war room members") +} + +// GetIncidentWarRoomDefaultObservers returns historical responders eligible for observer invitation. +func (c *Client) GetIncidentWarRoomDefaultObservers(ctx context.Context, incidentID string) ([]IncidentWarRoomObserver, error) { + out, err := postData[struct { + Observers []IncidentWarRoomObserver `json:"observers"` + }](c, ctx, "/incident/war-room/default-observers", map[string]any{ + "incident_id": incidentID, + }, "failed to get incident war room default observers") + if err != nil { + return nil, err + } + return out.Observers, nil +} + +func buildIncidentNotifyBody(input *IncidentNotifyInput) map[string]any { + notify := map[string]any{} + if input == nil { + return notify + } + if input.FollowPreference { + notify["follow_preference"] = true + } + if len(input.PersonalChannels) > 0 { + notify["personal_channels"] = input.PersonalChannels + } + if input.TemplateID != "" { + notify["template_id"] = input.TemplateID + } + return notify +} diff --git a/incident_lifecycle_test.go b/incident_lifecycle_test.go new file mode 100644 index 0000000..9d245c7 --- /dev/null +++ b/incident_lifecycle_test.go @@ -0,0 +1,178 @@ +package flashduty + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestUnackIncidentsPostsIncidentIDs(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/incident/unack" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + body := decodeJSONBody(t, r) + if !reflect.DeepEqual(body["incident_ids"], []any{"inc-1", "inc-2"}) { + t.Fatalf("incident_ids = %#v, want inc-1/inc-2", body["incident_ids"]) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + + if err := client.UnackIncidents(context.Background(), []string{"inc-1", "inc-2"}); err != nil { + t.Fatalf("UnackIncidents error: %v", err) + } +} + +func TestCommentIncidentsIncludesNotifyOptions(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/incident/comment" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + body := decodeJSONBody(t, r) + if body["comment"] != "investigating" { + t.Fatalf("comment = %#v, want investigating", body["comment"]) + } + notify, ok := body["notify"].(map[string]any) + if !ok { + t.Fatalf("notify missing or wrong type: %#v", body["notify"]) + } + if notify["follow_preference"] != true || notify["template_id"] != "tpl-1" { + t.Fatalf("unexpected notify: %#v", notify) + } + if !reflect.DeepEqual(notify["personal_channels"], []any{"email", "sms"}) { + t.Fatalf("personal_channels = %#v", notify["personal_channels"]) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + + err := client.CommentIncidents(context.Background(), &IncidentCommentInput{ + IncidentIDs: []string{"inc-1"}, + Comment: "investigating", + MuteReply: true, + Notify: &IncidentNotifyInput{ + FollowPreference: true, + PersonalChannels: []string{"email", "sms"}, + TemplateID: "tpl-1", + }, + }) + if err != nil { + t.Fatalf("CommentIncidents error: %v", err) + } +} + +func TestCreateIncidentWarRoomIncludesObserversAndDecodesChat(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/incident/war-room/create" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + body := decodeJSONBody(t, r) + if body["incident_id"] != "inc-1" || body["integration_id"] != float64(42) { + t.Fatalf("unexpected request body: %#v", body) + } + if body["add_observers"] != true { + t.Fatalf("add_observers = %#v, want true", body["add_observers"]) + } + if !reflect.DeepEqual(body["member_ids"], []any{float64(101), float64(202)}) { + t.Fatalf("member_ids = %#v", body["member_ids"]) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "chat_id": "chat-1", + "chat_name": "INC outage", + "share_link": "https://chat.example/1", + }, + }) + }) + + out, err := client.CreateIncidentWarRoom(context.Background(), &IncidentWarRoomCreateInput{ + IncidentID: "inc-1", + IntegrationID: 42, + MemberIDs: []int64{101, 202}, + AddObservers: true, + }) + if err != nil { + t.Fatalf("CreateIncidentWarRoom error: %v", err) + } + if out.ChatID != "chat-1" || out.ShareLink != "https://chat.example/1" { + t.Fatalf("unexpected war room: %#v", out) + } +} + +func TestListIncidentWarRoomsDecodesItems(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/incident/war-room/list" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + body := decodeJSONBody(t, r) + if body["incident_id"] != "inc-1" { + t.Fatalf("incident_id = %#v, want inc-1", body["incident_id"]) + } + if _, ok := body["integration_id"]; ok { + t.Fatalf("unexpected integration_id in request: %#v", body) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "items": []any{ + map[string]any{ + "integration_id": 42, + "chat_id": "chat-1", + "chat_name": "INC outage", + "incident_id": "inc-1", + "status": "enabled", + "plugin_type": "feishu_app", + }, + }, + }, + }) + }) + + out, err := client.ListIncidentWarRooms(context.Background(), &IncidentWarRoomListInput{IncidentID: "inc-1"}) + if err != nil { + t.Fatalf("ListIncidentWarRooms error: %v", err) + } + if len(out.Items) != 1 || out.Items[0].IntegrationID != 42 || out.Items[0].ChatID != "chat-1" { + t.Fatalf("unexpected war room list: %#v", out) + } +} + +func TestListWarRoomEnabledDataSourcesDecodesItems(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/datasource/im/war-room-enabled/list" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + body := decodeJSONBody(t, r) + if len(body) != 0 { + t.Fatalf("expected empty request body, got %#v", body) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "items": []any{ + map[string]any{ + "data_source_id": 42, + "name": "Feishu", + "plugin_type": "feishu_app", + "category": "im", + "settings": map[string]any{ + "war_room_enabled": true, + }, + }, + }, + }, + }) + }) + + out, err := client.ListWarRoomEnabledDataSources(context.Background()) + if err != nil { + t.Fatalf("ListWarRoomEnabledDataSources error: %v", err) + } + if len(out.Items) != 1 || out.Items[0].DataSourceID != 42 || out.Items[0].PluginType != "feishu_app" { + t.Fatalf("unexpected datasource list: %#v", out) + } +} diff --git a/incidents.go b/incidents.go index 33f8242..c45ebfe 100644 --- a/incidents.go +++ b/incidents.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" "golang.org/x/sync/errgroup" ) @@ -20,9 +19,9 @@ type ListIncidentsInput struct { // a non-zero ChannelID is wrapped into a single-element ChannelIDs slice. // The backend /incident/list endpoint expects channel_ids (array) — singular // channel_id is silently ignored. - ChannelID int64 - StartTime int64 // Unix timestamp (seconds), required if no IncidentIDs - EndTime int64 // Unix timestamp (seconds), required if no IncidentIDs + ChannelID int64 + StartTime int64 // Unix timestamp (seconds), required if no IncidentIDs + EndTime int64 // Unix timestamp (seconds), required if no IncidentIDs // Query is the backend's full-text search field on /incident/list. It // searches across title/labels/content via Doris and also resolves a 24-char // ObjectID to incident_ids and a 6-char string to a num lookup. Prefer this @@ -236,45 +235,29 @@ func (c *Client) ListSimilarIncidents(ctx context.Context, incidentID string, li "limit": limit, } - resp, err := c.makeRequest(ctx, "POST", "/incident/past/list", requestBody) + result, err := postData[struct { + Items []RawIncident `json:"items"` + Total int `json:"total"` + }](c, ctx, "/incident/past/list", requestBody, "unable to find similar incidents") if err != nil { - return nil, fmt.Errorf("unable to find similar incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawIncident `json:"items"` - Total int `json:"total"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil || len(result.Data.Items) == 0 { + if result == nil || len(result.Items) == 0 { return &ListIncidentsOutput{ Incidents: []EnrichedIncident{}, Total: 0, }, nil } - enrichedIncidents, err := c.enrichIncidents(ctx, result.Data.Items) + enrichedIncidents, err := c.enrichIncidents(ctx, result.Items) if err != nil { return nil, fmt.Errorf("unable to load additional incident details: %w", err) } return &ListIncidentsOutput{ Incidents: enrichedIncidents, - Total: result.Data.Total, + Total: result.Total, }, nil } @@ -306,21 +289,15 @@ func (c *Client) CreateIncident(ctx context.Context, input *CreateIncidentInput) } } - resp, err := c.makeRequest(ctx, "POST", "/incident/create", requestBody) + data, err := postData[any](c, ctx, "/incident/create", requestBody, "failed to create incident") if err != nil { - return nil, fmt.Errorf("failed to create incident: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error + if data == nil { + return nil, nil } - return result.Data, nil + return *data, nil } // UpdateIncidentInput contains parameters for updating an incident @@ -374,21 +351,9 @@ func (c *Client) UpdateIncident(ctx context.Context, input *UpdateIncidentInput) } if len(resetBody) > 1 { - resp, err := c.makeRequest(ctx, "POST", "/incident/reset", resetBody) - if err != nil { - return nil, fmt.Errorf("unable to update incident: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { + if err := postEmpty(c, ctx, "/incident/reset", resetBody, "unable to update incident"); err != nil { return nil, err } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } } if len(input.CustomFields) > 0 { @@ -422,25 +387,7 @@ func (c *Client) AckIncidents(ctx context.Context, incidentIDs []string) error { "incident_ids": incidentIDs, } - resp, err := c.makeRequest(ctx, "POST", "/incident/ack", requestBody) - if err != nil { - return fmt.Errorf("unable to acknowledge incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + return postEmpty(c, ctx, "/incident/ack", requestBody, "unable to acknowledge incidents") } // CloseIncidents closes (resolves) one or more incidents @@ -449,25 +396,7 @@ func (c *Client) CloseIncidents(ctx context.Context, incidentIDs []string) error "incident_ids": incidentIDs, } - resp, err := c.makeRequest(ctx, "POST", "/incident/resolve", requestBody) - if err != nil { - return fmt.Errorf("unable to close incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + return postEmpty(c, ctx, "/incident/resolve", requestBody, "unable to close incidents") } // fetchIncidentsByIDs fetches incidents by their IDs @@ -476,32 +405,16 @@ func (c *Client) fetchIncidentsByIDs(ctx context.Context, incidentIDs []string) "incident_ids": incidentIDs, } - resp, err := c.makeRequest(ctx, "POST", "/incident/list-by-ids", requestBody) + result, err := postData[struct { + Items []RawIncident `json:"items"` + }](c, ctx, "/incident/list-by-ids", requestBody, "unable to fetch incidents by IDs") if err != nil { return nil, err } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawIncident `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - if result.Data == nil { + if result == nil { return nil, nil } - return result.Data.Items, nil + return result.Items, nil } // fetchIncidentsByFilters fetches incidents by filters @@ -529,32 +442,16 @@ func (c *Client) fetchIncidentsByFilters(ctx context.Context, progress, severity requestBody["title"] = title } - resp, err := c.makeRequest(ctx, "POST", "/incident/list", requestBody) + result, err := postData[struct { + Items []RawIncident `json:"items"` + }](c, ctx, "/incident/list", requestBody, "unable to fetch incidents") if err != nil { return nil, err } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawIncident `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - if result.Data == nil { + if result == nil { return nil, nil } - return result.Data.Items, nil + return result.Items, nil } // GetIncidentDetailInput contains parameters for getting incident detail @@ -577,32 +474,16 @@ func (c *Client) GetIncidentDetail(ctx context.Context, input *GetIncidentDetail "incident_id": input.IncidentID, } - resp, err := c.makeRequest(ctx, "POST", "/incident/info", requestBody) + incident, err := postOptionalData[IncidentDetail](c, ctx, "/incident/info", requestBody, "failed to get incident detail") if err != nil { - return nil, fmt.Errorf("failed to get incident detail: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *IncidentDetail `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil { + if incident == nil { return nil, fmt.Errorf("incident not found: %s", input.IncidentID) } - return &GetIncidentDetailOutput{Incident: *result.Data}, nil + return &GetIncidentDetailOutput{Incident: *incident}, nil } // GetIncidentFeedInput contains parameters for getting incident feed/timeline @@ -641,31 +522,15 @@ func (c *Client) GetIncidentFeed(ctx context.Context, input *GetIncidentFeedInpu "asc": input.Asc, } - resp, err := c.makeRequest(ctx, "POST", "/incident/feed", requestBody) + result, err := postData[struct { + Items []RawTimelineItem `json:"items"` + HasNextPage bool `json:"has_next_page"` + }](c, ctx, "/incident/feed", requestBody, "failed to get incident feed") if err != nil { - return nil, fmt.Errorf("failed to get incident feed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawTimelineItem `json:"items"` - HasNextPage bool `json:"has_next_page"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil || len(result.Data.Items) == 0 { + if result == nil || len(result.Items) == 0 { return &GetIncidentFeedOutput{ Items: []TimelineEvent{}, HasNextPage: false, @@ -673,17 +538,17 @@ func (c *Client) GetIncidentFeed(ctx context.Context, input *GetIncidentFeedInpu } // Enrich with person names - personIDs := collectTimelinePersonIDs(result.Data.Items) + personIDs := collectTimelinePersonIDs(result.Items) personMap, err := c.fetchPersonInfos(ctx, personIDs) if err != nil { return nil, fmt.Errorf("failed to load person details for feed: %w", err) } - enrichedItems := enrichTimelineItems(result.Data.Items, personMap) + enrichedItems := enrichTimelineItems(result.Items, personMap) return &GetIncidentFeedOutput{ Items: enrichedItems, - HasNextPage: result.Data.HasNextPage, + HasNextPage: result.HasNextPage, }, nil } @@ -756,41 +621,25 @@ func (c *Client) ListPostMortems(ctx context.Context, input *ListPostMortemsInpu requestBody["search_after_ctx"] = input.SearchAfterCtx } - resp, err := c.makeRequest(ctx, "POST", "/incident/post-mortem/list", requestBody) + result, err := postData[struct { + Items []PostMortem `json:"items"` + Total int `json:"total"` + HasNextPage bool `json:"has_next_page"` + SearchAfterCtx string `json:"search_after_ctx,omitempty"` + }](c, ctx, "/incident/post-mortem/list", requestBody, "failed to list post-mortems") if err != nil { - return nil, fmt.Errorf("failed to list post-mortems: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []PostMortem `json:"items"` - Total int `json:"total"` - HasNextPage bool `json:"has_next_page"` - SearchAfterCtx string `json:"search_after_ctx,omitempty"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } postMortems := []PostMortem{} total := 0 hasNextPage := false searchAfterCtx := "" - if result.Data != nil { - postMortems = result.Data.Items - total = result.Data.Total - hasNextPage = result.Data.HasNextPage - searchAfterCtx = result.Data.SearchAfterCtx + if result != nil { + postMortems = result.Items + total = result.Total + hasNextPage = result.HasNextPage + searchAfterCtx = result.SearchAfterCtx } return &ListPostMortemsOutput{ @@ -809,24 +658,7 @@ func (c *Client) updateCustomField(ctx context.Context, incidentID, fieldName st "field_value": fieldValue, } - resp, err := c.makeRequest(ctx, "POST", "/incident/field/reset", requestBody) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - return nil + return postEmpty(c, ctx, "/incident/field/reset", requestBody, "failed to update custom field") } // MergeIncidentsInput contains parameters for merging incidents @@ -846,25 +678,7 @@ func (c *Client) MergeIncidents(ctx context.Context, input *MergeIncidentsInput) "target_incident_id": input.TargetIncidentID, } - resp, err := c.makeRequest(ctx, "POST", "/incident/merge", requestBody) - if err != nil { - return fmt.Errorf("failed to merge incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + return postEmpty(c, ctx, "/incident/merge", requestBody, "failed to merge incidents") } // SnoozeIncidentsInput contains parameters for snoozing incidents @@ -884,25 +698,7 @@ func (c *Client) SnoozeIncidents(ctx context.Context, input *SnoozeIncidentsInpu "minutes": input.Minutes, } - resp, err := c.makeRequest(ctx, "POST", "/incident/snooze", requestBody) - if err != nil { - return fmt.Errorf("failed to snooze incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + return postEmpty(c, ctx, "/incident/snooze", requestBody, "failed to snooze incidents") } // ReopenIncidents reopens one or more closed incidents @@ -911,25 +707,7 @@ func (c *Client) ReopenIncidents(ctx context.Context, incidentIDs []string) erro "incident_ids": incidentIDs, } - resp, err := c.makeRequest(ctx, "POST", "/incident/reopen", requestBody) - if err != nil { - return fmt.Errorf("failed to reopen incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + return postEmpty(c, ctx, "/incident/reopen", requestBody, "failed to reopen incidents") } // ReassignIncidentsInput contains parameters for reassigning incidents @@ -952,23 +730,5 @@ func (c *Client) ReassignIncidents(ctx context.Context, input *ReassignIncidents }, } - resp, err := c.makeRequest(ctx, "POST", "/incident/assign", requestBody) - if err != nil { - return fmt.Errorf("failed to reassign incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + return postEmpty(c, ctx, "/incident/assign", requestBody, "failed to reassign incidents") } diff --git a/insight.go b/insight.go index 131a836..ae91a3f 100644 --- a/insight.go +++ b/insight.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" ) // InsightQueryInput contains common parameters for all /insight/* endpoints @@ -87,32 +86,16 @@ func (c *Client) QueryInsightByTeam(ctx context.Context, input *InsightQueryInpu return nil, fmt.Errorf("query input is required") } - resp, err := c.makeRequest(ctx, "POST", "/insight/team", input.buildRequestBody()) + result, err := postData[struct { + Items []DimensionInsightItem `json:"items"` + }](c, ctx, "/insight/team", input.buildRequestBody(), "failed to query team insights") if err != nil { - return nil, fmt.Errorf("failed to query team insights: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []DimensionInsightItem `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } items := []DimensionInsightItem{} - if result.Data != nil { - items = result.Data.Items + if result != nil { + items = result.Items } return &QueryInsightByTeamOutput{Items: items}, nil @@ -129,32 +112,16 @@ func (c *Client) QueryInsightByResponder(ctx context.Context, input *InsightQuer return nil, fmt.Errorf("query input is required") } - resp, err := c.makeRequest(ctx, "POST", "/insight/responder", input.buildRequestBody()) + result, err := postData[struct { + Items []ResponderInsightItem `json:"items"` + }](c, ctx, "/insight/responder", input.buildRequestBody(), "failed to query responder insights") if err != nil { - return nil, fmt.Errorf("failed to query responder insights: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []ResponderInsightItem `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } items := []ResponderInsightItem{} - if result.Data != nil { - items = result.Data.Items + if result != nil { + items = result.Items } return &QueryInsightByResponderOutput{Items: items}, nil @@ -171,32 +138,16 @@ func (c *Client) QueryInsightByChannel(ctx context.Context, input *InsightQueryI return nil, fmt.Errorf("query input is required") } - resp, err := c.makeRequest(ctx, "POST", "/insight/channel", input.buildRequestBody()) + result, err := postData[struct { + Items []DimensionInsightItem `json:"items"` + }](c, ctx, "/insight/channel", input.buildRequestBody(), "failed to query channel insights") if err != nil { - return nil, fmt.Errorf("failed to query channel insights: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []DimensionInsightItem `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } items := []DimensionInsightItem{} - if result.Data != nil { - items = result.Data.Items + if result != nil { + items = result.Items } return &QueryInsightByChannelOutput{Items: items}, nil @@ -238,32 +189,16 @@ func (c *Client) QueryInsightAlertTopK(ctx context.Context, input *QueryInsightA body["asc"] = true } - resp, err := c.makeRequest(ctx, "POST", "/insight/alert/topk-by-label", body) + result, err := postData[struct { + Items []InsightAlertByLabelItem `json:"items"` + }](c, ctx, "/insight/alert/topk-by-label", body, "failed to query alert top-K") if err != nil { - return nil, fmt.Errorf("failed to query alert top-K: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []InsightAlertByLabelItem `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } items := []InsightAlertByLabelItem{} - if result.Data != nil { - items = result.Data.Items + if result != nil { + items = result.Items } return &QueryInsightAlertTopKOutput{Items: items}, nil @@ -309,41 +244,25 @@ func (c *Client) QueryInsightIncidentList(ctx context.Context, input *QueryInsig body["search_after_ctx"] = input.SearchAfterCtx } - resp, err := c.makeRequest(ctx, "POST", "/insight/incident/list", body) + result, err := postData[struct { + Items []InsightIncidentItem `json:"items"` + Total int `json:"total"` + HasNextPage bool `json:"has_next_page"` + SearchAfterCtx string `json:"search_after_ctx,omitempty"` + }](c, ctx, "/insight/incident/list", body, "failed to query insight incidents") if err != nil { - return nil, fmt.Errorf("failed to query insight incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []InsightIncidentItem `json:"items"` - Total int `json:"total"` - HasNextPage bool `json:"has_next_page"` - SearchAfterCtx string `json:"search_after_ctx,omitempty"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } items := []InsightIncidentItem{} total := 0 hasNextPage := false searchAfterCtx := "" - if result.Data != nil { - items = result.Data.Items - total = result.Data.Total - hasNextPage = result.Data.HasNextPage - searchAfterCtx = result.Data.SearchAfterCtx + if result != nil { + items = result.Items + total = result.Total + hasNextPage = result.HasNextPage + searchAfterCtx = result.SearchAfterCtx } return &QueryInsightIncidentListOutput{ diff --git a/member_info.go b/member_info.go index ce40a86..50dfb0f 100644 --- a/member_info.go +++ b/member_info.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" ) type MemberInfo struct { @@ -20,32 +19,14 @@ type MemberInfo struct { CreatedAt int64 `json:"created_at"` } -type memberInfoResponse struct { - Error *DutyError `json:"error,omitempty"` - Data *MemberInfo `json:"data,omitempty"` -} - func (c *Client) GetMemberInfo(ctx context.Context) (*MemberInfo, error) { - resp, err := c.makeRequest(ctx, "POST", "/member/info", map[string]any{}) + data, err := postOptionalData[MemberInfo](c, ctx, "/member/info", map[string]any{}, "unable to get member info") if err != nil { - return nil, fmt.Errorf("unable to get member info: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result memberInfoResponse - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil { + if data == nil { return nil, fmt.Errorf("empty member info in response") } - return result.Data, nil + return data, nil } diff --git a/members.go b/members.go index fe7489b..9c35ce6 100644 --- a/members.go +++ b/members.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" ) const defaultMembersQueryLimit = 20 @@ -62,29 +61,21 @@ func (c *Client) ListMembers(ctx context.Context, input *ListMembersInput) (*Lis requestBody["query"] = input.Email } - resp, err := c.makeRequest(ctx, "POST", "/member/list", requestBody) + result, err := postOptionalData[struct { + P int `json:"p"` + Limit int `json:"limit"` + Total int `json:"total"` + Items []MemberItem `json:"items"` + }](c, ctx, "/member/list", requestBody, "unable to list members") if err != nil { - return nil, fmt.Errorf("unable to list members: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result MemberListResponse - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } members := []MemberItem{} total := 0 - if result.Data != nil { - members = result.Data.Items - total = result.Data.Total + if result != nil { + members = result.Items + total = result.Total } return &ListMembersOutput{ diff --git a/monitors.go b/monitors.go index f20fe71..bd1ddf4 100644 --- a/monitors.go +++ b/monitors.go @@ -2,8 +2,6 @@ package flashduty import ( "context" - "fmt" - "net/http" ) // QueryMonitorRuleStatusInput contains parameters for querying monitor rule status @@ -33,31 +31,15 @@ func (c *Client) QueryMonitorRuleStatus(ctx context.Context, input *QueryMonitor requestBody := map[string]any{} - resp, err := c.makeRequest(ctx, "POST", "/monit/rule/counter/status", requestBody) + statuses, err := postData[[]MonitorRuleFolderStatus](c, ctx, "/monit/rule/counter/status", requestBody, "failed to query monitor rule status") if err != nil { - return nil, fmt.Errorf("failed to query monitor rule status: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data []MonitorRuleFolderStatus `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - statuses := []MonitorRuleFolderStatus{} - if result.Data != nil { - statuses = result.Data + out := []MonitorRuleFolderStatus{} + if statuses != nil { + out = *statuses } - return &QueryMonitorRuleStatusOutput{Statuses: statuses}, nil + return &QueryMonitorRuleStatusOutput{Statuses: out}, nil } diff --git a/reports.go b/reports.go index 7404879..7d847ee 100644 --- a/reports.go +++ b/reports.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" ) // QueryNotificationTrendInput contains parameters for querying notification trends @@ -45,32 +44,16 @@ func (c *Client) QueryNotificationTrend(ctx context.Context, input *QueryNotific requestBody["channel_ids"] = input.ChannelIDs } - resp, err := c.makeRequest(ctx, "POST", "/report/oncall/notifications", requestBody) + result, err := postData[struct { + Items []NotificationTrendPoint `json:"items"` + }](c, ctx, "/report/oncall/notifications", requestBody, "failed to query notification trend") if err != nil { - return nil, fmt.Errorf("failed to query notification trend: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []NotificationTrendPoint `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } dataPoints := []NotificationTrendPoint{} - if result.Data != nil { - dataPoints = result.Data.Items + if result != nil { + dataPoints = result.Items } return &QueryNotificationTrendOutput{DataPoints: dataPoints}, nil @@ -110,32 +93,16 @@ func (c *Client) QueryChangeTrend(ctx context.Context, input *QueryChangeTrendIn "end_time": input.EndTime, } - resp, err := c.makeRequest(ctx, "POST", "/report/oncall/changes", requestBody) + result, err := postData[struct { + Items []ChangeTrendPoint `json:"items"` + }](c, ctx, "/report/oncall/changes", requestBody, "failed to query change trend") if err != nil { - return nil, fmt.Errorf("failed to query change trend: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []ChangeTrendPoint `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } dataPoints := []ChangeTrendPoint{} - if result.Data != nil { - dataPoints = result.Data.Items + if result != nil { + dataPoints = result.Items } return &QueryChangeTrendOutput{DataPoints: dataPoints}, nil diff --git a/schedules.go b/schedules.go index 7738e7d..fc9a071 100644 --- a/schedules.go +++ b/schedules.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" ) // ListSchedulesWithSlotsInput contains parameters for listing schedules with computed slots @@ -188,35 +187,19 @@ func (c *Client) ListSchedulesWithSlots(ctx context.Context, input *ListSchedule requestBody["team_ids"] = teamIDs } - resp, err := c.makeRequest(ctx, "POST", "/schedule/list", requestBody) + result, err := postData[struct { + Items []ScheduleDetail `json:"items"` + Total int64 `json:"total"` + }](c, ctx, "/schedule/list", requestBody, "failed to list schedules") if err != nil { - return nil, fmt.Errorf("failed to list schedules: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []ScheduleDetail `json:"items"` - Total int64 `json:"total"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } schedules := []ScheduleDetail{} total := int64(0) - if result.Data != nil { - schedules = result.Data.Items - total = result.Data.Total + if result != nil { + schedules = result.Items + total = result.Total } return &ListSchedulesWithSlotsOutput{ @@ -255,30 +238,14 @@ func (c *Client) GetScheduleDetail(ctx context.Context, input *GetScheduleDetail "end": input.End, } - resp, err := c.makeRequest(ctx, "POST", "/schedule/info", requestBody) + schedule, err := postOptionalData[ScheduleDetail](c, ctx, "/schedule/info", requestBody, "failed to get schedule detail") if err != nil { - return nil, fmt.Errorf("failed to get schedule detail: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *ScheduleDetail `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil { + if schedule == nil { return nil, fmt.Errorf("schedule not found: %d", input.ScheduleID) } - return &GetScheduleDetailOutput{Schedule: *result.Data}, nil + return &GetScheduleDetailOutput{Schedule: *schedule}, nil } diff --git a/statuspage.go b/statuspage.go index ac167d0..153dac6 100644 --- a/statuspage.go +++ b/statuspage.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "net/http" "net/url" "strconv" "strings" @@ -14,39 +13,23 @@ import ( // ListStatusPages queries status pages, optionally filtering by page IDs func (c *Client) ListStatusPages(ctx context.Context, pageIDs []int64) ([]StatusPage, error) { - resp, err := c.makeRequest(ctx, "GET", "/status-page/list", nil) + result, err := getData[struct { + Items []struct { + PageID int64 `json:"page_id"` + PageName string `json:"name"` + URLName string `json:"url_name,omitempty"` + Description string `json:"description,omitempty"` + Components []struct { + ComponentID string `json:"component_id"` + Name string `json:"name"` + } `json:"components,omitempty"` + } `json:"items"` + }](c, ctx, "/status-page/list", "failed to list status pages") if err != nil { - return nil, fmt.Errorf("failed to list status pages: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - PageID int64 `json:"page_id"` - PageName string `json:"name"` - URLName string `json:"url_name,omitempty"` - Description string `json:"description,omitempty"` - Components []struct { - ComponentID string `json:"component_id"` - Name string `json:"name"` - } `json:"components,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil || len(result.Data.Items) == 0 { + if result == nil || len(result.Items) == 0 { return []StatusPage{}, nil } @@ -57,7 +40,7 @@ func (c *Client) ListStatusPages(ctx context.Context, pageIDs []int64) ([]Status } pages := make([]StatusPage, 0) - for _, item := range result.Data.Items { + for _, item := range result.Items { if len(pageIDs) > 0 { if _, ok := pageIDSet[item.PageID]; !ok { continue @@ -111,35 +94,19 @@ func (c *Client) ListStatusChanges(ctx context.Context, input *ListStatusChanges params := url.Values{} params.Set("page_id", strconv.FormatInt(input.PageID, 10)) params.Set("type", input.ChangeType) - resp, err := c.makeRequest(ctx, "GET", "/status-page/change/active/list?"+params.Encode(), nil) + result, err := getData[struct { + Items []StatusChange `json:"items"` + Total int `json:"total"` + }](c, ctx, "/status-page/change/active/list?"+params.Encode(), "failed to list status changes") if err != nil { - return nil, fmt.Errorf("failed to list status changes: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []StatusChange `json:"items"` - Total int `json:"total"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } changes := []StatusChange{} total := 0 - if result.Data != nil { - changes = result.Data.Items - total = result.Data.Total + if result != nil { + changes = result.Items + total = result.Total } return &ListStatusChangesOutput{ @@ -211,21 +178,15 @@ func (c *Client) CreateStatusIncident(ctx context.Context, input *CreateStatusIn "notify_subscribers": input.NotifySubscribers, } - resp, err := c.makeRequest(ctx, "POST", "/status-page/change/create", requestBody) + data, err := postData[any](c, ctx, "/status-page/change/create", requestBody, "failed to create status incident") if err != nil { - return nil, fmt.Errorf("failed to create status incident: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error + if data == nil { + return nil, nil } - return result.Data, nil + return *data, nil } // CreateChangeTimelineInput contains parameters for adding a timeline entry @@ -258,21 +219,7 @@ func (c *Client) CreateChangeTimeline(ctx context.Context, input *CreateChangeTi } } - resp, err := c.makeRequest(ctx, "POST", "/status-page/change/timeline/create", requestBody) - if err != nil { - return fmt.Errorf("failed to create timeline: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + return postEmpty(c, ctx, "/status-page/change/timeline/create", requestBody, "failed to create timeline") } // StartStatusPageMigrationInput contains parameters for starting a status page @@ -363,31 +310,15 @@ func (c *Client) StartStatusPageEmailSubscriberMigration(ctx context.Context, in } func (c *Client) startStatusPageMigration(ctx context.Context, path string, body map[string]any) (*StartStatusPageMigrationOutput, error) { - resp, err := c.makeRequest(ctx, "POST", path, body) + result, err := postOptionalData[StartStatusPageMigrationOutput](c, ctx, path, body, "failed to start status page migration") if err != nil { - return nil, fmt.Errorf("failed to start status page migration: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *StartStatusPageMigrationOutput `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil { + if result == nil { return nil, errors.New("status page migration response missing data") } - return result.Data, nil + return result, nil } // GetStatusPageMigrationStatus fetches the current state of a status page @@ -399,31 +330,15 @@ func (c *Client) GetStatusPageMigrationStatus(ctx context.Context, jobID string) params := url.Values{} params.Set("job_id", jobID) - resp, err := c.makeRequest(ctx, "GET", "/status-page/migration/status?"+params.Encode(), nil) + result, err := getOptionalData[StatusPageMigrationJob](c, ctx, "/status-page/migration/status?"+params.Encode(), "failed to get status page migration status") if err != nil { - return nil, fmt.Errorf("failed to get status page migration status: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *StatusPageMigrationJob `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil { + if result == nil { return nil, fmt.Errorf("status page migration status response missing data") } - return result.Data, nil + return result, nil } // CancelStatusPageMigration requests cancellation of an in-flight status page @@ -433,25 +348,7 @@ func (c *Client) CancelStatusPageMigration(ctx context.Context, jobID string) er return errors.New("jobID is required") } - resp, err := c.makeRequest(ctx, "POST", "/status-page/migration/cancel", map[string]any{ + return postEmpty(c, ctx, "/status-page/migration/cancel", map[string]any{ "job_id": jobID, - }) - if err != nil { - return fmt.Errorf("failed to cancel status page migration: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result FlashdutyResponse - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + }, "failed to cancel status page migration") } diff --git a/teams.go b/teams.go index d7a740e..7857a50 100644 --- a/teams.go +++ b/teams.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" ) const defaultTeamsQueryLimit = 20 @@ -33,36 +32,20 @@ func (c *Client) ListTeams(ctx context.Context, input *ListTeamsInput) (*ListTea "team_ids": input.TeamIDs, } - resp, err := c.makeRequest(ctx, "POST", "/team/infos", requestBody) + result, err := postData[struct { + Items []struct { + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name"` + PersonIDs []int64 `json:"person_ids"` + } `json:"items"` + }](c, ctx, "/team/infos", requestBody, "unable to retrieve teams") if err != nil { - return nil, fmt.Errorf("unable to retrieve teams: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - TeamID int64 `json:"team_id"` - TeamName string `json:"team_name"` - PersonIDs []int64 `json:"person_ids"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } teams := []TeamInfo{} - if result.Data != nil { - for _, t := range result.Data.Items { + if result != nil { + for _, t := range result.Items { teams = append(teams, TeamInfo{ TeamID: t.TeamID, TeamName: t.TeamName, @@ -111,45 +94,29 @@ func (c *Client) ListTeams(ctx context.Context, input *ListTeamsInput) (*ListTea requestBody["person_id"] = input.PersonID } - resp, err := c.makeRequest(ctx, "POST", "/team/list", requestBody) + result, err := postData[struct { + Items []struct { + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name"` + PersonIDs []int64 `json:"person_ids"` + } `json:"items"` + Total int `json:"total"` + }](c, ctx, "/team/list", requestBody, "unable to list teams") if err != nil { - return nil, fmt.Errorf("unable to list teams: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - TeamID int64 `json:"team_id"` - TeamName string `json:"team_name"` - PersonIDs []int64 `json:"person_ids"` - } `json:"items"` - Total int `json:"total"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } teams := []TeamInfo{} total := 0 - if result.Data != nil { - for _, t := range result.Data.Items { + if result != nil { + for _, t := range result.Items { teams = append(teams, TeamInfo{ TeamID: t.TeamID, TeamName: t.TeamName, PersonIDs: t.PersonIDs, }) } - total = result.Data.Total + total = result.Total } c.enrichTeamMembers(ctx, teams) @@ -207,32 +174,14 @@ func (c *Client) GetTeamInfo(ctx context.Context, input *TeamGetInput) (*TeamIte infoBody["ref_id"] = input.RefID } - resp, err := c.makeRequest(ctx, "POST", "/team/info", infoBody) + team, err := postOptionalData[TeamItem](c, ctx, "/team/info", infoBody, "unable to get team info") if err != nil { - return nil, fmt.Errorf("unable to get team info: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *TeamItem `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil { + if team == nil { return nil, fmt.Errorf("team not found") } - team := result.Data - // Collect all person IDs that need enrichment: members + creator (if name missing). enrichIDs := make([]int64, 0, len(team.PersonIDs)+1) enrichIDs = append(enrichIDs, team.PersonIDs...) @@ -303,31 +252,15 @@ func (c *Client) UpsertTeam(ctx context.Context, input *TeamUpsertInput) (*TeamU requestBody["reset_if_name_exist"] = true } - resp, err := c.makeRequest(ctx, "POST", "/team/upsert", requestBody) + result, err := postOptionalData[TeamUpsertOutput](c, ctx, "/team/upsert", requestBody, "unable to upsert team") if err != nil { - return nil, fmt.Errorf("unable to upsert team: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *TeamUpsertOutput `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } - if result.Data == nil { + if result == nil { return nil, fmt.Errorf("unexpected empty response from team upsert") } - return result.Data, nil + return result, nil } // DeleteTeam permanently deletes a team by ID, name, or ref_id. @@ -343,25 +276,5 @@ func (c *Client) DeleteTeam(ctx context.Context, input *TeamDeleteInput) error { requestBody["ref_id"] = input.RefID } - resp, err := c.makeRequest(ctx, "POST", "/team/delete", requestBody) - if err != nil { - return fmt.Errorf("unable to delete team: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { - return err - } - if result.Error != nil { - return result.Error - } - - return nil + return postEmpty(c, ctx, "/team/delete", requestBody, "unable to delete team") } diff --git a/templates.go b/templates.go index 2e88138..a1b4178 100644 --- a/templates.go +++ b/templates.go @@ -3,7 +3,6 @@ package flashduty import ( "context" "fmt" - "net/http" "slices" ) @@ -78,30 +77,14 @@ func (c *Client) GetPresetTemplate(ctx context.Context, input *GetPresetTemplate "id": PresetTemplateID, } - resp, err := c.makeRequest(ctx, "POST", "/template/info", requestBody) + result, err := postData[map[string]interface{}](c, ctx, "/template/info", requestBody, "failed to fetch preset template") if err != nil { - return nil, fmt.Errorf("failed to fetch preset template: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } templateCode := "" - if result.Data != nil { - if val, ok := result.Data[fieldName]; ok { + if result != nil { + if val, ok := (*result)[fieldName]; ok { if str, ok := val.(string); ok { templateCode = str } @@ -154,38 +137,22 @@ func (c *Client) ValidateTemplate(ctx context.Context, input *ValidateTemplateIn requestBody["incident_id"] = input.IncidentID } - resp, err := c.makeRequest(ctx, "POST", "/template/preview", requestBody) + result, err := postOptionalData[struct { + Success bool `json:"success"` + Content string `json:"content"` + Message string `json:"message"` + }](c, ctx, "/template/preview", requestBody, "failed to validate template") if err != nil { - return nil, fmt.Errorf("failed to validate template: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(c.logger, resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Success bool `json:"success"` - Content string `json:"content"` - Message string `json:"message"` - } `json:"data,omitempty"` - } - if err := parseResponse(c.logger, resp, &result); err != nil { return nil, err } - if result.Error != nil { - return nil, result.Error - } success := false renderedPreview := "" errorMessage := "" - if result.Data != nil { - success = result.Data.Success - renderedPreview = result.Data.Content - errorMessage = result.Data.Message + if result != nil { + success = result.Success + renderedPreview = result.Content + errorMessage = result.Message } renderedSize := len(renderedPreview)