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 new file mode 100644 index 0000000..4315742 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ 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..5cbf61d 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,59 @@ 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) | +| `/__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 | + +#### Example: Get All Requests + +```bash +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 +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 new file mode 100644 index 0000000..abfa40d --- /dev/null +++ b/app.py @@ -0,0 +1,92 @@ +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 = deque(maxlen=MAX_REQUEST_HISTORY) + + +@app.route('/__last_request__', methods=['GET']) +def get_last_request(): + return jsonify(last_request), 200 + + +@app.route('/__all_requests__', methods=['GET']) +def get_all_requests(): + return jsonify(list(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 + last_request = None + 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 + + 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) 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