Skip to content

Commit 25115af

Browse files
committed
feat(data): add data argument to payload-based methods
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 e8f926f commit 25115af

File tree

9 files changed

+418
-2
lines changed

9 files changed

+418
-2
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

example_openapis/book-create.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"title": "The Lord of the Rings",
3+
"author": "J.R.R. Tolkien",
4+
"published": 1954,
5+
"metadata": {
6+
"isbn": "978-0-618-00222-1",
7+
"pages": 1178,
8+
"publisher": {
9+
"name": "Houghton Mifflin",
10+
"location": "Boston"
11+
}
12+
},
13+
"genres": [
14+
"fantasy",
15+
"adventure",
16+
"epic"
17+
],
18+
"available": true
19+
}

example_openapis/book-update.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"title": "The Lord of the Rings: Updated Edition",
3+
"metadata": {
4+
"isbn": "978-0-618-00222-1",
5+
"pages": 1200,
6+
"edition": 2,
7+
"publisher": {
8+
"name": "New Publisher Inc.",
9+
"location": "New York"
10+
}
11+
},
12+
"available": false
13+
}

example_openapis/complex-book.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"title": "Complex Book Example",
3+
"author": "Multi Author",
4+
"authors": [
5+
{
6+
"name": "Author One",
7+
"role": "writer",
8+
"bio": "Primary author of the work"
9+
},
10+
{
11+
"name": "Author Two",
12+
"role": "editor",
13+
"bio": "Editorial contributions"
14+
}
15+
],
16+
"categories": [
17+
{
18+
"name": "Science Fiction",
19+
"subcategories": [
20+
"Space Opera",
21+
"Hard SF"
22+
]
23+
},
24+
{
25+
"name": "Adventure",
26+
"subcategories": [
27+
"Quest",
28+
"Journey"
29+
]
30+
}
31+
],
32+
"metadata": {
33+
"format": "hardback",
34+
"isbn": "978-1-234-56789-0",
35+
"publisher": {
36+
"name": "Example Publisher",
37+
"address": {
38+
"street": "123 Publishing Ave",
39+
"city": "Book City",
40+
"state": "NY",
41+
"zip": "12345"
42+
},
43+
"contact": {
44+
"email": "contact@example-publisher.com",
45+
"phone": "+1-555-123-4567"
46+
}
47+
}
48+
},
49+
"reviews": [
50+
{
51+
"reviewer": "Book Critic",
52+
"rating": 5,
53+
"comment": "Excellent work!"
54+
},
55+
{
56+
"reviewer": "Another Reader",
57+
"rating": 4,
58+
"comment": "Really enjoyed it."
59+
}
60+
]
61+
}

example_openapis/invalid.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"invalid": "json",
3+
"missing": "closing brace"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"reason": "Quality assurance review",
3+
"reviewer": {
4+
"name": "Jane Doe",
5+
"department": "Editorial",
6+
"email": "jane.doe@publisher.com"
7+
},
8+
"priority": "high",
9+
"notes": "Please review the technical accuracy of chapters 5-8",
10+
"deadline": "2024-12-01T00:00:00Z"
11+
}

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+
}

0 commit comments

Comments
 (0)