httpbinx 1.11.1__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.11.1 → httpbinx-1.13.0}/PKG-INFO +3 -3
- {httpbinx-1.11.1 → httpbinx-1.13.0}/pyproject.toml +3 -3
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/__init__.py +1 -1
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/meta.py +2 -3
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/anything.py +26 -3
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/auth.py +5 -3
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/dynamicdata.py +41 -6
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/images.py +1 -2
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/response.py +9 -10
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/redirects.py +25 -2
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/schemas.py +17 -14
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_anything.py +6 -1
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_auth.py +2 -4
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_dynamic_data.py +39 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_http_methods.py +4 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_images.py +8 -0
- httpbinx-1.13.0/tests/test_openapi.py +23 -0
- httpbinx-1.13.0/tests/test_response_inspection.py +50 -0
- httpbinx-1.13.0/tests/test_schemas.py +26 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/LICENSE +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/README.md +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/cli.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/constants.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/examples/__init__.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/helpers.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/main.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/__init__.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/cookies.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/httpmethods.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/__init__.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/request.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/responseformats.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/statuscodes.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/UTF-8-demo.txt +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/bombs/bomb-1GB.br +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/bombs/bomb-1GB.gz +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/favicon.png +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/httbinx_logo.png +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/httpbinx_cover.png +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/jackal.jpg +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/pig_icon.png +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/svg_logo.svg +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/wolf_1.webp +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/templates/moby.html +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/templates/sample.xml +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/__init__.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/conftest.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_cookies.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_redirects.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_request_inspection.py +0 -0
- {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_response_formats.py +0 -0
- {httpbinx-1.11.1 → 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.
|
|
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
|
|
@@ -35,8 +35,8 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
35
35
|
Project-URL: Homepage, https://github.com/imleowoo/httpbinx
|
|
36
36
|
Project-URL: Repository, https://github.com/imleowoo/httpbinx
|
|
37
37
|
Requires-Python: >=3.10
|
|
38
|
-
Requires-Dist: fastapi
|
|
39
|
-
Requires-Dist: pydantic
|
|
38
|
+
Requires-Dist: fastapi<0.140.0,>=0.139.2
|
|
39
|
+
Requires-Dist: pydantic<3.0.0,>=2.13.4
|
|
40
40
|
Requires-Dist: uvicorn
|
|
41
41
|
Requires-Dist: starlette
|
|
42
42
|
Requires-Dist: jinja2
|
|
@@ -11,8 +11,8 @@ description = "HTTP Request & Response Service, written in Python + FastAPI."
|
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.10"
|
|
13
13
|
dependencies = [
|
|
14
|
-
"fastapi",
|
|
15
|
-
"pydantic",
|
|
14
|
+
"fastapi>=0.139.2,<0.140.0",
|
|
15
|
+
"pydantic>=2.13.4,<3.0.0",
|
|
16
16
|
"uvicorn",
|
|
17
17
|
"starlette",
|
|
18
18
|
"jinja2",
|
|
@@ -31,7 +31,7 @@ classifiers = [
|
|
|
31
31
|
"Programming Language :: Python :: 3.14",
|
|
32
32
|
"License :: OSI Approved :: MIT License",
|
|
33
33
|
]
|
|
34
|
-
version = "1.
|
|
34
|
+
version = "1.13.0"
|
|
35
35
|
|
|
36
36
|
[project.license]
|
|
37
37
|
file = "LICENSE"
|
|
@@ -4,7 +4,6 @@ These tags are used to organize and categorize different endpoints in the API do
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from functools import lru_cache
|
|
7
|
-
from typing import List
|
|
8
7
|
|
|
9
8
|
from pydantic import BaseModel, Field
|
|
10
9
|
|
|
@@ -19,7 +18,7 @@ class Tag(BaseModel):
|
|
|
19
18
|
class TagsMetadata(BaseModel):
|
|
20
19
|
"""Container for a list of API tags."""
|
|
21
20
|
|
|
22
|
-
tags:
|
|
21
|
+
tags: list[Tag] = Field(..., description='List of API tags')
|
|
23
22
|
|
|
24
23
|
|
|
25
24
|
TAGS_METADATA = TagsMetadata(
|
|
@@ -40,7 +39,7 @@ TAGS_METADATA = TagsMetadata(
|
|
|
40
39
|
|
|
41
40
|
|
|
42
41
|
@lru_cache
|
|
43
|
-
def get_tags_metadata() ->
|
|
42
|
+
def get_tags_metadata() -> list[dict]:
|
|
44
43
|
"""
|
|
45
44
|
Returns the tags metadata in the format expected by FastAPI.
|
|
46
45
|
"""
|
|
@@ -21,12 +21,35 @@ class BombTypes(str, Enum):
|
|
|
21
21
|
bombs_path: Path = get_bomb_file_path()
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
@router.
|
|
25
|
-
'/anything',
|
|
24
|
+
@router.get(
|
|
25
|
+
'/anything',
|
|
26
|
+
response_model=RequestInfo,
|
|
27
|
+
summary='Returns anything passed in request data.',
|
|
28
|
+
response_description='Anything passed in request',
|
|
29
|
+
)
|
|
30
|
+
@router.post(
|
|
31
|
+
'/anything',
|
|
32
|
+
response_model=RequestInfo,
|
|
33
|
+
summary='Returns anything passed in request data.',
|
|
34
|
+
response_description='Anything passed in request',
|
|
35
|
+
)
|
|
36
|
+
@router.put(
|
|
37
|
+
'/anything',
|
|
38
|
+
response_model=RequestInfo,
|
|
39
|
+
summary='Returns anything passed in request data.',
|
|
40
|
+
response_description='Anything passed in request',
|
|
41
|
+
)
|
|
42
|
+
@router.delete(
|
|
43
|
+
'/anything',
|
|
44
|
+
response_model=RequestInfo,
|
|
45
|
+
summary='Returns anything passed in request data.',
|
|
46
|
+
response_description='Anything passed in request',
|
|
47
|
+
)
|
|
48
|
+
@router.patch(
|
|
49
|
+
'/anything',
|
|
26
50
|
response_model=RequestInfo,
|
|
27
51
|
summary='Returns anything passed in request data.',
|
|
28
52
|
response_description='Anything passed in request',
|
|
29
|
-
methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'TRACE'],
|
|
30
53
|
)
|
|
31
54
|
async def anything(request: Request):
|
|
32
55
|
return await to_request_info(request)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Auth"""
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
6
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
5
7
|
|
|
6
8
|
router = APIRouter(
|
|
@@ -14,8 +16,8 @@ security = HTTPBasic()
|
|
|
14
16
|
summary='Prompts the user for authorization using HTTP Basic Auth.',
|
|
15
17
|
response_description='TODO',
|
|
16
18
|
)
|
|
17
|
-
async def basic_auth(user: str, password: str, credentials: HTTPBasicCredentials
|
|
19
|
+
async def basic_auth(user: str, password: str, credentials: Annotated[HTTPBasicCredentials, Depends(security)]):
|
|
18
20
|
if not (credentials and credentials.username == user and credentials.password == password):
|
|
19
|
-
raise HTTPException(status_code=401)
|
|
21
|
+
raise HTTPException(status_code=401, headers={'WWW-Authenticate': 'Basic'})
|
|
20
22
|
|
|
21
23
|
return {'authenticated': True, 'user': user}
|
|
@@ -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"""
|
|
@@ -67,9 +72,32 @@ async def random_bytes(
|
|
|
67
72
|
return OctetStreamResponse(content=content) # TODO use StreamingResponse
|
|
68
73
|
|
|
69
74
|
|
|
70
|
-
@router.
|
|
75
|
+
@router.get(
|
|
76
|
+
'/delay/{delay}',
|
|
77
|
+
response_model=RequestInfo,
|
|
78
|
+
summary='Returns a delayed response (max of 10 seconds).',
|
|
79
|
+
response_description='A delayed response.',
|
|
80
|
+
)
|
|
81
|
+
@router.post(
|
|
82
|
+
'/delay/{delay}',
|
|
83
|
+
response_model=RequestInfo,
|
|
84
|
+
summary='Returns a delayed response (max of 10 seconds).',
|
|
85
|
+
response_description='A delayed response.',
|
|
86
|
+
)
|
|
87
|
+
@router.put(
|
|
88
|
+
'/delay/{delay}',
|
|
89
|
+
response_model=RequestInfo,
|
|
90
|
+
summary='Returns a delayed response (max of 10 seconds).',
|
|
91
|
+
response_description='A delayed response.',
|
|
92
|
+
)
|
|
93
|
+
@router.delete(
|
|
94
|
+
'/delay/{delay}',
|
|
95
|
+
response_model=RequestInfo,
|
|
96
|
+
summary='Returns a delayed response (max of 10 seconds).',
|
|
97
|
+
response_description='A delayed response.',
|
|
98
|
+
)
|
|
99
|
+
@router.patch(
|
|
71
100
|
'/delay/{delay}',
|
|
72
|
-
methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'TRACE'],
|
|
73
101
|
response_model=RequestInfo,
|
|
74
102
|
summary='Returns a delayed response (max of 10 seconds).',
|
|
75
103
|
response_description='A delayed response.',
|
|
@@ -86,7 +114,7 @@ async def delay_response(*, delay: float = Path(..., ge=0, le=10, description='d
|
|
|
86
114
|
response_description='A dripped response.',
|
|
87
115
|
)
|
|
88
116
|
async def drip(
|
|
89
|
-
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'),
|
|
90
118
|
numbytes: int = Query(
|
|
91
119
|
default=10,
|
|
92
120
|
gt=0,
|
|
@@ -97,12 +125,19 @@ async def drip(
|
|
|
97
125
|
delay: float = Query(default=2, ge=0, description='The amount of time (in seconds) to delay before responding'),
|
|
98
126
|
):
|
|
99
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)
|
|
100
132
|
# Number of seconds to pause during each data generation
|
|
101
|
-
pause =
|
|
133
|
+
pause = duration / num_chunks
|
|
102
134
|
|
|
103
135
|
async def generate_content():
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
remaining = numbytes
|
|
137
|
+
while remaining > 0:
|
|
138
|
+
chunk = min(chunk_size, remaining)
|
|
139
|
+
remaining -= chunk
|
|
140
|
+
yield b'*' * chunk
|
|
106
141
|
await asyncio.sleep(pause)
|
|
107
142
|
|
|
108
143
|
return StreamingResponse(content=generate_content(), media_type='application/octet-stream', status_code=code)
|
|
@@ -28,8 +28,7 @@ router = APIRouter(tags=['Images'], default_response_class=ImageResponse)
|
|
|
28
28
|
response_description='An image.',
|
|
29
29
|
)
|
|
30
30
|
async def image(request: Request):
|
|
31
|
-
accept = request.headers.get('accept')
|
|
32
|
-
accept = accept.lower()
|
|
31
|
+
accept = (request.headers.get('accept') or '').lower()
|
|
33
32
|
if 'image/webp' in accept:
|
|
34
33
|
return await image_webp()
|
|
35
34
|
elif 'image/svg+xml' in accept:
|
|
@@ -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(
|
|
@@ -28,9 +28,32 @@ async def absolute_redirect_n_times(*, n: int = Path(..., title='Redirects n tim
|
|
|
28
28
|
return _redirect(request, type_=RedirectTypes.ABSOLUTE, n=n)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
@router.
|
|
31
|
+
@router.get(
|
|
32
|
+
'/redirect-to',
|
|
33
|
+
summary='302/3XX Redirects to the given URL.',
|
|
34
|
+
response_description='A redirection.',
|
|
35
|
+
response_class=Response,
|
|
36
|
+
)
|
|
37
|
+
@router.post(
|
|
38
|
+
'/redirect-to',
|
|
39
|
+
summary='302/3XX Redirects to the given URL.',
|
|
40
|
+
response_description='A redirection.',
|
|
41
|
+
response_class=Response,
|
|
42
|
+
)
|
|
43
|
+
@router.put(
|
|
44
|
+
'/redirect-to',
|
|
45
|
+
summary='302/3XX Redirects to the given URL.',
|
|
46
|
+
response_description='A redirection.',
|
|
47
|
+
response_class=Response,
|
|
48
|
+
)
|
|
49
|
+
@router.delete(
|
|
50
|
+
'/redirect-to',
|
|
51
|
+
summary='302/3XX Redirects to the given URL.',
|
|
52
|
+
response_description='A redirection.',
|
|
53
|
+
response_class=Response,
|
|
54
|
+
)
|
|
55
|
+
@router.patch(
|
|
32
56
|
'/redirect-to',
|
|
33
|
-
methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'TRACE'],
|
|
34
57
|
summary='302/3XX Redirects to the given URL.',
|
|
35
58
|
response_description='A redirection.',
|
|
36
59
|
response_class=Response,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from enum import Enum
|
|
3
3
|
from functools import lru_cache
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import Any
|
|
5
5
|
|
|
6
6
|
from pydantic import AnyHttpUrl, BaseModel, Field
|
|
7
7
|
from starlette.requests import Request
|
|
@@ -33,15 +33,15 @@ class RequestInfo(BaseModel):
|
|
|
33
33
|
"""
|
|
34
34
|
|
|
35
35
|
url: AnyHttpUrl = Field(title='Request URL')
|
|
36
|
-
args:
|
|
37
|
-
headers:
|
|
38
|
-
origin:
|
|
39
|
-
form:
|
|
40
|
-
data:
|
|
41
|
-
files:
|
|
42
|
-
json_data:
|
|
36
|
+
args: dict[str, Any] = Field(default_factory=dict, title='Request Args')
|
|
37
|
+
headers: dict[str, str] = Field(default_factory=dict, title='Request Headers')
|
|
38
|
+
origin: str = Field('', title="Client's IP")
|
|
39
|
+
form: dict[str, Any] = Field(default_factory=dict, title='Request Form')
|
|
40
|
+
data: str | bytes = Field(b'', title='Request Data')
|
|
41
|
+
files: dict[str, Any] = Field(default_factory=dict, title='Upload Files')
|
|
42
|
+
json_data: str = Field('', alias='json', title='Content-Type: application/json', description='serialized')
|
|
43
43
|
method: HTTPMethod = Field(HTTPMethod.get, title='HTTP Request Method')
|
|
44
|
-
extras:
|
|
44
|
+
extras: dict[str, Any] = Field(default_factory=dict, title='The Other Information')
|
|
45
45
|
|
|
46
46
|
@classmethod
|
|
47
47
|
def get_properties(cls):
|
|
@@ -49,7 +49,7 @@ class RequestInfo(BaseModel):
|
|
|
49
49
|
|
|
50
50
|
@lru_cache
|
|
51
51
|
def properties():
|
|
52
|
-
return tuple(cls.
|
|
52
|
+
return tuple(cls.model_json_schema()['properties'].keys())
|
|
53
53
|
|
|
54
54
|
return properties()
|
|
55
55
|
|
|
@@ -81,11 +81,14 @@ class RequestAttrs:
|
|
|
81
81
|
If there are more than one value
|
|
82
82
|
for a key, the result will have a list of values for the key.
|
|
83
83
|
Otherwise it will have the plain value."""
|
|
84
|
-
out =
|
|
84
|
+
out = {}
|
|
85
85
|
for k, v in self.request.query_params.multi_items():
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
if k in out:
|
|
87
|
+
existing = out[k]
|
|
88
|
+
if isinstance(existing, list):
|
|
89
|
+
existing.append(v)
|
|
90
|
+
else:
|
|
91
|
+
out[k] = [existing, v]
|
|
89
92
|
else:
|
|
90
93
|
out[k] = v
|
|
91
94
|
return out
|
|
@@ -7,11 +7,16 @@ class TestAnything:
|
|
|
7
7
|
"""Tests for the /anything endpoint."""
|
|
8
8
|
|
|
9
9
|
def test_all_methods(self, client):
|
|
10
|
-
for method in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'
|
|
10
|
+
for method in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']:
|
|
11
11
|
response = client.request(method=method, url='/anything')
|
|
12
12
|
assert response.status_code == status.HTTP_200_OK
|
|
13
13
|
assert response.json()['method'] == method
|
|
14
14
|
|
|
15
|
+
def test_trace_is_not_allowed(self, client):
|
|
16
|
+
response = client.request(method='TRACE', url='/anything')
|
|
17
|
+
|
|
18
|
+
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
|
19
|
+
|
|
15
20
|
def test_with_body(self, client):
|
|
16
21
|
response = client.post('/anything', json={'hello': 'world'})
|
|
17
22
|
assert response.status_code == status.HTTP_200_OK
|
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from base64 import b64encode
|
|
4
4
|
|
|
5
|
-
import pytest
|
|
6
5
|
from starlette import status
|
|
7
6
|
|
|
8
7
|
|
|
9
|
-
@pytest.mark.xfail(reason='Source bug: HTTPBasic not used correctly in auth endpoint - missing Depends()')
|
|
10
8
|
def test_basic_auth_valid(client):
|
|
11
9
|
credentials = b64encode(b'admin:secret').decode()
|
|
12
10
|
response = client.get('/basic-auth/admin/secret', headers={'Authorization': f'Basic {credentials}'})
|
|
@@ -14,14 +12,14 @@ def test_basic_auth_valid(client):
|
|
|
14
12
|
assert response.json() == {'authenticated': True, 'user': 'admin'}
|
|
15
13
|
|
|
16
14
|
|
|
17
|
-
@pytest.mark.xfail(reason='Source bug: HTTPBasic not used correctly in auth endpoint - missing Depends()')
|
|
18
15
|
def test_basic_auth_wrong_password(client):
|
|
19
16
|
credentials = b64encode(b'admin:wrong').decode()
|
|
20
17
|
response = client.get('/basic-auth/admin/secret', headers={'Authorization': f'Basic {credentials}'})
|
|
21
18
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
19
|
+
assert response.headers['www-authenticate'] == 'Basic'
|
|
22
20
|
|
|
23
21
|
|
|
24
|
-
@pytest.mark.xfail(reason='Source bug: HTTPBasic not used correctly in auth endpoint - missing Depends()')
|
|
25
22
|
def test_basic_auth_missing_header(client):
|
|
26
23
|
response = client.get('/basic-auth/admin/secret')
|
|
27
24
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
25
|
+
assert response.headers['www-authenticate'] == 'Basic'
|
|
@@ -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."""
|
|
@@ -25,6 +25,10 @@ class TestGet:
|
|
|
25
25
|
response = client.get('/get?foo=bar&baz=1')
|
|
26
26
|
assert response.json()['args'] == {'foo': 'bar', 'baz': '1'}
|
|
27
27
|
|
|
28
|
+
def test_with_repeated_query_params(self, client):
|
|
29
|
+
response = client.get('/get?tag=python&tag=fastapi&tag=pydantic')
|
|
30
|
+
assert response.json()['args'] == {'tag': ['python', 'fastapi', 'pydantic']}
|
|
31
|
+
|
|
28
32
|
|
|
29
33
|
class TestPost:
|
|
30
34
|
"""Tests for POST /post."""
|
|
@@ -44,6 +44,14 @@ def test_image_accept_not_acceptable(client):
|
|
|
44
44
|
assert response.status_code == status.HTTP_406_NOT_ACCEPTABLE
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def test_image_without_accept_header_is_not_acceptable(client):
|
|
48
|
+
client.headers.pop('accept')
|
|
49
|
+
|
|
50
|
+
response = client.get('/image')
|
|
51
|
+
|
|
52
|
+
assert response.status_code == status.HTTP_406_NOT_ACCEPTABLE
|
|
53
|
+
|
|
54
|
+
|
|
47
55
|
def test_image_png(client):
|
|
48
56
|
response = client.get('/image/png')
|
|
49
57
|
assert response.status_code == status.HTTP_200_OK
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""OpenAPI schema tests."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_multi_method_routes_have_unique_operation_ids(client):
|
|
5
|
+
"""Every documented operation for a multi-method endpoint has a unique ID."""
|
|
6
|
+
schema = client.get('/openapi.json').json()
|
|
7
|
+
paths = ['/anything', '/delay/{delay}', '/redirect-to']
|
|
8
|
+
operation_ids = [
|
|
9
|
+
operation['operationId']
|
|
10
|
+
for path in paths
|
|
11
|
+
for operation in schema['paths'][path].values()
|
|
12
|
+
if isinstance(operation, dict) and 'operationId' in operation
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
assert len(operation_ids) == len(set(operation_ids))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_multi_method_routes_do_not_document_trace(client):
|
|
19
|
+
"""TRACE is not part of httpbinx's public API."""
|
|
20
|
+
schema = client.get('/openapi.json').json()
|
|
21
|
+
paths = ['/anything', '/delay/{delay}', '/redirect-to']
|
|
22
|
+
|
|
23
|
+
assert all('trace' not in schema['paths'][path] for path in paths)
|
|
@@ -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''
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Schema tests."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
from pydantic import PydanticDeprecatedSince20
|
|
6
|
+
|
|
7
|
+
from httpbinx.schemas import RequestInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_request_info_properties_do_not_use_deprecated_pydantic_schema_api():
|
|
11
|
+
with warnings.catch_warnings():
|
|
12
|
+
warnings.simplefilter('error', PydanticDeprecatedSince20)
|
|
13
|
+
properties = RequestInfo.get_properties()
|
|
14
|
+
|
|
15
|
+
assert properties == (
|
|
16
|
+
'url',
|
|
17
|
+
'args',
|
|
18
|
+
'headers',
|
|
19
|
+
'origin',
|
|
20
|
+
'form',
|
|
21
|
+
'data',
|
|
22
|
+
'files',
|
|
23
|
+
'json',
|
|
24
|
+
'method',
|
|
25
|
+
'extras',
|
|
26
|
+
)
|
|
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
|