fastapi-rfc9457 0.1.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 (33) hide show
  1. fastapi_rfc9457-0.1.0/.gitignore +13 -0
  2. fastapi_rfc9457-0.1.0/PKG-INFO +94 -0
  3. fastapi_rfc9457-0.1.0/README.md +81 -0
  4. fastapi_rfc9457-0.1.0/example/README.md +52 -0
  5. fastapi_rfc9457-0.1.0/pyproject.toml +66 -0
  6. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/__init__.py +54 -0
  7. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/_version.py +24 -0
  8. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/builtins.py +101 -0
  9. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/client.py +82 -0
  10. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/docs.py +144 -0
  11. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/handlers.py +138 -0
  12. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/integration.py +76 -0
  13. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/models.py +36 -0
  14. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/openapi.py +401 -0
  15. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/problem.py +155 -0
  16. fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/py.typed +0 -0
  17. fastapi_rfc9457-0.1.0/tests/conftest.py +1 -0
  18. fastapi_rfc9457-0.1.0/tests/static/expected_errors.py +23 -0
  19. fastapi_rfc9457-0.1.0/tests/static/valid_usage.py +19 -0
  20. fastapi_rfc9457-0.1.0/tests/test_builtins.py +49 -0
  21. fastapi_rfc9457-0.1.0/tests/test_client.py +79 -0
  22. fastapi_rfc9457-0.1.0/tests/test_docs.py +100 -0
  23. fastapi_rfc9457-0.1.0/tests/test_example.py +77 -0
  24. fastapi_rfc9457-0.1.0/tests/test_example_client.py +74 -0
  25. fastapi_rfc9457-0.1.0/tests/test_extension_fields_pep563.py +19 -0
  26. fastapi_rfc9457-0.1.0/tests/test_handlers.py +156 -0
  27. fastapi_rfc9457-0.1.0/tests/test_integration.py +63 -0
  28. fastapi_rfc9457-0.1.0/tests/test_lifespan.py +44 -0
  29. fastapi_rfc9457-0.1.0/tests/test_models.py +27 -0
  30. fastapi_rfc9457-0.1.0/tests/test_openapi.py +255 -0
  31. fastapi_rfc9457-0.1.0/tests/test_problem.py +128 -0
  32. fastapi_rfc9457-0.1.0/tests/test_public_api.py +34 -0
  33. fastapi_rfc9457-0.1.0/tests/test_static_typing.py +33 -0
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # hatch-vcs generated
13
+ src/fastapi_rfc9457/_version.py
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-rfc9457
3
+ Version: 0.1.0
4
+ Summary: Typed, batteries-included RFC 9457 Problem Details for FastAPI & Pydantic v2.
5
+ Author: Fabian Zills
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: fastapi>=0.137.2
9
+ Requires-Dist: pydantic>=2.13.4
10
+ Provides-Extra: client
11
+ Requires-Dist: httpx>=0.27; extra == 'client'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # fastapi-rfc9457
15
+
16
+ Typed, batteries-included [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html)
17
+ "Problem Details for HTTP APIs" for FastAPI & Pydantic.
18
+
19
+ Define an error once - it serializes as `application/problem+json`, documents
20
+ itself in OpenAPI, and parses back into a typed exception on the client.
21
+
22
+ ```python
23
+ from fastapi import FastAPI
24
+ from fastapi_rfc9457 import Problem, add_problem_handlers, get_problem_docs_router, problems
25
+
26
+
27
+ class OutOfCredit(Problem):
28
+ """The account does not have enough credit."""
29
+ type = "/problems/out-of-credit"
30
+ title = "Out of Credit"
31
+ status = 403
32
+ balance: int # typed extension members, checked at the raise site
33
+ accounts: list[str]
34
+
35
+
36
+ class AccountSuspended(Problem):
37
+ """The account is suspended and cannot be charged."""
38
+ type = "/problems/account-suspended"
39
+ title = "Account Suspended"
40
+ status = 403
41
+
42
+
43
+ app = FastAPI()
44
+ add_problem_handlers(app) # handlers + problem+json OpenAPI
45
+ app.include_router(get_problem_docs_router(), prefix="/problems") # dereferenceable type URIs
46
+
47
+
48
+ @app.get("/charge", responses=problems(OutOfCredit, AccountSuspended))
49
+ async def charge() -> dict:
50
+ raise OutOfCredit(detail="Not enough credit.", balance=30, accounts=["/acct/12"])
51
+ ```
52
+
53
+ ## Accurate OpenAPI, for free
54
+
55
+ One route can declare several failure modes. Distinct statuses get their own
56
+ response; same-status problems become a `oneOf` union you flip through in
57
+ Swagger's **Examples** dropdown — all under `application/problem+json`.
58
+
59
+ ![Swagger error responses with a problem+json examples dropdown](docs/img/swagger-errors.png)
60
+
61
+ ## Dereferenceable `type` URIs
62
+
63
+ Mount the docs router and every problem `type` resolves to a live page listing
64
+ its typed extension members.
65
+
66
+ ![Problem type documentation page](docs/img/doc-page.png)
67
+
68
+ ## Features
69
+
70
+ - **Typed + validated extension members** that round-trip through serialization, OpenAPI, and the client.
71
+ - **Accurate per-route OpenAPI** under `application/problem+json`.
72
+ - **Structured 422** that preserves field + list-index mapping (no flattening).
73
+ - **Client-side parsing** back into typed problems.
74
+
75
+ ## Install
76
+
77
+ ```bash
78
+ uv add fastapi-rfc9457 # add fastapi-rfc9457[client] for the httpx hook
79
+ ```
80
+
81
+ ## Example
82
+
83
+ ```bash
84
+ cd example && uv run uvicorn main:app --reload # then open localhost:8000/docs
85
+ ```
86
+
87
+ See [`example/`](./example) for the full runnable app, and
88
+ [`example/client.py`](./example/client.py) for the httpx hook (`fastapi-rfc9457[client]`)
89
+ that raises those problems back as typed exceptions on the consumer side.
90
+
91
+ ## Notes
92
+
93
+ - Replaces FastAPI's default 422 body with `application/problem+json`.
94
+
@@ -0,0 +1,81 @@
1
+ # fastapi-rfc9457
2
+
3
+ Typed, batteries-included [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html)
4
+ "Problem Details for HTTP APIs" for FastAPI & Pydantic.
5
+
6
+ Define an error once - it serializes as `application/problem+json`, documents
7
+ itself in OpenAPI, and parses back into a typed exception on the client.
8
+
9
+ ```python
10
+ from fastapi import FastAPI
11
+ from fastapi_rfc9457 import Problem, add_problem_handlers, get_problem_docs_router, problems
12
+
13
+
14
+ class OutOfCredit(Problem):
15
+ """The account does not have enough credit."""
16
+ type = "/problems/out-of-credit"
17
+ title = "Out of Credit"
18
+ status = 403
19
+ balance: int # typed extension members, checked at the raise site
20
+ accounts: list[str]
21
+
22
+
23
+ class AccountSuspended(Problem):
24
+ """The account is suspended and cannot be charged."""
25
+ type = "/problems/account-suspended"
26
+ title = "Account Suspended"
27
+ status = 403
28
+
29
+
30
+ app = FastAPI()
31
+ add_problem_handlers(app) # handlers + problem+json OpenAPI
32
+ app.include_router(get_problem_docs_router(), prefix="/problems") # dereferenceable type URIs
33
+
34
+
35
+ @app.get("/charge", responses=problems(OutOfCredit, AccountSuspended))
36
+ async def charge() -> dict:
37
+ raise OutOfCredit(detail="Not enough credit.", balance=30, accounts=["/acct/12"])
38
+ ```
39
+
40
+ ## Accurate OpenAPI, for free
41
+
42
+ One route can declare several failure modes. Distinct statuses get their own
43
+ response; same-status problems become a `oneOf` union you flip through in
44
+ Swagger's **Examples** dropdown — all under `application/problem+json`.
45
+
46
+ ![Swagger error responses with a problem+json examples dropdown](docs/img/swagger-errors.png)
47
+
48
+ ## Dereferenceable `type` URIs
49
+
50
+ Mount the docs router and every problem `type` resolves to a live page listing
51
+ its typed extension members.
52
+
53
+ ![Problem type documentation page](docs/img/doc-page.png)
54
+
55
+ ## Features
56
+
57
+ - **Typed + validated extension members** that round-trip through serialization, OpenAPI, and the client.
58
+ - **Accurate per-route OpenAPI** under `application/problem+json`.
59
+ - **Structured 422** that preserves field + list-index mapping (no flattening).
60
+ - **Client-side parsing** back into typed problems.
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ uv add fastapi-rfc9457 # add fastapi-rfc9457[client] for the httpx hook
66
+ ```
67
+
68
+ ## Example
69
+
70
+ ```bash
71
+ cd example && uv run uvicorn main:app --reload # then open localhost:8000/docs
72
+ ```
73
+
74
+ See [`example/`](./example) for the full runnable app, and
75
+ [`example/client.py`](./example/client.py) for the httpx hook (`fastapi-rfc9457[client]`)
76
+ that raises those problems back as typed exceptions on the consumer side.
77
+
78
+ ## Notes
79
+
80
+ - Replaces FastAPI's default 422 body with `application/problem+json`.
81
+
@@ -0,0 +1,52 @@
1
+ # fastapi-rfc9457 — example app
2
+
3
+ ```bash
4
+ uv run uvicorn main:app --reload # open http://localhost:8000/docs
5
+ ```
6
+
7
+ ```bash
8
+ curl -i localhost:8000/posts/999 # 404 application/problem+json
9
+ curl -i "localhost:8000/search?limit=abc" # 422 errors[] with loc ["query","limit"]
10
+ curl -i localhost:8000/charge # 401 NotAuthenticated (no token)
11
+ curl -i "localhost:8000/charge?token=x" # 403 OutOfCredit + typed balance/accounts
12
+ curl -s localhost:8000/problems/out-of-credit # dereferenced type-doc page
13
+ ```
14
+
15
+ `/charge` declares three problems via `problems(...)`: a 401 and a same-status
16
+ 403 `oneOf` union (`OutOfCredit` / `AccountSuspended`) — see them in `/docs`.
17
+
18
+ ## Consuming it with the httpx hook
19
+
20
+ `fastapi-rfc9457[client]` ships an httpx event hook that turns every
21
+ `application/problem+json` reply back into the same typed exception the server
22
+ raised. [`client.py`](./client.py) is a runnable consumer of the server above.
23
+
24
+ With the demo server running (above), in another shell from this `example/`
25
+ directory:
26
+
27
+ ```bash
28
+ uv add fastapi-rfc9457[client] # the httpx extra
29
+ uv run client.py # connects to http://localhost:8000
30
+ ```
31
+
32
+ It prints:
33
+
34
+ ```text
35
+ GET /posts/1 -> {'id': '1', 'title': 'Hello'}
36
+ PostNotFound -> Post 999 not found
37
+ OutOfCredit -> balance=30, accounts=['/accounts/12']
38
+ NotAuthenticated -> Log in to charge this account.
39
+ ```
40
+
41
+ The hook is synchronous (`response.read()`), so it wires onto `httpx.Client`;
42
+ `httpx.AsyncClient` is not supported today.
43
+
44
+ ## Notes
45
+
46
+ - This replaces FastAPI's default 422 `application/json` body with
47
+ `application/problem+json` carrying a structured `errors[]` extension member —
48
+ intended, since the goal is a uniform error surface.
49
+ - Under `app.debug=True`, Starlette renders a traceback page *instead of*
50
+ invoking the 500 handler, so the `problem+json` 500 path isn't exercised in
51
+ debug; Starlette also re-raises the unhandled exception after the handler runs
52
+ (for logging). Both are expected.
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fastapi-rfc9457"
7
+ description = "Typed, batteries-included RFC 9457 Problem Details for FastAPI & Pydantic v2."
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ dynamic = ["version"]
11
+ license = "MIT"
12
+ authors = [{ name = "Fabian Zills" }]
13
+ dependencies = ["fastapi>=0.137.2", "pydantic>=2.13.4"]
14
+
15
+ [project.optional-dependencies]
16
+ client = ["httpx>=0.27"]
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=8.0",
21
+ "pytest-asyncio>=0.24",
22
+ "httpx>=0.27",
23
+ "pyright>=1.1.390",
24
+ "ruff>=0.8",
25
+ "pre-commit>=4.0",
26
+ "uvicorn>=0.49.0",
27
+ ]
28
+
29
+ [tool.hatch.version]
30
+ source = "vcs"
31
+ fallback-version = "0.0.0-dev"
32
+
33
+ [tool.hatch.build.hooks.vcs]
34
+ version-file = "src/fastapi_rfc9457/_version.py"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/fastapi_rfc9457"]
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["src/", "tests/", "README.md", "pyproject.toml"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
44
+ testpaths = ["tests"]
45
+ pythonpath = ["."]
46
+ filterwarnings = [
47
+ # Starlette's TestClient now prefers httpx2; the httpx warning is expected.
48
+ "ignore::DeprecationWarning",
49
+ ]
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ src = ["src", "tests"]
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "UP", "B", "SIM", "C4", "PIE", "RUF"]
57
+ ignore = ["B008"] # FastAPI's Depends()/Query() defaults are a deliberate pattern
58
+
59
+ [tool.ruff.lint.per-file-ignores]
60
+ "tests/static/*" = ["F821", "F811", "B018", "E501"] # intentional type-error fixtures
61
+
62
+ [tool.pyright]
63
+ include = ["src", "tests/static/valid_usage.py"]
64
+ exclude = ["tests/static/expected_errors.py", "**/_version.py"]
65
+ pythonVersion = "3.11"
66
+ typeCheckingMode = "standard"
@@ -0,0 +1,54 @@
1
+ """fastapi-rfc9457 — typed RFC 9457 Problem Details for FastAPI & Pydantic v2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from .builtins import (
8
+ BadRequest,
9
+ Conflict,
10
+ Forbidden,
11
+ InternalServerError,
12
+ InvalidParam,
13
+ NotAuthenticated,
14
+ NotFound,
15
+ TooManyRequests,
16
+ UnprocessableContent,
17
+ ValidationProblem,
18
+ )
19
+ from .client import httpx_raise_hook, parse_problem, raise_for_problem
20
+ from .docs import get_problem_docs_router
21
+ from .integration import add_problem_handlers, problem_details_lifespan
22
+ from .models import PROBLEM_MEDIA_TYPE, ProblemDetail
23
+ from .openapi import problems
24
+ from .problem import Problem, ProblemError
25
+
26
+ try:
27
+ __version__ = version("fastapi-rfc9457")
28
+ except PackageNotFoundError: # pragma: no cover
29
+ __version__ = "0.0.0"
30
+
31
+ __all__ = [
32
+ "PROBLEM_MEDIA_TYPE",
33
+ "BadRequest",
34
+ "Conflict",
35
+ "Forbidden",
36
+ "InternalServerError",
37
+ "InvalidParam",
38
+ "NotAuthenticated",
39
+ "NotFound",
40
+ "Problem",
41
+ "ProblemDetail",
42
+ "ProblemError",
43
+ "TooManyRequests",
44
+ "UnprocessableContent",
45
+ "ValidationProblem",
46
+ "__version__",
47
+ "add_problem_handlers",
48
+ "get_problem_docs_router",
49
+ "httpx_raise_hook",
50
+ "parse_problem",
51
+ "problem_details_lifespan",
52
+ "problems",
53
+ "raise_for_problem",
54
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,101 @@
1
+ """Ready-to-use built-in problem types and the structured-validation types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from .problem import Problem
10
+
11
+
12
+ class BadRequest(Problem):
13
+ """The request was malformed."""
14
+
15
+ title = "Bad Request"
16
+ status = 400
17
+
18
+
19
+ class NotAuthenticated(Problem):
20
+ """Authentication is required and has failed or not been provided."""
21
+
22
+ title = "Unauthorized"
23
+ status = 401
24
+
25
+
26
+ class Forbidden(Problem):
27
+ """You do not have permission to access this resource."""
28
+
29
+ title = "Forbidden"
30
+ status = 403
31
+
32
+
33
+ class NotFound(Problem):
34
+ """The requested resource was not found."""
35
+
36
+ title = "Not Found"
37
+ status = 404
38
+
39
+
40
+ class Conflict(Problem):
41
+ """The request conflicts with the current state of the resource."""
42
+
43
+ title = "Conflict"
44
+ status = 409
45
+
46
+
47
+ class UnprocessableContent(Problem):
48
+ """The request was well-formed but could not be processed."""
49
+
50
+ title = "Unprocessable Content"
51
+ status = 422
52
+
53
+
54
+ class TooManyRequests(Problem):
55
+ """You have sent too many requests in a given amount of time."""
56
+
57
+ title = "Too Many Requests"
58
+ status = 429
59
+
60
+
61
+ class InternalServerError(Problem):
62
+ """The server encountered an unexpected condition."""
63
+
64
+ title = "Internal Server Error"
65
+ status = 500
66
+
67
+
68
+ class InvalidParam(BaseModel):
69
+ """One field-level validation failure (RFC 9457 extension member).
70
+
71
+ Attributes
72
+ ----------
73
+ loc : list[str | int]
74
+ Faithful FastAPI location, e.g. ``["body", 0, "task_name"]`` — the list
75
+ index is preserved (why we use ``loc`` rather than RFC 7807's ``name``).
76
+ We deliberately omit a JSON Pointer rendering: ``loc`` already carries
77
+ strictly more (it keeps int indices distinct from string keys and avoids
78
+ the ``/``-escaping ambiguity a flat pointer would introduce), and a
79
+ location-class-prefixed pointer like ``/query/limit`` does not point into
80
+ any real request document.
81
+ detail : str
82
+ The pydantic error message.
83
+ type : str
84
+ The pydantic error code, e.g. ``"missing"``.
85
+ input : Any
86
+ The offending value; populated only when ``strip_debug`` is False.
87
+ """
88
+
89
+ loc: list[str | int]
90
+ detail: str
91
+ type: str
92
+ input: Any = None
93
+
94
+
95
+ class ValidationProblem(Problem):
96
+ """The request failed validation."""
97
+
98
+ type = "/problems/validation"
99
+ title = "Unprocessable Content"
100
+ status = 422
101
+ errors: list[InvalidParam]
@@ -0,0 +1,82 @@
1
+ """Client-side parsing of ``application/problem+json`` back into typed problems."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .models import PROBLEM_MEDIA_TYPE, ProblemDetail
9
+ from .problem import Problem, ProblemError, extension_fields, iter_problem_types
10
+
11
+
12
+ def _coerce(source: Any) -> dict[str, Any]:
13
+ if isinstance(source, dict):
14
+ return source
15
+ if isinstance(source, (bytes, bytearray, str)):
16
+ return json.loads(source)
17
+ if hasattr(source, "json"): # httpx.Response / requests.Response
18
+ return source.json()
19
+ raise TypeError(f"Cannot parse a problem from {type(source)!r}")
20
+
21
+
22
+ def parse_problem(source: Any) -> ProblemDetail | Problem:
23
+ """Parse a problem document into a typed ``Problem`` or generic ``ProblemDetail``.
24
+
25
+ Parameters
26
+ ----------
27
+ source : Any
28
+ A response object (with ``.json()``), a ``dict``, ``bytes``, or ``str``.
29
+
30
+ Returns
31
+ -------
32
+ ProblemDetail | Problem
33
+ The registered ``Problem`` subclass (extension members restored typed)
34
+ when ``type`` is known, otherwise a generic ``ProblemDetail``.
35
+ """
36
+ detail = ProblemDetail.model_validate(_coerce(source))
37
+ cls = next((c for c in iter_problem_types() if c.type == detail.type), None)
38
+ if cls is None:
39
+ return detail
40
+ extensions = {
41
+ name: getattr(detail, name) for name in extension_fields(cls) if hasattr(detail, name)
42
+ }
43
+ return cls(detail=detail.detail, instance=detail.instance, **extensions) # type: ignore[call-arg]
44
+
45
+
46
+ def _content_type(response: Any) -> str:
47
+ headers = getattr(response, "headers", {})
48
+ return headers.get("content-type", "") if hasattr(headers, "get") else ""
49
+
50
+
51
+ def raise_for_problem(response: Any) -> None:
52
+ """Raise the mapped ``Problem`` / ``ProblemError`` if this is a problem response.
53
+
54
+ Parameters
55
+ ----------
56
+ response : Any
57
+ A response object with ``.headers`` and ``.json()``.
58
+ """
59
+ if PROBLEM_MEDIA_TYPE not in _content_type(response):
60
+ return
61
+ parsed = parse_problem(response)
62
+ if isinstance(parsed, Problem):
63
+ raise parsed
64
+ raise ProblemError(parsed)
65
+
66
+
67
+ def httpx_raise_hook():
68
+ """Return an httpx ``response`` event hook that auto-raises on problem responses.
69
+
70
+ Returns
71
+ -------
72
+ Callable
73
+ Use as ``httpx.Client(event_hooks={"response": [httpx_raise_hook()]})``.
74
+ """
75
+
76
+ def hook(response: Any) -> None:
77
+ if PROBLEM_MEDIA_TYPE in _content_type(response):
78
+ if hasattr(response, "read"):
79
+ response.read()
80
+ raise_for_problem(response)
81
+
82
+ return hook