httpbinx 1.12.0__tar.gz → 1.13.1__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 (54) hide show
  1. {httpbinx-1.12.0 → httpbinx-1.13.1}/PKG-INFO +1 -1
  2. {httpbinx-1.12.0 → httpbinx-1.13.1}/pyproject.toml +1 -1
  3. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/__init__.py +1 -1
  4. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/main.py +0 -1
  5. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/dynamicdata.py +16 -4
  6. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/inspection/response.py +9 -10
  7. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/responseformats.py +23 -3
  8. httpbinx-1.13.1/src/httpbinx/templates/legacy.html +18 -0
  9. httpbinx-1.13.1/tests/test_cors.py +31 -0
  10. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_dynamic_data.py +39 -0
  11. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_response_formats.py +23 -4
  12. httpbinx-1.13.1/tests/test_response_inspection.py +50 -0
  13. {httpbinx-1.12.0 → httpbinx-1.13.1}/LICENSE +0 -0
  14. {httpbinx-1.12.0 → httpbinx-1.13.1}/README.md +0 -0
  15. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/cli.py +0 -0
  16. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/constants.py +0 -0
  17. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/examples/__init__.py +0 -0
  18. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/helpers.py +0 -0
  19. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/meta.py +0 -0
  20. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/__init__.py +0 -0
  21. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/anything.py +0 -0
  22. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/auth.py +0 -0
  23. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/cookies.py +0 -0
  24. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/httpmethods.py +0 -0
  25. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/images.py +0 -0
  26. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/inspection/__init__.py +0 -0
  27. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/inspection/request.py +0 -0
  28. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/redirects.py +0 -0
  29. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/routers/statuscodes.py +0 -0
  30. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/schemas.py +0 -0
  31. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/UTF-8-demo.txt +0 -0
  32. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/bombs/bomb-1GB.br +0 -0
  33. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/bombs/bomb-1GB.gz +0 -0
  34. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/favicon.png +0 -0
  35. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/images/httbinx_logo.png +0 -0
  36. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/images/httpbinx_cover.png +0 -0
  37. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/images/jackal.jpg +0 -0
  38. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/images/pig_icon.png +0 -0
  39. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/images/svg_logo.svg +0 -0
  40. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/static/images/wolf_1.webp +0 -0
  41. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/templates/moby.html +0 -0
  42. {httpbinx-1.12.0 → httpbinx-1.13.1}/src/httpbinx/templates/sample.xml +0 -0
  43. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/__init__.py +0 -0
  44. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/conftest.py +0 -0
  45. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_anything.py +0 -0
  46. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_auth.py +0 -0
  47. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_cookies.py +0 -0
  48. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_http_methods.py +0 -0
  49. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_images.py +0 -0
  50. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_openapi.py +0 -0
  51. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_redirects.py +0 -0
  52. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_request_inspection.py +0 -0
  53. {httpbinx-1.12.0 → httpbinx-1.13.1}/tests/test_schemas.py +0 -0
  54. {httpbinx-1.12.0 → httpbinx-1.13.1}/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.1
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.1"
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.1'
6
6
 
7
7
  app.version = __version__
