From 9b39a309dcff297444eebda684bef67f45c11397 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:07:18 +0000 Subject: [PATCH 1/6] Initial plan From 1decd464b2cb4329f19eeb027f6a915f03710b40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:13:26 +0000 Subject: [PATCH 2/6] Add support for capturing all requests for parallel test runs Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- Dockerfile | 1 + README.md | 23 ++++++++++++++++ __pycache__/app.cpython-312.pyc | Bin 0 -> 2185 bytes app.py | 47 ++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 __pycache__/app.cpython-312.pyc create mode 100644 app.py diff --git a/Dockerfile b/Dockerfile index a183a21..b20357b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ USER catcher WORKDIR /home/catcher COPY --chown=catcher:catcher --from=build /root/http-request-catcher /home/catcher +COPY --chown=catcher:catcher app.py /home/catcher/app.py EXPOSE 5000 diff --git a/README.md b/README.md index 25700c2..f026c4d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,29 @@ Refer [docs](https://docs.docker.com/) for general documentation and guides for docker run appwrite/requestcatcher ``` +### API Endpoints + +The RequestCatcher exposes the following endpoints: + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/__last_request__` | GET | Returns the last captured request | +| `/__all_requests__` | GET | Returns all captured requests (useful for parallel test runs) | +| `/__clear__` | POST, DELETE | Clears all captured requests | +| `/*` | Any | Captures any request made to any path | + +#### Example: Get All Requests + +```bash +curl http://localhost:5000/__all_requests__ +``` + +#### Example: Clear Requests + +```bash +curl -X POST http://localhost:5000/__clear__ +``` + ### Environment Variables This container supports all environment variables supplied by the original smarterdm/http-request-catcher Docker image. diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..39c6a8d858b94fa5d05fc44d60a4577468918d8d GIT binary patch literal 2185 zcmbspO-~zF@V)&sc>Mu35R)b%p-{GJaH_OYR3W4&P*MpIRNRZz%H8@c*v9LnZ`X|= z7e|T|dTOIqB9)Mesvd~Q52@51AhZfv5j9nN$<2tIICW;%HjX6@RcE!cv-4)=&G)a7 zNH+l}eD+20XNi!%@uvw&gE+oV3E3cwFj^)S$r6gRP^OlU6)Z6;T2fZ>d16_%l&nID zKum#2JD`Vq+8H_; zi?HaD&~{U-O)GMSR-E;)-XdKhEb$Y4lH3sk*UpgcYsj5O(|>wLE2^j&V*|@$gzzNT zxzwJGMh?!g)RVzxMkra8S7rVDOFY!67h;BMx<$*>MNj^)Y&grF%FQpUrsH~Q$+0WN z#Z?ef&B|9rZdcZlw=$Xep^pQJp__)AFPMC8ttXw!fwG%x=;m^sI5V9=P0Mr(Hgodr zo?+nl9={1_gSd_I65=)rM@X~ej4lcb|w8DClT;!CFcS}eQ=jECVp?gdmM|Ac$LpV$u% zZ4K{+M|PzV9s{_nVO!R&P>1NCDy^Oiv|P^szG4b=qlBPIbHC`%in+xPDRje5>baQ z(cq3oz8!uZWY8N2b66vFg(T0_W)Bj5fWp1LYbP}ANh^k1;3<&NRZpD%B*XiG<{?0y zbZh#?6dyoRp1+Z~dCOC8&u2cmJ+}ZJg_yZ$RLiczyFg$~OHZJs4-ie}TU`Z5*foA+ zqrioZgWmv3t&VGQpbJ}M307Grl+;cRfrZxWR~H@eq0NfF8k_v!+mdgwOPKTyaIv?1 zC}BZ6pt%G}iH-yKZJseD4(G%HM}`~nC%SkR%M>1BVG`8aU=qfddTkyjc?3~r1+96yW? zY-kU)XYt{^`0&=ue*B%o#K5NdW9*06eq!t}*8k1jukRkl&!NzF(P#0oz4+LE{OzN# zl2mH5ucAcmc_t0)NdueO@6yPNMyq++^)K&ziT*7j6XVle0XccuuR;ku-iID?j$r} z*{oVNC;4?y!xTBV4(lSN^f|fkC(-wa{+#sxrG$62%lpc3O?(kc)U<l0g}+he', methods=['PUT', 'POST', 'GET', 'HEAD', 'DELETE', 'PATCH', 'OPTIONS']) +def catch(path): + global last_request, all_requests + + last_request = { + 'method': request.method, + 'data': request.data.decode('utf-8'), + 'headers': dict(request.headers), + 'url': request.url, + 'time': datetime.now().isoformat(), + } + all_requests.append(last_request) + + return '', 200 + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000) From a20da37c061e78cada2129aff3c7ab7257fd29a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:13:44 +0000 Subject: [PATCH 3/6] Add .gitignore to exclude Python cache files Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- .gitignore | 3 +++ __pycache__/app.cpython-312.pyc | Bin 2185 -> 0 bytes 2 files changed, 3 insertions(+) create mode 100644 .gitignore delete mode 100644 __pycache__/app.cpython-312.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bbe7b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.pyo diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 39c6a8d858b94fa5d05fc44d60a4577468918d8d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2185 zcmbspO-~zF@V)&sc>Mu35R)b%p-{GJaH_OYR3W4&P*MpIRNRZz%H8@c*v9LnZ`X|= z7e|T|dTOIqB9)Mesvd~Q52@51AhZfv5j9nN$<2tIICW;%HjX6@RcE!cv-4)=&G)a7 zNH+l}eD+20XNi!%@uvw&gE+oV3E3cwFj^)S$r6gRP^OlU6)Z6;T2fZ>d16_%l&nID zKum#2JD`Vq+8H_; zi?HaD&~{U-O)GMSR-E;)-XdKhEb$Y4lH3sk*UpgcYsj5O(|>wLE2^j&V*|@$gzzNT zxzwJGMh?!g)RVzxMkra8S7rVDOFY!67h;BMx<$*>MNj^)Y&grF%FQpUrsH~Q$+0WN z#Z?ef&B|9rZdcZlw=$Xep^pQJp__)AFPMC8ttXw!fwG%x=;m^sI5V9=P0Mr(Hgodr zo?+nl9={1_gSd_I65=)rM@X~ej4lcb|w8DClT;!CFcS}eQ=jECVp?gdmM|Ac$LpV$u% zZ4K{+M|PzV9s{_nVO!R&P>1NCDy^Oiv|P^szG4b=qlBPIbHC`%in+xPDRje5>baQ z(cq3oz8!uZWY8N2b66vFg(T0_W)Bj5fWp1LYbP}ANh^k1;3<&NRZpD%B*XiG<{?0y zbZh#?6dyoRp1+Z~dCOC8&u2cmJ+}ZJg_yZ$RLiczyFg$~OHZJs4-ie}TU`Z5*foA+ zqrioZgWmv3t&VGQpbJ}M307Grl+;cRfrZxWR~H@eq0NfF8k_v!+mdgwOPKTyaIv?1 zC}BZ6pt%G}iH-yKZJseD4(G%HM}`~nC%SkR%M>1BVG`8aU=qfddTkyjc?3~r1+96yW? zY-kU)XYt{^`0&=ue*B%o#K5NdW9*06eq!t}*8k1jukRkl&!NzF(P#0oz4+LE{OzN# zl2mH5ucAcmc_t0)NdueO@6yPNMyq++^)K&ziT*7j6XVle0XccuuR;ku-iID?j$r} z*{oVNC;4?y!xTBV4(lSN^f|fkC(-wa{+#sxrG$62%lpc3O?(kc)U<l0g}+he Date: Sat, 10 Jan 2026 14:40:20 +0000 Subject: [PATCH 4/6] Add /__find_request__ endpoint to search requests by header/body values Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- README.md | 23 +++++++++++++++++++++++ app.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/README.md b/README.md index f026c4d..b52a3af 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ The RequestCatcher exposes the following endpoints: |----------|--------|-------------| | `/__last_request__` | GET | Returns the last captured request | | `/__all_requests__` | GET | Returns all captured requests (useful for parallel test runs) | +| `/__find_request__` | GET | Find requests matching header/body values (see below) | | `/__clear__` | POST, DELETE | Clears all captured requests | | `/*` | Any | Captures any request made to any path | @@ -45,6 +46,28 @@ The RequestCatcher exposes the following endpoints: curl http://localhost:5000/__all_requests__ ``` +#### Example: Find Specific Request + +Find requests by header value: +```bash +curl "http://localhost:5000/__find_request__?header_X-Custom-Id=abc123" +``` + +Find requests by body content: +```bash +curl "http://localhost:5000/__find_request__?body=test-event" +``` + +Find requests by HTTP method: +```bash +curl "http://localhost:5000/__find_request__?method=POST" +``` + +Combine multiple filters: +```bash +curl "http://localhost:5000/__find_request__?method=POST&header_Content-Type=application/json" +``` + #### Example: Clear Requests ```bash diff --git a/app.py b/app.py index f2676c9..e3db8e1 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,47 @@ def get_all_requests(): return jsonify(all_requests), 200 +@app.route('/__find_request__', methods=['GET']) +def find_request(): + """ + Find requests matching header or body values. + Query parameters: + - header_=: Match requests with specific header value + - body=: Match requests containing this value in body + - method=: Match requests with specific HTTP method + - url=: Match requests with URL containing this value + """ + matches = [] + + for req in all_requests: + match = True + + for key, value in request.args.items(): + if key.startswith('header_'): + header_name = key[7:] # Remove 'header_' prefix + req_headers = {k.lower(): v for k, v in req['headers'].items()} + if req_headers.get(header_name.lower()) != value: + match = False + break + elif key == 'body': + if value not in req.get('data', ''): + match = False + break + elif key == 'method': + if req.get('method', '').upper() != value.upper(): + match = False + break + elif key == 'url': + if value not in req.get('url', ''): + match = False + break + + if match: + matches.append(req) + + return jsonify(matches), 200 + + @app.route('/__clear__', methods=['POST', 'DELETE']) def clear_requests(): global last_request, all_requests From 34ca91aa8d3421ca98435b89edd952307277825f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 09:15:12 +0000 Subject: [PATCH 5/6] Use deque with configurable maxlen to cap memory usage Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- README.md | 6 +++++- app.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b52a3af..5cbf61d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,11 @@ curl -X POST http://localhost:5000/__clear__ ### Environment Variables -This container supports all environment variables supplied by the original smarterdm/http-request-catcher Docker image. +| Variable | Default | Description | +|----------|---------|-------------| +| `MAX_REQUEST_HISTORY` | 1000 | Maximum number of requests to store in history. Older requests are automatically removed when this limit is exceeded. | + +This container also supports all environment variables supplied by the original smarterdm/http-request-catcher Docker image. ### Build diff --git a/app.py b/app.py index e3db8e1..abfa40d 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,15 @@ +from collections import deque from datetime import datetime from flask import Flask, request, jsonify from os import environ app = Flask('HTTP Request Catcher') +# Maximum number of requests to store in history (configurable via environment variable) +MAX_REQUEST_HISTORY = int(environ.get('MAX_REQUEST_HISTORY', 1000)) + last_request = None -all_requests = [] +all_requests = deque(maxlen=MAX_REQUEST_HISTORY) @app.route('/__last_request__', methods=['GET']) @@ -15,7 +19,7 @@ def get_last_request(): @app.route('/__all_requests__', methods=['GET']) def get_all_requests(): - return jsonify(all_requests), 200 + return jsonify(list(all_requests)), 200 @app.route('/__find_request__', methods=['GET']) @@ -61,16 +65,16 @@ def find_request(): @app.route('/__clear__', methods=['POST', 'DELETE']) def clear_requests(): - global last_request, all_requests + global last_request last_request = None - all_requests = [] + all_requests.clear() return '', 204 @app.route('/', defaults={'path': ''}, methods=['PUT', 'POST', 'GET', 'HEAD', 'DELETE', 'PATCH', 'OPTIONS']) @app.route('/', methods=['PUT', 'POST', 'GET', 'HEAD', 'DELETE', 'PATCH', 'OPTIONS']) def catch(path): - global last_request, all_requests + global last_request last_request = { 'method': request.method, From ef1f96f8199e8601a3ea4f95cecd9f4bd80a03af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:44:36 +0000 Subject: [PATCH 6/6] Add tests for all API routes and GitHub workflow Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- .github/workflows/tests.yml | 31 ++++ .gitignore | 1 + test_app.py | 281 ++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 test_app.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b368063 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flask pytest + + - name: Run tests + run: | + pytest test_app.py -v diff --git a/.gitignore b/.gitignore index 3bbe7b6..4315742 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc *.pyo +.pytest_cache/ diff --git a/test_app.py b/test_app.py new file mode 100644 index 0000000..7d051f5 --- /dev/null +++ b/test_app.py @@ -0,0 +1,281 @@ +"""Tests for the HTTP Request Catcher API.""" +import json +import pytest +from app import app, all_requests, MAX_REQUEST_HISTORY + + +@pytest.fixture +def client(): + """Create a test client for the Flask app.""" + app.config['TESTING'] = True + with app.test_client() as client: + # Clear any existing requests before each test + client.post('/__clear__') + yield client + + +class TestCatchEndpoint: + """Tests for the catch-all endpoint.""" + + def test_catch_get_request(self, client): + """Test catching a GET request.""" + response = client.get('/test-path') + assert response.status_code == 200 + + def test_catch_post_request(self, client): + """Test catching a POST request with body.""" + response = client.post('/webhook', data='{"event": "test"}') + assert response.status_code == 200 + + def test_catch_put_request(self, client): + """Test catching a PUT request.""" + response = client.put('/resource/123', data='updated data') + assert response.status_code == 200 + + def test_catch_delete_request(self, client): + """Test catching a DELETE request.""" + response = client.delete('/resource/123') + assert response.status_code == 200 + + def test_catch_patch_request(self, client): + """Test catching a PATCH request.""" + response = client.patch('/resource/123', data='{"field": "value"}') + assert response.status_code == 200 + + def test_catch_request_with_headers(self, client): + """Test catching a request with custom headers.""" + response = client.post( + '/webhook', + data='test', + headers={'X-Custom-Header': 'custom-value'} + ) + assert response.status_code == 200 + + +class TestLastRequestEndpoint: + """Tests for the /__last_request__ endpoint.""" + + def test_last_request_empty(self, client): + """Test getting last request when none have been made.""" + response = client.get('/__last_request__') + assert response.status_code == 200 + assert response.json is None + + def test_last_request_after_single_request(self, client): + """Test getting last request after making one request.""" + client.post('/webhook', data='test-body') + response = client.get('/__last_request__') + + assert response.status_code == 200 + data = response.json + assert data['method'] == 'POST' + assert data['data'] == 'test-body' + assert '/webhook' in data['url'] + + def test_last_request_returns_most_recent(self, client): + """Test that last request returns the most recent request.""" + client.post('/first', data='first') + client.post('/second', data='second') + client.post('/third', data='third') + + response = client.get('/__last_request__') + assert response.status_code == 200 + assert '/third' in response.json['url'] + assert response.json['data'] == 'third' + + +class TestAllRequestsEndpoint: + """Tests for the /__all_requests__ endpoint.""" + + def test_all_requests_empty(self, client): + """Test getting all requests when none have been made.""" + response = client.get('/__all_requests__') + assert response.status_code == 200 + assert response.json == [] + + def test_all_requests_single(self, client): + """Test getting all requests after making one request.""" + client.post('/webhook', data='test') + response = client.get('/__all_requests__') + + assert response.status_code == 200 + assert len(response.json) == 1 + assert response.json[0]['method'] == 'POST' + + def test_all_requests_multiple(self, client): + """Test getting all requests after making multiple requests.""" + client.get('/first') + client.post('/second', data='data') + client.put('/third', data='update') + + response = client.get('/__all_requests__') + assert response.status_code == 200 + assert len(response.json) == 3 + + # Verify order (oldest first) + assert '/first' in response.json[0]['url'] + assert '/second' in response.json[1]['url'] + assert '/third' in response.json[2]['url'] + + def test_all_requests_preserves_headers(self, client): + """Test that all requests preserve headers.""" + client.post( + '/webhook', + data='test', + headers={'X-Test-Header': 'test-value'} + ) + + response = client.get('/__all_requests__') + assert response.status_code == 200 + headers = response.json[0]['headers'] + assert headers.get('X-Test-Header') == 'test-value' + + +class TestFindRequestEndpoint: + """Tests for the /__find_request__ endpoint.""" + + def test_find_request_by_method(self, client): + """Test finding requests by HTTP method.""" + client.get('/path1') + client.post('/path2', data='data') + client.get('/path3') + + response = client.get('/__find_request__?method=POST') + assert response.status_code == 200 + assert len(response.json) == 1 + assert response.json[0]['method'] == 'POST' + + def test_find_request_by_body(self, client): + """Test finding requests by body content.""" + client.post('/webhook', data='event-type-1') + client.post('/webhook', data='event-type-2') + client.post('/webhook', data='event-type-1-extended') + + response = client.get('/__find_request__?body=event-type-1') + assert response.status_code == 200 + # Should match both 'event-type-1' and 'event-type-1-extended' + assert len(response.json) == 2 + + def test_find_request_by_header(self, client): + """Test finding requests by header value.""" + client.post('/webhook', headers={'X-Request-Id': 'abc123'}) + client.post('/webhook', headers={'X-Request-Id': 'def456'}) + client.post('/webhook', headers={'X-Request-Id': 'abc123'}) + + response = client.get('/__find_request__?header_X-Request-Id=abc123') + assert response.status_code == 200 + assert len(response.json) == 2 + + def test_find_request_by_url(self, client): + """Test finding requests by URL content.""" + client.get('/api/users') + client.get('/api/posts') + client.get('/api/users/123') + + response = client.get('/__find_request__?url=/api/users') + assert response.status_code == 200 + assert len(response.json) == 2 + + def test_find_request_multiple_filters(self, client): + """Test finding requests with multiple filters.""" + client.post('/webhook', data='test', headers={'X-Type': 'event'}) + client.post('/webhook', data='other', headers={'X-Type': 'event'}) + client.get('/webhook', headers={'X-Type': 'event'}) + + response = client.get('/__find_request__?method=POST&header_X-Type=event') + assert response.status_code == 200 + assert len(response.json) == 2 + for req in response.json: + assert req['method'] == 'POST' + + def test_find_request_no_matches(self, client): + """Test finding requests when no matches exist.""" + client.post('/webhook', data='test') + + response = client.get('/__find_request__?method=DELETE') + assert response.status_code == 200 + assert response.json == [] + + def test_find_request_case_insensitive_header(self, client): + """Test that header matching is case-insensitive for header names.""" + client.post('/webhook', headers={'X-Custom-Header': 'value'}) + + response = client.get('/__find_request__?header_x-custom-header=value') + assert response.status_code == 200 + assert len(response.json) == 1 + + +class TestClearEndpoint: + """Tests for the /__clear__ endpoint.""" + + def test_clear_with_post(self, client): + """Test clearing requests with POST method.""" + client.post('/webhook', data='test') + client.get('/path') + + response = client.post('/__clear__') + assert response.status_code == 204 + + # Verify requests are cleared + all_response = client.get('/__all_requests__') + assert all_response.json == [] + + last_response = client.get('/__last_request__') + assert last_response.json is None + + def test_clear_with_delete(self, client): + """Test clearing requests with DELETE method.""" + client.post('/webhook', data='test') + + response = client.delete('/__clear__') + assert response.status_code == 204 + + # Verify requests are cleared + all_response = client.get('/__all_requests__') + assert all_response.json == [] + + def test_clear_empty(self, client): + """Test clearing when no requests exist.""" + response = client.post('/__clear__') + assert response.status_code == 204 + + +class TestRequestCapture: + """Tests for request capture functionality.""" + + def test_captures_timestamp(self, client): + """Test that requests capture timestamp.""" + client.post('/webhook') + + response = client.get('/__last_request__') + assert 'time' in response.json + # Basic ISO format check + assert 'T' in response.json['time'] + + def test_captures_full_url(self, client): + """Test that requests capture full URL with query params.""" + client.get('/path?param1=value1¶m2=value2') + + response = client.get('/__last_request__') + assert 'param1=value1' in response.json['url'] + assert 'param2=value2' in response.json['url'] + + def test_captures_request_body(self, client): + """Test that POST body is captured correctly.""" + body = '{"key": "value", "nested": {"a": 1}}' + client.post('/webhook', data=body) + + response = client.get('/__last_request__') + assert response.json['data'] == body + + +class TestDequeMaxLength: + """Tests for deque max length functionality.""" + + def test_max_request_history_default(self): + """Test that MAX_REQUEST_HISTORY has a default value.""" + assert MAX_REQUEST_HISTORY > 0 + + def test_deque_has_maxlen(self): + """Test that all_requests deque has maxlen set.""" + assert all_requests.maxlen == MAX_REQUEST_HISTORY