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.
Files changed (52) hide show
  1. {httpbinx-1.11.1 → httpbinx-1.13.0}/PKG-INFO +3 -3
  2. {httpbinx-1.11.1 → httpbinx-1.13.0}/pyproject.toml +3 -3
  3. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/__init__.py +1 -1
  4. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/meta.py +2 -3
  5. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/anything.py +26 -3
  6. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/auth.py +5 -3
  7. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/dynamicdata.py +41 -6
  8. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/images.py +1 -2
  9. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/response.py +9 -10
  10. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/redirects.py +25 -2
  11. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/schemas.py +17 -14
  12. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_anything.py +6 -1
  13. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_auth.py +2 -4
  14. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_dynamic_data.py +39 -0
  15. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_http_methods.py +4 -0
  16. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_images.py +8 -0
  17. httpbinx-1.13.0/tests/test_openapi.py +23 -0
  18. httpbinx-1.13.0/tests/test_response_inspection.py +50 -0
  19. httpbinx-1.13.0/tests/test_schemas.py +26 -0
  20. {httpbinx-1.11.1 → httpbinx-1.13.0}/LICENSE +0 -0
  21. {httpbinx-1.11.1 → httpbinx-1.13.0}/README.md +0 -0
  22. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/cli.py +0 -0
  23. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/constants.py +0 -0
  24. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/examples/__init__.py +0 -0
  25. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/helpers.py +0 -0
  26. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/main.py +0 -0
  27. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/__init__.py +0 -0
  28. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/cookies.py +0 -0
  29. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/httpmethods.py +0 -0
  30. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/__init__.py +0 -0
  31. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/inspection/request.py +0 -0
  32. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/responseformats.py +0 -0
  33. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/routers/statuscodes.py +0 -0
  34. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/UTF-8-demo.txt +0 -0
  35. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/bombs/bomb-1GB.br +0 -0
  36. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/bombs/bomb-1GB.gz +0 -0
  37. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/favicon.png +0 -0
  38. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/httbinx_logo.png +0 -0
  39. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/httpbinx_cover.png +0 -0
  40. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/jackal.jpg +0 -0
  41. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/pig_icon.png +0 -0
  42. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/svg_logo.svg +0 -0
  43. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/static/images/wolf_1.webp +0 -0
  44. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/templates/moby.html +0 -0
  45. {httpbinx-1.11.1 → httpbinx-1.13.0}/src/httpbinx/templates/sample.xml +0 -0
  46. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/__init__.py +0 -0
  47. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/conftest.py +0 -0
  48. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_cookies.py +0 -0
  49. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_redirects.py +0 -0
  50. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_request_inspection.py +0 -0
  51. {httpbinx-1.11.1 → httpbinx-1.13.0}/tests/test_response_formats.py +0 -0
  52. {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.11.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.11.1"
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.11.1'
5
+ __version__ = '1.13.0'
6
6
 
7
7
  app.version = __version__
@@ -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: List[Tag] = Field(..., description='List of API 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() -> List[dict]:
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.api_route(
25
- '/anything', # TODO path regex
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 fastapi import APIRouter, HTTPException
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 = security):
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.api_route(
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 each byte'),
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 = int(duration / numbytes)
133
+ pause = duration / num_chunks
102
134
 
103
135
  async def generate_content():
104
- for _ in range(numbytes):
105
- yield b'*'
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 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(
@@ -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.api_route(
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 AnyStr, Dict, Union
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: Dict = Field(default_factory=dict, title='Request Args')
37
- headers: Dict = Field(default_factory=dict, title='Request Headers')
38
- origin: AnyStr = Field('', title="Client's IP")
39
- form: Dict = Field(default_factory=dict, title='Request Form')
40
- data: Union[str, bytes] = Field(b'', title='Request Data')
41
- files: Dict = Field(default_factory=dict, title='Upload Files')
42
- json_data: AnyStr = Field('', alias='json', title='Content-Type: application/json', description='serialized')
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: Dict = Field(default_factory=dict, title='The Other Information')
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.schema()['properties'].keys())
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 = dict()
84
+ out = {}
85
85
  for k, v in self.request.query_params.multi_items():
86
- exist = out.get(k)
87
- if exist:
88
- out[k] = exist.append(v) if isinstance(exist, list) else [exist, v]
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', 'TRACE']:
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