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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions automations.go

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

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

import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)

// TriggerWriteFire triggers an Automation run through its HTTP POST trigger URL.
//
// This endpoint authenticates with the trigger's one-time bearer token rather
// than the account app_key used by the generated API methods.
//
// API: POST /safari/automation/triggers/{trigger_id}/fire (automation-trigger-write-fire).
func (s *AutomationsService) TriggerWriteFire(ctx context.Context, triggerID, token string, req *AutomationFireAPITriggerRequest) (*AutomationFireAPITriggerResponse, *Response, error) {
triggerID = strings.TrimSpace(triggerID)
if triggerID == "" {
return nil, nil, fmt.Errorf("flashduty: automation trigger_id is required")
}
token = strings.TrimSpace(token)
if token == "" {
return nil, nil, fmt.Errorf("flashduty: automation trigger token is required")
}

path := "/safari/automation/triggers/" + url.PathEscape(triggerID) + "/fire"
out := new(AutomationFireAPITriggerResponse)
resp, err := s.client.doMethodWithoutAppKey(ctx, http.MethodPost, path, req, out, func(httpReq *http.Request) {
httpReq.Header.Set("Authorization", "Bearer "+token)
})
if err != nil {
return nil, resp, err
}
return out, resp, nil
}
94 changes: 94 additions & 0 deletions automations_fire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package flashduty

import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)

func TestAutomationTriggerWriteFireUsesBearerTokenAndDecodesAccepted(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/safari/automation/triggers/trig_abc/fire" {
t.Errorf("path = %s", r.URL.Path)
}
if got := r.URL.Query().Get("app_key"); got != "" {
t.Errorf("app_key must not be sent for trigger fire, got %q", got)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok_secret" {
t.Errorf("Authorization = %q", got)
}
if got := r.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q", got)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"text":"deployment finished"`) ||
!strings.Contains(string(body), `"dedup_key":"deploy-1"`) {
t.Errorf("body = %s", body)
}

