Skip to content

Commit 3cf5f13

Browse files
committed
Fix action ergonomics and add storage commands
1 parent 00c9d00 commit 3cf5f13

12 files changed

Lines changed: 1016 additions & 21 deletions

client/asset.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package client
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/url"
8+
)
9+
10+
// UploadAssetStream streams a file to Daptin's asset upload endpoint.
11+
func (e *ExtendedClient) UploadAssetStream(entityName, referenceID, columnName, filename, contentType string, size int64, body io.Reader) (map[string]interface{}, error) {
12+
u := fmt.Sprintf("%s/asset/%s/%s/%s/upload?operation=stream&filename=%s",
13+
e.Endpoint,
14+
url.PathEscape(entityName),
15+
url.PathEscape(referenceID),
16+
url.PathEscape(columnName),
17+
url.QueryEscape(filename),
18+
)
19+
20+
resp, err := e.nextRequest().
21+
SetHeader("Content-Type", contentType).
22+
SetHeader("X-File-Type", contentType).
23+
SetHeader("X-File-Size", fmt.Sprintf("%d", size)).
24+
SetBody(body).
25+
Post(u)
26+
if err := e.checkResponse(resp, err); err != nil {
27+
return nil, err
28+
}
29+
30+
var data map[string]interface{}
31+
if err := json.Unmarshal(resp.Body(), &data); err != nil {
32+
return nil, fmt.Errorf("parse upload response: %w", err)
33+
}
34+
return data, nil
35+
}
36+
37+
// CompleteAssetUpload finalizes an asset upload and updates the row's file column.
38+
func (e *ExtendedClient) CompleteAssetUpload(entityName, referenceID, columnName, filename, uploadID, contentType string, size int64) (map[string]interface{}, error) {
39+
u := fmt.Sprintf("%s/asset/%s/%s/%s/upload?operation=complete&upload_id=%s&filename=%s",
40+
e.Endpoint,
41+
url.PathEscape(entityName),
42+
url.PathEscape(referenceID),
43+
url.PathEscape(columnName),
44+
url.QueryEscape(uploadID),
45+
url.QueryEscape(filename),
46+
)
47+
48+
body := map[string]interface{}{
49+
"fileName": filename,
50+
"size": size,
51+
"type": contentType,
52+
}
53+
resp, err := e.nextRequest().
54+
SetHeader("X-File-Type", contentType).
55+
SetHeader("X-File-Size", fmt.Sprintf("%d", size)).
56+
SetBody(body).
57+
Post(u)
58+
if err := e.checkResponse(resp, err); err != nil {
59+
return nil, err
60+
}
61+
62+
var data map[string]interface{}
63+
if err := json.Unmarshal(resp.Body(), &data); err != nil {
64+
return nil, fmt.Errorf("parse complete response: %w", err)
65+
}
66+
return data, nil
67+
}

cmd/action.go

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"syscall"
1010

