Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions cmd/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"strconv"
"strings"
"text/tabwriter"

Expand All @@ -23,6 +24,17 @@ var (
docCreateBody string
docCreateOutput string
docCreateJQ string

docGetOutput string
docGetJQ string

docEditBody string
docEditOutput string
docEditJQ string

docDeleteYes bool
docDeleteOutput string
docDeleteJQ string
)

var documentCmd = &cobra.Command{
Expand Down Expand Up @@ -59,10 +71,51 @@ The body must contain route_code, datas, and a form identifier
RunE: runDocumentCreate,
}

var documentGetCmd = &cobra.Command{
Use: "get <docid>",
Short: "Get a document",
Long: `Retrieve a single document via GET /api/v1/documents/{docid}.

The response varies by form and is returned as JSON; use --jq to extract
specific fields.`,
Args: cobra.ExactArgs(1),
RunE: runDocumentGet,
}

var documentEditCmd = &cobra.Command{
Use: "edit <docid>",
Short: "Update a document",
Long: `Update a document via PATCH /api/v1/documents/{docid}.

The request body is provided with --body, which accepts one of:
- inline JSON string (e.g. --body '{"route_code":"r1","datas":[...]}')
- a path to a JSON file (e.g. --body ./patch.json)
- "-" to read the body from stdin (e.g. --body -)

The body may contain wf_type, datas, route_code, wf_comment, reason, etc.
When performing a workflow-only operation, omit datas.
See "xp schema document.update" for shape.`,
Args: cobra.ExactArgs(1),
RunE: runDocumentEdit,
}

var documentDeleteCmd = &cobra.Command{
Use: "delete <docid>",
Short: "Delete a document",
Long: `Delete a document via DELETE /api/v1/documents/{docid}.

By default the command prompts for confirmation. Pass --yes to skip it.`,
Args: cobra.ExactArgs(1),
RunE: runDocumentDelete,
}

func init() {
rootCmd.AddCommand(documentCmd)
documentCmd.AddCommand(documentSearchCmd)
documentCmd.AddCommand(documentCreateCmd)
documentCmd.AddCommand(documentGetCmd)
documentCmd.AddCommand(documentEditCmd)
documentCmd.AddCommand(documentDeleteCmd)

f := documentSearchCmd.Flags()
f.StringVar(&docSearchBody, "body", "", "search condition JSON: inline, file path, or - for stdin")
Expand All @@ -76,6 +129,20 @@ func init() {
cf.StringVar(&docCreateBody, "body", "", "request body JSON: inline, file path, or - for stdin (required)")
cf.StringVarP(&docCreateOutput, "output", "o", "", "output format: table|json (default: table on TTY, json otherwise)")
cf.StringVar(&docCreateJQ, "jq", "", "apply a gojq filter to the JSON response (forces JSON output)")

gf := documentGetCmd.Flags()
gf.StringVarP(&docGetOutput, "output", "o", "", "output format: json (default)")
gf.StringVar(&docGetJQ, "jq", "", "apply a gojq filter to the JSON response")

ef := documentEditCmd.Flags()
ef.StringVar(&docEditBody, "body", "", "request body JSON: inline, file path, or - for stdin (required)")
ef.StringVarP(&docEditOutput, "output", "o", "", "output format: table|json (default: table on TTY, json otherwise)")
ef.StringVar(&docEditJQ, "jq", "", "apply a gojq filter to the JSON response (forces JSON output)")

df := documentDeleteCmd.Flags()
df.BoolVarP(&docDeleteYes, "yes", "y", false, "skip the interactive confirmation prompt")
df.StringVarP(&docDeleteOutput, "output", "o", "", "output format: table|json (default: table on TTY, json otherwise)")
df.StringVar(&docDeleteJQ, "jq", "", "apply a gojq filter to the JSON response (forces JSON output)")
}

func runDocumentSearch(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -150,6 +217,108 @@ func runDocumentCreate(cmd *cobra.Command, args []string) error {
})
}

func runDocumentGet(cmd *cobra.Command, args []string) error {
docID, err := parseDocID(args[0])
if err != nil {
return err
}
client, err := newClientFromFlags(cmd.Context())
if err != nil {
return err
}
raw, err := client.GetDocument(cmd.Context(), docID)
if err != nil {
return err
}

if docGetJQ != "" {
return runJQ(raw, docGetJQ)
}
// The response is a complex document; always emit JSON.
return writeJSON(os.Stdout, raw)
}

func runDocumentEdit(cmd *cobra.Command, args []string) error {
docID, err := parseDocID(args[0])
if err != nil {
return err
}
client, err := newClientFromFlags(cmd.Context())
if err != nil {
return err
}
bodyBytes, err := loadSearchBody(docEditBody)
if err != nil {
return err
}
if len(bodyBytes) == 0 {
return fmt.Errorf("--body is required for document edit")
}

res, err := client.UpdateDocument(cmd.Context(), docID, bodyBytes)
if err != nil {
return err
}

return render(res, resolveOutputFormat(docEditOutput), docEditJQ, func() error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
defer w.Flush()
fmt.Fprintln(w, "DOCID\tMESSAGE_TYPE\tMESSAGE")
fmt.Fprintf(w, "%d\t%d\t%s\n", res.DocID, res.MessageType, res.Message)
return nil
})
}

