Skip to content

Commit 0049225

Browse files
authored
feat(data): add data argument to payload-based methods (#22)
To help simplify the modification and mutation of complex data structures, add a `--@data` flag which can consume a file directly. resolves #21.
1 parent d55af54 commit 0049225

File tree

5 files changed

+434
-4
lines changed

5 files changed

+434
-4
lines changed

docs/userguide.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,55 @@ lists are specified as a comma-separated list:
178178
aepcli bookstore book-edition create --book "peter-pan" --publisher "consistent-house" --tags "fantasy,childrens"
179179
```
180180

181+
### JSON File Input with --@data Flag
182+
183+
For complex resource data or when working with arrays of objects, you can use the `--@data` flag to read resource data from JSON files.
184+
185+
#### Basic Usage
186+
187+
Create a JSON file containing the resource data:
188+
189+
```json
190+
{
191+
"title": "The Lord of the Rings",
192+
"author": "J.R.R. Tolkien",
193+
"published": 1954,
194+
"metadata": {
195+
"isbn": "978-0-618-00222-1",
196+
"pages": 1178,
197+
"publisher": {
198+
"name": "Houghton Mifflin",
199+
"location": "Boston"
200+
}
201+
},
202+
"genres": ["fantasy", "adventure", "epic"],
203+
"available": true
204+
}
205+
```
206+
207+
Then use the flag to reference the file:
208+
209+
```bash
210+
aepcli bookstore book create lotr --@data book.json
211+
```
212+
213+
#### File Reference Syntax
214+
215+
- Relative paths are resolved from the current working directory
216+
- Absolute paths are also supported
217+
218+
```bash
219+
# Using relative path
220+
aepcli bookstore book create --@data ./data/book.json
221+
222+
# Using absolute path
223+
aepcli bookstore book create --@data /home/user/books/fantasy.json
224+
```
225+
226+
#### Mutually Exclusive with Field Flags
227+
228+
The `--@data` flag cannot be used together with individual field flags. This prevents confusion about which values should be used.
229+
181230
### Logging HTTP requests and Dry Runs
182231

183232
aepcli supports logging http requests and dry runs. To log http requests, use the

internal/service/flagtypes.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package service
33
import (
44
"encoding/csv"
55
"encoding/json"
6+
"fmt"
7+
"os"
68
"strings"
79
)
810

@@ -56,3 +58,64 @@ func (f *ArrayFlag) Set(v string) error {
5658
func (f *ArrayFlag) Type() string {
5759
return "array"
5860
}
61+
62+
// DataFlag handles file references with @file syntax
63+
type DataFlag struct {
64+
Target *map[string]interface{}
65+
}
66+
67+
func (f *DataFlag) String() string {
68+
if f.Target == nil || *f.Target == nil {
69+
return ""
70+
}
71+
b, err := json.Marshal(*f.Target)
72+
if err != nil {
73+
return "failed to marshal object"
74+
}
75+
return string(b)
76+
}
77+
78+
func (f *DataFlag) Set(v string) error {
79+
// The filename is provided directly (no @ prefix needed)
80+
filename := v
81+
if filename == "" {
82+
return fmt.Errorf("filename cannot be empty")
83+
}
84+
85+
// Read the file
86+
data, err := os.ReadFile(filename)
87+
if err != nil {
88+
if os.IsNotExist(err) {
89+
return fmt.Errorf("unable to read file '%s': no such file or directory", filename)
90+
}
91+
return fmt.Errorf("unable to read file '%s': %v", filename, err)
92+
}
93+
94+
// Parse JSON
95+
var jsonData map[string]interface{}
96+
if err := json.Unmarshal(data, &jsonData); err != nil {
97+
// Try to provide line/column information if possible
98+
if syntaxErr, ok := err.(*json.SyntaxError); ok {
99+
// Calculate line and column from offset
100+
line := 1
101+
col := 1
102+
for i := int64(0); i < syntaxErr.Offset; i++ {
103+
if i < int64(len(data)) && data[i] == '\n' {
104+
line++
105+
col = 1
106+
} else {
107+
col++
108+
}
109+
}
110+
return fmt.Errorf("invalid JSON in '%s': %s at line %d, column %d", filename, syntaxErr.Error(), line, col)
111+
}
112+
return fmt.Errorf("invalid JSON in '%s': %v", filename, err)
113+
}
114+
115+
*f.Target = jsonData
116+
return nil
117+
}
118+
119+
func (f *DataFlag) Type() string {
120+
return "data"
121+
}

internal/service/flagtypes_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package service
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
func TestDataFlag(t *testing.T) {
11+
// Create a temporary directory for test files
12+
tempDir := t.TempDir()
13+
14+
// Test data
15+
validJSON := map[string]interface{}{
16+
"title": "Test Book",
17+
"author": "Test Author",
18+
"metadata": map[string]interface{}{
19+
"isbn": "123-456-789",
20+
"pages": float64(300), // JSON numbers are float64
21+
},
22+
}
23+
24+
t.Run("valid JSON file", func(t *testing.T) {
25+
// Create a temporary JSON file
26+
jsonData, _ := json.Marshal(validJSON)
27+
testFile := filepath.Join(tempDir, "valid.json")
28+
err := os.WriteFile(testFile, jsonData, 0644)
29+
if err != nil {
30+
t.Fatalf("Failed to create test file: %v", err)
31+
}
32+
33+
// Test the flag
34+
var target map[string]interface{}
35+
flag := &DataFlag{Target: &target}
36+
37+
err = flag.Set(testFile)
38+
if err != nil {
39+
t.Fatalf("Expected no error, got: %v", err)
40+
}
41+
42+
// Check that the data was parsed correctly
43+
if target["title"] != "Test Book" {
44+
t.Errorf("Expected title 'Test Book', got: %v", target["title"])
45+
}
46+
if target["author"] != "Test Author" {
47+
t.Errorf("Expected author 'Test Author', got: %v", target["author"])
48+
}
49+
})
50+
51+
t.Run("empty filename", func(t *testing.T) {
52+
var target map[string]interface{}
53+
flag := &DataFlag{Target: &target}
54+
55+
err := flag.Set("")
56+
if err == nil {
57+
t.Fatal("Expected error for empty filename")
58+
}
59+
60+
expectedError := "filename cannot be empty"
61+
if err.Error() != expectedError {
62+
t.Errorf("Expected error: %s, got: %s", expectedError, err.Error())
63+
}
64+
})
65+
66+
t.Run("file not found", func(t *testing.T) {
67+
var target map[string]interface{}
68+
flag := &DataFlag{Target: &target}
69+
70+
err := flag.Set("nonexistent.json")
71+
if err == nil {
72+
t.Fatal("Expected error for nonexistent file")
73+
}
74+
75+
if !contains(err.Error(), "unable to read file 'nonexistent.json': no such file or directory") {
76+
t.Errorf("Expected file not found error, got: %s", err.Error())
77+
}
78+
})
79+
80+
t.Run("invalid JSON", func(t *testing.T) {
81+
// Create a file with invalid JSON
82+
invalidJSON := `{"title": "Test", "missing": "closing brace"`
83+
testFile := filepath.Join(tempDir, "invalid.json")
84+
err := os.WriteFile(testFile, []byte(invalidJSON), 0644)
85+
if err != nil {
86+
t.Fatalf("Failed to create test file: %v", err)
87+
}
88+
89+
var target map[string]interface{}
90+
flag := &DataFlag{Target: &target}
91+
92+
err = flag.Set(testFile)
93+
if err == nil {
94+
t.Fatal("Expected error for invalid JSON")
95+
}
96+
97+
if !contains(err.Error(), "invalid JSON in") {
98+
t.Errorf("Expected invalid JSON error, got: %s", err.Error())
99+
}
100+
})
101+
102+
t.Run("string representation", func(t *testing.T) {
103+
target := map[string]interface{}{
104+
"title": "Test Book",
105+
}
106+
flag := &DataFlag{Target: &target}
107+
108+
str := flag.String()
109+
expected := `{"title":"Test Book"}`
110+
if str != expected {
111+
t.Errorf("Expected string: %s, got: %s", expected, str)
112+
}
113+
})
114+
115+
t.Run("type", func(t *testing.T) {
116+
flag := &DataFlag{}
117+
if flag.Type() != "data" {
118+
t.Errorf("Expected type 'data', got: %s", flag.Type())
119+
}
120+
})
121+
}
122+
123+
// Helper function to check if a string contains a substring
124+
func contains(str, substr string) bool {
125+
return len(str) >= len(substr) && (str == substr ||
126+
(len(str) > len(substr) &&
127+
(str[:len(substr)] == substr ||
128+
str[len(str)-len(substr):] == substr ||
129+
containsInMiddle(str, substr))))
130+
}
131+
132+
func containsInMiddle(str, substr string) bool {
133+
for i := 0; i <= len(str)-len(substr); i++ {
134+
if str[i:i+len(substr)] == substr {
135+
return true
136+
}
137+
}
138+
return false
139+
}

internal/service/resource_definition.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ func ExecuteResourceCommand(r *api.Resource, args []string) (*http.Request, stri
6060
args = cobra.ExactArgs(0)
6161
}
6262
createArgs := map[string]interface{}{}
63+
var dataContent map[string]interface{}
64+
createArgs["data"] = &dataContent
65+
6366
createCmd := &cobra.Command{
6467
Use: use,
6568
Short: fmt.Sprintf("Create a %v", strings.ToLower(r.Singular)),
@@ -72,14 +75,17 @@ func ExecuteResourceCommand(r *api.Resource, args []string) (*http.Request, stri
7275
}
7376
jsonBody, err := generateJsonPayload(cmd, createArgs)
7477
if err != nil {
75-
slog.Error(fmt.Sprintf("unable to create json body for update: %v", err))
78+
slog.Error(fmt.Sprintf("unable to create json body for create: %v", err))
7679
}
7780
req, err = http.NewRequest("POST", p, strings.NewReader(string(jsonBody)))
7881
if err != nil {
7982
slog.Error(fmt.Sprintf("error creating post request: %v", err))
8083
}
8184
},
8285
}
86+
87+
createCmd.Flags().Var(&DataFlag{&dataContent}, "@data", "Read resource data from JSON file")
88+
8389
addSchemaFlags(createCmd, *r.Schema, createArgs)
8490
c.AddCommand(createCmd)
8591
}
@@ -101,6 +107,9 @@ func ExecuteResourceCommand(r *api.Resource, args []string) (*http.Request, stri
101107
if r.Methods.Update != nil {
102108

103109
updateArgs := map[string]interface{}{}
110+
var updateDataContent map[string]interface{}
111+
updateArgs["data"] = &updateDataContent
112+
104113
updateCmd := &cobra.Command{
105114
Use: "update [id]",
106115
Short: fmt.Sprintf("Update a %v", strings.ToLower(r.Singular)),
@@ -118,6 +127,9 @@ func ExecuteResourceCommand(r *api.Resource, args []string) (*http.Request, stri
118127
}
119128
},
120129
}
130+
131+
updateCmd.Flags().Var(&DataFlag{&updateDataContent}, "@data", "Read resource data from JSON file")
132+
121133
addSchemaFlags(updateCmd, *r.Schema, updateArgs)
122134
c.AddCommand(updateCmd)
123135
}
@@ -151,6 +163,8 @@ func ExecuteResourceCommand(r *api.Resource, args []string) (*http.Request, stri
151163
}
152164
for _, cm := range r.CustomMethods {
153165
customArgs := map[string]interface{}{}
166+
var customDataContent map[string]interface{}
167+
154168
customCmd := &cobra.Command{
155169
Use: fmt.Sprintf(":%s [id]", cm.Name),
156170
Short: fmt.Sprintf("%v a %v", cm.Method, strings.ToLower(r.Singular)),
@@ -161,15 +175,18 @@ func ExecuteResourceCommand(r *api.Resource, args []string) (*http.Request, stri
161175
if cm.Method == "POST" {
162176
jsonBody, inner_err := generateJsonPayload(cmd, customArgs)
163177
if inner_err != nil {
164-
slog.Error(fmt.Sprintf("unable to create json body for update: %v", inner_err))
178+
slog.Error(fmt.Sprintf("unable to create json body for custom method: %v", inner_err))
165179
}
166180
req, err = http.NewRequest(cm.Method, p, strings.NewReader(string(jsonBody)))
167181
} else {
168182
req, err = http.NewRequest(cm.Method, p, nil)
169183
}
170184
},
171185
}
186+
172187
if cm.Method == "POST" {
188+
customArgs["data"] = &customDataContent
189+
customCmd.Flags().Var(&DataFlag{&customDataContent}, "@data", "Read resource data from JSON file")
173190
addSchemaFlags(customCmd, *cm.Request, customArgs)
174191
}
175192
c.AddCommand(customCmd)
@@ -229,8 +246,45 @@ func addSchemaFlags(c *cobra.Command, schema openapi.Schema, args map[string]int
229246
}
230247

231248
func generateJsonPayload(c *cobra.Command, args map[string]interface{}) (string, error) {
249+
// Check if --@data flag was used
250+
dataFlag := c.Flags().Lookup("@data")
251+
if dataFlag != nil && dataFlag.Changed {
252+
// Check for conflicts with other flags
253+
var conflictingFlags []string
254+
for key := range args {
255+
if key == "data" {
256+
continue // Skip the internal data key
257+
}
258+
if flag := c.Flags().Lookup(key); flag != nil && flag.Changed {
259+
conflictingFlags = append(conflictingFlags, "--"+key)
260+
}
261+
}
262+
263+
if len(conflictingFlags) > 0 {
264+
return "", fmt.Errorf("--@data flag cannot be used with individual field flags (%s)", strings.Join(conflictingFlags, ", "))
265+
}
266+
267+
// Get the data from the --@data flag
268+
if dataValue, ok := args["data"]; ok {
269+
if dataMap, ok := dataValue.(*map[string]interface{}); ok && *dataMap != nil {
270+
jsonBody, err := json.Marshal(*dataMap)
271+
if err != nil {
272+
return "", fmt.Errorf("error marshalling JSON from --@data: %v", err)
273+
}
274+
return string(jsonBody), nil
275+
}
276+
}
277+
278+
// If --@data flag was used but no data was set, return empty object
279+
return "{}", nil
280+
}
281+
282+
// Original logic for individual flags
232283
body := map[string]interface{}{}
233284
for key, value := range args {
285+
if key == "data" {
286+
continue // Skip the data field when building from individual flags
287+
}
234288
if c.Flags().Lookup(key).Changed {
235289
body[key] = value
236290
}

0 commit comments

Comments
 (0)