test_fastapi_access_logger.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. from unittest import mock
  2. from uuid import uuid4
  3. import pytest
  4. from fastapi.routing import APIRoute
  5. from starlette.requests import Request
  6. from starlette.responses import JSONResponse
  7. from starlette.responses import StreamingResponse
  8. from clean_python import InMemoryGateway
  9. from clean_python.fastapi import FastAPIAccessLogger
  10. from clean_python.fastapi import get_correlation_id
  11. SOME_UUID = uuid4()
  12. @pytest.fixture
  13. def fastapi_access_logger():
  14. return FastAPIAccessLogger(hostname="myhost", gateway_override=InMemoryGateway([]))
  15. @pytest.fixture
  16. def req():
  17. # a copy-paste from a local session, with some values removed / shortened
  18. scope = {
  19. "type": "http",
  20. "asgi": {"version": "3.0", "spec_version": "2.3"},
  21. "http_version": "1.1",
  22. "server": ("172.20.0.6", 80),
  23. "client": ("172.20.0.1", 45584),
  24. "scheme": "http",
  25. "root_path": "/v1-beta",
  26. "headers": [
  27. (b"host", b"localhost:8000"),
  28. (b"connection", b"keep-alive"),
  29. (b"accept", b"application/json"),
  30. (b"authorization", b"..."),
  31. (b"user-agent", b"Mozilla/5.0 ..."),
  32. (b"referer", b"http://localhost:8000/v1-beta/docs"),
  33. (b"accept-encoding", b"gzip, deflate, br"),
  34. (b"accept-language", b"en-US,en;q=0.9"),
  35. (b"cookie", b"..."),
  36. (b"x-correlation-id", str(SOME_UUID).encode()),
  37. ],
  38. "state": {},
  39. "method": "GET",
  40. "path": "/rasters",
  41. "raw_path": b"/v1-beta/rasters",
  42. "query_string": b"limit=50&offset=0&order_by=id",
  43. "path_params": {},
  44. "app_root_path": "",
  45. "route": APIRoute(
  46. endpoint=lambda x: x,
  47. path="/rasters",
  48. name="v1-beta/raster_list",
  49. methods=["GET"],
  50. ),
  51. }
  52. return Request(scope)
  53. @pytest.fixture
  54. def response():
  55. return JSONResponse({"foo": "bar"})
  56. @pytest.fixture
  57. def call_next(response):
  58. async def func(request):
  59. assert get_correlation_id(request) == SOME_UUID
  60. return response
  61. return func
  62. @mock.patch("time.time", return_value=0.0)
  63. async def test_logging(time, fastapi_access_logger, req, response, call_next):
  64. await fastapi_access_logger(req, call_next)
  65. assert len(fastapi_access_logger.gateway.data) == 0
  66. await response.background()
  67. (actual,) = fastapi_access_logger.gateway.data.values()
  68. actual.pop("id")
  69. assert actual == {
  70. "tag_suffix": "access_log",
  71. "remote_address": "172.20.0.1",
  72. "method": "GET",
  73. "path": "/v1-beta/rasters",
  74. "portal": "localhost:8000",
  75. "referer": "http://localhost:8000/v1-beta/docs",
  76. "user_agent": "Mozilla/5.0 ...",
  77. "query_params": "limit=50&offset=0&order_by=id",
  78. "view_name": "v1-beta/raster_list",
  79. "status": 200,
  80. "content_type": "application/json",
  81. "content_length": 13,
  82. "time": 0.0,
  83. "request_time": 0.0,
  84. "correlation_id": str(SOME_UUID),
  85. }
  86. @pytest.fixture
  87. def req_minimal():
  88. # https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope
  89. scope = {
  90. "type": "http",
  91. "asgi": {"version": "3.0"},
  92. "http_version": "1.1",
  93. "method": "GET",
  94. "scheme": "http",
  95. "path": "/",
  96. "query_string": "",
  97. "headers": [(b"abc", b"def")],
  98. }
  99. return Request(scope)
  100. @pytest.fixture
  101. def streaming_response():
  102. async def numbers(minimum, maximum):
  103. yield ("<html><body><ul>")
  104. for number in range(minimum, maximum + 1):
  105. yield "<li>%d</li>" % number
  106. yield ("</ul></body></html>")
  107. return StreamingResponse(numbers(1, 3), media_type="text/html")
  108. @pytest.fixture
  109. def call_next_streaming(streaming_response):
  110. async def func(request):
  111. assert get_correlation_id(request) == SOME_UUID
  112. return streaming_response
  113. return func
  114. @mock.patch("time.time", return_value=0.0)
  115. @mock.patch("clean_python.fastapi.fastapi_access_logger.uuid4", return_value=SOME_UUID)
  116. async def test_logging_minimal(
  117. time,
  118. uuid4,
  119. fastapi_access_logger,
  120. req_minimal,
  121. streaming_response,
  122. call_next_streaming,
  123. ):
  124. await fastapi_access_logger(req_minimal, call_next_streaming)
  125. assert req_minimal["headers"] == [
  126. (b"abc", b"def"),
  127. (b"x-correlation-id", str(SOME_UUID).encode()),
  128. ]
  129. assert len(fastapi_access_logger.gateway.data) == 0
  130. await streaming_response.background()
  131. (actual,) = fastapi_access_logger.gateway.data.values()
  132. actual.pop("id")
  133. assert actual == {
  134. "tag_suffix": "access_log",
  135. "remote_address": None,
  136. "method": "GET",
  137. "path": "/",
  138. "portal": "",
  139. "referer": None,
  140. "user_agent": None,
  141. "query_params": "",
  142. "view_name": None,
  143. "status": 200,
  144. "content_type": "text/html; charset=utf-8",
  145. "content_length": None,
  146. "time": 0.0,
  147. "request_time": 0.0,
  148. "correlation_id": str(SOME_UUID),
  149. }
  150. @pytest.fixture
  151. def req_health():
  152. scope = {
  153. "type": "http",
  154. "asgi": {"version": "3.0"},
  155. "http_version": "1.1",
  156. "method": "GET",
  157. "scheme": "http",
  158. "path": "/",
  159. "query_string": "",
  160. "headers": [],
  161. "route": APIRoute(
  162. endpoint=lambda x: x,
  163. path="/health",
  164. name="health_check",
  165. methods=["GET"],
  166. ),
  167. }
  168. return Request(scope)
  169. @pytest.fixture
  170. def call_next_no_correlation_id(response):
  171. async def func(request):
  172. assert get_correlation_id(request) is None
  173. return response
  174. return func
  175. @mock.patch("time.time", return_value=0.0)
  176. async def test_logging_health_check_skipped(
  177. time,
  178. fastapi_access_logger,
  179. req_health,
  180. streaming_response,
  181. call_next_no_correlation_id,
  182. ):
  183. await fastapi_access_logger(req_health, call_next_no_correlation_id)
  184. assert streaming_response.background is None