librepost-app 0.1.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.
- librepost/__init__.py +3 -0
- librepost/__main__.py +3 -0
- librepost/cli.py +301 -0
- librepost/environments.py +71 -0
- librepost/httpfile.py +205 -0
- librepost/leakcheck.py +182 -0
- librepost/py.typed +0 -0
- librepost/runner.py +118 -0
- librepost/secretstore.py +73 -0
- librepost/server.py +312 -0
- librepost/static/app.css +311 -0
- librepost/static/app.js +62 -0
- librepost/static/icon.svg +1 -0
- librepost/static/vendor/htmx-2.0.11.min.js +1 -0
- librepost/static/vendor/htmx-LICENSE.txt +13 -0
- librepost/template.py +95 -0
- librepost/templates/editor.html +38 -0
- librepost/templates/empty.html +6 -0
- librepost/templates/error.html +1 -0
- librepost/templates/index.html +49 -0
- librepost/templates/response.html +41 -0
- librepost/templates/status.html +1 -0
- librepost/templates/tree.html +20 -0
- librepost/workspace.py +135 -0
- librepost_app-0.1.0.dist-info/METADATA +195 -0
- librepost_app-0.1.0.dist-info/RECORD +30 -0
- librepost_app-0.1.0.dist-info/WHEEL +4 -0
- librepost_app-0.1.0.dist-info/entry_points.txt +4 -0
- librepost_app-0.1.0.dist-info/licenses/LICENSE +21 -0
- librepost_app-0.1.0.dist-info/licenses/src/librepost/static/vendor/htmx-LICENSE.txt +13 -0
librepost/__init__.py
ADDED
librepost/__main__.py
ADDED
librepost/cli.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import getpass
|
|
5
|
+
import secrets
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from . import __version__, environments, leakcheck, runner, secretstore
|
|
17
|
+
from .environments import EnvFileError
|
|
18
|
+
from .httpfile import HttpFileError, parse
|
|
19
|
+
from .secretstore import SecretError
|
|
20
|
+
from .template import TemplateError
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
help="A free, local-first API client. Requests are .http files; secrets stay in your keychain.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=False,
|
|
26
|
+
)
|
|
27
|
+
secret_app = typer.Typer(help="Store secrets in the OS keychain for {{secret:keychain/<name>}}.")
|
|
28
|
+
app.add_typer(secret_app, name="secret")
|
|
29
|
+
|
|
30
|
+
out = Console(highlight=False)
|
|
31
|
+
err = Console(stderr=True, highlight=False)
|
|
32
|
+
|
|
33
|
+
DEFAULT_PORT = 8765
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _version(value: bool) -> None:
|
|
37
|
+
if value:
|
|
38
|
+
out.print(f"librepost {__version__}")
|
|
39
|
+
raise typer.Exit()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.callback()
|
|
43
|
+
def main(
|
|
44
|
+
version: Annotated[
|
|
45
|
+
bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")
|
|
46
|
+
] = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _bind(port: int) -> socket.socket:
|
|
52
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
53
|
+
if sys.platform != "win32":
|
|
54
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
55
|
+
try:
|
|
56
|
+
sock.bind(("127.0.0.1", port))
|
|
57
|
+
except OSError:
|
|
58
|
+
if port == 0:
|
|
59
|
+
raise
|
|
60
|
+
sock.bind(("127.0.0.1", 0))
|
|
61
|
+
return sock
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def ui(
|
|
66
|
+
directory: Annotated[
|
|
67
|
+
Path, typer.Argument(help="Folder holding your .http files.", file_okay=False)
|
|
68
|
+
] = Path("."),
|
|
69
|
+
port: Annotated[
|
|
70
|
+
int, typer.Option(help="Port on 127.0.0.1 (a free one is picked if it's taken).")
|
|
71
|
+
] = DEFAULT_PORT,
|
|
72
|
+
open_browser: Annotated[
|
|
73
|
+
bool, typer.Option("--open/--no-open", help="Open the UI in your browser.")
|
|
74
|
+
] = True,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Start the UI on 127.0.0.1 and open it in your browser."""
|
|
77
|
+
import uvicorn
|
|
78
|
+
|
|
79
|
+
from .server import create_app
|
|
80
|
+
|
|
81
|
+
if not directory.is_dir():
|
|
82
|
+
err.print(f"[red]Not a folder:[/] {directory}")
|
|
83
|
+
raise typer.Exit(2)
|
|
84
|
+
sock = _bind(port)
|
|
85
|
+
port = sock.getsockname()[1]
|
|
86
|
+
token = secrets.token_urlsafe(32)
|
|
87
|
+
url = f"http://127.0.0.1:{port}/?token={token}"
|
|
88
|
+
application = create_app(directory, port=port, token=token)
|
|
89
|
+
|
|
90
|
+
out.print(f"[bold green]librepost {__version__}[/] serving [bold]{directory.resolve()}[/]")
|
|
91
|
+
out.print(f"Open: [link={url}]{url}[/link]")
|
|
92
|
+
out.print(
|
|
93
|
+
"[dim]Only this machine can connect, and only with the link above. Ctrl+C stops it.[/]"
|
|
94
|
+
)
|
|
95
|
+
if open_browser:
|
|
96
|
+
webbrowser.open(url)
|
|
97
|
+
config = uvicorn.Config(application, log_level="warning", access_log=False, server_header=False)
|
|
98
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
99
|
+
uvicorn.Server(config).run(sockets=[sock])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def run(
|
|
104
|
+
file: Annotated[Path, typer.Argument(help="An .http file.", exists=True, dir_okay=False)],
|
|
105
|
+
name: Annotated[
|
|
106
|
+
str | None, typer.Option("--name", "-n", help="Request name (default: the first one).")
|
|
107
|
+
] = None,
|
|
108
|
+
env: Annotated[str | None, typer.Option("--env", "-e", help="Environment to use.")] = None,
|
|
109
|
+
verbose: Annotated[
|
|
110
|
+
bool, typer.Option("--verbose", "-v", help="Show the request and response headers.")
|
|
111
|
+
] = False,
|
|
112
|
+
fail: Annotated[
|
|
113
|
+
bool, typer.Option("--fail", "-f", help="Exit with status 22 on HTTP 4xx/5xx.")
|
|
114
|
+
] = False,
|
|
115
|
+
insecure: Annotated[
|
|
116
|
+
bool, typer.Option("--insecure", "-k", help="Don't verify TLS certificates.")
|
|
117
|
+
] = False,
|
|
118
|
+
timeout: Annotated[float, typer.Option(help="Timeout in seconds.")] = 30.0,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Send a request from an .http file and print the response."""
|
|
121
|
+
try:
|
|
122
|
+
httpfile = parse(file.read_text(encoding="utf-8"))
|
|
123
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
124
|
+
err.print(f"[red]Can't read {file}:[/] {exc}")
|
|
125
|
+
raise typer.Exit(2) from None
|
|
126
|
+
if not httpfile.requests:
|
|
127
|
+
err.print(f"[red]No requests in {file}.[/]")
|
|
128
|
+
raise typer.Exit(2)
|
|
129
|
+
if name is None:
|
|
130
|
+
request = httpfile.requests[0]
|
|
131
|
+
else:
|
|
132
|
+
matches = [r for r in httpfile.requests if r.name == name]
|
|
133
|
+
if not matches:
|
|
134
|
+
names = ", ".join(r.name for r in httpfile.requests if r.name) or "(none)"
|
|
135
|
+
err.print(f"[red]No request named {name!r}.[/] Names in this file: {names}")
|
|
136
|
+
raise typer.Exit(2)
|
|
137
|
+
request = matches[0]
|
|
138
|
+
|
|
139
|
+
root = _workspace_root(file)
|
|
140
|
+
try:
|
|
141
|
+
variables = dict(httpfile.variables)
|
|
142
|
+
variables.update(environments.variables(root, env))
|
|
143
|
+
prepared = runner.prepare(request, variables)
|
|
144
|
+
except (EnvFileError, TemplateError, SecretError, HttpFileError) as exc:
|
|
145
|
+
err.print(f"[red]{exc}[/]")
|
|
146
|
+
raise typer.Exit(2) from None
|
|
147
|
+
|
|
148
|
+
if verbose:
|
|
149
|
+
for line in prepared.masked().split("\n\n", 1)[0].splitlines():
|
|
150
|
+
err.print(f"[dim]> {line}[/]")
|
|
151
|
+
err.print()
|
|
152
|
+
try:
|
|
153
|
+
result = runner.send(prepared, timeout=timeout, verify=not insecure)
|
|
154
|
+
except httpx.HTTPError as exc:
|
|
155
|
+
err.print(f"[red]{type(exc).__name__}:[/] {prepared.renderer.mask(str(exc))}")
|
|
156
|
+
raise typer.Exit(1) from None
|
|
157
|
+
|
|
158
|
+
colour = "green" if result.status < 400 else "red"
|
|
159
|
+
err.print(
|
|
160
|
+
f"[bold {colour}]{result.status} {result.reason}[/] "
|
|
161
|
+
f"[dim]{result.elapsed_ms:.0f} ms, {len(result.body)} bytes[/]"
|
|
162
|
+
)
|
|
163
|
+
if verbose:
|
|
164
|
+
for key, value in result.headers:
|
|
165
|
+
err.print(f"[dim]< {key}: {value}[/]")
|
|
166
|
+
err.print()
|
|
167
|
+
sys.stdout.buffer.write(result.body)
|
|
168
|
+
if result.body and not result.body.endswith(b"\n") and sys.stdout.isatty():
|
|
169
|
+
sys.stdout.write("\n")
|
|
170
|
+
sys.stdout.flush()
|
|
171
|
+
if fail and result.status >= 400:
|
|
172
|
+
raise typer.Exit(22)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _workspace_root(file: Path) -> Path:
|
|
176
|
+
"""The nearest folder above the file that has an environment file, else the file's folder."""
|
|
177
|
+
start = file.resolve().parent
|
|
178
|
+
for folder in (start, *start.parents):
|
|
179
|
+
if (folder / environments.PUBLIC_FILE).is_file() or (
|
|
180
|
+
folder / environments.PRIVATE_FILE
|
|
181
|
+
).is_file():
|
|
182
|
+
return folder
|
|
183
|
+
if (folder / ".git").exists():
|
|
184
|
+
break
|
|
185
|
+
return start
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def check(
|
|
190
|
+
paths: Annotated[
|
|
191
|
+
list[Path] | None, typer.Argument(help="Files or folders (default: current folder).")
|
|
192
|
+
] = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Find literal secrets in .http and environment files. Exits 1 if any are found."""
|
|
195
|
+
targets = paths or [Path(".")]
|
|
196
|
+
leaks = leakcheck.scan(targets, display_root=Path.cwd())
|
|
197
|
+
for leak in leaks:
|
|
198
|
+
out.print(f"[bold]{leak.path}:{leak.line}[/] [red]{leak.rule}[/] {leak.message}")
|
|
199
|
+
if leaks:
|
|
200
|
+
out.print(
|
|
201
|
+
f"\n[red]{len(leaks)} possible secret(s).[/] Use {{{{secret:...}}}} references instead."
|
|
202
|
+
)
|
|
203
|
+
raise typer.Exit(1)
|
|
204
|
+
out.print("[green]No literal secrets found.[/]")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
EXAMPLE_ENV = """{
|
|
208
|
+
"dev": {
|
|
209
|
+
"baseUrl": "https://httpbin.org",
|
|
210
|
+
"token": "{{secret:keychain/demo-token}}"
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
EXAMPLE_HTTP = """# Requests are plain text: commit them, review them in pull requests, share them.
|
|
216
|
+
# @name get-example
|
|
217
|
+
GET {{baseUrl}}/get?hello=world
|
|
218
|
+
Accept: application/json
|
|
219
|
+
|
|
220
|
+
###
|
|
221
|
+
|
|
222
|
+
# The token comes from your OS keychain. Add it with: librepost secret set demo-token
|
|
223
|
+
# @name bearer-example
|
|
224
|
+
GET {{baseUrl}}/bearer
|
|
225
|
+
Authorization: Bearer {{token}}
|
|
226
|
+
|
|
227
|
+
###
|
|
228
|
+
|
|
229
|
+
# @name post-example
|
|
230
|
+
POST {{baseUrl}}/post
|
|
231
|
+
Content-Type: application/json
|
|
232
|
+
|
|
233
|
+
{
|
|
234
|
+
"id": "{{$uuid}}",
|
|
235
|
+
"sentAt": "{{$isoTimestamp}}"
|
|
236
|
+
}
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command()
|
|
241
|
+
def init(
|
|
242
|
+
directory: Annotated[Path, typer.Argument(file_okay=False)] = Path("."),
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Create an example environment file and requests, and gitignore the private env file."""
|
|
245
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
written = []
|
|
247
|
+
for rel, text in (
|
|
248
|
+
(environments.PUBLIC_FILE, EXAMPLE_ENV),
|
|
249
|
+
("requests/example.http", EXAMPLE_HTTP),
|
|
250
|
+
):
|
|
251
|
+
path = directory / rel
|
|
252
|
+
if path.exists():
|
|
253
|
+
out.print(f"[dim]Kept existing {rel}[/]")
|
|
254
|
+
continue
|
|
255
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
257
|
+
written.append(rel)
|
|
258
|
+
|
|
259
|
+
gitignore = directory / ".gitignore"
|
|
260
|
+
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
261
|
+
if environments.PRIVATE_FILE not in existing.splitlines():
|
|
262
|
+
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
|
263
|
+
with gitignore.open("a", encoding="utf-8", newline="\n") as f:
|
|
264
|
+
f.write(
|
|
265
|
+
f"{prefix}# librepost: per-machine values, never committed\n{environments.PRIVATE_FILE}\n"
|
|
266
|
+
)
|
|
267
|
+
written.append(".gitignore")
|
|
268
|
+
|
|
269
|
+
for rel in written:
|
|
270
|
+
out.print(f"[green]Wrote[/] {rel}")
|
|
271
|
+
out.print("\nNext: [bold]librepost ui[/] (and optionally: librepost secret set demo-token)")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@secret_app.command("set")
|
|
275
|
+
def secret_set(name: Annotated[str, typer.Argument(help="Secret name.")]) -> None:
|
|
276
|
+
"""Store a secret in the OS keychain. The value is read without echoing it."""
|
|
277
|
+
value = (
|
|
278
|
+
getpass.getpass(f"Value for {name}: ")
|
|
279
|
+
if sys.stdin.isatty()
|
|
280
|
+
else sys.stdin.readline().rstrip("\n")
|
|
281
|
+
)
|
|
282
|
+
if not value:
|
|
283
|
+
err.print("[red]Empty value; nothing stored.[/]")
|
|
284
|
+
raise typer.Exit(2)
|
|
285
|
+
try:
|
|
286
|
+
secretstore.store(name, value)
|
|
287
|
+
except SecretError as exc:
|
|
288
|
+
err.print(f"[red]{exc}[/]")
|
|
289
|
+
raise typer.Exit(1) from None
|
|
290
|
+
out.print(f"[green]Stored[/] {name}. Use it as {{{{secret:keychain/{name}}}}}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@secret_app.command("delete")
|
|
294
|
+
def secret_delete(name: Annotated[str, typer.Argument(help="Secret name.")]) -> None:
|
|
295
|
+
"""Remove a secret from the OS keychain."""
|
|
296
|
+
try:
|
|
297
|
+
secretstore.delete(name)
|
|
298
|
+
except SecretError as exc:
|
|
299
|
+
err.print(f"[red]{exc}[/]")
|
|
300
|
+
raise typer.Exit(1) from None
|
|
301
|
+
out.print(f"[green]Deleted[/] {name}.")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Environments, in the JetBrains HTTP Client format.
|
|
2
|
+
|
|
3
|
+
`http-client.env.json` is committed and holds shared, non-secret values.
|
|
4
|
+
`http-client.private.env.json` is gitignored and overrides it per machine:
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"$shared": {"apiVersion": "v2"},
|
|
8
|
+
"dev": {"baseUrl": "https://dev.example.com", "token": "{{secret:keychain/dev-token}}"},
|
|
9
|
+
"prod": {"baseUrl": "https://api.example.com"}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
`$shared` values apply to every environment.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
PUBLIC_FILE = "http-client.env.json"
|
|
21
|
+
PRIVATE_FILE = "http-client.private.env.json"
|
|
22
|
+
SHARED = "$shared"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EnvFileError(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read(path: Path) -> dict[str, dict[str, str]]:
|
|
30
|
+
if not path.is_file():
|
|
31
|
+
return {}
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
35
|
+
raise EnvFileError(f"Can't read {path.name}: {exc}") from None
|
|
36
|
+
if not isinstance(data, dict):
|
|
37
|
+
raise EnvFileError(f"{path.name} must contain a JSON object of environments.")
|
|
38
|
+
result: dict[str, dict[str, str]] = {}
|
|
39
|
+
for env, values in data.items():
|
|
40
|
+
if not isinstance(values, dict):
|
|
41
|
+
raise EnvFileError(f"{path.name}: environment {env!r} must be an object.")
|
|
42
|
+
result[env] = {str(k): _to_text(v) for k, v in values.items()}
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _to_text(value: object) -> str:
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
return value
|
|
49
|
+
return json.dumps(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load(root: Path) -> dict[str, dict[str, str]]:
|
|
53
|
+
"""All environments under `root`, with private values overriding public ones."""
|
|
54
|
+
merged = _read(root / PUBLIC_FILE)
|
|
55
|
+
for env, values in _read(root / PRIVATE_FILE).items():
|
|
56
|
+
merged.setdefault(env, {}).update(values)
|
|
57
|
+
return merged
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def names(root: Path) -> list[str]:
|
|
61
|
+
return sorted(env for env in load(root) if env != SHARED)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def variables(root: Path, env: str | None) -> dict[str, str]:
|
|
65
|
+
envs = load(root)
|
|
66
|
+
result = dict(envs.get(SHARED, {}))
|
|
67
|
+
if env:
|
|
68
|
+
if env not in envs:
|
|
69
|
+
raise EnvFileError(f"Unknown environment {env!r}.")
|
|
70
|
+
result.update(envs[env])
|
|
71
|
+
return result
|
librepost/httpfile.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Read and write `.http` files, the plain-text request format used by the JetBrains HTTP
|
|
2
|
+
Client and the VS Code REST Client.
|
|
3
|
+
|
|
4
|
+
A file holds one or more requests separated by lines starting with `###`:
|
|
5
|
+
|
|
6
|
+
@baseUrl = https://example.com
|
|
7
|
+
|
|
8
|
+
# @name login
|
|
9
|
+
POST {{baseUrl}}/login
|
|
10
|
+
Content-Type: application/json
|
|
11
|
+
|
|
12
|
+
{"user": "alice"}
|
|
13
|
+
|
|
14
|
+
###
|
|
15
|
+
|
|
16
|
+
GET {{baseUrl}}/me
|
|
17
|
+
|
|
18
|
+
Supported: `# @name`, `// comments`, `@var = value` file variables, multi-line query
|
|
19
|
+
continuations (indented lines starting with `?` or `&`) and `HTTP/x` suffixes. Response
|
|
20
|
+
handler scripts (`> {% ... %}`) and output redirects (`>> file`, `<> file`) are kept
|
|
21
|
+
when the file is saved, but librepost does not run them.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
|
|
29
|
+
METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT")
|
|
30
|
+
|
|
31
|
+
_SEPARATOR = re.compile(r"^###(.*)$")
|
|
32
|
+
_REQUEST_LINE = re.compile(
|
|
33
|
+
r"^(?P<method>[A-Z]+)\s+(?P<url>\S.*?)(?:\s+HTTP/\d(?:\.\d)?)?\s*$",
|
|
34
|
+
)
|
|
35
|
+
_COMMENT = re.compile(r"^\s*(#|//)")
|
|
36
|
+
_NAME = re.compile(r"^\s*(?:#|//)\s*@name\s*[= ]\s*(?P<name>\S.*?)\s*$")
|
|
37
|
+
_FILE_VARIABLE = re.compile(r"^@(?P<name>[A-Za-z_][\w.-]*)\s*=\s*(?P<value>.*?)\s*$")
|
|
38
|
+
_URL_CONTINUATION = re.compile(r"^\s+[?&]")
|
|
39
|
+
# Lines that end the body: response handlers and output redirects.
|
|
40
|
+
_TRAILER = re.compile(r"^(> \{%|> \S|>>!? |<> )")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HttpFileError(ValueError):
|
|
44
|
+
"""A request can't be written as valid `.http` text."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Request:
|
|
49
|
+
method: str = "GET"
|
|
50
|
+
url: str = ""
|
|
51
|
+
headers: list[tuple[str, str]] = field(default_factory=list)
|
|
52
|
+
body: str = ""
|
|
53
|
+
name: str = ""
|
|
54
|
+
# Comment lines above the request line (other than `@name`), kept verbatim.
|
|
55
|
+
comments: list[str] = field(default_factory=list)
|
|
56
|
+
# Response handlers and redirects after the body, kept verbatim but never run.
|
|
57
|
+
trailer: str = ""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def label(self) -> str:
|
|
61
|
+
return self.name or f"{self.method} {self.url}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class HttpFile:
|
|
66
|
+
requests: list[Request] = field(default_factory=list)
|
|
67
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse(text: str) -> HttpFile:
|
|
71
|
+
result = HttpFile()
|
|
72
|
+
for separator_name, block in _blocks(text):
|
|
73
|
+
request = _parse_block(block, result.variables)
|
|
74
|
+
if request is None:
|
|
75
|
+
continue
|
|
76
|
+
if not request.name and separator_name:
|
|
77
|
+
request.name = separator_name
|
|
78
|
+
result.requests.append(request)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _blocks(text: str) -> list[tuple[str, list[str]]]:
|
|
83
|
+
blocks: list[tuple[str, list[str]]] = [("", [])]
|
|
84
|
+
for line in text.replace("\r\n", "\n").split("\n"):
|
|
85
|
+
match = _SEPARATOR.match(line)
|
|
86
|
+
if match:
|
|
87
|
+
blocks.append((match.group(1).strip(), []))
|
|
88
|
+
else:
|
|
89
|
+
blocks[-1][1].append(line)
|
|
90
|
+
return blocks
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_block(lines: list[str], variables: dict[str, str]) -> Request | None:
|
|
94
|
+
request = Request()
|
|
95
|
+
i = 0
|
|
96
|
+
# Comments, names and file variables before the request line.
|
|
97
|
+
while i < len(lines):
|
|
98
|
+
line = lines[i]
|
|
99
|
+
if not line.strip():
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
name = _NAME.match(line)
|
|
103
|
+
if name:
|
|
104
|
+
request.name = name.group("name")
|
|
105
|
+
elif _COMMENT.match(line):
|
|
106
|
+
request.comments.append(line.rstrip())
|
|
107
|
+
else:
|
|
108
|
+
variable = _FILE_VARIABLE.match(line)
|
|
109
|
+
if not variable:
|
|
110
|
+
break
|
|
111
|
+
variables[variable.group("name")] = variable.group("value")
|
|
112
|
+
i += 1
|
|
113
|
+
if i == len(lines):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
request_line = lines[i].strip()
|
|
117
|
+
match = _REQUEST_LINE.match(request_line)
|
|
118
|
+
if match and match.group("method") in METHODS:
|
|
119
|
+
request.method = match.group("method")
|
|
120
|
+
request.url = match.group("url")
|
|
121
|
+
else:
|
|
122
|
+
request.url = re.sub(r"\s+HTTP/\d(?:\.\d)?\s*$", "", request_line)
|
|
123
|
+
i += 1
|
|
124
|
+
while i < len(lines) and _URL_CONTINUATION.match(lines[i]):
|
|
125
|
+
request.url += lines[i].strip()
|
|
126
|
+
i += 1
|
|
127
|
+
|
|
128
|
+
while i < len(lines) and lines[i].strip():
|
|
129
|
+
line = lines[i]
|
|
130
|
+
i += 1
|
|
131
|
+
if _COMMENT.match(line):
|
|
132
|
+
continue
|
|
133
|
+
key, sep, value = line.partition(":")
|
|
134
|
+
if sep:
|
|
135
|
+
request.headers.append((key.strip(), value.strip()))
|
|
136
|
+
|
|
137
|
+
body = lines[i + 1 :] if i < len(lines) else []
|
|
138
|
+
for j, line in enumerate(body):
|
|
139
|
+
if _TRAILER.match(line):
|
|
140
|
+
request.trailer = "\n".join(body[j:]).strip("\n")
|
|
141
|
+
body = body[:j]
|
|
142
|
+
break
|
|
143
|
+
request.body = "\n".join(body).strip("\n")
|
|
144
|
+
return request
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def validate(request: Request) -> None:
|
|
148
|
+
"""Raise HttpFileError if the request would not survive a save and reload."""
|
|
149
|
+
if not request.url.strip():
|
|
150
|
+
raise HttpFileError("The URL is empty.")
|
|
151
|
+
if "\n" in request.url or "\n" in request.name:
|
|
152
|
+
raise HttpFileError("The URL and name must be on one line.")
|
|
153
|
+
if not re.fullmatch(r"[A-Z]+", request.method):
|
|
154
|
+
raise HttpFileError(f"Invalid method: {request.method!r}")
|
|
155
|
+
for key, value in request.headers:
|
|
156
|
+
if not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", key):
|
|
157
|
+
raise HttpFileError(f"Invalid header name: {key!r}")
|
|
158
|
+
if "\n" in value:
|
|
159
|
+
raise HttpFileError(f"Header {key} has a line break in its value.")
|
|
160
|
+
for line in request.body.split("\n"):
|
|
161
|
+
if _SEPARATOR.match(line):
|
|
162
|
+
raise HttpFileError("A body line can't start with ###: it separates requests.")
|
|
163
|
+
if _TRAILER.match(line):
|
|
164
|
+
raise HttpFileError(
|
|
165
|
+
f"A body line can't start with {line[:3]!r}: .http files use it for response handlers."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def dumps(httpfile: HttpFile) -> str:
|
|
170
|
+
parts: list[str] = []
|
|
171
|
+
if httpfile.variables:
|
|
172
|
+
parts.append("\n".join(f"@{k} = {v}" for k, v in httpfile.variables.items()))
|
|
173
|
+
for request in httpfile.requests:
|
|
174
|
+
validate(request)
|
|
175
|
+
lines = list(request.comments)
|
|
176
|
+
if request.name:
|
|
177
|
+
lines.append(f"# @name {request.name}")
|
|
178
|
+
lines.append(f"{request.method} {request.url}")
|
|
179
|
+
lines.extend(f"{k}: {v}" for k, v in request.headers)
|
|
180
|
+
if request.body:
|
|
181
|
+
lines.extend(["", request.body])
|
|
182
|
+
if request.trailer:
|
|
183
|
+
lines.extend(["", request.trailer])
|
|
184
|
+
parts.append("\n".join(lines))
|
|
185
|
+
if httpfile.variables and httpfile.requests:
|
|
186
|
+
head, first, *rest = parts
|
|
187
|
+
parts = [f"{head}\n\n{first}", *rest]
|
|
188
|
+
return "\n\n###\n\n".join(parts) + "\n"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_headers(text: str) -> list[tuple[str, str]]:
|
|
192
|
+
"""Parse `Name: value` lines, as typed in the editor."""
|
|
193
|
+
headers = []
|
|
194
|
+
for number, line in enumerate(text.replace("\r\n", "\n").split("\n"), start=1):
|
|
195
|
+
if not line.strip():
|
|
196
|
+
continue
|
|
197
|
+
key, sep, value = line.partition(":")
|
|
198
|
+
if not sep or not key.strip():
|
|
199
|
+
raise HttpFileError(f"Header line {number} is not 'Name: value': {line.strip()!r}")
|
|
200
|
+
headers.append((key.strip(), value.strip()))
|
|
201
|
+
return headers
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def format_headers(headers: list[tuple[str, str]]) -> str:
|
|
205
|
+
return "\n".join(f"{k}: {v}" for k, v in headers)
|