capstan 1.0.0__tar.gz
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.
- capstan-1.0.0/LICENSE.txt +21 -0
- capstan-1.0.0/PKG-INFO +112 -0
- capstan-1.0.0/README.md +98 -0
- capstan-1.0.0/pyproject.toml +77 -0
- capstan-1.0.0/src/capstan/__init__.py +0 -0
- capstan-1.0.0/src/capstan/cli.py +34 -0
- capstan-1.0.0/src/capstan/model.py +46 -0
- capstan-1.0.0/src/capstan/server.py +70 -0
- capstan-1.0.0/src/capstan/tui.py +117 -0
- capstan-1.0.0/src/capstan/tui.tcss +17 -0
- capstan-1.0.0/src/capstan/util.py +17 -0
- capstan-1.0.0/src/capstan/widgets/__init__.py +0 -0
- capstan-1.0.0/src/capstan/widgets/request_body.py +56 -0
- capstan-1.0.0/src/capstan/widgets/request_details.py +63 -0
- capstan-1.0.0/src/capstan/widgets/request_item.py +19 -0
- capstan-1.0.0/src/capstan/widgets/sidebar.py +32 -0
- capstan-1.0.0/src/capstan/widgets/status_bar.py +18 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julien Hadley Jack
|
|
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.
|
capstan-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capstan
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A webhook inspector for local development
|
|
5
|
+
Author: Julien Hadley Jack
|
|
6
|
+
Author-email: Julien Hadley Jack <git@jlhj.de>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE.txt
|
|
9
|
+
Requires-Dist: aiohttp>=3.13.5
|
|
10
|
+
Requires-Dist: cyclopts>=4.10.0
|
|
11
|
+
Requires-Dist: textual>=8.1.1
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Capstan: The Webhook Inspector
|
|
16
|
+
|
|
17
|
+
<!-- [[[cog
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from scripts.cog_readme import copy_image
|
|
20
|
+
copy_image(
|
|
21
|
+
Path("tests/__snapshots__/test_tui_app/test_snapshot_populated.raw"),
|
|
22
|
+
Path("docs/screenshot.svg"),
|
|
23
|
+
)
|
|
24
|
+
]]] -->
|
|
25
|
+
<!-- file checksum: 7122778bb8772c8540cb272658e2a897cc33e7313bc034f4884beaa1be9bd497 -->
|
|
26
|
+

