httpbinx 1.12.0__tar.gz → 1.13.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.
- {httpbinx-1.12.0 → httpbinx-1.13.0}/PKG-INFO +1 -1
- {httpbinx-1.12.0 → httpbinx-1.13.0}/pyproject.toml +1 -1
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/__init__.py +1 -1
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/dynamicdata.py +16 -4
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/response.py +9 -10
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_dynamic_data.py +39 -0
- httpbinx-1.13.0/tests/test_response_inspection.py +50 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/LICENSE +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/README.md +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/cli.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/constants.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/examples/__init__.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/helpers.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/main.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/meta.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/__init__.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/anything.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/auth.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/cookies.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/httpmethods.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/images.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/__init__.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/request.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/redirects.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/responseformats.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/statuscodes.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/schemas.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/UTF-8-demo.txt +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/bombs/bomb-1GB.br +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/bombs/bomb-1GB.gz +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/favicon.png +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/httbinx_logo.png +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/httpbinx_cover.png +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/jackal.jpg +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/pig_icon.png +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/svg_logo.svg +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/wolf_1.webp +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/templates/moby.html +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/templates/sample.xml +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/__init__.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/conftest.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_anything.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_auth.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_cookies.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_http_methods.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_images.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_openapi.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_redirects.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_request_inspection.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_response_formats.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_schemas.py +0 -0
- {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_status_codes.py +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import base64
|
|
5
5
|
import binascii
|
|
6
|
+
import math
|
|
6
7
|
import random
|
|
7
8
|
import uuid
|
|
8
9
|
|
|
@@ -18,6 +19,10 @@ from httpbinx.schemas import RequestInfo
|
|
|
18
19
|
|
|
19
20
|
router = APIRouter(tags=['Dynamic data'])
|
|
20
21
|
|
|
22
|
+
# Upper bound on the number of chunks /drip yields so large payloads keep a
|
|
23
|
+
# meaningful pause between chunks instead of sleeping once per byte.
|
|
24
|
+
DRIP_MAX_CHUNKS = 100
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
class OctetStreamResponse(Response):
|
|
23
28
|
"""set response headers Content-Type: application/octet-stream"""
|
|
@@ -109,7 +114,7 @@ async def delay_response(*, delay: float = Path(..., ge=0, le=10, description='d
|
|
|
109
114
|
response_description='A dripped response.',
|
|
110
115
|
)
|
|
111
116
|
async def drip(
|
|
112
|
-
duration: float = Query(default=2, description='The amount of time (in seconds) over which to drip
|
|
117
|
+
duration: float = Query(default=2, description='The amount of time (in seconds) over which to drip the response'),
|
|
113
118
|
numbytes: int = Query(
|
|
114
119
|
default=10,
|
|
115
120
|
gt=0,
|
|
@@ -120,12 +125,19 @@ async def drip(
|
|
|
120
125
|
delay: float = Query(default=2, ge=0, description='The amount of time (in seconds) to delay before responding'),
|
|
121
126
|
):
|
|
122
127
|
await asyncio.sleep(delay)
|
|
128
|
+
# Drip in chunks so large `numbytes` do not turn into a burst of zero-length
|
|
129
|
+
# sleeps. The whole payload is paced over `duration` regardless of `numbytes`.
|
|
130
|
+
chunk_size = max(1, math.ceil(numbytes / DRIP_MAX_CHUNKS))
|
|
131
|
+
num_chunks = math.ceil(numbytes / chunk_size)
|
|
123
132
|
# Number of seconds to pause during each data generation
|
|
124
|
-
pause =
|
|
133
|
+
pause = duration / num_chunks
|
|
125
134
|
|
|
126
135
|
async def generate_content():
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
remaining = numbytes
|
|
137
|
+
while remaining > 0:
|
|
138
|
+
chunk = min(chunk_size, remaining)
|
|
139
|
+
remaining -= chunk
|
|
140
|
+
yield b'*' * chunk
|
|
129
141
|
await asyncio.sleep(pause)
|
|
130
142
|
|
|
131
143
|
return StreamingResponse(content=generate_content(), media_type='application/octet-stream', status_code=code)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import uuid
|
|
2
2
|
from email.utils import formatdate
|
|
3
3
|
|
|
4
|
-
from fastapi import APIRouter, Path
|
|
4
|
+
from fastapi import APIRouter, Path, Response
|
|
5
5
|
from starlette import status
|
|
6
6
|
from starlette.requests import Request
|
|
7
7
|
|
|
@@ -19,14 +19,13 @@ router = APIRouter(
|
|
|
19
19
|
' is present. Returns the same as a GET otherwise.',
|
|
20
20
|
response_description='TODO',
|
|
21
21
|
)
|
|
22
|
-
async def cache(request: Request):
|
|
22
|
+
async def cache(request: Request, response: Response):
|
|
23
23
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/If-Modified-Since
|
|
24
24
|
is_conditional = request.headers.get('If-Modified-Since') or request.headers.get('If-None-Match')
|
|
25
25
|
if is_conditional is None:
|
|
26
|
-
response = await httpmethods.get(request)
|
|
27
26
|
response.headers['Last-Modified'] = formatdate()
|
|
28
27
|
response.headers['ETag'] = uuid.uuid4().hex
|
|
29
|
-
return
|
|
28
|
+
return await httpmethods.get(request)
|
|
30
29
|
else:
|
|
31
30
|
return status_code_response(status.HTTP_304_NOT_MODIFIED)
|
|
32
31
|
|
|
@@ -34,17 +33,18 @@ async def cache(request: Request):
|
|
|
34
33
|
@router.get(
|
|
35
34
|
'/cache/{value}', summary='Sets a Cache-Control header for n seconds.', response_description='Cache control set'
|
|
36
35
|
)
|
|
37
|
-
async def cache_control(
|
|
38
|
-
|
|
36
|
+
async def cache_control(
|
|
37
|
+
*, value: int = Path(..., title='Cache-Control max-age value'), request: Request, response: Response
|
|
38
|
+
):
|
|
39
39
|
response.headers['Cache-Control'] = f'public, max-age={value}'
|
|
40
|
-
return
|
|
40
|
+
return await httpmethods.get(request)
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
@router.get(
|
|
44
44
|
'/etag/{etag}',
|
|
45
45
|
summary='Assumes the resource has the given etag and responds to If-None-Match and If-Match headers appropriately.',
|
|
46
46
|
)
|
|
47
|
-
async def set_etag(*, etag: str = Path(..., title='ETag value'), request: Request):
|
|
47
|
+
async def set_etag(*, etag: str = Path(..., title='ETag value'), request: Request, response: Response):
|
|
48
48
|
# TODO set If-None-Match
|
|
49
49
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/If-None-Match
|
|
50
50
|
if_none_match = parse_multi_value_header(request.headers.get('If-None-Match'))
|
|
@@ -59,9 +59,8 @@ async def set_etag(*, etag: str = Path(..., title='ETag value'), request: Reques
|
|
|
59
59
|
if etag not in if_match and '*' not in if_match:
|
|
60
60
|
return status_code_response(status.HTTP_412_PRECONDITION_FAILED)
|
|
61
61
|
# Special cases don't apply, return normal response
|
|
62
|
-
response = await httpmethods.get(request)
|
|
63
62
|
response.headers['ETag'] = etag
|
|
64
|
-
return
|
|
63
|
+
return await httpmethods.get(request)
|
|
65
64
|
|
|
66
65
|
|
|
67
66
|
@router.api_route(
|
|
@@ -65,6 +65,45 @@ class TestDrip:
|
|
|
65
65
|
assert response.status_code == status.HTTP_200_OK
|
|
66
66
|
assert len(response.content) == 5
|
|
67
67
|
|
|
68
|
+
def test_respects_duration(self, client):
|
|
69
|
+
duration = 0.2
|
|
70
|
+
start = time.time()
|
|
71
|
+
response = client.get(f'/drip?duration={duration}&numbytes=10&delay=0')
|
|
72
|
+
elapsed = time.time() - start
|
|
73
|
+
assert response.status_code == status.HTTP_200_OK
|
|
74
|
+
assert len(response.content) == 10
|
|
75
|
+
assert elapsed >= duration
|
|
76
|
+
|
|
77
|
+
def test_large_numbytes_is_paced(self, client):
|
|
78
|
+
duration = 0.2
|
|
79
|
+
numbytes = 100_000
|
|
80
|
+
start = time.time()
|
|
81
|
+
response = client.get(f'/drip?duration={duration}&numbytes={numbytes}&delay=0')
|
|
82
|
+
elapsed = time.time() - start
|
|
83
|
+
assert response.status_code == status.HTTP_200_OK
|
|
84
|
+
assert len(response.content) == numbytes
|
|
85
|
+
assert elapsed >= duration
|
|
86
|
+
|
|
87
|
+
def test_large_numbytes_drips_without_per_byte_sleeps(self, client, monkeypatch):
|
|
88
|
+
import asyncio as asyncio_module
|
|
89
|
+
import types
|
|
90
|
+
|
|
91
|
+
from httpbinx.routers import dynamicdata
|
|
92
|
+
|
|
93
|
+
delays = []
|
|
94
|
+
|
|
95
|
+
async def fake_sleep(delay):
|
|
96
|
+
delays.append(delay)
|
|
97
|
+
await asyncio_module.sleep(0)
|
|
98
|
+
|
|
99
|
+
monkeypatch.setattr(dynamicdata, 'asyncio', types.SimpleNamespace(sleep=fake_sleep))
|
|
100
|
+
|
|
101
|
+
response = client.get('/drip?duration=1&numbytes=100_000&delay=0')
|
|
102
|
+
assert response.status_code == status.HTTP_200_OK
|
|
103
|
+
assert len(response.content) == 100_000
|
|
104
|
+
# One sleep for the initial delay plus a bounded number of chunk sleeps.
|
|
105
|
+
assert len(delays) <= 101
|
|
106
|
+
|
|
68
107
|
|
|
69
108
|
class TestLinks:
|
|
70
109
|
"""Tests for the /links endpoint."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from email.utils import parsedate_to_datetime
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_cache_emits_validators_without_mutating_request_headers(client):
|
|
7
|
+
response = client.get('/cache?name=value', headers={'X-Probe': 'preserved'})
|
|
8
|
+
assert response.status_code == 200
|
|
9
|
+
assert parsedate_to_datetime(response.headers['Last-Modified'])
|
|
10
|
+
assert response.headers['ETag']
|
|
11
|
+
body = response.json()
|
|
12
|
+
assert body['args'] == {'name': 'value'}
|
|
13
|
+
assert body['headers']['x-probe'] == 'preserved'
|
|
14
|
+
assert 'Last-Modified' not in body['headers']
|
|
15
|
+
assert 'ETag' not in body['headers']
|
|
16
|
+
for header in ['If-Modified-Since', 'If-None-Match']:
|
|
17
|
+
conditional = client.get('/cache', headers={header: response.headers['ETag']})
|
|
18
|
+
assert conditional.status_code == 304
|
|
19
|
+
assert conditional.content == b''
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parametrize('seconds', [0, 60, 3600])
|
|
23
|
+
def test_cache_control_is_an_http_header(client, seconds):
|
|
24
|
+
response = client.get(f'/cache/{seconds}', headers={'Cache-Control': 'no-cache'})
|
|
25
|
+
assert response.status_code == 200
|
|
26
|
+
assert response.headers['Cache-Control'] == f'public, max-age={seconds}'
|
|
27
|
+
assert response.json()['headers']['cache-control'] == 'no-cache'
|
|
28
|
+
assert 'Cache-Control' not in response.json()['headers']
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize(
|
|
32
|
+
('headers', 'status_code'),
|
|
33
|
+
[
|
|
34
|
+
({}, 200),
|
|
35
|
+
({'If-None-Match': 'other'}, 200),
|
|
36
|
+
({'If-Match': 'probe'}, 200),
|
|
37
|
+
({'If-None-Match': 'probe'}, 304),
|
|
38
|
+
({'If-None-Match': '*'}, 304),
|
|
39
|
+
({'If-Match': 'other'}, 412),
|
|
40
|
+
],
|
|
41
|
+
)
|
|
42
|
+
def test_etag_response_header_and_existing_conditions(client, headers, status_code):
|
|
43
|
+
response = client.get('/etag/probe', headers=headers)
|
|
44
|
+
assert response.status_code == status_code
|
|
45
|
+
if status_code in [200, 304]:
|
|
46
|
+
assert response.headers['ETag'] == 'probe'
|
|
47
|
+
if status_code == 200:
|
|
48
|
+
assert 'ETag' not in response.json()['headers']
|
|
49
|
+
elif status_code == 304:
|
|
50
|
+
assert response.content == b''
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|