1111
"github.com/daptin/daptin-cli/client"
12+
"github.com/daptin/daptin-cli/render"
1213
daptinClient "github.com/daptin/daptin-go-client"
1314
"github.com/urfave/cli/v2"
1415
"golang.org/x/term"
@@ -48,7 +49,7 @@ func executeCommand(appCtx *AppContext) *cli.Command {
4849
slog.Debug("interactive mode enabled, fetching schema")
4950
schema, schemaErr := fetchActionSchemaFromServer(appCtx, entityName, actionName)
5051
if schemaErr == nil {
51-
prompts := MissingFields(schema, attrs)
52+
prompts := MissingFields(schema.InFields, attrs)
5253
filled, err := promptUser(prompts)
5354
if err != nil {
5455
return err
@@ -70,6 +71,9 @@ func executeCommand(appCtx *AppContext) *cli.Command {
7071

7172
// Pure: compute effects from responses
7273
effects := ProcessResponses(responses)
74+
if len(effects) == 0 {
75+
effects = append(effects, BuildActionSuccessEffect(entityName, actionName, c.String("reference-id")))
76+
}
7377

7478
// IO boundary: apply effects
7579
return applyEffects(effects, appCtx)
@@ -103,50 +107,66 @@ func applyEffects(effects []ResponseEffect, appCtx *AppContext) error {
103107
if err := appCtx.Renderer.RenderObject(e.Data); err != nil {
104108
return err
105109
}
110+
case "success":
111+
if appCtx.Quiet {
112+
continue
113+
}
114+
if _, ok := appCtx.Renderer.(*render.JsonRenderer); ok {
115+
if err := appCtx.Renderer.RenderObject(e.Data); err != nil {
116+
return err
117+
}
118+
continue
119+
}
120+
fmt.Fprintln(os.Stdout, e.Message)
106121
}
107122
}
108123
return nil
109124
}
110125

111126
// fetchActionSchemaFromServer fetches InFields for an action via the API.
112127
// IO boundary: makes HTTP calls, then delegates to pure functions.
113-
func fetchActionSchemaFromServer(appCtx *AppContext, entityName, actionName string) ([]map[string]interface{}, error) {
128+
func fetchActionSchemaFromServer(appCtx *AppContext, entityName, actionName string) (ActionSchema, error) {
114129
slog.Debug("fetching action schema", "entity", entityName, "action", actionName)
115130
worlds, err := appCtx.Client.FindAll("world", daptinClient.DaptinQueryParameters{
116131
"page[size]": 500,
117132
})
118133
if err != nil {
119-
return nil, err
134+
return ActionSchema{}, err
120135
}
121136

122137
worldAttrs := client.MapArray(worlds, "attributes")
123138
worldRefId := FindWorldRefId(worldAttrs, entityName)
124139
if worldRefId == "" {
125-
return nil, fmt.Errorf("entity %q not found", entityName)
140+
return ActionSchema{}, fmt.Errorf("entity %q not found", entityName)
126141
}
127142

128143
actions, err := appCtx.Client.FindAll("action", daptinClient.DaptinQueryParameters{
129144
"page[size]": 500,
130145
})
131146
if err != nil {
132-
return nil, err
147+
return ActionSchema{}, err
133148
}
134149

135150
actionAttrs := client.MapArray(actions, "attributes")
136-
actionRefId := FindActionRefId(actionAttrs, worldRefId, actionName)
137-
if actionRefId == "" {
138-
return nil, fmt.Errorf("action %q not found on %q", actionName, entityName)
151+
schema := FindActionMetadata(actionAttrs, worldRefId, entityName, actionName)
152+
if schema.ReferenceID == "" {
153+
return ActionSchema{}, fmt.Errorf("action %q not found on %q", actionName, entityName)
139154
}
140155

141156
// Execute get_action_schema to retrieve the schema (base64 encoded)
142157
responses, err := appCtx.Client.Execute("get_action_schema", "action", daptinClient.JsonApiObject{
143-
"action_id": actionRefId,
158+
"action_id": schema.ReferenceID,
144159
})
145160
if err != nil {
146-
return nil, err
161+
return ActionSchema{}, err
147162
}
148163

149-
return DecodeActionSchemaResponse(responses)
164+
inFields, err := DecodeActionSchemaResponse(responses)
165+
if err != nil {
166+
return ActionSchema{}, err
167+
}
168+
schema.InFields = inFields
169+
return schema, nil
150170
}
151171

152172
// promptUser performs IO to collect values for missing fields.

cmd/action_response.go

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"log/slog"
8+
"strings"
89

910
daptinClient "github.com/daptin/daptin-go-client"
1011
)
@@ -24,6 +25,14 @@ type ResponseEffect struct {
2425
Data map[string]interface{}
2526
}
2627

28+
type ActionSchema struct {
29+
EntityName string
30+
ActionName string
31+
ReferenceID string
32+
InstanceOptional bool
33+
InFields []map[string]interface{}
34+
}
35+
2736
// ProcessResponses converts raw action responses into a list of effects.
2837
// Pure function: values in, values out. No IO.
2938
func ProcessResponses(responses []daptinClient.DaptinActionResponse) []ResponseEffect {
@@ -59,6 +68,20 @@ func ProcessResponses(responses []daptinClient.DaptinActionResponse) []ResponseE
5968
return effects
6069
}
6170

71+
func BuildActionSuccessEffect(entityName, actionName, referenceID string) ResponseEffect {
72+
message := fmt.Sprintf("OK: %s.%s executed", entityName, actionName)
73+
data := map[string]interface{}{
74+
"ok": true,
75+
"entity": entityName,
76+
"action": actionName,
77+
}
78+
if referenceID != "" {
79+
message = fmt.Sprintf("%s for %s", message, referenceID)
80+
data["reference_id"] = referenceID
81+
}
82+
return ResponseEffect{Type: "success", Message: message, Data: data}
83+
}
84+
6285
// FieldPrompt describes a field that needs user input.
6386
// Pure value — computed from schema + already-provided attrs.
6487
type FieldPrompt struct {
@@ -136,14 +159,25 @@ func FindWorldRefId(worldAttrs []map[string]interface{}, entityName string) stri
136159
// FindActionRefId finds an action's reference_id by name and world_id from pre-fetched action attributes.
137160
// Pure function.
138161
func FindActionRefId(actionAttrs []map[string]interface{}, worldRefId, actionName string) string {
162+
meta := FindActionMetadata(actionAttrs, worldRefId, "", actionName)
163+
return meta.ReferenceID
164+
}
165+
166+
func FindActionMetadata(actionAttrs []map[string]interface{}, worldRefId, entityName, actionName string) ActionSchema {
139167
for _, a := range actionAttrs {
140168
if a["action_name"] == actionName && a["world_id"] == worldRefId {
141-
if refId, ok := a["reference_id"].(string); ok {
142-
return refId
169+
meta := ActionSchema{
170+
EntityName: entityName,
171+
ActionName: actionName,
172+
InstanceOptional: boolValue(a["instance_optional"]),
143173
}
174+
if refID, ok := a["reference_id"].(string); ok {
175+
meta.ReferenceID = refID
176+
}
177+
return meta
144178
}
145179
}
146-
return ""
180+
return ActionSchema{}
147181
}
148182

149183
// DecodeActionSchemaResponse extracts and parses InFields from a get_action_schema response.
@@ -166,3 +200,14 @@ func DecodeActionSchemaResponse(responses []daptinClient.DaptinActionResponse) (
166200
}
167201
return nil, fmt.Errorf("no schema in response")
168202
}
203+
204+
func boolValue(value interface{}) bool {
205+
switch v := value.(type) {
206+
case bool:
207+
return v
208+
case string:
209+
return strings.EqualFold(v, "true")
210+
default:
211+
return false
212+
}
213+
}

cmd/action_response_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,23 @@ func TestProcessResponses_StoreSetNonToken(t *testing.T) {
189189
}
190190
}
191191

192+
func TestBuildActionSuccessEffect(t *testing.T) {
193+
effect := BuildActionSuccessEffect("integration", "install_integration", "ref-123")
194+
195+
if effect.Type != "success" {
196+
t.Fatalf("expected success effect, got %s", effect.Type)
197+
}
198+
if effect.Message != "OK: integration.install_integration executed for ref-123" {
199+
t.Errorf("unexpected message: %s", effect.Message)
200+
}
201+
if effect.Data["ok"] != true {
202+
t.Errorf("expected ok=true, got %v", effect.Data["ok"])
203+
}
204+
if effect.Data["reference_id"] != "ref-123" {
205+
t.Errorf("expected reference_id ref-123, got %v", effect.Data["reference_id"])
206+
}
207+
}
208+
192209
// --- MissingFields tests ---
193210

194211
func TestMissingFields_AllProvided(t *testing.T) {
@@ -360,6 +377,39 @@ func TestFindActionRefId_WrongWorld(t *testing.T) {
360377
}
361378
}
362379

380+
func TestFindActionMetadata(t *testing.T) {
381+
actions := []map[string]interface{}{
382+
{"action_name": "install_integration", "world_id": "uuid-integration", "reference_id": "ref-install", "instance_optional": false},
383+
}
384+
385+
meta := FindActionMetadata(actions, "uuid-integration", "integration", "install_integration")
386+
387+
if meta.ReferenceID != "ref-install" {
388+
t.Errorf("expected ref-install, got %s", meta.ReferenceID)
389+
}
390+
if meta.EntityName != "integration" {
391+
t.Errorf("expected integration, got %s", meta.EntityName)
392+
}
393+
if meta.ActionName != "install_integration" {
394+
t.Errorf("expected install_integration, got %s", meta.ActionName)
395+
}
396+
if meta.InstanceOptional {
397+
t.Error("expected instance_optional false")
398+
}
399+
}
400+
401+
func TestFindActionMetadata_StringBool(t *testing.T) {
402+
actions := []map[string]interface{}{
403+
{"action_name": "signin", "world_id": "uuid-user", "reference_id": "ref-signin", "instance_optional": "true"},
404+
}
405+
406+
meta := FindActionMetadata(actions, "uuid-user", "user_account", "signin")
407+
408+
if !meta.InstanceOptional {
409+
t.Error("expected string true to parse as true")
410+
}
411+
}
412+
363413
func TestDecodeActionSchemaResponse_Valid(t *testing.T) {
364414
// Base64 of: {"InFields":[{"ColumnName":"email","ColumnType":"email"}]}
365415
schema := `{"InFields":[{"ColumnName":"email","ColumnType":"email"}]}`

cmd/app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ func NewApp(cfg *config.Config, version string) *cli.App {
121121
unrelateCommand(appCtx),
122122
describeCommand(appCtx),
123123
executeCommand(appCtx),
124+
storageCommand(appCtx),
125+
assetCommand(appCtx),
124126
permissionCommand(appCtx),
125127
wsCommand(appCtx),
126128
},

0 commit comments

Comments
 (0)