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.
Files changed (52) hide show
  1. {httpbinx-1.12.0 → httpbinx-1.13.0}/PKG-INFO +1 -1
  2. {httpbinx-1.12.0 → httpbinx-1.13.0}/pyproject.toml +1 -1
  3. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/__init__.py +1 -1
  4. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/dynamicdata.py +16 -4
  5. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/response.py +9 -10
  6. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_dynamic_data.py +39 -0
  7. httpbinx-1.13.0/tests/test_response_inspection.py +50 -0
  8. {httpbinx-1.12.0 → httpbinx-1.13.0}/LICENSE +0 -0
  9. {httpbinx-1.12.0 → httpbinx-1.13.0}/README.md +0 -0
  10. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/cli.py +0 -0
  11. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/constants.py +0 -0
  12. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/examples/__init__.py +0 -0
  13. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/helpers.py +0 -0
  14. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/main.py +0 -0
  15. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/meta.py +0 -0
  16. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/__init__.py +0 -0
  17. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/anything.py +0 -0
  18. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/auth.py +0 -0
  19. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/cookies.py +0 -0
  20. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/httpmethods.py +0 -0
  21. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/images.py +0 -0
  22. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/__init__.py +0 -0
  23. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/request.py +0 -0
  24. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/redirects.py +0 -0
  25. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/responseformats.py +0 -0
  26. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/routers/statuscodes.py +0 -0
  27. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/schemas.py +0 -0
  28. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/UTF-8-demo.txt +0 -0
  29. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/bombs/bomb-1GB.br +0 -0
  30. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/bombs/bomb-1GB.gz +0 -0
  31. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/favicon.png +0 -0
  32. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/httbinx_logo.png +0 -0
  33. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/httpbinx_cover.png +0 -0
  34. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/jackal.jpg +0 -0
  35. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/pig_icon.png +0 -0
  36. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/svg_logo.svg +0 -0
  37. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/static/images/wolf_1.webp +0 -0
  38. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/templates/moby.html +0 -0
  39. {httpbinx-1.12.0 → httpbinx-1.13.0}/src/httpbinx/templates/sample.xml +0 -0
  40. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/__init__.py +0 -0
  41. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/conftest.py +0 -0
  42. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_anything.py +0 -0
  43. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_auth.py +0 -0
  44. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_cookies.py +0 -0
  45. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_http_methods.py +0 -0
  46. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_images.py +0 -0
  47. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_openapi.py +0 -0
  48. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_redirects.py +0 -0
  49. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_request_inspection.py +0 -0
  50. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_response_formats.py +0 -0
  51. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_schemas.py +0 -0
  52. {httpbinx-1.12.0 → httpbinx-1.13.0}/tests/test_status_codes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: httpbinx
3
- Version: 1.12.0
3
+ Version: 1.13.0
4
4
  Summary: HTTP Request & Response Service, written in Python + FastAPI.
5
5
  Author-Email: Leo <imleowoo@outlook.com>
6
6
  License: MIT License
@@ -31,7 +31,7 @@ classifiers = [
31
31
  "Programming Language :: Python :: 3.14",
32
32
  "License :: OSI Approved :: MIT License",
33
33
  ]
34
- version = "1.12.0"
34
+ version = "1.13.0"
35
35
 
36
36
  [project.license]
37
37
  file = "LICENSE"
@@ -2,6 +2,6 @@
2
2
 
3
3
  from .main import app
4
4
 
5
- __version__ = '1.12.0'
5
+ __version__ = '1.13.0'
6
6
 
7
7
  app.version = __version__
@@ -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 each byte'),
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 = int(duration / numbytes)
133
+ pause = duration / num_chunks
125
134
 
126
135
  async def generate_content():
127
- for _ in range(numbytes):
128
- yield b'*'
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 response
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(*, value: int = Path(..., title='Cache-Control max-age value'), request: Request):
38
- response = await httpmethods.get(request)
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 response
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 response
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