func runDocumentDelete(cmd *cobra.Command, args []string) error {
docID, err := parseDocID(args[0])
if err != nil {
return err
}
if !docDeleteYes {
if !confirmDelete(docID) {
return fmt.Errorf("aborted")
}
}
client, err := newClientFromFlags(cmd.Context())
if err != nil {
return err
}

res, err := client.DeleteDocument(cmd.Context(), docID)
if err != nil {
return err
}

return render(res, resolveOutputFormat(docDeleteOutput), docDeleteJQ, func() error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
defer w.Flush()
fmt.Fprintln(w, "MESSAGE_TYPE\tMESSAGE")
fmt.Fprintf(w, "%d\t%s\n", res.MessageType, res.Message)
return nil
})
}

func parseDocID(s string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return 0, fmt.Errorf("invalid docid %q: must be a positive integer", s)
}
return n, nil
}

// confirmDelete prompts on stderr and reads a yes/no answer from stdin.
// Anything other than "y" / "yes" (case-insensitive) aborts.
func confirmDelete(docID int) bool {
fmt.Fprintf(os.Stderr, "Really delete document %d? [y/N]: ", docID)
var ans string
_, _ = fmt.Fscanln(os.Stdin, &ans)
switch strings.ToLower(strings.TrimSpace(ans)) {
case "y", "yes":
return true
}
return false
}

// loadSearchBody resolves --body into JSON bytes.
func loadSearchBody(spec string) (json.RawMessage, error) {
if spec == "" {
Expand Down
50 changes: 50 additions & 0 deletions cmd/document_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,56 @@ func TestRunDocumentCreate_RequiresBody(t *testing.T) {
}
}

func TestParseDocID(t *testing.T) {
cases := []struct {
in string
want int
wantErr bool
}{
{"1", 1, false},
{"999", 999, false},
{" 42 ", 42, false},
{"0", 0, true},
{"-3", 0, true},
{"abc", 0, true},
{"", 0, true},
}
for _, c := range cases {
got, err := parseDocID(c.in)
if c.wantErr {
if err == nil {
t.Errorf("parseDocID(%q) = %d, want error", c.in, got)
}
continue
}
if err != nil {
t.Errorf("parseDocID(%q) err = %v", c.in, err)
}
if got != c.want {
t.Errorf("parseDocID(%q) = %d, want %d", c.in, got, c.want)
}
}
}

func TestRunDocumentEdit_RequiresBody(t *testing.T) {
docEditBody = ""
t.Setenv("XPOINT_SUBDOMAIN", "acme")
t.Setenv("XPOINT_API_ACCESS_TOKEN", "tok")
t.Setenv("XPOINT_GENERIC_API_TOKEN", "")

err := runDocumentEdit(documentEditCmd, []string{"999"})
if err == nil || !strings.Contains(err.Error(), "--body is required") {
t.Errorf("err = %v", err)
}
}

func TestRunDocumentEdit_InvalidDocID(t *testing.T) {
err := runDocumentEdit(documentEditCmd, []string{"not-a-number"})
if err == nil || !strings.Contains(err.Error(), "invalid docid") {
t.Errorf("err = %v", err)
}
}

func TestLoadSearchBody_Stdin(t *testing.T) {
orig := os.Stdin
t.Cleanup(func() { os.Stdin = orig })
Expand Down
11 changes: 7 additions & 4 deletions cmd/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ var schemaCmd = &cobra.Command{
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
document.create POST /api/v1/documents
form.list GET /api/v1/forms
approval.list GET /api/v1/approvals
document.search POST /api/v1/search/documents
document.create POST /api/v1/documents
document.get GET /api/v1/documents/{docid}
document.update PATCH /api/v1/documents/{docid}
document.delete DELETE /api/v1/documents/{docid}

Run without arguments to list supported aliases.`,
Args: cobra.MaximumNArgs(1),
Expand Down
28 changes: 28 additions & 0 deletions internal/schema/document.delete.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"method": "DELETE",
"path": "/api/v1/documents/{docid}",
"summary": "書類削除",
"description": "指定された ID の書類を削除する。削除権限が必要。\n",
"parameters": [
{
"name": "docid",
"in": "path",
"type": "integer",
"required": true,
"description": "書類ID"
}
],
"response": {
"type": "object",
"properties": {
"message_type": {
"type": "integer",
"description": "メッセージタイプ"
},
"message": {
"type": "string",
"description": "メッセージ内容"
}
}
}
}
61 changes: 61 additions & 0 deletions internal/schema/document.get.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"method": "GET",
"path": "/api/v1/documents/{docid}",
"summary": "書類内容取得",
"description": "指定された ID の書類内容を取得する。応答にはコメント情報、添付ファイル情報 (メタデータ) も含まれる。添付ファイルの実体は別 API でダウンロードする。\n閲覧権限が必要。OAuth2 認証を利用する場合は対象書類へのアクセス権限が必要。\n",
"parameters": [
{
"name": "docid",
"in": "path",
"type": "integer",
"required": true,
"description": "書類ID"
}
],
"response": {
"type": "object",
"description": "書類情報。フォーム定義に依存する可変なデータ構造を持つため、主要なフィールドのみ記載する。",
"properties": {
"docid": {
"type": "integer",
"description": "書類ID"
},
"form": {
"type": "object",
"description": "フォーム情報 (id, code, name など)"
},
"route": {
"type": "object",
"description": "承認ルート情報"
},
"datas": {
"type": "array",
"description": "書類データ (ページごと)"
},
"comments": {
"type": "array",
"description": "コメント一覧 (本文/投稿者/日時等)"
},
"attachments": {
"type": "array",
"description": "添付ファイルのメタデータ一覧"
},
"status": {
"type": "string",
"description": "承認状態"
},
"writer": {
"type": "string",
"description": "申請者"
},
"write_datetime": {
"type": "string",
"description": "申請日時"
},
"update_datetime": {
"type": "string",
"description": "更新日時"
}
}
}
}
Loading