Skip to content

Commit 57f46db

Browse files
buty4649claude
andauthored
system: マスタ管理サブコマンド (list/show/data/import/upload) を追加 (#33)
管理者向けの system master API 一式を呼び出す `xpoint system master` サブコマンド群を追加。 - `system master list` — GET /api/v1/system/master でマスタ一覧取得 - `system master show <master_table_name>` — GET /api/v1/system/master/ {master_table_name} でユーザ固有マスタのプロパティ (フィールド定義) を取得する - `system master data <master_code>` — GET /api/v1/system/master/ {master_code}/data[.json|.csv] でマスタデータを取得する。 --type (0: 簡易 / 1: ユーザ固有)・--format (json|csv)・ --rows/--offset/--file-name/--delimiter/--title/--no-title/--fields など CSV エクスポート用のクエリも公開 - `system master import <master_code>` — PUT /api/v1/system/master/ {master_code}/data で簡易マスタへ JSON 形式のデータを投入する。 --data にインラインJSON、ファイル、-(stdin) を指定できる。 --overwrite で既存データを置き換える - `system master upload <master_table_name>` — POST /multiapi/v1/ system/master/{master_table_name}/data でユーザ固有マスタの インポート用 CSV をアップロードする (インポートは実行しない)。 --file に CSV パスまたは -(stdin)、--overwrite で上書き可能 ついでに downloadBytes を拡張した downloadBytesWithContentType を 追加し、レスポンスの Content-Type も返せるようにした。 refs #25 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fdb17c9 commit 57f46db

File tree

8 files changed

+849
-5
lines changed

8 files changed

+849
-5
lines changed

cmd/system_master.go

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"text/tabwriter"
11+
12+
"github.com/pepabo/xpoint-cli/internal/xpoint"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var (
17+
systemMasterListOutput string
18+
systemMasterListJQ string
19+
systemMasterShowOutput string
20+
systemMasterShowJQ string
21+
22+
systemMasterDataType int
23+
systemMasterDataRows int
24+
systemMasterDataOffset int
25+
systemMasterDataFormat string
26+
systemMasterDataFileName string
27+
systemMasterDataDelimiter string
28+
systemMasterDataTitle bool
29+
systemMasterDataNoTitle bool
30+
systemMasterDataFields string
31+
systemMasterDataOutput string
32+
systemMasterDataJQ string
33+
34+
systemMasterImportOverwrite bool
35+
systemMasterImportData string
36+
systemMasterImportJQ string
37+
38+
systemMasterUploadFile string
39+
systemMasterUploadOverwrite bool
40+
)
41+
42+
var systemMasterCmd = &cobra.Command{
43+
Use: "master",
44+
Short: "Manage X-point masters via admin APIs",
45+
}
46+
47+
var systemMasterListCmd = &cobra.Command{
48+
Use: "list",
49+
Short: "List masters (admin)",
50+
Long: "List all masters via GET /api/v1/system/master. Requires an administrator account.",
51+
RunE: runSystemMasterList,
52+
}
53+
54+
var systemMasterShowCmd = &cobra.Command{
55+
Use: "show <master_table_name>",
56+
Short: "Show a user-specific master's property definition",
57+
Long: `Fetch user-specific master property info via
58+
GET /api/v1/system/master/{master_table_name}. Requires an administrator
59+
account.`,
60+
Args: cobra.ExactArgs(1),
61+
RunE: runSystemMasterShow,
62+
}
63+
64+
var systemMasterDataCmd = &cobra.Command{
65+
Use: "data <master_code>",
66+
Short: "Export master data (JSON or CSV)",
67+
Long: `Export master rows via GET /api/v1/system/master/{master_code}/data.
68+
69+
--type (required) selects the master kind:
70+
0 simple master
71+
1 user-specific master (pass the table_name as <master_code>)
72+
73+
--format defaults to json. Use --format csv for CSV output; the CSV
74+
payload is written to stdout (or --output FILE / DIR/).`,
75+
Args: cobra.ExactArgs(1),
76+
RunE: runSystemMasterData,
77+
}
78+
79+
var systemMasterImportCmd = &cobra.Command{
80+
Use: "import <master_code>",
81+
Short: "Import rows into a simple master",
82+
Long: `Import data rows into a simple master via
83+
PUT /api/v1/system/master/{master_code}/data.
84+
85+
--data takes a JSON array of {"code","value"} objects, either inline,
86+
as a file path, or - for stdin.
87+
Pass --overwrite to replace existing data instead of appending.`,
88+
Args: cobra.ExactArgs(1),
89+
RunE: runSystemMasterImport,
90+
}
91+
92+
var systemMasterUploadCmd = &cobra.Command{
93+
Use: "upload <master_table_name>",
94+
Short: "Upload a CSV for a user-specific master's import staging",
95+
Long: `Upload a CSV via POST /multiapi/v1/system/master/{master_table_name}/data.
96+
97+
The upload only stages the file; the import itself is run later from
98+
the admin site's task management (manually or by schedule).`,
99+
Args: cobra.ExactArgs(1),
100+
RunE: runSystemMasterUpload,
101+
}
102+
103+
func init() {
104+
systemCmd.AddCommand(systemMasterCmd)
105+
systemMasterCmd.AddCommand(systemMasterListCmd)
106+
systemMasterCmd.AddCommand(systemMasterShowCmd)
107+
systemMasterCmd.AddCommand(systemMasterDataCmd)
108+
systemMasterCmd.AddCommand(systemMasterImportCmd)
109+
systemMasterCmd.AddCommand(systemMasterUploadCmd)
110+
111+
lf := systemMasterListCmd.Flags()
112+
lf.StringVarP(&systemMasterListOutput, "output", "o", "", "output format: table|json (default: table on TTY, json otherwise)")
113+
lf.StringVar(&systemMasterListJQ, "jq", "", "apply a gojq filter to the JSON response (forces JSON output)")
114+
115+
sf := systemMasterShowCmd.Flags()
116+
sf.StringVarP(&systemMasterShowOutput, "output", "o", "", "output format: table|json (default: table on TTY, json otherwise)")
117+
sf.StringVar(&systemMasterShowJQ, "jq", "", "apply a gojq filter to the JSON response (forces JSON output)")
118+
119+
df := systemMasterDataCmd.Flags()
120+
df.IntVar(&systemMasterDataType, "type", -1, "master_type: 0=simple master, 1=user-specific master (required)")
121+
df.IntVar(&systemMasterDataRows, "rows", 0, "number of rows to fetch (0 = omit; server default 100; max 1000)")
122+
df.IntVar(&systemMasterDataOffset, "offset", 0, "offset (0 = omit; server default 0)")
123+
df.StringVar(&systemMasterDataFormat, "format", "json", "output format: json | csv")
124+
df.StringVar(&systemMasterDataFileName, "file-name", "", "CSV file name hint (CSV only; default: {master_code}.csv)")
125+
df.StringVar(&systemMasterDataDelimiter, "delimiter", "", "CSV delimiter: comma | tab (CSV only; default comma)")
126+
df.BoolVar(&systemMasterDataTitle, "title", false, "CSV only (user-specific master): include field names on the first row (default: true)")
127+
df.BoolVar(&systemMasterDataNoTitle, "no-title", false, "CSV only (user-specific master): omit field names from the first row")
128+
df.StringVar(&systemMasterDataFields, "fields", "", "CSV only (simple master): comma-separated list of field names to include")
129+
df.StringVarP(&systemMasterDataOutput, "output", "o", "", "output path: FILE, DIR/, - for stdout (default: stdout for JSON, server-provided filename for CSV)")
130+
df.StringVar(&systemMasterDataJQ, "jq", "", "apply a gojq filter to the JSON response (JSON format only)")
131+
_ = systemMasterDataCmd.MarkFlagRequired("type")
132+
133+
imf := systemMasterImportCmd.Flags()
134+
imf.BoolVar(&systemMasterImportOverwrite, "overwrite", false, "replace existing simple master data instead of appending")
135+
imf.StringVar(&systemMasterImportData, "data", "", "JSON array of {\"code\",\"value\"} rows: inline, file path, or - for stdin (required)")
136+
imf.StringVar(&systemMasterImportJQ, "jq", "", "apply a gojq filter to the JSON response")
137+
_ = systemMasterImportCmd.MarkFlagRequired("data")
138+
139+
uf := systemMasterUploadCmd.Flags()
140+
uf.StringVar(&systemMasterUploadFile, "file", "", "path to the CSV file to upload, or - for stdin (required)")
141+
uf.BoolVar(&systemMasterUploadOverwrite, "overwrite", false, "overwrite the existing staged CSV for this master")
142+
_ = systemMasterUploadCmd.MarkFlagRequired("file")
143+
}
144+
145+
func runSystemMasterList(cmd *cobra.Command, args []string) error {
146+
client, err := newClientFromFlags(cmd.Context())
147+
if err != nil {
148+
return err
149+
}
150+
res, err := client.ListMasters(cmd.Context())
151+
if err != nil {
152+
return err
153+
}
154+
155+
return render(res, resolveOutputFormat(systemMasterListOutput), systemMasterListJQ, func() error {
156+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
157+
defer w.Flush()
158+
fmt.Fprintln(w, "TYPE\tTYPE_NAME\tCODE\tTABLE_NAME\tITEMS\tNAME\tREMARKS")
159+
for _, m := range res.Master {
160+
code := m.Code
161+
if code == "" {
162+
code = "-"
163+
}
164+
tbl := m.TableName
165+
if tbl == "" {
166+
tbl = "-"
167+
}
168+
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\t%s\n",
169+
m.Type, m.TypeName, code, tbl, m.ItemCount, m.Name, m.Remarks,
170+
)
171+
}
172+
return nil
173+
})
174+
}
175+
176+
func runSystemMasterShow(cmd *cobra.Command, args []string) error {
177+
tableName := strings.TrimSpace(args[0])
178+
if tableName == "" {
179+
return fmt.Errorf("master_table_name is required")
180+
}
181+
client, err := newClientFromFlags(cmd.Context())
182+
if err != nil {
183+
return err
184+
}
185+
res, err := client.GetUserMasterInfo(cmd.Context(), tableName)
186+
if err != nil {
187+
return err
188+
}
189+
return render(res, resolveOutputFormat(systemMasterShowOutput), systemMasterShowJQ, func() error {
190+
fmt.Fprintf(os.Stdout, "TABLE: %s\n", res.TableName)
191+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
192+
defer w.Flush()
193+
fmt.Fprintln(w, "ID\tTYPE\tLENGTH\tPK\tINDEX")
194+
for _, f := range res.Fields {
195+
fmt.Fprintf(w, "%s\t%s\t%v\t%t\t%t\n", f.ID, f.Type, f.Length, f.PrimaryKey, f.Index)
196+
}
197+
return nil
198+
})
199+
}
200+
201+
func runSystemMasterData(cmd *cobra.Command, args []string) error {
202+
masterCode := strings.TrimSpace(args[0])
203+
if masterCode == "" {
204+
return fmt.Errorf("master_code is required")
205+
}
206+
if systemMasterDataType != 0 && systemMasterDataType != 1 {
207+
return fmt.Errorf("--type must be 0 (simple) or 1 (user-specific), got %d", systemMasterDataType)
208+
}
209+
format := strings.ToLower(strings.TrimSpace(systemMasterDataFormat))
210+
switch format {
211+
case "", "json":
212+
format = "json"
213+
case "csv":
214+
default:
215+
return fmt.Errorf("unknown --format %q (must be json or csv)", systemMasterDataFormat)
216+
}
217+
if systemMasterDataTitle && systemMasterDataNoTitle {
218+
return fmt.Errorf("--title and --no-title are mutually exclusive")
219+
}
220+
221+
p := xpoint.MasterDataParams{MasterType: systemMasterDataType}
222+
if cmd.Flags().Changed("rows") {
223+
v := systemMasterDataRows
224+
p.Rows = &v
225+
}
226+
if cmd.Flags().Changed("offset") {
227+
v := systemMasterDataOffset
228+
p.Offset = &v
229+
}
230+
if format == "csv" {
231+
p.FileName = systemMasterDataFileName
232+
p.Delimiter = systemMasterDataDelimiter
233+
p.Fields = systemMasterDataFields
234+
if systemMasterDataNoTitle {
235+
b := false
236+
p.Title = &b
237+
} else if cmd.Flags().Changed("title") {
238+
v := systemMasterDataTitle
239+
p.Title = &v
240+
}
241+
}
242+
243+
client, err := newClientFromFlags(cmd.Context())
244+
if err != nil {
245+
return err
246+
}
247+
filename, body, _, err := client.GetMasterData(cmd.Context(), masterCode, format, p)
248+
if err != nil {
249+
return err
250+
}
251+
252+
if format == "json" {
253+
if systemMasterDataJQ != "" {
254+
return runJQ(json.RawMessage(body), systemMasterDataJQ)
255+
}
256+
switch systemMasterDataOutput {
257+
case "", "-":
258+
_, werr := os.Stdout.Write(body)
259+
return werr
260+
}
261+
dst := resolveDownloadPath(systemMasterDataOutput, fallbackName(filename, masterCode+".json"), 0)
262+
if err := os.WriteFile(dst, body, 0o600); err != nil {
263+
return fmt.Errorf("write master data: %w", err)
264+
}
265+
fmt.Fprintf(os.Stderr, "saved: %s (%d bytes)\n", dst, len(body))
266+
return nil
267+
}
268+
269+
// CSV
270+
if systemMasterDataOutput == "-" {
271+
_, werr := os.Stdout.Write(body)
272+
return werr
273+
}
274+
if systemMasterDataOutput == "" && !isTerminal(os.Stdout) {
275+
_, werr := os.Stdout.Write(body)
276+
return werr
277+
}
278+
dst := resolveDownloadPath(systemMasterDataOutput, fallbackName(filename, masterCode+".csv"), 0)
279+
if err := os.WriteFile(dst, body, 0o600); err != nil {
280+
return fmt.Errorf("write csv: %w", err)
281+
}
282+
fmt.Fprintf(os.Stderr, "saved: %s (%d bytes)\n", dst, len(body))
283+
return nil
284+
}
285+
286+
func runSystemMasterImport(cmd *cobra.Command, args []string) error {
287+
masterCode := strings.TrimSpace(args[0])
288+
if masterCode == "" {
289+
return fmt.Errorf("master_code is required")
290+
}
291+
raw, err := loadStringInput(systemMasterImportData)
292+
if err != nil {
293+
return fmt.Errorf("load --data: %w", err)
294+
}
295+
var items []xpoint.SimpleMasterDataItem
296+
if err := json.Unmarshal([]byte(raw), &items); err != nil {
297+
return fmt.Errorf("--data must be a JSON array of {\"code\",\"value\"}: %w", err)
298+
}
299+
300+
req := xpoint.ImportSimpleMasterRequest{Data: items}
301+
if cmd.Flags().Changed("overwrite") {
302+
v := systemMasterImportOverwrite
303+
req.Overwrite = &v
304+
}
305+
306+
client, err := newClientFromFlags(cmd.Context())
307+
if err != nil {
308+
return err
309+
}
310+
out, err := client.ImportSimpleMasterData(cmd.Context(), masterCode, req)
311+
if err != nil {
312+
return err
313+
}
314+
if systemMasterImportJQ != "" {
315+
return runJQ(out, systemMasterImportJQ)
316+
}
317+
return writeJSON(os.Stdout, out)
318+
}
319+
320+
func runSystemMasterUpload(cmd *cobra.Command, args []string) error {
321+
tableName := strings.TrimSpace(args[0])
322+
if tableName == "" {
323+
return fmt.Errorf("master_table_name is required")
324+
}
325+
content, fileName, err := readUploadFile(systemMasterUploadFile)
326+
if err != nil {
327+
return fmt.Errorf("read --file: %w", err)
328+
}
329+
330+
var overwrite *bool
331+
if cmd.Flags().Changed("overwrite") {
332+
v := systemMasterUploadOverwrite
333+
overwrite = &v
334+
}
335+
336+
client, err := newClientFromFlags(cmd.Context())
337+
if err != nil {
338+
return err
339+
}
340+
res, err := client.UploadUserMasterCSV(cmd.Context(), tableName, fileName, content, overwrite)
341+
if err != nil {
342+
return err
343+
}
344+
return writeJSON(os.Stdout, res)
345+
}
346+
347+
// fallbackName returns name when non-empty, else alt.
348+
func fallbackName(name, alt string) string {
349+
if name != "" {
350+
return name
351+
}
352+
return alt
353+
}
354+
355+
// readUploadFile reads the CSV file contents and returns the bytes plus a
356+
// suggested filename for the multipart form-data part. "-" reads from stdin
357+
// and yields a synthetic "upload.csv" filename.
358+
func readUploadFile(path string) ([]byte, string, error) {
359+
if path == "-" {
360+
b, err := io.ReadAll(os.Stdin)
361+
if err != nil {
362+
return nil, "", fmt.Errorf("read stdin: %w", err)
363+
}
364+
return b, "upload.csv", nil
365+
}
366+
b, err := os.ReadFile(path)
367+
if err != nil {
368+
return nil, "", err
369+
}
370+
return b, filepath.Base(path), nil
371+
}

internal/schema/schema_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ func TestAliases_Sorted(t *testing.T) {
3838
"query.list",
3939
"system.form.list",
4040
"system.form.show",
41+
"system.master.data",
42+
"system.master.import",
43+
"system.master.list",
44+
"system.master.show",
45+
"system.master.upload",
4146
}
4247
if len(got) != len(want) {
4348
t.Fatalf("aliases = %v", got)

0 commit comments

Comments
 (0)