diff --git a/cmd/schema.go b/cmd/schema.go new file mode 100644 index 0000000..28b4930 --- /dev/null +++ b/cmd/schema.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/pepabo/xpoint-cli/internal/schema" + "github.com/spf13/cobra" +) + +var schemaCmd = &cobra.Command{ + Use: "schema [service.resource.method]", + Short: "Show the schema for an X-point operation", + Long: `Print the schema object for a given X-point endpoint. + +Supported aliases map to the CLI's commands: + form.list GET /api/v1/forms + approval.list GET /api/v1/approvals + document.search POST /api/v1/search/documents + +Run without arguments to list supported aliases.`, + Args: cobra.MaximumNArgs(1), + RunE: runSchema, +} + +func init() { + rootCmd.AddCommand(schemaCmd) +} + +func runSchema(_ *cobra.Command, args []string) error { + if len(args) == 0 { + fmt.Fprintln(os.Stdout, "Supported aliases:") + for _, a := range schema.Aliases() { + fmt.Fprintf(os.Stdout, " %s\n", a) + } + return nil + } + alias := strings.TrimSpace(args[0]) + op, err := schema.Lookup(alias) + if err != nil { + return err + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(op) +} diff --git a/cmd/schema_test.go b/cmd/schema_test.go new file mode 100644 index 0000000..11a3f14 --- /dev/null +++ b/cmd/schema_test.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestSchemaCmd_ListsAliases(t *testing.T) { + out, err := captureStdout(t, func() error { + return runSchema(schemaCmd, nil) + }) + if err != nil { + t.Fatalf("runSchema: %v", err) + } + for _, want := range []string{"form.list", "approval.list", "document.search"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +func TestSchemaCmd_EmitsJSON(t *testing.T) { + out, err := captureStdout(t, func() error { + return runSchema(schemaCmd, []string{"form.list"}) + }) + if err != nil { + t.Fatalf("runSchema: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(out), &decoded); err != nil { + t.Fatalf("output not JSON: %v (%s)", err, out) + } + if decoded["method"] != "GET" || decoded["path"] != "/api/v1/forms" { + t.Errorf("decoded = %v", decoded) + } +} + +func TestSchemaCmd_UnknownAlias(t *testing.T) { + err := runSchema(schemaCmd, []string{"nope"}) + if err == nil || !strings.Contains(err.Error(), "unknown schema alias") { + t.Errorf("err = %v", err) + } +} diff --git a/internal/schema/approval.list.json b/internal/schema/approval.list.json new file mode 100644 index 0000000..c42e57a --- /dev/null +++ b/internal/schema/approval.list.json @@ -0,0 +1,135 @@ +{ + "method": "GET", + "path": "/api/v1/approvals", + "summary": "承認待ち一覧取得", + "description": "指定した承認状況タイプに該当する書類の一覧を返す。", + "parameters": [ + { + "name": "stat", + "in": "query", + "type": "integer", + "required": true, + "description": "承認状況タイプ。\n10:承認待ち / 20:承認完了 / 30:申請中 / 40:差戻 / 50:取止 ほか\n詳細は X-point API ドキュメントを参照。\n" + }, + { + "name": "fgid", + "in": "query", + "type": "integer", + "description": "フォームグループID で絞り込み" + }, + { + "name": "fid", + "in": "query", + "type": "integer", + "description": "フォームID で絞り込み" + }, + { + "name": "step", + "in": "query", + "type": "integer", + "description": "ステップ番号で絞り込み" + }, + { + "name": "record_no", + "in": "query", + "type": "integer", + "description": "取得開始レコード番号" + }, + { + "name": "get_line", + "in": "query", + "type": "integer", + "description": "取得件数" + }, + { + "name": "proxy_user", + "in": "query", + "type": "string", + "description": "代理ユーザーコード" + }, + { + "name": "filter", + "in": "query", + "type": "string", + "description": "絞り込み条件式 (例 `cr_dt between \"2023-01-01\" and \"2023-12-31\"`)" + }, + { + "name": "show_hidden_doc", + "in": "query", + "type": "boolean", + "description": "非表示書類も含めて取得するか" + } + ], + "response": { + "type": "object", + "properties": { + "total_count": { + "type": "integer", + "description": "該当件数" + }, + "approval_list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "docid": { + "type": "integer", + "description": "書類ID" + }, + "hidden": { + "type": "boolean", + "description": "非表示書類フラグ (show_hidden_doc=true 指定時のみ含まれる)" + }, + "attachment": { + "type": "boolean", + "description": "添付ファイルフラグ" + }, + "comment": { + "type": "boolean", + "description": "コメントフラグ" + }, + "title1": { + "type": "string", + "description": "件名1" + }, + "title2": { + "type": "string", + "description": "件名2" + }, + "form_name": { + "type": "string", + "description": "フォーム名称" + }, + "status": { + "type": "string", + "description": "ステータス名称" + }, + "display_status": { + "type": "string", + "description": "画面表示ステータス名称" + }, + "apply_datetime": { + "type": "string", + "description": "申請日時 (YYYY/MM/DD hh:mm:ss)" + }, + "apply_user": { + "type": "string", + "description": "申請者氏名" + }, + "approval_user": { + "type": "array", + "description": "現在ステップの承認者一覧", + "items": { + "type": "string" + } + }, + "lastaprv_datetime": { + "type": "string", + "description": "最終更新日時 (YYYY/MM/DD hh:mm:ss)" + } + } + } + } + } + } +} diff --git a/internal/schema/document.search.json b/internal/schema/document.search.json new file mode 100644 index 0000000..7aae64f --- /dev/null +++ b/internal/schema/document.search.json @@ -0,0 +1,130 @@ +{ + "method": "POST", + "path": "/api/v1/search/documents", + "summary": "書類検索", + "description": "検索条件を JSON ボディで指定して書類を検索する。\nボディは省略可 (省略時は全書類が対象)。\n", + "parameters": [ + { + "name": "size", + "in": "query", + "type": "integer", + "description": "1ページあたりの取得件数" + }, + { + "name": "offset", + "in": "query", + "type": "integer", + "description": "取得開始位置" + }, + { + "name": "page", + "in": "query", + "type": "integer", + "description": "ページ番号" + } + ], + "requestBody": { + "contentType": "application/json", + "description": "任意の検索条件を JSON で指定する。代表的なキー:\n * title 件名1/件名2 (部分一致)\n * form_name フォーム名称 (部分一致)\n * fgid / fid フォームグループID / フォームID\n * comment_flg コメントフラグ (0:条件なし 1:あり 2:なし)\n * order 並び替え条件 (例 `-title1`)\nその他の条件キーは X-point API ドキュメントを参照。\n" + }, + "response": { + "type": "object", + "properties": { + "total_count": { + "type": "integer", + "description": "該当件数" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "docid": { + "type": "integer", + "description": "書類ID" + }, + "has_attachments": { + "type": "boolean", + "description": "添付ファイルの有無" + }, + "has_comments": { + "type": "boolean", + "description": "コメントの有無" + }, + "title1": { + "type": "string", + "description": "件名1" + }, + "title2": { + "type": "string", + "description": "件名2" + }, + "form": { + "type": "object", + "description": "フォーム情報", + "properties": { + "id": { + "type": "integer", + "description": "フォームID" + }, + "code": { + "type": "string", + "description": "フォームコード" + }, + "name": { + "type": "string", + "description": "フォーム名称" + } + } + }, + "route": { + "type": "object", + "description": "承認ルート情報", + "properties": { + "code": { + "type": "string", + "description": "承認ルートコード" + }, + "name": { + "type": "string", + "description": "承認ルート名称" + } + } + }, + "step": { + "type": "integer", + "description": "ステップ番号" + }, + "stat": { + "type": "integer", + "description": "承認状況 (-1 は通常フォーム)" + }, + "write_datetime": { + "type": "string", + "description": "申請日時 (YYYY/MM/DD hh:mm:ss)" + }, + "update_datetime": { + "type": "string", + "description": "更新日時 (YYYY/MM/DD hh:mm:ss)" + }, + "writer": { + "type": "string", + "description": "申請者" + }, + "current_approvers": { + "type": "array", + "description": "現在ステップの承認者一覧", + "items": { + "type": "string" + } + }, + "url": { + "type": "string", + "description": "書類URL" + } + } + } + } + } + } +} diff --git a/internal/schema/form.list.json b/internal/schema/form.list.json new file mode 100644 index 0000000..001b30b --- /dev/null +++ b/internal/schema/form.list.json @@ -0,0 +1,71 @@ +{ + "method": "GET", + "path": "/api/v1/forms", + "summary": "利用可能フォーム一覧取得", + "description": "認証ユーザーが利用できるフォームのフォームID・名称を\n所属グループ (グループID・グループ名) とセットで返す。\n", + "parameters": [], + "response": { + "type": "object", + "properties": { + "form_group": { + "type": "array", + "description": "フォームグループ情報 (空配列になり得る)", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "フォームグループID" + }, + "name": { + "type": "string", + "description": "フォームグループ名称" + }, + "form": { + "type": "array", + "description": "所属フォーム情報", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "フォームID" + }, + "code": { + "type": "string", + "description": "フォームコード" + }, + "name": { + "type": "string", + "description": "フォーム名称" + }, + "route": { + "type": "array", + "description": "承認ルート情報", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "承認ルートID" + }, + "code": { + "type": "string", + "description": "承認ルートコード (通常フォーム/自動選択ルートでは含まれない)" + }, + "name": { + "type": "string", + "description": "承認ルート名称" + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go new file mode 100644 index 0000000..fe233c6 --- /dev/null +++ b/internal/schema/schema.go @@ -0,0 +1,82 @@ +// Package schema serves xpoint-cli's curated operation schemas. +// Each supported operation is stored as its own JSON file in this +// directory (e.g. form.list.json) and embedded into the binary. +package schema + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "sort" + "strings" + "sync" +) + +//go:embed *.json +var files embed.FS + +var ( + loadOnce sync.Once + ops map[string]map[string]any + aliases []string + loadErr error +) + +func load() (map[string]map[string]any, []string, error) { + loadOnce.Do(func() { + entries, err := fs.ReadDir(files, ".") + if err != nil { + loadErr = fmt.Errorf("read embedded schema dir: %w", err) + return + } + ops = make(map[string]map[string]any, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + alias := strings.TrimSuffix(e.Name(), ".json") + data, err := fs.ReadFile(files, e.Name()) + if err != nil { + loadErr = fmt.Errorf("read %s: %w", e.Name(), err) + return + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + loadErr = fmt.Errorf("parse %s: %w", e.Name(), err) + return + } + ops[alias] = m + } + aliases = make([]string, 0, len(ops)) + for k := range ops { + aliases = append(aliases, k) + } + sort.Strings(aliases) + }) + return ops, aliases, loadErr +} + +// Aliases returns the sorted list of supported dotted aliases. +func Aliases() []string { + _, a, err := load() + if err != nil { + return nil + } + out := make([]string, len(a)) + copy(out, a) + return out +} + +// Lookup returns the schema object for the given alias. +func Lookup(alias string) (map[string]any, error) { + o, _, err := load() + if err != nil { + return nil, err + } + op, ok := o[alias] + if !ok { + return nil, fmt.Errorf("unknown schema alias %q (run `xp schema` to list supported aliases)", alias) + } + return op, nil +} diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go new file mode 100644 index 0000000..f702b85 --- /dev/null +++ b/internal/schema/schema_test.go @@ -0,0 +1,83 @@ +package schema + +import ( + "strings" + "testing" +) + +func TestAliases_Sorted(t *testing.T) { + got := Aliases() + want := []string{"approval.list", "document.search", "form.list"} + if len(got) != len(want) { + t.Fatalf("aliases = %v", got) + } + for i, w := range want { + if got[i] != w { + t.Errorf("aliases[%d] = %q, want %q", i, got[i], w) + } + } +} + +func TestLookup_Unknown(t *testing.T) { + _, err := Lookup("nope.missing") + if err == nil || !strings.Contains(err.Error(), "unknown schema alias") { + t.Errorf("err = %v", err) + } +} + +func TestLookup_FormList(t *testing.T) { + op, err := Lookup("form.list") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if op["method"] != "GET" { + t.Errorf("method = %v", op["method"]) + } + if op["path"] != "/api/v1/forms" { + t.Errorf("path = %v", op["path"]) + } + // form.id must be integer per our curated schema (upstream spec has a bug). + resp, _ := op["response"].(map[string]any) + props, _ := resp["properties"].(map[string]any) + fg, _ := props["form_group"].(map[string]any) + fgItems, _ := fg["items"].(map[string]any) + fgProps, _ := fgItems["properties"].(map[string]any) + formArr, _ := fgProps["form"].(map[string]any) + formItems, _ := formArr["items"].(map[string]any) + formProps, _ := formItems["properties"].(map[string]any) + formID, _ := formProps["id"].(map[string]any) + if formID["type"] != "integer" { + t.Errorf("form.id type = %v, want integer", formID["type"]) + } +} + +func TestLookup_ApprovalList_RequiredStat(t *testing.T) { + op, err := Lookup("approval.list") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + params, _ := op["parameters"].([]any) + if len(params) == 0 { + t.Fatal("no parameters") + } + first, _ := params[0].(map[string]any) + if first["name"] != "stat" || first["required"] != true { + t.Errorf("first param = %v", first) + } +} + +func TestLookup_DocumentSearch(t *testing.T) { + op, err := Lookup("document.search") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if op["method"] != "POST" { + t.Errorf("method = %v", op["method"]) + } + if op["path"] != "/api/v1/search/documents" { + t.Errorf("path = %v", op["path"]) + } + if _, ok := op["requestBody"].(map[string]any); !ok { + t.Errorf("requestBody missing") + } +}