@@ -18,7 +18,6 @@ app = FastAPI(
18
18
  app.add_middleware(
19
19
  CORSMiddleware, # type: ignore
20
20
  allow_origins=['*'],
21
- allow_credentials=True,
22
21
  allow_methods=['*'],
23
22
  allow_headers=['*'],
24
23
  )
@@ -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(
@@ -134,14 +134,34 @@ async def json_endpoint():
134
134
 
135
135
 
136
136
  @router.get(
137
- '/robot.txt',
137
+ '/legacy',
138
+ response_class=HTMLResponse,
139
+ summary='Returns the legacy landing page.',
140
+ response_description='An HTML page listing the available endpoints.',
141
+ )
142
+ async def legacy_page(request: Request, templates: Jinja2Templates = Depends(get_templates)):
143
+ schema = request.app.openapi()
144
+ endpoints = []
145
+ for path, methods in sorted(schema.get('paths', {}).items()):
146
+ for method, operation in methods.items():
147
+ if method.upper() not in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'}:
148
+ continue
149
+ endpoints.append({'method': method.upper(), 'path': path, 'summary': operation.get('summary', '')})
150
+ return templates.TemplateResponse(
151
+ request,
152
+ 'legacy.html',
153
+ context={'request': request, 'endpoints': endpoints},
154
+ )
155
+
156
+
157
+ @router.get(
158
+ '/robots.txt',
138
159
  response_class=PlainTextResponse,
139
160
  summary='Returns some robots.txt rules.',
140
161
  response_description='Robots file',
141
162
  )
142
163
  async def robots_page():
143
- response = PlainTextResponse(content=ROBOT_TXT)
144
- return response
164
+ return PlainTextResponse(content=ROBOT_TXT)
145
165
 
146
166
 
147
167
  @router.get(
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>httpbinx - HTTP Request &amp; Response Service</title>
6
+ </head>
7
+ <body>
8
+ <h1>httpbinx</h1>
9
+ <p>HTTP Request &amp; Response Service, written in Python + FastAPI.</p>
10
+ <p>See the <a href="/">interactive API documentation</a> for details and examples.</p>
11
+ <h2>Endpoints</h2>
12
+ <ul>
13
+ {% for endpoint in endpoints %}
14
+ <li><a href="{{ endpoint.path }}"><code>{{ endpoint.method }} {{ endpoint.path }}</code></a> {{ endpoint.summary }}</li>
15
+ {% endfor %}
16
+ </ul>
17
+ </body>
18
+ </html>
@@ -0,0 +1,31 @@
1
+ """CORS configuration tests."""
2
+
3
+ ORIGIN = 'https://evil.example.com'
4
+
5
+
6
+ def test_wildcard_origin_is_returned_as_is(client):
7
+ """A wildcard allowlist is echoed literally, not per-request origin."""
8
+ response = client.get('/get', headers={'Origin': ORIGIN})
9
+
10
+ assert response.headers['access-control-allow-origin'] == '*'
11
+
12
+
13
+ def test_credentials_are_not_allowed_for_any_origin(client):
14
+ """Credentials must not be combined with a wildcard origin allowlist."""
15
+ response = client.get('/get', headers={'Origin': ORIGIN})
16
+
17
+ assert 'access-control-allow-credentials' not in response.headers
18
+
19
+
20
+ def test_preflight_does_not_allow_credentials(client):
21
+ """Preflight responses must not advertise credentialed cross-origin access."""
22
+ response = client.options(
23
+ '/get',
24
+ headers={
25
+ 'Origin': ORIGIN,
26
+ 'Access-Control-Request-Method': 'GET',
27
+ },
28
+ )
29
+
30
+ assert response.headers['access-control-allow-origin'] == '*'
31
+ assert 'access-control-allow-credentials' not in response.headers
@@ -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."""
@@ -83,15 +83,34 @@ class TestJson:
83
83
  assert len(data['slides']) == 2
84
84
 
85
85
 
86
- class TestRobot:
87
- """Tests for the /robot.txt endpoint."""
86
+ class TestLegacy:
87
+ """Tests for the /legacy landing page endpoint."""
88
88
 
89
- def test_content(self, client):
90
- response = client.get('/robot.txt')
89
+ def test_ok_and_html(self, client):
90
+ response = client.get('/legacy')
91
+ assert response.status_code == status.HTTP_200_OK
92
+ assert response.headers['content-type'].startswith('text/html')
93
+
94
+ def test_lists_endpoints(self, client):
95
+ response = client.get('/legacy')
96
+ assert '/get' in response.text
97
+ assert '/robots.txt' in response.text
98
+
99
+
100
+ class TestRobots:
101
+ """Tests for the /robots.txt endpoint."""
102
+
103
+ def test_canonical_route(self, client):
104
+ response = client.get('/robots.txt')
91
105
  assert response.status_code == status.HTTP_200_OK
106
+ assert response.headers['content-type'].startswith('text/plain')
92
107
  assert 'User-agent' in response.text
93
108
  assert 'Disallow' in response.text
94
109
 
110
+ def test_nonstandard_robot_txt_is_gone(self, client):
111
+ response = client.get('/robot.txt')
112
+ assert response.status_code == status.HTTP_404_NOT_FOUND
113
+
95
114
 
96
115
  @pytest.mark.skip(reason='Endpoint uses CWD-relative path to templates (source bug)')
97
116
  def test_xml(client):
@@ -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