w.Header().Set("Flashcat-Request-Id", "RIDF")
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"request_id":"RIDF","data":{"run_id":"taskrun_1","rule_id":"auto_1","trigger_kind":"http_post","status":"running"}}`)
})

out, resp, err := c.Automations.TriggerWriteFire(context.Background(), "trig_abc", "tok_secret", &AutomationFireAPITriggerRequest{
Text: "deployment finished",
DedupKey: "deploy-1",
})
if err != nil {
t.Fatalf("TriggerWriteFire error: %v", err)
}
if resp == nil || resp.StatusCode != http.StatusAccepted || resp.RequestID != "RIDF" {
t.Fatalf("response meta = %+v", resp)
}
if out == nil || out.RunID != "taskrun_1" || out.RuleID != "auto_1" || out.TriggerKind != "http_post" || out.Status != "running" {
t.Fatalf("decoded response = %+v", out)
}
}

func TestAutomationTriggerWriteFireValidatesInputs(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not be sent")
})

if _, _, err := c.Automations.TriggerWriteFire(context.Background(), "", "tok", nil); err == nil {
t.Fatal("expected empty trigger_id error")
}
if _, _, err := c.Automations.TriggerWriteFire(context.Background(), "trig_abc", "", nil); err == nil {
t.Fatal("expected empty token error")
}
}

func TestAutomationRuleUpdateRequestCanSendFalseValues(t *testing.T) {
payload, err := json.Marshal(&AutomationRuleUpdateRequest{
RuleID: "auto_1",
Enabled: Bool(false),
ScheduleTriggerEnabled: Bool(false),
HTTPPostTriggerEnabled: Bool(false),
RotateHTTPPostTriggerToken: false,
TeamID: Int64(0),
EnvironmentID: String(""),
})
if err != nil {
t.Fatal(err)
}
got := string(payload)
for _, want := range []string{
`"rule_id":"auto_1"`,
`"enabled":false`,
`"schedule_trigger_enabled":false`,
`"http_post_trigger_enabled":false`,
`"team_id":0`,
`"environment_id":""`,
} {
if !strings.Contains(got, want) {
t.Fatalf("payload %s missing %s", got, want)
}
}
if strings.Contains(got, "rotate_http_post_trigger_token") {
t.Fatalf("zero rotate flag should stay omitted: %s", got)
}
}
29 changes: 29 additions & 0 deletions automations_models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package flashduty

// AutomationRuleUpdateRequest updates mutable fields on an Automation rule.
// Pointer fields preserve partial-update semantics: nil means leave unchanged,
// while a non-nil zero value is sent to the API.
type AutomationRuleUpdateRequest struct {
// Target rule ID.
RuleID string `json:"rule_id,omitempty" toon:"rule_id,omitempty"`
// New rule name.
Name *string `json:"name,omitempty" toon:"name,omitempty"`
// Only the current value is accepted; personal/team scope is immutable after creation.
TeamID *int64 `json:"team_id,omitempty" toon:"team_id,omitempty"`
// Whether the rule is enabled.
Enabled *bool `json:"enabled,omitempty" toon:"enabled,omitempty"`
// Run cadence. Supports 4 fields (`hour day month weekday`, minute defaults to 0) and 5 fields (`minute hour day month weekday`).
CronExpr *string `json:"cron_expr,omitempty" toon:"cron_expr,omitempty"`
// Whether the schedule trigger is enabled.
ScheduleTriggerEnabled *bool `json:"schedule_trigger_enabled,omitempty" toon:"schedule_trigger_enabled,omitempty"`
// New task prompt.
Prompt *string `json:"prompt,omitempty" toon:"prompt,omitempty"`
// Runtime environment kind. Omit or send an empty value for automatic selection.
EnvironmentKind *string `json:"environment_kind,omitempty" toon:"environment_kind,omitempty"`
// BYOC Runner ID.
EnvironmentID *string `json:"environment_id,omitempty" toon:"environment_id,omitempty"`
// Whether the HTTP POST trigger is enabled. Sending true creates one when missing.
HTTPPostTriggerEnabled *bool `json:"http_post_trigger_enabled,omitempty" toon:"http_post_trigger_enabled,omitempty"`
// Whether to rotate the HTTP POST trigger token. The new token is returned only in this response.
RotateHTTPPostTriggerToken bool `json:"rotate_http_post_trigger_token,omitempty" toon:"rotate_http_post_trigger_token,omitempty"`
}
25 changes: 21 additions & 4 deletions flashduty.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,20 @@ type pageMeta struct {
// parameter and JSON-encoding body when non-nil. Most Flashduty endpoints are
// POST actions; a handful are GET with query parameters.
func (c *Client) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) {
return c.newRequestWithAppKey(ctx, method, path, body, true)
}

func (c *Client) newRequestWithAppKey(ctx context.Context, method, path string, body any, withAppKey bool) (*http.Request, error) {
rel, err := url.Parse(strings.TrimPrefix(path, "/"))
if err != nil {
return nil, fmt.Errorf("flashduty: invalid path %q: %w", path, err)
}
u := c.BaseURL.ResolveReference(rel)
q := u.Query()
q.Set("app_key", c.appKey)
u.RawQuery = q.Encode()
if withAppKey {
q := u.Query()
q.Set("app_key", c.appKey)
u.RawQuery = q.Encode()
}

var buf io.Reader
var rawBody []byte
Expand Down Expand Up @@ -164,10 +170,21 @@ func (c *Client) doGet(ctx context.Context, path string, opt, out any) (*Respons
// (when non-nil), and returns a Response. A non-nil envelope error or a non-2xx
// status yields an *ErrorResponse (or *RateLimitError on 429).
func (c *Client) doMethod(ctx context.Context, method, path string, body, out any) (*Response, error) {
req, err := c.newRequest(ctx, method, path, body)
return c.doMethodWithAppKey(ctx, method, path, body, out, true, nil)
}

func (c *Client) doMethodWithoutAppKey(ctx context.Context, method, path string, body, out any, configure func(*http.Request)) (*Response, error) {
return c.doMethodWithAppKey(ctx, method, path, body, out, false, configure)
}

func (c *Client) doMethodWithAppKey(ctx context.Context, method, path string, body, out any, withAppKey bool, configure func(*http.Request)) (*Response, error) {
req, err := c.newRequestWithAppKey(ctx, method, path, body, withAppKey)
if err != nil {
return nil, err
}
if configure != nil {
configure(req)
}
httpResp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("flashduty: request to %s failed: %v", sanitizeURL(req.URL), sanitizeError(err))
Expand Down
37 changes: 33 additions & 4 deletions internal/cmd/gen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,15 @@ func run() error {
root: root,
schemas: asMap(asMap(spec["components"])["schemas"]),
emptyTypes: map[string]bool{},
skip: map[string]bool{"SuccessEnvelope": true, "ErrorResponse": true, "DutyError": true},
queued: map[string]bool{},
synth: map[string]any{},
reqSynth: map[string]bool{},
skip: map[string]bool{
"SuccessEnvelope": true,
"ErrorResponse": true,
"DutyError": true,
"AutomationRuleUpdateRequest": true, // hand-written to preserve partial-update pointer semantics.
},
queued: map[string]bool{},
synth: map[string]any{},
reqSynth: map[string]bool{},
}
g.detectEmpty()
g.reqGoNames = g.computeRequestReachable(asMap(spec["paths"]))
Expand Down Expand Up @@ -169,6 +174,12 @@ func (g *Gen) collectServices(paths map[string]any) []service {
if isStreamingOp(o) {
continue
}
// The generated client is an app_key client and does not template path
// parameters. Endpoints with operation-level non-AppKey auth or path
// params need hand-written methods.
if needsHandWrittenOperation(o) {
continue
}
tag, _ := tags[0].(string)
byTag[tag] = append(byTag[tag], opEntry{p, m, o})
}
Expand Down Expand Up @@ -231,6 +242,24 @@ func isStreamingOp(o map[string]any) bool {
return !hasJSON
}

func needsHandWrittenOperation(o map[string]any) bool {
for _, raw := range asSlice(o["parameters"]) {
if str(asMap(raw), "in") == "path" {
return true
}
}
rawSecurity, ok := o["security"]
if !ok {
return false
}
for _, raw := range asSlice(rawSecurity) {
if _, ok := asMap(raw)["AppKeyAuth"]; ok {
return false
}
}
return true
}

// getRequestType synthesizes a request struct from a GET op's query parameters
// and records it for emission. Returns "" when the op has no query parameters.
func (g *Gen) getRequestType(o map[string]any, hint string) string {
Expand Down
Loading