papycli 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- papycli/__init__.py +5 -0
- papycli/api_call.py +192 -0
- papycli/completion.py +168 -0
- papycli/config.py +93 -0
- papycli/init_cmd.py +43 -0
- papycli/main.py +231 -0
- papycli/spec_loader.py +142 -0
- papycli/summary.py +110 -0
- papycli-0.4.0.dist-info/METADATA +252 -0
- papycli-0.4.0.dist-info/RECORD +13 -0
- papycli-0.4.0.dist-info/WHEEL +4 -0
- papycli-0.4.0.dist-info/entry_points.txt +2 -0
- papycli-0.4.0.dist-info/licenses/LICENSE +21 -0
papycli/__init__.py
ADDED
papycli/api_call.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""HTTP リクエスト実行・パステンプレートマッチング・パラメータ構築."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# パステンプレートマッチング
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _template_to_regex(template: str) -> tuple[str, list[str]]:
|
|
18
|
+
"""/pet/{petId} → (r'/pet/([^/]+)', ['petId']) に変換する。"""
|
|
19
|
+
param_names: list[str] = []
|
|
20
|
+
parts = re.split(r"\{([^}]+)\}", template)
|
|
21
|
+
pattern_parts: list[str] = []
|
|
22
|
+
for i, part in enumerate(parts):
|
|
23
|
+
if i % 2 == 0:
|
|
24
|
+
pattern_parts.append(re.escape(part))
|
|
25
|
+
else:
|
|
26
|
+
param_names.append(part)
|
|
27
|
+
pattern_parts.append("([^/]+)")
|
|
28
|
+
return "".join(pattern_parts), param_names
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def match_path_template(
|
|
32
|
+
resource: str, templates: list[str]
|
|
33
|
+
) -> tuple[str, dict[str, str]] | None:
|
|
34
|
+
"""resource をテンプレート一覧にマッチさせる。
|
|
35
|
+
|
|
36
|
+
完全一致を優先し、次にテンプレート変数が少ない(具体的な)順。
|
|
37
|
+
マッチしない場合は None を返す。
|
|
38
|
+
"""
|
|
39
|
+
if resource in templates:
|
|
40
|
+
return resource, {}
|
|
41
|
+
|
|
42
|
+
matches: list[tuple[str, dict[str, str], int]] = []
|
|
43
|
+
for template in templates:
|
|
44
|
+
pattern, param_names = _template_to_regex(template)
|
|
45
|
+
m = re.fullmatch(pattern, resource)
|
|
46
|
+
if m:
|
|
47
|
+
path_params = dict(zip(param_names, m.groups()))
|
|
48
|
+
matches.append((template, path_params, len(param_names)))
|
|
49
|
+
|
|
50
|
+
if not matches:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
matches.sort(key=lambda x: x[2])
|
|
54
|
+
template, path_params, _ = matches[0]
|
|
55
|
+
return template, path_params
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def expand_path(template: str, path_params: dict[str, str]) -> str:
|
|
59
|
+
"""/pet/{petId} + {petId: '99'} → /pet/99"""
|
|
60
|
+
result = template
|
|
61
|
+
for name, value in path_params.items():
|
|
62
|
+
result = result.replace(f"{{{name}}}", value)
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# パラメータ構築
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _set_or_append(obj: dict[str, Any], key: str, value: str) -> None:
|
|
72
|
+
"""dict にキーが既存なら配列に、なければ単値として設定する。"""
|
|
73
|
+
if key not in obj:
|
|
74
|
+
obj[key] = value
|
|
75
|
+
elif isinstance(obj[key], list):
|
|
76
|
+
obj[key].append(value)
|
|
77
|
+
else:
|
|
78
|
+
obj[key] = [obj[key], value]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_body(pairs: Sequence[tuple[str, str]]) -> dict[str, Any]:
|
|
82
|
+
"""(-p name value) ペアから JSON ボディ dict を構築する。
|
|
83
|
+
|
|
84
|
+
- 同じキーを繰り返すと JSON 配列になる
|
|
85
|
+
- ドット記法 (category.id) で 1 レベルのネストオブジェクトになる
|
|
86
|
+
"""
|
|
87
|
+
result: dict[str, Any] = {}
|
|
88
|
+
for name, value in pairs:
|
|
89
|
+
if "." in name:
|
|
90
|
+
parent, child = name.split(".", 1)
|
|
91
|
+
if parent not in result:
|
|
92
|
+
result[parent] = {}
|
|
93
|
+
parent_obj = result[parent]
|
|
94
|
+
if not isinstance(parent_obj, dict):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Cannot use dot notation on '{parent}': already a scalar or array"
|
|
97
|
+
)
|
|
98
|
+
_set_or_append(parent_obj, child, value)
|
|
99
|
+
else:
|
|
100
|
+
_set_or_append(result, name, value)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# ヘッダー解析
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_headers(
|
|
110
|
+
header_strings: Sequence[str],
|
|
111
|
+
custom_header_env: str | None = None,
|
|
112
|
+
) -> dict[str, str]:
|
|
113
|
+
""""-H Header: Value" 文字列と PAPYCLI_CUSTOM_HEADER 環境変数からヘッダー dict を構築する。
|
|
114
|
+
|
|
115
|
+
環境変数より -H オプションが優先される。
|
|
116
|
+
"""
|
|
117
|
+
headers: dict[str, str] = {}
|
|
118
|
+
|
|
119
|
+
if custom_header_env:
|
|
120
|
+
for line in custom_header_env.splitlines():
|
|
121
|
+
line = line.strip()
|
|
122
|
+
if line and ":" in line:
|
|
123
|
+
k, _, v = line.partition(":")
|
|
124
|
+
headers[k.strip()] = v.strip()
|
|
125
|
+
|
|
126
|
+
for h in header_strings:
|
|
127
|
+
if ":" not in h:
|
|
128
|
+
raise ValueError(f"Invalid header format: {h!r} (expected 'Name: Value')")
|
|
129
|
+
k, _, v = h.partition(":")
|
|
130
|
+
headers[k.strip()] = v.strip()
|
|
131
|
+
|
|
132
|
+
return headers
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# HTTP 実行
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def call_api(
|
|
141
|
+
method: str,
|
|
142
|
+
resource: str,
|
|
143
|
+
base_url: str,
|
|
144
|
+
apidef: dict[str, Any],
|
|
145
|
+
*,
|
|
146
|
+
query_params: Sequence[tuple[str, str]] = (),
|
|
147
|
+
body_params: Sequence[tuple[str, str]] = (),
|
|
148
|
+
raw_body: str | None = None,
|
|
149
|
+
extra_headers: Sequence[str] = (),
|
|
150
|
+
) -> requests.Response:
|
|
151
|
+
"""API を呼び出し、レスポンスを返す。"""
|
|
152
|
+
if not base_url:
|
|
153
|
+
raise RuntimeError(
|
|
154
|
+
"Base URL is not configured. Edit papycli.conf and set the 'url' field."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
templates = list(apidef.keys())
|
|
158
|
+
match = match_path_template(resource, templates)
|
|
159
|
+
if match is None:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"No matching path for '{resource}'.\n"
|
|
162
|
+
f"Available paths: {', '.join(templates)}"
|
|
163
|
+
)
|
|
164
|
+
template, path_params = match
|
|
165
|
+
|
|
166
|
+
ops: list[dict[str, Any]] = apidef[template]
|
|
167
|
+
if not any(op["method"] == method for op in ops):
|
|
168
|
+
available = [op["method"] for op in ops]
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Method '{method}' is not defined for '{template}'. "
|
|
171
|
+
f"Available: {', '.join(available)}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
expanded = expand_path(template, path_params)
|
|
175
|
+
url = base_url.rstrip("/") + expanded
|
|
176
|
+
|
|
177
|
+
custom_env = os.environ.get("PAPYCLI_CUSTOM_HEADER")
|
|
178
|
+
headers = parse_headers(extra_headers, custom_env)
|
|
179
|
+
|
|
180
|
+
json_body: dict[str, Any] | None = None
|
|
181
|
+
if raw_body is not None:
|
|
182
|
+
json_body = json.loads(raw_body)
|
|
183
|
+
elif body_params:
|
|
184
|
+
json_body = build_body(body_params)
|
|
185
|
+
|
|
186
|
+
return requests.request(
|
|
187
|
+
method=method.upper(),
|
|
188
|
+
url=url,
|
|
189
|
+
params=list(query_params),
|
|
190
|
+
json=json_body,
|
|
191
|
+
headers=headers,
|
|
192
|
+
)
|
papycli/completion.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""シェル補完ロジックとスクリプト生成。
|
|
2
|
+
|
|
3
|
+
補完の仕組み:
|
|
4
|
+
bash/zsh スクリプトが `papycli _complete <current_index> <words...>` を呼び出す。
|
|
5
|
+
`_complete` コマンドが補完候補を 1 行 1 候補の形式で標準出力に返す。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
METHODS = ["get", "post", "put", "patch", "delete"]
|
|
14
|
+
MANAGEMENT_COMMANDS = ["init", "use", "conf", "summary", "completion-script"]
|
|
15
|
+
ALL_COMMANDS = METHODS + MANAGEMENT_COMMANDS
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# シェルスクリプトテンプレート
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
BASH_SCRIPT = """\
|
|
22
|
+
_papycli_completion() {
|
|
23
|
+
local IFS=$'\\n'
|
|
24
|
+
COMPREPLY=($(papycli _complete "${COMP_CWORD}" "${COMP_WORDS[@]}" 2>/dev/null))
|
|
25
|
+
}
|
|
26
|
+
complete -o nospace -F _papycli_completion papycli
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
ZSH_SCRIPT = """\
|
|
30
|
+
_papycli() {
|
|
31
|
+
local -a completions
|
|
32
|
+
completions=(${(f)"$(papycli _complete "$((CURRENT - 1))" "${words[@]}" 2>/dev/null)"})
|
|
33
|
+
if [[ ${#completions[@]} -gt 0 ]]; then
|
|
34
|
+
_describe '' completions
|
|
35
|
+
fi
|
|
36
|
+
}
|
|
37
|
+
compdef _papycli papycli
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_script(shell: str) -> str:
|
|
42
|
+
"""指定シェル向けの補完スクリプトを返す。"""
|
|
43
|
+
if shell == "bash":
|
|
44
|
+
return BASH_SCRIPT
|
|
45
|
+
return ZSH_SCRIPT
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# 補完ロジック(純粋関数 — apidef を引数で受け取る)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _find_op(
|
|
54
|
+
apidef: dict[str, Any], method: str, resource: str
|
|
55
|
+
) -> dict[str, Any] | None:
|
|
56
|
+
"""resource にマッチするテンプレートを探し、指定 method の operation を返す。"""
|
|
57
|
+
from papycli.api_call import match_path_template
|
|
58
|
+
|
|
59
|
+
match = match_path_template(resource, list(apidef.keys()))
|
|
60
|
+
if match is None:
|
|
61
|
+
return None
|
|
62
|
+
template, _ = match
|
|
63
|
+
return next((o for o in apidef[template] if o["method"] == method), None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _complete_resources(apidef: dict[str, Any], incomplete: str) -> list[str]:
|
|
67
|
+
return [p for p in sorted(apidef.keys()) if p.startswith(incomplete)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _complete_param_names(
|
|
71
|
+
apidef: dict[str, Any],
|
|
72
|
+
method: str,
|
|
73
|
+
resource: str,
|
|
74
|
+
kind: str,
|
|
75
|
+
incomplete: str,
|
|
76
|
+
) -> list[str]:
|
|
77
|
+
op = _find_op(apidef, method, resource)
|
|
78
|
+
if op is None:
|
|
79
|
+
return []
|
|
80
|
+
key = "query_parameters" if kind == "query" else "post_parameters"
|
|
81
|
+
return [p["name"] for p in op.get(key, []) if p["name"].startswith(incomplete)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _complete_enum_values(
|
|
85
|
+
apidef: dict[str, Any],
|
|
86
|
+
method: str,
|
|
87
|
+
resource: str,
|
|
88
|
+
kind: str,
|
|
89
|
+
param_name: str,
|
|
90
|
+
incomplete: str,
|
|
91
|
+
) -> list[str]:
|
|
92
|
+
op = _find_op(apidef, method, resource)
|
|
93
|
+
if op is None:
|
|
94
|
+
return []
|
|
95
|
+
key = "query_parameters" if kind == "query" else "post_parameters"
|
|
96
|
+
for p in op.get(key, []):
|
|
97
|
+
if p["name"] == param_name and "enum" in p:
|
|
98
|
+
return [str(v) for v in p["enum"] if str(v).startswith(incomplete)]
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def completions_for_context(
|
|
103
|
+
words: list[str],
|
|
104
|
+
current: int,
|
|
105
|
+
apidef: dict[str, Any] | None,
|
|
106
|
+
) -> list[str]:
|
|
107
|
+
"""コマンドラインのコンテキストに応じた補完候補を返す。
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
words: コマンドライントークンのリスト(words[0] = "papycli")
|
|
111
|
+
current: 補完中の単語のインデックス(0 始まり)
|
|
112
|
+
apidef: 現在の API 定義 dict。None の場合は空リストを返す。
|
|
113
|
+
"""
|
|
114
|
+
incomplete = words[current] if current < len(words) else ""
|
|
115
|
+
|
|
116
|
+
# サブコマンド名の補完
|
|
117
|
+
if current == 1:
|
|
118
|
+
return [c for c in ALL_COMMANDS if c.startswith(incomplete)]
|
|
119
|
+
|
|
120
|
+
# words[1] が HTTP メソッドでない場合は補完なし
|
|
121
|
+
if len(words) < 2 or words[1] not in METHODS:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
method = words[1]
|
|
125
|
+
|
|
126
|
+
# リソースパスの補完
|
|
127
|
+
if current == 2:
|
|
128
|
+
if apidef is None:
|
|
129
|
+
return []
|
|
130
|
+
return _complete_resources(apidef, incomplete)
|
|
131
|
+
|
|
132
|
+
resource = words[2] if len(words) > 2 else ""
|
|
133
|
+
prev = words[current - 1] if current >= 1 else ""
|
|
134
|
+
prev_prev = words[current - 2] if current >= 2 else ""
|
|
135
|
+
|
|
136
|
+
if apidef is None:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
# -q NAME → クエリパラメータ名
|
|
140
|
+
if prev == "-q":
|
|
141
|
+
return _complete_param_names(apidef, method, resource, "query", incomplete)
|
|
142
|
+
|
|
143
|
+
# -q NAME VALUE → enum 値
|
|
144
|
+
if prev_prev == "-q":
|
|
145
|
+
return _complete_enum_values(apidef, method, resource, "query", prev, incomplete)
|
|
146
|
+
|
|
147
|
+
# -p NAME → ボディパラメータ名
|
|
148
|
+
if prev == "-p":
|
|
149
|
+
return _complete_param_names(apidef, method, resource, "body", incomplete)
|
|
150
|
+
|
|
151
|
+
# -p NAME VALUE → enum 値
|
|
152
|
+
if prev_prev == "-p":
|
|
153
|
+
return _complete_enum_values(apidef, method, resource, "body", prev, incomplete)
|
|
154
|
+
|
|
155
|
+
# オプション名
|
|
156
|
+
opts = ["-q", "-p", "-d", "-H", "--summary"]
|
|
157
|
+
return [o for o in opts if o.startswith(incomplete)]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_completions(words: list[str], current: int, conf_dir: Path | None = None) -> list[str]:
|
|
161
|
+
"""apidef をディスクから読み込んで補完候補を返す。失敗した場合は空リスト。"""
|
|
162
|
+
from papycli.config import get_conf_dir, load_current_apidef
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
apidef, _ = load_current_apidef(conf_dir or get_conf_dir())
|
|
166
|
+
except Exception:
|
|
167
|
+
apidef = None
|
|
168
|
+
return completions_for_context(words, current, apidef)
|
papycli/config.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""設定ファイル (papycli.conf) の読み書き."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
CONF_FILENAME = "papycli.conf"
|
|
9
|
+
APIS_DIRNAME = "apis"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_conf_dir() -> Path:
|
|
13
|
+
"""設定ディレクトリを返す。PAPYCLI_CONF_DIR 環境変数 or ~/.papycli"""
|
|
14
|
+
env = os.environ.get("PAPYCLI_CONF_DIR")
|
|
15
|
+
if env:
|
|
16
|
+
return Path(env)
|
|
17
|
+
return Path.home() / ".papycli"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_conf_path(conf_dir: Path | None = None) -> Path:
|
|
21
|
+
return (conf_dir or get_conf_dir()) / CONF_FILENAME
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_apis_dir(conf_dir: Path | None = None) -> Path:
|
|
25
|
+
return (conf_dir or get_conf_dir()) / APIS_DIRNAME
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_conf(conf_dir: Path | None = None) -> dict[str, Any]:
|
|
29
|
+
"""設定ファイルを読み込む。存在しない場合は空の dict を返す。"""
|
|
30
|
+
path = get_conf_path(conf_dir)
|
|
31
|
+
if not path.exists():
|
|
32
|
+
return {}
|
|
33
|
+
with path.open(encoding="utf-8") as f:
|
|
34
|
+
return json.load(f) # type: ignore[no-any-return]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_conf(conf: dict[str, Any], conf_dir: Path | None = None) -> None:
|
|
38
|
+
"""設定ファイルを保存する。ディレクトリが存在しない場合は作成する。"""
|
|
39
|
+
path = get_conf_path(conf_dir)
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
with path.open("w", encoding="utf-8") as f:
|
|
42
|
+
json.dump(conf, f, indent=2, ensure_ascii=False)
|
|
43
|
+
f.write("\n")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def register_api(
|
|
47
|
+
conf: dict[str, Any],
|
|
48
|
+
name: str,
|
|
49
|
+
openapispec: str,
|
|
50
|
+
apidef: str,
|
|
51
|
+
url: str,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""設定に API エントリを追加・更新する。default が未設定の場合は自動設定する。"""
|
|
54
|
+
conf[name] = {"openapispec": openapispec, "apidef": apidef, "url": url}
|
|
55
|
+
if "default" not in conf:
|
|
56
|
+
conf["default"] = name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_default_api(conf: dict[str, Any], name: str) -> None:
|
|
60
|
+
"""デフォルト API を変更する。"""
|
|
61
|
+
conf["default"] = name
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_default_api(conf: dict[str, Any]) -> str | None:
|
|
65
|
+
"""現在のデフォルト API 名を返す。未設定の場合は None。"""
|
|
66
|
+
return conf.get("default") # type: ignore[return-value]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_current_apidef(conf_dir: Path | None = None) -> tuple[dict[str, Any], str]:
|
|
70
|
+
"""現在のデフォルト API の (apidef dict, base_url) を返す。"""
|
|
71
|
+
resolved_dir = conf_dir or get_conf_dir()
|
|
72
|
+
conf = load_conf(resolved_dir)
|
|
73
|
+
api_name = get_default_api(conf)
|
|
74
|
+
if not api_name:
|
|
75
|
+
raise RuntimeError("No default API configured. Run 'papycli init <spec>' first.")
|
|
76
|
+
|
|
77
|
+
api_entry = conf.get(api_name)
|
|
78
|
+
if not isinstance(api_entry, dict):
|
|
79
|
+
raise RuntimeError(f"Invalid configuration for API '{api_name}'.")
|
|
80
|
+
|
|
81
|
+
base_url = str(api_entry.get("url", ""))
|
|
82
|
+
apidef_filename = str(api_entry.get("apidef", f"{api_name}.json"))
|
|
83
|
+
apidef_path = get_apis_dir(resolved_dir) / apidef_filename
|
|
84
|
+
|
|
85
|
+
if not apidef_path.exists():
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
f"API definition file not found: {apidef_path}\n"
|
|
88
|
+
"Run 'papycli init <spec>' to regenerate it."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
with apidef_path.open(encoding="utf-8") as f:
|
|
92
|
+
apidef: dict[str, Any] = json.load(f)
|
|
93
|
+
return apidef, base_url
|
papycli/init_cmd.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""--init コマンドの処理: OpenAPI spec を内部形式に変換して保存する。"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from papycli.config import get_apis_dir, register_api
|
|
7
|
+
from papycli.spec_loader import extract_base_url, load_spec, resolve_refs, spec_to_apidef
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def init_api(spec_path: Path, conf_dir: Path) -> tuple[str, str]:
|
|
11
|
+
"""OpenAPI spec を読み込み、内部形式に変換して保存する。
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
(api_name, base_url) のタプル
|
|
15
|
+
"""
|
|
16
|
+
raw_spec = load_spec(spec_path)
|
|
17
|
+
resolved_spec = resolve_refs(raw_spec, raw_spec)
|
|
18
|
+
apidef = spec_to_apidef(resolved_spec)
|
|
19
|
+
|
|
20
|
+
api_name = spec_path.stem
|
|
21
|
+
base_url = extract_base_url(resolved_spec)
|
|
22
|
+
|
|
23
|
+
apis_dir = get_apis_dir(conf_dir)
|
|
24
|
+
apis_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
apidef_path = apis_dir / f"{api_name}.json"
|
|
27
|
+
with apidef_path.open("w", encoding="utf-8") as f:
|
|
28
|
+
json.dump(apidef, f, indent=2, ensure_ascii=False)
|
|
29
|
+
f.write("\n")
|
|
30
|
+
|
|
31
|
+
return api_name, base_url
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_initialized_api(
|
|
35
|
+
conf: dict, # type: ignore[type-arg]
|
|
36
|
+
api_name: str,
|
|
37
|
+
spec_path: Path,
|
|
38
|
+
base_url: str,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""init 後に設定ファイルへ API エントリを登録する。"""
|
|
41
|
+
spec_filename = spec_path.name
|
|
42
|
+
apidef_filename = f"{api_name}.json"
|
|
43
|
+
register_api(conf, api_name, spec_filename, apidef_filename, base_url)
|
papycli/main.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""CLI エントリポイント."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from papycli import __version__
|
|
11
|
+
from papycli.api_call import call_api, match_path_template
|
|
12
|
+
from papycli.config import (
|
|
13
|
+
get_conf_dir,
|
|
14
|
+
get_conf_path,
|
|
15
|
+
load_conf,
|
|
16
|
+
load_current_apidef,
|
|
17
|
+
save_conf,
|
|
18
|
+
set_default_api,
|
|
19
|
+
)
|
|
20
|
+
from papycli.completion import generate_script, get_completions
|
|
21
|
+
from papycli.init_cmd import init_api, register_initialized_api
|
|
22
|
+
from papycli.summary import format_endpoint_detail, format_summary_csv, print_summary
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]})
|
|
26
|
+
@click.version_option(__version__, "-V", "--version")
|
|
27
|
+
@click.pass_context
|
|
28
|
+
def cli(ctx: click.Context) -> None:
|
|
29
|
+
"""papycli — OpenAPI 3.0 仕様から REST API を呼び出す CLI ツール."""
|
|
30
|
+
if ctx.invoked_subcommand is None:
|
|
31
|
+
click.echo(ctx.get_help())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# 設定系コマンド
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cli.command("init")
|
|
40
|
+
@click.argument("spec_file", metavar="SPEC_FILE", type=click.Path(exists=True, dir_okay=False))
|
|
41
|
+
def cmd_init(spec_file: str) -> None:
|
|
42
|
+
"""OpenAPI spec ファイルから API を初期化する。"""
|
|
43
|
+
spec_path = Path(spec_file)
|
|
44
|
+
conf_dir = get_conf_dir()
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
api_name, base_url = init_api(spec_path, conf_dir)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
click.echo(f"Error: {e}", err=True)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
conf = load_conf(conf_dir)
|
|
53
|
+
register_initialized_api(conf, api_name, spec_path, base_url)
|
|
54
|
+
save_conf(conf, conf_dir)
|
|
55
|
+
|
|
56
|
+
click.echo(f"Initialized API '{api_name}'")
|
|
57
|
+
if base_url:
|
|
58
|
+
click.echo(f" Base URL : {base_url}")
|
|
59
|
+
else:
|
|
60
|
+
click.echo(" Base URL : (not set — edit papycli.conf to add url)")
|
|
61
|
+
click.echo(f" Conf dir : {conf_dir}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@cli.command("use")
|
|
65
|
+
@click.argument("api_name", metavar="API_NAME")
|
|
66
|
+
def cmd_use(api_name: str) -> None:
|
|
67
|
+
"""アクティブな API を切り替える。"""
|
|
68
|
+
conf_dir = get_conf_dir()
|
|
69
|
+
conf = load_conf(conf_dir)
|
|
70
|
+
|
|
71
|
+
if api_name not in conf:
|
|
72
|
+
registered = [k for k in conf if k != "default"]
|
|
73
|
+
if registered:
|
|
74
|
+
click.echo(f"Error: API '{api_name}' is not registered.", err=True)
|
|
75
|
+
click.echo(f"Registered APIs: {', '.join(registered)}", err=True)
|
|
76
|
+
else:
|
|
77
|
+
click.echo("Error: No APIs registered. Run 'papycli init <spec>' first.", err=True)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
set_default_api(conf, api_name)
|
|
81
|
+
save_conf(conf, conf_dir)
|
|
82
|
+
click.echo(f"Switched default API to '{api_name}'")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@cli.command("conf")
|
|
86
|
+
def cmd_conf() -> None:
|
|
87
|
+
"""現在の設定と環境変数を表示する。"""
|
|
88
|
+
conf_dir = get_conf_dir()
|
|
89
|
+
conf_path = get_conf_path(conf_dir)
|
|
90
|
+
|
|
91
|
+
click.echo(f"Conf dir : {conf_dir}")
|
|
92
|
+
click.echo(f"Conf file : {conf_path}")
|
|
93
|
+
click.echo("")
|
|
94
|
+
|
|
95
|
+
conf = load_conf(conf_dir)
|
|
96
|
+
if not conf:
|
|
97
|
+
click.echo("(no configuration — run 'papycli init <spec>' to get started)")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
click.echo(json.dumps(conf, indent=2, ensure_ascii=False))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# summary コマンド
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@cli.command("summary")
|
|
109
|
+
@click.argument("resource", required=False, default=None)
|
|
110
|
+
@click.option("--csv", "as_csv", is_flag=True, help="CSV フォーマットで出力する")
|
|
111
|
+
def cmd_summary(resource: str | None, as_csv: bool) -> None:
|
|
112
|
+
"""登録済み API のエンドポイント一覧を表示する。
|
|
113
|
+
|
|
114
|
+
RESOURCE を指定するとそのパスプレフィックスで絞り込む。
|
|
115
|
+
"""
|
|
116
|
+
conf_dir = get_conf_dir()
|
|
117
|
+
try:
|
|
118
|
+
apidef, _ = load_current_apidef(conf_dir)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
click.echo(f"Error: {e}", err=True)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
|
|
123
|
+
if as_csv:
|
|
124
|
+
click.echo(format_summary_csv(apidef), nl=False)
|
|
125
|
+
else:
|
|
126
|
+
print_summary(apidef, resource_filter=resource)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# API 呼び出しコマンド (get / post / put / patch / delete)
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _print_response(resp: requests.Response) -> None:
|
|
135
|
+
click.echo(f"HTTP {resp.status_code} {resp.reason}")
|
|
136
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
137
|
+
if "application/json" in content_type:
|
|
138
|
+
try:
|
|
139
|
+
click.echo(json.dumps(resp.json(), indent=2, ensure_ascii=False))
|
|
140
|
+
return
|
|
141
|
+
except ValueError:
|
|
142
|
+
pass
|
|
143
|
+
if resp.text:
|
|
144
|
+
click.echo(resp.text)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _api_command(method: str) -> click.Command:
|
|
148
|
+
"""HTTP メソッドごとの CLI コマンドを生成する。"""
|
|
149
|
+
|
|
150
|
+
@click.command(method, help=f"HTTP {method.upper()} リクエストを送信する。")
|
|
151
|
+
@click.argument("resource")
|
|
152
|
+
@click.option("-q", "query_params", multiple=True, nargs=2, metavar="NAME VALUE",
|
|
153
|
+
help="クエリパラメータ(繰り返し可)")
|
|
154
|
+
@click.option("-p", "body_params", multiple=True, nargs=2, metavar="NAME VALUE",
|
|
155
|
+
help="ボディパラメータ(繰り返し可)")
|
|
156
|
+
@click.option("-d", "raw_body", default=None, metavar="JSON",
|
|
157
|
+
help="生の JSON ボディ(-p を上書き)")
|
|
158
|
+
@click.option("-H", "extra_headers", multiple=True, metavar="HEADER: VALUE",
|
|
159
|
+
help="カスタム HTTP ヘッダー(繰り返し可)")
|
|
160
|
+
@click.option("--summary", "show_summary", is_flag=True,
|
|
161
|
+
help="リクエストを送らずにエンドポイント情報を表示する")
|
|
162
|
+
def _cmd(
|
|
163
|
+
resource: str,
|
|
164
|
+
query_params: tuple[tuple[str, str], ...],
|
|
165
|
+
body_params: tuple[tuple[str, str], ...],
|
|
166
|
+
raw_body: str | None,
|
|
167
|
+
extra_headers: tuple[str, ...],
|
|
168
|
+
show_summary: bool,
|
|
169
|
+
) -> None:
|
|
170
|
+
conf_dir = get_conf_dir()
|
|
171
|
+
try:
|
|
172
|
+
apidef, base_url = load_current_apidef(conf_dir)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
click.echo(f"Error: {e}", err=True)
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
|
|
177
|
+
if show_summary:
|
|
178
|
+
match = match_path_template(resource, list(apidef.keys()))
|
|
179
|
+
if match is None:
|
|
180
|
+
click.echo(f"Error: No matching path for '{resource}'", err=True)
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
template, _ = match
|
|
183
|
+
click.echo(format_endpoint_detail(apidef, method, template))
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
resp = call_api(
|
|
188
|
+
method, resource, base_url, apidef,
|
|
189
|
+
query_params=query_params,
|
|
190
|
+
body_params=body_params,
|
|
191
|
+
raw_body=raw_body,
|
|
192
|
+
extra_headers=extra_headers,
|
|
193
|
+
)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
click.echo(f"Error: {e}", err=True)
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
_print_response(resp)
|
|
199
|
+
|
|
200
|
+
return _cmd
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
for _method in ("get", "post", "put", "patch", "delete"):
|
|
204
|
+
cli.add_command(_api_command(_method))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# シェル補完コマンド
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@cli.command("completion-script")
|
|
213
|
+
@click.argument("shell", type=click.Choice(["bash", "zsh"]))
|
|
214
|
+
def cmd_completion_script(shell: str) -> None:
|
|
215
|
+
"""シェル補完スクリプトを出力する。
|
|
216
|
+
|
|
217
|
+
使い方 (bash): eval "$(papycli completion-script bash)"
|
|
218
|
+
|
|
219
|
+
使い方 (zsh): eval "$(papycli completion-script zsh)"
|
|
220
|
+
"""
|
|
221
|
+
click.echo(generate_script(shell), nl=False)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@cli.command("_complete", hidden=True, context_settings={"ignore_unknown_options": True})
|
|
225
|
+
@click.argument("current_index", type=int)
|
|
226
|
+
@click.argument("words", nargs=-1, type=click.UNPROCESSED)
|
|
227
|
+
def cmd_complete(current_index: int, words: tuple[str, ...]) -> None:
|
|
228
|
+
"""シェル補完スクリプトから呼ばれる内部コマンド。補完候補を 1 行 1 候補で出力する。"""
|
|
229
|
+
results = get_completions(list(words), current_index, get_conf_dir())
|
|
230
|
+
if results:
|
|
231
|
+
click.echo("\n".join(results))
|
papycli/spec_loader.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""OpenAPI spec の読み込み・$ref 解決・内部フォーマット変換."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_spec(path: Path) -> dict[str, Any]:
|
|
12
|
+
"""JSON または YAML の OpenAPI spec ファイルを読み込む。"""
|
|
13
|
+
with path.open(encoding="utf-8") as f:
|
|
14
|
+
if path.suffix in (".yaml", ".yml"):
|
|
15
|
+
return yaml.safe_load(f) # type: ignore[no-any-return]
|
|
16
|
+
return json.load(f) # type: ignore[no-any-return]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_json_pointer(ref: str, root: dict[str, Any]) -> Any:
|
|
20
|
+
"""'#/a/b/c' 形式の JSON Pointer を解決する。"""
|
|
21
|
+
if not ref.startswith("#/"):
|
|
22
|
+
raise ValueError(f"Unsupported $ref: {ref!r} (only internal '#/...' refs are supported)")
|
|
23
|
+
parts = ref[2:].split("/")
|
|
24
|
+
node: Any = root
|
|
25
|
+
for part in parts:
|
|
26
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
27
|
+
if not isinstance(node, dict) or part not in node:
|
|
28
|
+
raise KeyError(f"$ref target not found: {ref!r}")
|
|
29
|
+
node = node[part]
|
|
30
|
+
return node
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_refs(
|
|
34
|
+
obj: Any,
|
|
35
|
+
root: dict[str, Any],
|
|
36
|
+
_visited: frozenset[str] = frozenset(),
|
|
37
|
+
) -> Any:
|
|
38
|
+
"""オブジェクト中のすべての $ref を再帰的に解決する。循環参照は空 dict で打ち切る。"""
|
|
39
|
+
if isinstance(obj, dict):
|
|
40
|
+
if "$ref" in obj:
|
|
41
|
+
ref: str = obj["$ref"]
|
|
42
|
+
if ref in _visited:
|
|
43
|
+
return {} # 循環参照ガード
|
|
44
|
+
target = _resolve_json_pointer(ref, root)
|
|
45
|
+
return resolve_refs(copy.deepcopy(target), root, _visited | {ref})
|
|
46
|
+
return {k: resolve_refs(v, root, _visited) for k, v in obj.items()}
|
|
47
|
+
if isinstance(obj, list):
|
|
48
|
+
return [resolve_refs(item, root, _visited) for item in obj]
|
|
49
|
+
return obj
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_schema_properties(
|
|
53
|
+
schema: dict[str, Any],
|
|
54
|
+
) -> tuple[list[str], dict[str, Any]]:
|
|
55
|
+
"""スキーマから (required フィールド名リスト, properties dict) を取り出す。allOf に対応。"""
|
|
56
|
+
required: list[str] = []
|
|
57
|
+
properties: dict[str, Any] = {}
|
|
58
|
+
|
|
59
|
+
for sub in schema.get("allOf", []):
|
|
60
|
+
r, p = _extract_schema_properties(sub)
|
|
61
|
+
required.extend(r)
|
|
62
|
+
properties.update(p)
|
|
63
|
+
|
|
64
|
+
required.extend(schema.get("required", []))
|
|
65
|
+
properties.update(schema.get("properties", {}))
|
|
66
|
+
return required, properties
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _param_entry(name: str, schema: dict[str, Any], required: bool) -> dict[str, Any]:
|
|
70
|
+
entry: dict[str, Any] = {
|
|
71
|
+
"name": name,
|
|
72
|
+
"type": schema.get("type", "string"),
|
|
73
|
+
"required": required,
|
|
74
|
+
}
|
|
75
|
+
if "enum" in schema:
|
|
76
|
+
entry["enum"] = schema["enum"]
|
|
77
|
+
return entry
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def spec_to_apidef(spec: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
"""解決済み OpenAPI spec を papycli 内部 API 定義フォーマットに変換する。"""
|
|
82
|
+
apidef: dict[str, Any] = {}
|
|
83
|
+
paths: dict[str, Any] = spec.get("paths", {})
|
|
84
|
+
|
|
85
|
+
for path, path_item in paths.items():
|
|
86
|
+
# path レベルの共通パラメータ
|
|
87
|
+
common_params: dict[str, Any] = {
|
|
88
|
+
p["name"]: p for p in path_item.get("parameters", [])
|
|
89
|
+
}
|
|
90
|
+
methods = []
|
|
91
|
+
|
|
92
|
+
for method in ("get", "post", "put", "patch", "delete"):
|
|
93
|
+
operation: dict[str, Any] | None = path_item.get(method)
|
|
94
|
+
if operation is None:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
# path レベル + operation レベルをマージ (operation が優先)
|
|
98
|
+
merged_params = {**common_params}
|
|
99
|
+
for p in operation.get("parameters", []):
|
|
100
|
+
merged_params[p["name"]] = p
|
|
101
|
+
|
|
102
|
+
query_parameters = [
|
|
103
|
+
_param_entry(p["name"], p.get("schema", {}), bool(p.get("required", False)))
|
|
104
|
+
for p in merged_params.values()
|
|
105
|
+
if p.get("in") == "query"
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# リクエストボディ (application/json のみ)
|
|
109
|
+
post_parameters: list[dict[str, Any]] = []
|
|
110
|
+
json_schema = (
|
|
111
|
+
operation.get("requestBody", {})
|
|
112
|
+
.get("content", {})
|
|
113
|
+
.get("application/json", {})
|
|
114
|
+
.get("schema", {})
|
|
115
|
+
)
|
|
116
|
+
if json_schema:
|
|
117
|
+
req_fields, props = _extract_schema_properties(json_schema)
|
|
118
|
+
post_parameters = [
|
|
119
|
+
_param_entry(name, prop_schema, name in req_fields)
|
|
120
|
+
for name, prop_schema in props.items()
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
methods.append(
|
|
124
|
+
{
|
|
125
|
+
"method": method,
|
|
126
|
+
"query_parameters": query_parameters,
|
|
127
|
+
"post_parameters": post_parameters,
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if methods:
|
|
132
|
+
apidef[path] = methods
|
|
133
|
+
|
|
134
|
+
return apidef
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def extract_base_url(spec: dict[str, Any]) -> str:
|
|
138
|
+
"""spec から servers[0].url を返す。存在しない場合は空文字。"""
|
|
139
|
+
servers = spec.get("servers", [])
|
|
140
|
+
if servers:
|
|
141
|
+
return str(servers[0].get("url", ""))
|
|
142
|
+
return ""
|
papycli/summary.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""--summary / --summary-csv の出力処理。"""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
from typing import Any, TextIO
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _format_param(p: dict[str, Any], flag: str) -> str:
|
|
12
|
+
"""パラメータを表示用文字列に変換する。
|
|
13
|
+
|
|
14
|
+
例: -q status*[available|pending|sold] / -p photoUrls*[]
|
|
15
|
+
"""
|
|
16
|
+
name = p["name"]
|
|
17
|
+
annotation = ""
|
|
18
|
+
if p.get("required"):
|
|
19
|
+
annotation += "*"
|
|
20
|
+
if p.get("type") == "array":
|
|
21
|
+
annotation += "[]"
|
|
22
|
+
result = f"{flag} {name}{annotation}"
|
|
23
|
+
if "enum" in p:
|
|
24
|
+
enum_str = "|".join(str(e) for e in p["enum"])
|
|
25
|
+
result += f"[{enum_str}]"
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_rows(
|
|
30
|
+
apidef: dict[str, Any],
|
|
31
|
+
resource_filter: str | None = None,
|
|
32
|
+
) -> list[tuple[str, str, str]]:
|
|
33
|
+
"""(METHOD, path, params_str) のリストを返す。テストや CSV 生成にも利用。"""
|
|
34
|
+
rows: list[tuple[str, str, str]] = []
|
|
35
|
+
for path in sorted(apidef.keys()):
|
|
36
|
+
if resource_filter and not path.startswith(resource_filter):
|
|
37
|
+
continue
|
|
38
|
+
for op in apidef[path]:
|
|
39
|
+
q_parts = [_format_param(p, "-q") for p in op.get("query_parameters", [])]
|
|
40
|
+
p_parts = [_format_param(p, "-p") for p in op.get("post_parameters", [])]
|
|
41
|
+
params_str = " ".join(q_parts + p_parts)
|
|
42
|
+
rows.append((op["method"].upper(), path, params_str))
|
|
43
|
+
return rows
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def print_summary(
|
|
47
|
+
apidef: dict[str, Any],
|
|
48
|
+
resource_filter: str | None = None,
|
|
49
|
+
*,
|
|
50
|
+
file: TextIO | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""エンドポイント一覧を rich テーブルで出力する。"""
|
|
53
|
+
rows = build_rows(apidef, resource_filter)
|
|
54
|
+
if not rows:
|
|
55
|
+
click_echo = print if file is None else lambda s: file.write(s + "\n")
|
|
56
|
+
click_echo("(no endpoints found)")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
table = Table(
|
|
60
|
+
show_header=True,
|
|
61
|
+
header_style="bold",
|
|
62
|
+
box=None,
|
|
63
|
+
pad_edge=False,
|
|
64
|
+
show_edge=False,
|
|
65
|
+
)
|
|
66
|
+
table.add_column("METHOD", min_width=8)
|
|
67
|
+
table.add_column("PATH", min_width=24)
|
|
68
|
+
table.add_column("PARAMETERS")
|
|
69
|
+
for method, path, params in rows:
|
|
70
|
+
table.add_row(method, path, params)
|
|
71
|
+
|
|
72
|
+
Console(file=file, highlight=False, no_color=(file is not None)).print(table)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_summary_csv(apidef: dict[str, Any]) -> str:
|
|
76
|
+
"""エンドポイント一覧を CSV 文字列で返す。"""
|
|
77
|
+
buf = io.StringIO()
|
|
78
|
+
writer = csv.writer(buf)
|
|
79
|
+
writer.writerow(["method", "path", "query_parameters", "post_parameters"])
|
|
80
|
+
for path in sorted(apidef.keys()):
|
|
81
|
+
for op in apidef[path]:
|
|
82
|
+
q_names = ";".join(p["name"] for p in op.get("query_parameters", []))
|
|
83
|
+
p_names = ";".join(p["name"] for p in op.get("post_parameters", []))
|
|
84
|
+
writer.writerow([op["method"].upper(), path, q_names, p_names])
|
|
85
|
+
return buf.getvalue()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_endpoint_detail(
|
|
89
|
+
apidef: dict[str, Any],
|
|
90
|
+
method: str,
|
|
91
|
+
template: str,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""特定メソッド+パスのエンドポイント詳細を文字列で返す。"""
|
|
94
|
+
ops = apidef.get(template, [])
|
|
95
|
+
op = next((o for o in ops if o["method"] == method), None)
|
|
96
|
+
if op is None:
|
|
97
|
+
return f"{method.upper()} {template}: not defined"
|
|
98
|
+
|
|
99
|
+
lines = [f"{method.upper()} {template}"]
|
|
100
|
+
if op.get("query_parameters"):
|
|
101
|
+
lines.append(" Query parameters:")
|
|
102
|
+
for p in op["query_parameters"]:
|
|
103
|
+
lines.append(f" {_format_param(p, '-q')}")
|
|
104
|
+
if op.get("post_parameters"):
|
|
105
|
+
lines.append(" Body parameters:")
|
|
106
|
+
for p in op["post_parameters"]:
|
|
107
|
+
lines.append(f" {_format_param(p, '-p')}")
|
|
108
|
+
if not op.get("query_parameters") and not op.get("post_parameters"):
|
|
109
|
+
lines.append(" (no parameters)")
|
|
110
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: papycli
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: A CLI tool to call REST APIs defined in OpenAPI 3.0 specs
|
|
5
|
+
Project-URL: Homepage, https://github.com/tmonj1/papycli
|
|
6
|
+
Project-URL: Repository, https://github.com/tmonj1/papycli
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/tmonj1/papycli/issues
|
|
8
|
+
Author-email: tmonj1 <tmonj1@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,cli,http,openapi,rest
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: click>=8.1
|
|
23
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
|
+
Requires-Dist: requests>=2.32
|
|
25
|
+
Requires-Dist: rich>=13.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# papycli — Python CLI for OpenAPI 3.0 REST APIs
|
|
29
|
+
|
|
30
|
+
`papycli` is an interactive CLI that reads OpenAPI 3.0 specs and lets you call REST API endpoints directly from the terminal.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- Auto-generates a CLI from any OpenAPI 3.0 spec
|
|
35
|
+
- Shell completion for bash and zsh
|
|
36
|
+
- Register and switch between multiple APIs
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
| Item | Notes |
|
|
41
|
+
|------|-------|
|
|
42
|
+
| Python | 3.12 or later |
|
|
43
|
+
|
|
44
|
+
No external tools (e.g. `jq`) required. Works with Python and pip alone.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install papycli
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Enable Shell Completion
|
|
55
|
+
|
|
56
|
+
**bash:**
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Add to ~/.bashrc or ~/.bash_profile
|
|
60
|
+
eval "$(papycli completion-script bash)"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**zsh:**
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Add to ~/.zshrc
|
|
67
|
+
eval "$(papycli completion-script zsh)"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Restart your shell or run `source ~/.bashrc` / `source ~/.zshrc` to apply.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Quick Start — Petstore Demo
|
|
75
|
+
|
|
76
|
+
This repository includes a demo using the [Swagger Petstore](https://github.com/swagger-api/swagger-petstore).
|
|
77
|
+
|
|
78
|
+
### 1. Start the Petstore server
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
docker compose -f examples/docker-compose.yml up -d
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The API will be available at `http://localhost:8080/api/v3/`.
|
|
85
|
+
|
|
86
|
+
### 2. Register the API
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
papycli init examples/petstore-oas3.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 3. Try some commands
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# List available endpoints
|
|
96
|
+
papycli summary
|
|
97
|
+
|
|
98
|
+
# GET /store/inventory
|
|
99
|
+
papycli get /store/inventory
|
|
100
|
+
|
|
101
|
+
# GET with a path parameter
|
|
102
|
+
papycli get /pet/99
|
|
103
|
+
|
|
104
|
+
# GET with a query parameter
|
|
105
|
+
papycli get /pet/findByStatus -q status available
|
|
106
|
+
|
|
107
|
+
# POST with body parameters
|
|
108
|
+
papycli post /pet -p name "My Dog" -p status available -p photoUrls "http://example.com/photo.jpg"
|
|
109
|
+
|
|
110
|
+
# POST with a raw JSON body
|
|
111
|
+
papycli post /pet -d '{"name": "My Dog", "status": "available", "photoUrls": ["http://example.com/photo.jpg"]}'
|
|
112
|
+
|
|
113
|
+
# Array parameter (repeat the same key)
|
|
114
|
+
papycli put /pet -p id 1 -p name "My Dog" -p photoUrls "http://example.com/a.jpg" -p photoUrls "http://example.com/b.jpg" -p status available
|
|
115
|
+
|
|
116
|
+
# Nested object (dot notation)
|
|
117
|
+
papycli put /pet -p id 1 -p name "My Dog" -p category.id 2 -p category.name "Dogs" -p photoUrls "http://example.com/photo.jpg" -p status available
|
|
118
|
+
|
|
119
|
+
# DELETE /pet/{petId}
|
|
120
|
+
papycli delete /pet/1
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 4. Tab completion
|
|
124
|
+
|
|
125
|
+
Once shell completion is enabled, tab completion is available:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
$ papycli <TAB>
|
|
129
|
+
get post put delete patch
|
|
130
|
+
|
|
131
|
+
$ papycli get <TAB>
|
|
132
|
+
/pet/findByStatus /pet/{petId} /store/inventory ...
|
|
133
|
+
|
|
134
|
+
$ papycli get /pet/findByStatus <TAB>
|
|
135
|
+
-q --summary --help
|
|
136
|
+
|
|
137
|
+
$ papycli get /pet/findByStatus -q <TAB>
|
|
138
|
+
status
|
|
139
|
+
|
|
140
|
+
$ papycli get /pet/findByStatus -q status <TAB>
|
|
141
|
+
available pending sold
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Adding Your Own API
|
|
147
|
+
|
|
148
|
+
### Step 1 — Run `init`
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
papycli init your-api-spec.json
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
This command will:
|
|
155
|
+
|
|
156
|
+
1. Resolve all `$ref` references in the OpenAPI spec
|
|
157
|
+
2. Convert the spec to papycli's internal API definition format
|
|
158
|
+
3. Save the result to `$PAPYCLI_CONF_DIR/apis/<name>.json`
|
|
159
|
+
4. Create or update `$PAPYCLI_CONF_DIR/papycli.conf`
|
|
160
|
+
|
|
161
|
+
The API name is derived from the filename (e.g. `your-api-spec.json` → `your-api-spec`).
|
|
162
|
+
|
|
163
|
+
### Step 2 — Set the base URL
|
|
164
|
+
|
|
165
|
+
If the spec contains `servers[0].url`, it is used automatically. Otherwise, edit `$PAPYCLI_CONF_DIR/papycli.conf` and set the `url` field:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"default": "your-api-spec",
|
|
170
|
+
"your-api-spec": {
|
|
171
|
+
"openapispec": "your-api-spec.json",
|
|
172
|
+
"apidef": "your-api-spec.json",
|
|
173
|
+
"url": "https://your-api-base-url/"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Managing Multiple APIs
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
# Register multiple APIs
|
|
182
|
+
papycli init petstore-oas3.json
|
|
183
|
+
papycli init myapi.json
|
|
184
|
+
|
|
185
|
+
# Switch the active API
|
|
186
|
+
papycli use myapi
|
|
187
|
+
|
|
188
|
+
# Show registered APIs and the current default
|
|
189
|
+
papycli conf
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Reference
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
# API management commands
|
|
198
|
+
papycli init <spec-file> Initialize an API from an OpenAPI spec file
|
|
199
|
+
papycli use <api-name> Switch the active API
|
|
200
|
+
papycli conf Show current configuration
|
|
201
|
+
papycli summary [resource] List available endpoints (filter by resource prefix)
|
|
202
|
+
Required params marked with *, array params with []
|
|
203
|
+
papycli summary --csv Output endpoints in CSV format
|
|
204
|
+
papycli completion-script <bash|zsh> Print a shell completion script
|
|
205
|
+
|
|
206
|
+
# API call commands
|
|
207
|
+
papycli <method> <resource> [options]
|
|
208
|
+
|
|
209
|
+
Methods:
|
|
210
|
+
get | post | put | patch | delete
|
|
211
|
+
|
|
212
|
+
Options:
|
|
213
|
+
-H <header: value> Custom HTTP header (repeatable)
|
|
214
|
+
-q <name> <value> Query parameter (repeatable)
|
|
215
|
+
-p <name> <value> Body parameter (repeatable)
|
|
216
|
+
- Repeat the same key to build a JSON array:
|
|
217
|
+
-p tags foo -p tags bar → {"tags":["foo","bar"]}
|
|
218
|
+
- Use dot notation to build a nested object:
|
|
219
|
+
-p category.id 1 -p category.name Dogs
|
|
220
|
+
→ {"category":{"id":"1","name":"Dogs"}}
|
|
221
|
+
-d <json> Raw JSON body (overrides -p)
|
|
222
|
+
--summary Show endpoint info without sending a request
|
|
223
|
+
--version Show version
|
|
224
|
+
--help / -h Show help
|
|
225
|
+
|
|
226
|
+
Environment variables:
|
|
227
|
+
PAPYCLI_CONF_DIR Path to the config directory (default: ~/.papycli)
|
|
228
|
+
PAPYCLI_CUSTOM_HEADER Custom HTTP headers applied to every request.
|
|
229
|
+
Separate multiple headers with newlines:
|
|
230
|
+
export PAPYCLI_CUSTOM_HEADER=$'Authorization: Bearer token\nX-Tenant: acme'
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Limitations
|
|
236
|
+
|
|
237
|
+
- Request bodies are `application/json` only
|
|
238
|
+
- Array parameters support scalar element types only (arrays of objects are not supported)
|
|
239
|
+
- Dot notation for nested objects supports one level of nesting only
|
|
240
|
+
- Pass auth headers via `-H "Authorization: Bearer token"` or the `PAPYCLI_CUSTOM_HEADER` env var
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Development
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
git clone https://github.com/tmonj1/papycli.git
|
|
248
|
+
cd papycli
|
|
249
|
+
pip install -e ".[dev]"
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
See [CLAUDE.md](CLAUDE.md) for details.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
papycli/__init__.py,sha256=eTFWGmWURp7CDwKBSpxW4XRDgrQtKoHSjETJQ3LB_nw,144
|
|
2
|
+
papycli/api_call.py,sha256=N_1h4NJmVpetQDgzCMXuNZfRNHoLebH2I3_44ERdasA,6261
|
|
3
|
+
papycli/completion.py,sha256=ziwxd7-QOlygbAbiD-aWUURpfISCFw-v_yFPAyQXAA8,5508
|
|
4
|
+
papycli/config.py,sha256=XC7YFXhUgnM0xTZ31UGaARflTeFa_J8Oef97grOje68,3153
|
|
5
|
+
papycli/init_cmd.py,sha256=zAhnxXTE1vnjGAv1jhkmCabngEpmh7wciNXh027nq3w,1377
|
|
6
|
+
papycli/main.py,sha256=rneHHmagwLB1j0AWovwKe5_TxFBtNfrY6hiKOUO16bQ,8179
|
|
7
|
+
papycli/spec_loader.py,sha256=tt57BNpQQgfMe1NJzudo6qQmhWPoXf04keHQ0dJ_K_E,4979
|
|
8
|
+
papycli/summary.py,sha256=ukKTl1HCEZEPJh1slOn6PBOg4ChiOGKR_mryvQzR54g,3768
|
|
9
|
+
papycli-0.4.0.dist-info/METADATA,sha256=UFO42F3_JIbDpAnzusK-q5_81FTIsk4NRsfkT6ZHRxs,6826
|
|
10
|
+
papycli-0.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
papycli-0.4.0.dist-info/entry_points.txt,sha256=4zszSs05Ut8mKUexwRtCahzOlMzirVqJ9BpCkByyJV8,45
|
|
12
|
+
papycli-0.4.0.dist-info/licenses/LICENSE,sha256=hXmISiCnU9vO9lfSKZSITZpnIgyXF2FBGaINpvmjLmc,1067
|
|
13
|
+
papycli-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Taro Monji
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|