Skip to content

Commit 1745eaf

Browse files
buty4649claude
andauthored
document: PDFダウンロードサブコマンドを追加 (#18)
GET /api/v1/documents/{docid}/pdf を `xp document download` として実装し、 併せて Claude Code 用のプロジェクト設定 (.claude/CLAUDE.md) と .gitignore の更新を行う。 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2026789 commit 1745eaf

File tree

10 files changed

+302
-1
lines changed

10 files changed

+302
-1
lines changed

.claude/CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# About the API Specification
2+
3+
* The API is defined using OpenAPI
4+
* The openapi.yaml file is located at tmp/openapi.yaml
5+
* If the file is not present, download it from https://atled-workflow.github.io/X-point-doc/api/openapi.yaml

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ go.work.sum
3838
# .vscode/
3939

4040
# Claude Code
41-
/.claude/
41+
/.claude/workspace
42+
/tmp

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ cat search.json | xp document search --body -
9797
xp document search --size 100 --page 2
9898
```
9999

100+
### ドキュメントのPDFダウンロード
101+
102+
```sh
103+
xp document download 266248 # カレントディレクトリにサーバ提供ファイル名で保存
104+
xp document download 266248 -o out.pdf # 指定パスに保存
105+
xp document download 266248 -o pdfs/ # 指定ディレクトリにサーバ提供ファイル名で保存
106+
xp document download 266248 -o - > out.pdf # 標準出力に書き出し
107+
```
108+
100109
### レスポンススキーマの確認
101110

102111
```sh

cmd/document.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"os"
8+
"path/filepath"
89
"strconv"
910
"strings"
1011
"text/tabwriter"
@@ -35,6 +36,8 @@ var (
3536
docDeleteYes bool
3637
docDeleteOutput string
3738
docDeleteJQ string
39+
40+
docDownloadOutput string
3841
)
3942

4043
var documentCmd = &cobra.Command{
@@ -109,13 +112,28 @@ By default the command prompts for confirmation. Pass --yes to skip it.`,
109112
RunE: runDocumentDelete,
110113
}
111114

115+
var documentDownloadCmd = &cobra.Command{
116+
Use: "download <docid>",
117+
Short: "Download a document as PDF",
118+
Long: `Download a document as PDF via GET /api/v1/documents/{docid}/pdf.
119+
120+
By default the PDF is saved to the current directory using the filename
121+
provided by the server (Content-Disposition). Use --output to override:
122+
--output FILE save to FILE
123+
--output DIR/ save into DIR/ using the server-provided filename
124+
--output - write the PDF to stdout`,
125+
Args: cobra.ExactArgs(1),
126+
RunE: runDocumentDownload,
127+
}
128+
112129
func init() {
113130
rootCmd.AddCommand(documentCmd)
114131
documentCmd.AddCommand(documentSearchCmd)
115132
documentCmd.AddCommand(documentCreateCmd)
116133
documentCmd.AddCommand(documentGetCmd)
117134
documentCmd.AddCommand(documentEditCmd)
118135
documentCmd.AddCommand(documentDeleteCmd)
136+
documentCmd.AddCommand(documentDownloadCmd)
119137

120138
f := documentSearchCmd.Flags()
121139
f.StringVar(&docSearchBody, "body", "", "search condition JSON: inline, file path, or - for stdin")
@@ -143,6 +161,9 @@ func init() {
143161
df.BoolVarP(&docDeleteYes, "yes", "y", false, "skip the interactive confirmation prompt")
144162
df.StringVarP(&docDeleteOutput, "output", "o", "", "output format: table|json (default: table on TTY, json otherwise)")
145163
df.StringVar(&docDeleteJQ, "jq", "", "apply a gojq filter to the JSON response (forces JSON output)")
164+
165+
dlf := documentDownloadCmd.Flags()
166+
dlf.StringVarP(&docDownloadOutput, "output", "o", "", "output path: FILE, DIR/, or - for stdout (default: server-provided filename in current directory)")
146167
}
147168

148169
func runDocumentSearch(cmd *cobra.Command, args []string) error {
@@ -312,6 +333,59 @@ func runDocumentDelete(cmd *cobra.Command, args []string) error {
312333
})
313334
}
314335

336+
func runDocumentDownload(cmd *cobra.Command, args []string) error {
337+
docID, err := parseDocID(args[0])
338+
if err != nil {
339+
return err
340+
}
341+
client, err := newClientFromFlags(cmd.Context())
342+
if err != nil {
343+
return err
344+
}
345+
346+
filename, data, err := client.DownloadPDF(cmd.Context(), docID)
347+
if err != nil {
348+
return err
349+
}
350+
351+
out := docDownloadOutput
352+
if out == "-" {
353+
_, werr := os.Stdout.Write(data)
354+
return werr
355+
}
356+
357+
dst := resolveDownloadPath(out, filename, docID)
358+
if err := os.WriteFile(dst, data, 0o600); err != nil {
359+
return fmt.Errorf("write pdf: %w", err)
360+
}
361+
fmt.Fprintf(os.Stderr, "saved: %s (%d bytes)\n", dst, len(data))
362+
return nil
363+
}
364+
365+
// resolveDownloadPath decides the on-disk path for a downloaded PDF.
366+
//
367+
// When out is empty, the server-provided filename is used in the current
368+
// directory (falling back to "<docid>.pdf"). When out ends with a path
369+
// separator or names an existing directory, the server-provided filename is
370+
// placed inside it. Otherwise out is used verbatim as the file path. The
371+
// server-provided name is base-name-cleaned to avoid path traversal.
372+
func resolveDownloadPath(out, serverName string, docID int) string {
373+
name := filepath.Base(filepath.Clean(serverName))
374+
if name == "." || name == string(filepath.Separator) || name == "" {
375+
name = fmt.Sprintf("%d.pdf", docID)
376+
}
377+
if out == "" {
378+
return name
379+
}
380+
if strings.HasSuffix(out, string(os.PathSeparator)) || strings.HasSuffix(out, "/") {
381+
return filepath.Join(out, name)
382+
}
383+
if info, err := os.Stat(out); err == nil && info.IsDir() {
384+
return filepath.Join(out, name)
385+
}
386+
return out
387+
}
388+
315389
func parseDocID(s string) (int, error) {
316390
n, err := strconv.Atoi(strings.TrimSpace(s))
317391
if err != nil || n <= 0 {

cmd/document_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,50 @@ import (
88
"testing"
99
)
1010

11+
func TestResolveDownloadPath_DefaultUsesServerName(t *testing.T) {
12+
got := resolveDownloadPath("", "経費.pdf", 42)
13+
if got != "経費.pdf" {
14+
t.Errorf("got = %q, want 経費.pdf", got)
15+
}
16+
}
17+
18+
func TestResolveDownloadPath_DefaultFallbackWhenEmpty(t *testing.T) {
19+
got := resolveDownloadPath("", "", 42)
20+
if got != "42.pdf" {
21+
t.Errorf("got = %q, want 42.pdf", got)
22+
}
23+
}
24+
25+
func TestResolveDownloadPath_StripsPathTraversal(t *testing.T) {
26+
got := resolveDownloadPath("", "../../etc/passwd", 99)
27+
if got != "passwd" {
28+
t.Errorf("got = %q, want passwd", got)
29+
}
30+
}
31+
32+
func TestResolveDownloadPath_ExplicitFile(t *testing.T) {
33+
got := resolveDownloadPath("out.pdf", "server.pdf", 1)
34+
if got != "out.pdf" {
35+
t.Errorf("got = %q, want out.pdf", got)
36+
}
37+
}
38+
39+
func TestResolveDownloadPath_DirectorySuffix(t *testing.T) {
40+
got := resolveDownloadPath("sub/", "doc.pdf", 1)
41+
if got != filepath.Join("sub", "doc.pdf") {
42+
t.Errorf("got = %q", got)
43+
}
44+
}
45+
46+
func TestResolveDownloadPath_ExistingDirectory(t *testing.T) {
47+
dir := t.TempDir()
48+
got := resolveDownloadPath(dir, "server.pdf", 1)
49+
want := filepath.Join(dir, "server.pdf")
50+
if got != want {
51+
t.Errorf("got = %q, want %q", got, want)
52+
}
53+
}
54+
1155
func TestLoadSearchBody_Empty(t *testing.T) {
1256
b, err := loadSearchBody("")
1357
if err != nil {

cmd/schema.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Supported aliases map to the CLI's commands:
2424
document.get GET /api/v1/documents/{docid}
2525
document.update PATCH /api/v1/documents/{docid}
2626
document.delete DELETE /api/v1/documents/{docid}
27+
document.download GET /api/v1/documents/{docid}/pdf
2728
2829
Run without arguments to list supported aliases.`,
2930
Args: cobra.MaximumNArgs(1),
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"method": "GET",
3+
"path": "/api/v1/documents/{docid}/pdf",
4+
"summary": "PDFダウンロード",
5+
"description": "指定された ID の書類の PDF をダウンロードする。閲覧権限が必要。OAuth2 認証を利用する場合は対象書類へのアクセス権限が必要。\n",
6+
"parameters": [
7+
{
8+
"name": "docid",
9+
"in": "path",
10+
"type": "integer",
11+
"required": true,
12+
"description": "書類ID"
13+
}
14+
],
15+
"response": {
16+
"type": "binary",
17+
"description": "PDF バイナリ (Content-Type: application/pdf)。ファイル名は Content-Disposition ヘッダで返却され、RFC 5987 の filename* 形式を含む。"
18+
}
19+
}

internal/schema/schema_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ func TestAliases_Sorted(t *testing.T) {
1111
"approval.list",
1212
"document.create",
1313
"document.delete",
14+
"document.download",
1415
"document.get",
1516
"document.search",
1617
"document.update",
@@ -126,6 +127,19 @@ func TestLookup_DocumentUpdate(t *testing.T) {
126127
}
127128
}
128129

130+
func TestLookup_DocumentDownload(t *testing.T) {
131+
op, err := Lookup("document.download")
132+
if err != nil {
133+
t.Fatalf("Lookup: %v", err)
134+
}
135+
if op["method"] != "GET" {
136+
t.Errorf("method = %v", op["method"])
137+
}
138+
if op["path"] != "/api/v1/documents/{docid}/pdf" {
139+
t.Errorf("path = %v", op["path"])
140+
}
141+
}
142+
129143
func TestLookup_DocumentDelete(t *testing.T) {
130144
op, err := Lookup("document.delete")
131145
if err != nil {

internal/xpoint/client.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/json"
88
"fmt"
99
"io"
10+
"mime"
1011
"net/http"
1112
"net/url"
1213
"os"
@@ -354,6 +355,62 @@ func (c *Client) DeleteDocument(ctx context.Context, docID int) (*DeleteDocument
354355
return &out, nil
355356
}
356357

358+
// DownloadPDF calls GET /api/v1/documents/{docid}/pdf and returns the PDF
359+
// bytes and the server-provided filename (parsed from Content-Disposition,
360+
// which may use RFC 5987 encoding). The filename is empty when the server
361+
// does not provide one.
362+
func (c *Client) DownloadPDF(ctx context.Context, docID int) (string, []byte, error) {
363+
path := fmt.Sprintf("/api/v1/documents/%d/pdf", docID)
364+
u := c.baseURL + path
365+
366+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
367+
if err != nil {
368+
return "", nil, err
369+
}
370+
c.auth.apply(req)
371+
req.Header.Set("Accept", "application/pdf")
372+
373+
debug := os.Getenv("XP_DEBUG") != ""
374+
if debug {
375+
fmt.Fprintf(os.Stderr, "[xp] %s %s\n", req.Method, u)
376+
}
377+
378+
resp, err := c.http.Do(req)
379+
if err != nil {
380+
return "", nil, fmt.Errorf("request failed: %w", err)
381+
}
382+
defer resp.Body.Close()
383+
384+
body, err := io.ReadAll(resp.Body)
385+
if err != nil {
386+
return "", nil, fmt.Errorf("read response body: %w", err)
387+
}
388+
if debug {
389+
fmt.Fprintf(os.Stderr, "[xp] <- %s (%d bytes)\n", resp.Status, len(body))
390+
if resp.StatusCode/100 != 2 {
391+
fmt.Fprintf(os.Stderr, "[xp] %s\n", string(body))
392+
}
393+
}
394+
if resp.StatusCode/100 != 2 {
395+
return "", nil, fmt.Errorf("xpoint api error: %s: %s", resp.Status, string(body))
396+
}
397+
return parseContentDispositionFilename(resp.Header.Get("Content-Disposition")), body, nil
398+
}
399+
400+
// parseContentDispositionFilename extracts a filename from a Content-Disposition
401+
// header value, preferring the RFC 5987 filename* form when present.
402+
func parseContentDispositionFilename(cd string) string {
403+
if cd == "" {
404+
return ""
405+
}
406+
if _, params, err := mime.ParseMediaType(cd); err == nil {
407+
if name := params["filename"]; name != "" {
408+
return name
409+
}
410+
}
411+
return ""
412+
}
413+
357414
// do executes an HTTP request and decodes a JSON response into out.
358415
func (c *Client) do(ctx context.Context, method, path string, q url.Values, body []byte, out any) error {
359416
u := c.baseURL + path

0 commit comments

Comments
 (0)