|
|
27
|
+
<!-- [[[end]]] -->
|
|
28
|
+
|
|
29
|
+
**Capstan** is a modern, lightweight, and terminal-based webhook inspector for local development. It provides a TUI (
|
|
30
|
+
Terminal User Interface) to monitor and inspect incoming HTTP requests, making it the perfect companion for debugging
|
|
31
|
+
webhooks and API integrations without leaving your terminal.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Overview**: Quick view of method, path, host, and timestamp.
|
|
36
|
+
- **Headers & Cookies**: Complete breakdown of all request headers and cookies.
|
|
37
|
+
- **Query Parameters**: Easy-to-read table of all URL parameters.
|
|
38
|
+
- **Body Analysis**:
|
|
39
|
+
- **Syntax Highlighting**: Built-in support for JSON, XML, and more.
|
|
40
|
+
- **Hexdump**: Automatic fallback to a polished hexdump for binary or unknown content.
|
|
41
|
+
|
|
42
|
+
Capstan is designed to be a simple and lightweight tool. As such, it does **not** support:
|
|
43
|
+
|
|
44
|
+
- **Persistence**: Captured requests are stored in-memory and are lost when the application is closed.
|
|
45
|
+
- **HTTPS/SSL**: The local server only supports HTTP. Use a tunnel like `ngrok` if you need to receive webhooks from
|
|
46
|
+
HTTPS sources or from the internet.
|
|
47
|
+
- **Dynamic Responses**: It returns a static response (status, body, headers) for all requests.
|
|
48
|
+
- **Request Replay**: There is no functionality to "replay" or "resend" captured requests.
|
|
49
|
+
|
|
50
|
+
[mitmproxy](https://www.mitmproxy.org/) can be used instead should those advanced features be needed.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
You can install Capstan using `uv` (recommended) :
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv tool install capstan
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Alternatively, you could install it with `pipx`:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pipx install capstan
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Quick Start
|
|
67
|
+
|
|
68
|
+
Simply run `capstan` to start the inspector:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
capstan
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
By default, Capstan will start a server on `http://127.0.0.1:8080`.
|
|
75
|
+
You can now send requests to this address (or any path under it),
|
|
76
|
+
and they will appear in the TUI.
|
|
77
|
+
|
|
78
|
+
You can send then HTTP requests to that address with arbitrary paths. Example:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
curl -X POST http://127.0.0.1:8080/test-webhook \
|
|
82
|
+
-H "Content-Type: application/json" \
|
|
83
|
+
-d '{"event": "user_signup", "user_id": 123}'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
See the help documentation for more information:
|
|
89
|
+
|
|
90
|
+
<!-- [[[cog
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
from scripts.cog_readme import run_command
|
|
93
|
+
run_command("capstan --help")
|
|
94
|
+
]]] -->
|
|
95
|
+
```bash
|
|
96
|
+
$ capstan --help
|
|
97
|
+
Usage: capstan COMMAND [OPTIONS]
|
|
98
|
+
|
|
99
|
+
A webhook inspector for local development.
|
|
100
|
+
|
|
101
|
+
╭─ Commands ───────────────────────────────────────────────────────────────────╮
|
|
102
|
+
│ --help (-h) Display this message and exit. │
|
|
103
|
+
│ --install-completion Install shell completion for this application. │
|
|
104
|
+
│ --version Display application version. │
|
|
105
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
106
|
+
╭─ Parameters ─────────────────────────────────────────────────────────────────╮
|
|
107
|
+
│ --host The listen address for the endpoint [default: 127.0.0.1] │
|
|
108
|
+
│ --port The port for the endpoint [default: 8080] │
|
|
109
|
+
│ --status The response status code for the webhook [default: 201] │
|
|
110
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
111
|
+
```
|
|
112
|
+
<!-- [[[end]]] -->
|
capstan-1.0.0/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Capstan: The Webhook Inspector
|
|
2
|
+
|
|
3
|
+
<!-- [[[cog
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from scripts.cog_readme import copy_image
|
|
6
|
+
copy_image(
|
|
7
|
+
Path("tests/__snapshots__/test_tui_app/test_snapshot_populated.raw"),
|
|
8
|
+
Path("docs/screenshot.svg"),
|
|
9
|
+
)
|
|
10
|
+
]]] -->
|
|
11
|
+
<!-- file checksum: 7122778bb8772c8540cb272658e2a897cc33e7313bc034f4884beaa1be9bd497 -->
|
|
12
|
+

|
|
13
|
+
<!-- [[[end]]] -->
|
|
14
|
+
|
|
15
|
+
**Capstan** is a modern, lightweight, and terminal-based webhook inspector for local development. It provides a TUI (
|
|
16
|
+
Terminal User Interface) to monitor and inspect incoming HTTP requests, making it the perfect companion for debugging
|
|
17
|
+
webhooks and API integrations without leaving your terminal.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Overview**: Quick view of method, path, host, and timestamp.
|
|
22
|
+
- **Headers & Cookies**: Complete breakdown of all request headers and cookies.
|
|
23
|
+
- **Query Parameters**: Easy-to-read table of all URL parameters.
|
|
24
|
+
- **Body Analysis**:
|
|
25
|
+
- **Syntax Highlighting**: Built-in support for JSON, XML, and more.
|
|
26
|
+
- **Hexdump**: Automatic fallback to a polished hexdump for binary or unknown content.
|
|
27
|
+
|
|
28
|
+
Capstan is designed to be a simple and lightweight tool. As such, it does **not** support:
|
|
29
|
+
|
|
30
|
+
- **Persistence**: Captured requests are stored in-memory and are lost when the application is closed.
|
|
31
|
+
- **HTTPS/SSL**: The local server only supports HTTP. Use a tunnel like `ngrok` if you need to receive webhooks from
|
|
32
|
+
HTTPS sources or from the internet.
|
|
33
|
+
- **Dynamic Responses**: It returns a static response (status, body, headers) for all requests.
|
|
34
|
+
- **Request Replay**: There is no functionality to "replay" or "resend" captured requests.
|
|
35
|
+
|
|
36
|
+
[mitmproxy](https://www.mitmproxy.org/) can be used instead should those advanced features be needed.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
You can install Capstan using `uv` (recommended) :
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv tool install capstan
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Alternatively, you could install it with `pipx`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pipx install capstan
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
Simply run `capstan` to start the inspector:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
capstan
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
By default, Capstan will start a server on `http://127.0.0.1:8080`.
|
|
61
|
+
You can now send requests to this address (or any path under it),
|
|
62
|
+
and they will appear in the TUI.
|
|
63
|
+
|
|
64
|
+
You can send then HTTP requests to that address with arbitrary paths. Example:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
curl -X POST http://127.0.0.1:8080/test-webhook \
|
|
68
|
+
-H "Content-Type: application/json" \
|
|
69
|
+
-d '{"event": "user_signup", "user_id": 123}'
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
See the help documentation for more information:
|
|
75
|
+
|
|
76
|
+
<!-- [[[cog
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
from scripts.cog_readme import run_command
|
|
79
|
+
run_command("capstan --help")
|
|
80
|
+
]]] -->
|
|
81
|
+
```bash
|
|
82
|
+
$ capstan --help
|
|
83
|
+
Usage: capstan COMMAND [OPTIONS]
|
|
84
|
+
|
|
85
|
+
A webhook inspector for local development.
|
|
86
|
+
|
|
87
|
+
╭─ Commands ───────────────────────────────────────────────────────────────────╮
|
|
88
|
+
│ --help (-h) Display this message and exit. │
|
|
89
|
+
│ --install-completion Install shell completion for this application. │
|
|
90
|
+
│ --version Display application version. │
|
|
91
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
92
|
+
╭─ Parameters ─────────────────────────────────────────────────────────────────╮
|
|
93
|
+
│ --host The listen address for the endpoint [default: 127.0.0.1] │
|
|
94
|
+
│ --port The port for the endpoint [default: 8080] │
|
|
95
|
+
│ --status The response status code for the webhook [default: 201] │
|
|
96
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
97
|
+
```
|
|
98
|
+
<!-- [[[end]]] -->
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "capstan"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "A webhook inspector for local development"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Julien Hadley Jack", email = "git@jlhj.de" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = [
|
|
11
|
+
"LICENSE.txt",
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"aiohttp>=3.13.5",
|
|
16
|
+
"cyclopts>=4.10.0",
|
|
17
|
+
"textual>=8.1.1",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
capstan = "capstan.cli:app"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.11.1,<0.12.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
build = [
|
|
29
|
+
"python-semantic-release>=10.5.3",
|
|
30
|
+
]
|
|
31
|
+
dev = [
|
|
32
|
+
"cogapp>=3.6.0",
|
|
33
|
+
"pytest>=9.0.2",
|
|
34
|
+
"pytest-asyncio>=1.3.0",
|
|
35
|
+
"pytest-cov>=7.0.0",
|
|
36
|
+
"pytest-github-report>=0.0.1",
|
|
37
|
+
"pytest-textual-snapshot>=1.0.0",
|
|
38
|
+
"ruff>=0.15.7",
|
|
39
|
+
"textual-dev>=1.8.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.uv]
|
|
43
|
+
required-version = ">=0.9.6"
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 120
|
|
47
|
+
|
|
48
|
+
[tool.semantic_release]
|
|
49
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
50
|
+
build_command = """
|
|
51
|
+
uv run cog -r README.md
|
|
52
|
+
uv lock --upgrade-package "$PACKAGE_NAME"
|
|
53
|
+
git add README.md uv.lock
|
|
54
|
+
uv build
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
[tool.semantic_release.changelog]
|
|
58
|
+
exclude_commit_patterns = [
|
|
59
|
+
'''chore(?:\([^)]*?\))?: .+''',
|
|
60
|
+
'''ci(?:\([^)]*?\))?: .+''',
|
|
61
|
+
'''refactor(?:\([^)]*?\))?: .+''',
|
|
62
|
+
'''style(?:\([^)]*?\))?: .+''',
|
|
63
|
+
'''test(?:\([^)]*?\))?: .+''',
|
|
64
|
+
'''docs(?:\([^)]*?\))?: .+''',
|
|
65
|
+
'''build(?:\([^)]*?\))?: .+''',
|
|
66
|
+
'''build\((?!deps\): .+)''',
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[semantic_release.changelog.default_templates]
|
|
70
|
+
changelog_file = "CHANGELOG.md"
|
|
71
|
+
|
|
72
|
+
[tool.pytest.ini_options]
|
|
73
|
+
asyncio_mode = "auto"
|
|
74
|
+
addopts = ["--snapshot-patch-pycharm-diff"]
|
|
75
|
+
markers = [
|
|
76
|
+
"capstan(app): Allows you to configure capstan-related settings.",
|
|
77
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
|
|
2
|
+
from cyclopts import App
|
|
3
|
+
|
|
4
|
+
from capstan.model import ResponseConfig, ServerConfig
|
|
5
|
+
from capstan.tui import TUIApp
|
|
6
|
+
|
|
7
|
+
app = App(name="capstan", help="A webhook inspector for local development.")
|
|
8
|
+
app.register_install_completion_command()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.default
|
|
12
|
+
def run(
|
|
13
|
+
*,
|
|
14
|
+
host: str = ServerConfig.host,
|
|
15
|
+
port: int = ServerConfig.port,
|
|
16
|
+
status: int = ResponseConfig.status,
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
:param port: The port for the endpoint
|
|
20
|
+
:param host: The listen address for the endpoint
|
|
21
|
+
:param status: The response status code for the webhook
|
|
22
|
+
"""
|
|
23
|
+
server_config = ServerConfig(
|
|
24
|
+
host=host,
|
|
25
|
+
port=port,
|
|
26
|
+
response=ResponseConfig(status=status),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
tui_app = TUIApp(server_config=server_config)
|
|
30
|
+
tui_app.run()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
app()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Coroutine, Mapping
|
|
5
|
+
|
|
6
|
+
from aiohttp import HttpVersion
|
|
7
|
+
from multidict import MultiMapping, MultiDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class RequestData(object):
|
|
12
|
+
timestamp: datetime
|
|
13
|
+
method: str
|
|
14
|
+
path: str
|
|
15
|
+
headers: MultiMapping[str]
|
|
16
|
+
body: bytes
|
|
17
|
+
query_params: MultiMapping[str]
|
|
18
|
+
host: str
|
|
19
|
+
http_version: HttpVersion
|
|
20
|
+
cookies: Mapping[str, str]
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def content_type(self) -> str:
|
|
24
|
+
return self.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ResponseConfig(object):
|
|
29
|
+
status: int = 201
|
|
30
|
+
headers: MultiMapping[str] = field(default_factory=lambda: MultiDict({"content-type": "application/json"}))
|
|
31
|
+
body: bytes = '{"status": "received"}'.encode()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ServerConfig(object):
|
|
36
|
+
host: str = "127.0.0.1"
|
|
37
|
+
port: int = 8080
|
|
38
|
+
response: ResponseConfig = field(default_factory=lambda: ResponseConfig())
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def listen_address(self) -> str:
|
|
42
|
+
# noinspection HttpUrlsUsage
|
|
43
|
+
return f"http://{self.host}:{self.port}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
RequestCallback = Callable[[RequestData], Coroutine[Any, Any, None]]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from aiohttp import web
|
|
4
|
+
|
|
5
|
+
from capstan.model import RequestCallback, RequestData, ServerConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WebhookServer:
|
|
9
|
+
def __init__(self, config: ServerConfig, callback: RequestCallback):
|
|
10
|
+
self.config = config
|
|
11
|
+
self.callback = callback
|
|
12
|
+
self.runner = None
|
|
13
|
+
|
|
14
|
+
async def handle_request(self, request: web.Request) -> web.Response:
|
|
15
|
+
request_data = RequestData(
|
|
16
|
+
timestamp=datetime.now(),
|
|
17
|
+
method=request.method,
|
|
18
|
+
path=request.url.path,
|
|
19
|
+
headers=request.headers,
|
|
20
|
+
body=await request.read(),
|
|
21
|
+
query_params=request.query,
|
|
22
|
+
host=request.host,
|
|
23
|
+
http_version=request.version,
|
|
24
|
+
cookies=request.cookies,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
await self.callback(request_data)
|
|
28
|
+
|
|
29
|
+
return web.Response(
|
|
30
|
+
status=self.config.response.status,
|
|
31
|
+
body=self.config.response.body,
|
|
32
|
+
headers=self.config.response.headers,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async def start(self):
|
|
36
|
+
"""Starts the aiohttp server within the provided event loop."""
|
|
37
|
+
app = web.Application()
|
|
38
|
+
app.router.add_route("*", "/{tail:.*}", self.handle_request)
|
|
39
|
+
|
|
40
|
+
self.runner = web.AppRunner(app)
|
|
41
|
+
await self.runner.setup()
|
|
42
|
+
site = web.TCPSite(self.runner, self.config.host, self.config.port)
|
|
43
|
+
await site.start()
|
|
44
|
+
|
|
45
|
+
async def stop(self):
|
|
46
|
+
"""Stops the server and cleans up resources."""
|
|
47
|
+
if self.runner:
|
|
48
|
+
await self.runner.cleanup()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _main(): # pragma: no cover
|
|
52
|
+
async def print_result(request: RequestData):
|
|
53
|
+
print(request)
|
|
54
|
+
|
|
55
|
+
config = ServerConfig(host="0.0.0.0", port=8085)
|
|
56
|
+
|
|
57
|
+
server = WebhookServer(config=config, callback=print_result)
|
|
58
|
+
await server.start()
|
|
59
|
+
print(f"Started web server at {config.listen_address}.")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
await asyncio.Event().wait()
|
|
63
|
+
except asyncio.CancelledError:
|
|
64
|
+
await server.stop()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__": # pragma: no cover
|
|
68
|
+
import asyncio
|
|
69
|
+
|
|
70
|
+
asyncio.run(_main())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from aiohttp import HttpVersion11
|
|
4
|
+
from multidict import MultiDict
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Horizontal, ScrollableContainer
|
|
8
|
+
from textual.reactive import reactive
|
|
9
|
+
from textual.widgets import Footer, Header
|
|
10
|
+
|
|
11
|
+
from capstan.model import RequestData, ServerConfig
|
|
12
|
+
from capstan.server import WebhookServer
|
|
13
|
+
from capstan.widgets.request_details import RequestDetails
|
|
14
|
+
from capstan.widgets.sidebar import Sidebar
|
|
15
|
+
from capstan.widgets.status_bar import StatusBar
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TUIApp(App):
|
|
19
|
+
TITLE = "Capstan"
|
|
20
|
+
SUB_TITLE = "The webhook inspector"
|
|
21
|
+
|
|
22
|
+
BINDINGS = [
|
|
23
|
+
Binding("r", "reset_list", "Reset list"),
|
|
24
|
+
Binding("c", "copy_body", "Copy body"),
|
|
25
|
+
Binding("a", "copy_listen_address", "Copy address"),
|
|
26
|
+
Binding("f", "follow_off", "Auto follow (on)"),
|
|
27
|
+
Binding("f", "follow_on", "Auto follow (off)"),
|
|
28
|
+
Binding("q", "quit", "Quit"),
|
|
29
|
+
Binding("t", "test_request", "Create a test request", show=False),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
CSS_PATH = "tui.tcss"
|
|
33
|
+
|
|
34
|
+
selected_request: reactive[RequestData | None] = reactive(None)
|
|
35
|
+
auto_follow: reactive[bool] = reactive(True)
|
|
36
|
+
|
|
37
|
+
def __init__(self, server_config: ServerConfig):
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.server_config = server_config
|
|
40
|
+
self.server = WebhookServer(config=server_config, callback=self.add_request)
|
|
41
|
+
|
|
42
|
+
def compose(self) -> ComposeResult:
|
|
43
|
+
yield Header()
|
|
44
|
+
with Horizontal():
|
|
45
|
+
yield Sidebar().data_bind(TUIApp.auto_follow)
|
|
46
|
+
with ScrollableContainer():
|
|
47
|
+
yield RequestDetails().data_bind(request=TUIApp.selected_request)
|
|
48
|
+
yield StatusBar(self.server_config.listen_address)
|
|
49
|
+
yield Footer()
|
|
50
|
+
|
|
51
|
+
async def on_mount(self) -> None:
|
|
52
|
+
"""Starts the webhook server when the application is mounted."""
|
|
53
|
+
await self.server.start()
|
|
54
|
+
|
|
55
|
+
async def on_unmount(self) -> None:
|
|
56
|
+
"""Gracefully shuts down the server when the application is closed."""
|
|
57
|
+
await self.server.stop()
|
|
58
|
+
|
|
59
|
+
async def action_test_request(self): # pragma: no cover
|
|
60
|
+
request = RequestData(
|
|
61
|
+
timestamp=datetime.now(),
|
|
62
|
+
method="POST",
|
|
63
|
+
path="/",
|
|
64
|
+
headers=MultiDict(),
|
|
65
|
+
body="".encode(),
|
|
66
|
+
query_params=MultiDict(),
|
|
67
|
+
host="127.0.0.1",
|
|
68
|
+
http_version=HttpVersion11,
|
|
69
|
+
cookies=dict(),
|
|
70
|
+
)
|
|
71
|
+
await self.add_request(request)
|
|
72
|
+
|
|
73
|
+
def action_reset_list(self):
|
|
74
|
+
self.query_one(Sidebar).clear()
|
|
75
|
+
self.selected_request = None
|
|
76
|
+
self.refresh_bindings()
|
|
77
|
+
|
|
78
|
+
def action_copy_body(self):
|
|
79
|
+
if not self.selected_request or not self.selected_request.body:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
self.copy_to_clipboard(self.selected_request.body.decode("utf-8"))
|
|
83
|
+
except UnicodeDecodeError:
|
|
84
|
+
self.notify("Can't copy body content", title="Copy to clipboard failed!")
|
|
85
|
+
|
|
86
|
+
def action_copy_listen_address(self):
|
|
87
|
+
self.copy_to_clipboard(self.server_config.listen_address)
|
|
88
|
+
|
|
89
|
+
async def add_request(self, request: RequestData):
|
|
90
|
+
await self.query_one(Sidebar).add_request(request)
|
|
91
|
+
|
|
92
|
+
def on_sidebar_request_selected(self, event: Sidebar.RequestSelected) -> None:
|
|
93
|
+
self.selected_request = event.request
|
|
94
|
+
self.refresh_bindings()
|
|
95
|
+
|
|
96
|
+
def action_follow_on(self):
|
|
97
|
+
self.auto_follow = True
|
|
98
|
+
self.refresh_bindings()
|
|
99
|
+
|
|
100
|
+
def action_follow_off(self):
|
|
101
|
+
self.auto_follow = False
|
|
102
|
+
self.refresh_bindings()
|
|
103
|
+
|
|
104
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
105
|
+
if action == "follow_on" and self.auto_follow:
|
|
106
|
+
return False
|
|
107
|
+
if action == "follow_off" and not self.auto_follow:
|
|
108
|
+
return False
|
|
109
|
+
if action == "copy_body" and (not self.selected_request or not self.selected_request.body):
|
|
110
|
+
return False
|
|
111
|
+
else:
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__": # pragma: no cover
|
|
116
|
+
app = TUIApp(server_config=ServerConfig())
|
|
117
|
+
app.run()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
def get_syntax_lexer(content_type: str) -> str | None:
|
|
2
|
+
"""
|
|
3
|
+
Get a pygmentize lexer based on a Content-Type header
|
|
4
|
+
"""
|
|
5
|
+
if "json" in content_type:
|
|
6
|
+
return "json"
|
|
7
|
+
if "xml" in content_type:
|
|
8
|
+
return "xml"
|
|
9
|
+
if "text" in content_type:
|
|
10
|
+
return "text"
|
|
11
|
+
if "form-urlencoded" in content_type:
|
|
12
|
+
return "urlencoded"
|
|
13
|
+
if "multipart/" in content_type:
|
|
14
|
+
return "mime"
|
|
15
|
+
if "application/octet-stream" in content_type:
|
|
16
|
+
return None
|
|
17
|
+
return "text"
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from rich.markup import escape
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
from rich.syntax import Syntax
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from capstan.model import RequestData
|
|
10
|
+
from capstan.util import get_syntax_lexer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RequestBody(Static):
|
|
14
|
+
BINARY_SIZE_LIMIT = 5 * 1024 # 5KB limit for performance
|
|
15
|
+
BINARY_ROW_SIZE = 16
|
|
16
|
+
|
|
17
|
+
request: reactive[RequestData] = reactive(None, recompose=True)
|
|
18
|
+
show_line_numbers: reactive[bool] = reactive(True, recompose=True)
|
|
19
|
+
|
|
20
|
+
def _hexdump(self, data: bytes) -> Table:
|
|
21
|
+
"""Generates a polished hexdump of the given bytes using a Rich Table."""
|
|
22
|
+
table = Table(show_header=True, header_style="bold magenta", box=None, padding=(0, 1))
|
|
23
|
+
table.add_column("Offset", style="dim", justify="right")
|
|
24
|
+
table.add_column("Hex", style="green")
|
|
25
|
+
table.add_column("ASCII", style="cyan")
|
|
26
|
+
|
|
27
|
+
display_data = data[:self.BINARY_SIZE_LIMIT]
|
|
28
|
+
|
|
29
|
+
for i in range(0, len(display_data), self.BINARY_ROW_SIZE):
|
|
30
|
+
chunk = display_data[i : i + self.BINARY_ROW_SIZE]
|
|
31
|
+
hex_part = " ".join(f"{b:02x}" for b in chunk)
|
|
32
|
+
ascii_part = "".join(chr(b) if 32 <= b <= 126 else "." for b in chunk)
|
|
33
|
+
table.add_row(f"{i:08x}", hex_part, escape(ascii_part))
|
|
34
|
+
|
|
35
|
+
if len(data) > self.BINARY_SIZE_LIMIT:
|
|
36
|
+
table.add_row("...", f"({len(data) - self.BINARY_SIZE_LIMIT} more bytes truncated)", "...")
|
|
37
|
+
|
|
38
|
+
return table
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
if not self.request or not self.request.body:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
content_type = self.request.content_type
|
|
45
|
+
lexer = get_syntax_lexer(content_type)
|
|
46
|
+
body = self.request.body
|
|
47
|
+
|
|
48
|
+
if lexer:
|
|
49
|
+
try:
|
|
50
|
+
decoded_body = body.decode("utf-8")
|
|
51
|
+
syntax = Syntax(decoded_body, lexer, word_wrap=True, line_numbers=self.show_line_numbers)
|
|
52
|
+
yield Static(Panel(syntax, title=f"Body ({content_type or 'text: unknown'})"))
|
|
53
|
+
except UnicodeDecodeError:
|
|
54
|
+
yield Static(Panel(self._hexdump(body), title="Body (binary: unknown)"))
|
|
55
|
+
else:
|
|
56
|
+
yield Static(Panel(self._hexdump(body), title=f"Body ({content_type or 'binary: unknown'})"))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from rich import box
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.reactive import reactive
|
|
6
|
+
from textual.widget import Widget
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from capstan.model import RequestData
|
|
10
|
+
from capstan.widgets.request_body import RequestBody
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RequestDetails(Static):
|
|
14
|
+
request: reactive[RequestData] = reactive(None, recompose=True)
|
|
15
|
+
|
|
16
|
+
def compose(self) -> ComposeResult:
|
|
17
|
+
if not self.request:
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
yield self.overview_panel()
|
|
21
|
+
if self.request.query_params:
|
|
22
|
+
yield self.query_panel()
|
|
23
|
+
if self.request.headers:
|
|
24
|
+
yield self.header_panel()
|
|
25
|
+
if self.request.cookies:
|
|
26
|
+
yield self.cookie_panel()
|
|
27
|
+
if self.request.body:
|
|
28
|
+
yield RequestBody().data_bind(RequestDetails.request)
|
|
29
|
+
|
|
30
|
+
def overview_panel(self) -> Widget:
|
|
31
|
+
timestamp = self.request.timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
32
|
+
rows = [
|
|
33
|
+
("Method", self.request.method),
|
|
34
|
+
("Path", self.request.path),
|
|
35
|
+
("Host", self.request.host),
|
|
36
|
+
("Timestamp", timestamp),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
table = self._create_table("Key", "Value", rows, show_header=False)
|
|
40
|
+
return Static(Panel(table, title="Overview"))
|
|
41
|
+
|
|
42
|
+
def query_panel(self) -> Widget:
|
|
43
|
+
table = self._create_table("Query", "Value", self.request.query_params.items())
|
|
44
|
+
return Static(Panel(table, title="Query params"))
|
|
45
|
+
|
|
46
|
+
def cookie_panel(self) -> Widget:
|
|
47
|
+
table = self._create_table("Parameter", "Value", self.request.cookies.items())
|
|
48
|
+
return Static(Panel(table, title="Cookies"))
|
|
49
|
+
|
|
50
|
+
def header_panel(self) -> Widget:
|
|
51
|
+
table = self._create_table("Header", "Value", self.request.headers.items())
|
|
52
|
+
return Static(Panel(table, title="Headers"))
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _create_table(header_1: str, header_2: str, rows: list[tuple[str, str]], show_header: bool = True) -> Table:
|
|
56
|
+
table = Table(show_header=show_header, box=box.SIMPLE, expand=True)
|
|
57
|
+
table.add_column(header_1, style="cyan", overflow="fold", ratio=1)
|
|
58
|
+
table.add_column(header_2, overflow="fold", ratio=3)
|
|
59
|
+
|
|
60
|
+
for key, value in rows:
|
|
61
|
+
table.add_row(key, str(value))
|
|
62
|
+
|
|
63
|
+
return table
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.widgets import Label, ListItem
|
|
3
|
+
|
|
4
|
+
from capstan.model import RequestData
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RequestItem(ListItem):
|
|
8
|
+
def __init__(self, request: RequestData):
|
|
9
|
+
super().__init__()
|
|
10
|
+
self.request = request
|
|
11
|
+
|
|
12
|
+
def compose(self) -> ComposeResult:
|
|
13
|
+
method = self.request.method
|
|
14
|
+
path = self.request.path
|
|
15
|
+
timestamp = self.request.timestamp
|
|
16
|
+
time_display = timestamp.strftime("%H:%M:%S")
|
|
17
|
+
|
|
18
|
+
yield Label(f"[bold cyan]{method}[/] {path}")
|
|
19
|
+
yield Label(time_display, classes="timestamp")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from textual.message import Message
|
|
2
|
+
from textual.reactive import reactive
|
|
3
|
+
from textual.widgets import ListView
|
|
4
|
+
|
|
5
|
+
from capstan.model import RequestData
|
|
6
|
+
from capstan.widgets.request_item import RequestItem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Sidebar(ListView):
|
|
10
|
+
auto_follow: reactive[bool] = reactive(True)
|
|
11
|
+
|
|
12
|
+
async def add_request(self, request: RequestData):
|
|
13
|
+
old_index = self.index
|
|
14
|
+
self.index = None
|
|
15
|
+
|
|
16
|
+
new_item = RequestItem(request)
|
|
17
|
+
await self.insert(0, [new_item])
|
|
18
|
+
|
|
19
|
+
if self.auto_follow or old_index is None:
|
|
20
|
+
self.index = 0
|
|
21
|
+
else:
|
|
22
|
+
self.index = old_index + 1
|
|
23
|
+
|
|
24
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
25
|
+
if isinstance(event.item, RequestItem):
|
|
26
|
+
self.post_message(self.RequestSelected(event.item))
|
|
27
|
+
|
|
28
|
+
class RequestSelected(Message):
|
|
29
|
+
def __init__(self, item: RequestItem) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.request: RequestData = item.request if item else None
|
|
32
|
+
self.item: RequestItem | None = item
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from textual.widgets import Label
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StatusBar(Label):
|
|
5
|
+
"""A clickable status bar that copies the listen address to clipboard."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, listen_address: str):
|
|
8
|
+
super().__init__()
|
|
9
|
+
self.listen_address = listen_address
|
|
10
|
+
self.content = self.original_message = f"Listening on {self.listen_address} (click to copy)"
|
|
11
|
+
|
|
12
|
+
def on_click(self) -> None:
|
|
13
|
+
self.app.copy_to_clipboard(self.listen_address)
|
|
14
|
+
self.content = f"Copied {self.listen_address} to clipboard!"
|
|
15
|
+
self.set_timer(3.0, self.reset_message)
|
|
16
|
+
|
|
17
|
+
def reset_message(self) -> None:
|
|
18
|
+
self.content = self.original_message
|