Repository: apiflask/apiflask (python)

Issue:
Support adding custom field to operation object (view function)
Hi, is there anyway to specify custom fields (starting with x-) for a given route with a metadata parameter in the doc decorator?


```python
@app.route("/compute-intermodal-routes-co2eq", methods=["GET"])
@app.doc(
    summary="Compute intermodal routes CO2eq emissions",
    description="This intermodal routing API endpoint ",
    security=[{"api_key": []}],
    metadata={"x-conf": {"lambda": {"memory_size": 1000, "concurrent_execution": -1}}},
)
```

For now I am using spec_processor
```python
@app.spec_processor
def get_spec(spec):
    spec["paths"]["/compute-intermodal-routes-co2eq"]["get"]["x-conf"] = {
        "lambda": {"memory_size": 200, "concurrent_execution": -1},
        "throttling": {"rateLimit": 10, "burstLimit": 1},
    }
```

Pull request description:
Support adding custom field to operation object (view function) #571
<!--
For features and bug fixes, before opening a PR, please open an issue describing
the bug or feature the PR will address. You can skip this step if it's a typo fix.

Replace this comment with a description of the change. Describe how it
addresses the linked issue.
-->

<!--
Link to relevant issues or previous PRs, one per line. Use "fixes" to
automatically close an issue.
-->

fixes #571

<!--
If needed, ensure each step in the checklist below is complete. If only docs were changed, these aren't relevant and can be removed.
-->

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the `docs` folder and in code docstring.
- [x] Add an entry in `CHANGES.md` summarizing the change and linking to the issue.
- [x] Add `*Version changed*` or `*Version added*` note in any relevant docs and docstring.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 7b1d7b5..b58e23f 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -4,9 +4,11 @@ Unreleased
 
 - Use postponed evaluation of annotations ([pr #585][pr_585]).
 - Add static OpenAPI docs example ([issue #587][issue_587]).
+- Add spec extensions support with `@doc(extensions=...)` for the view function ([issue #571][issue_571]).
 
 [pr_585]: https://github.com/apiflask/apiflask/pull/585
 [issue_587]: https://github.com/apiflask/apiflask/issues/587
+[issue_571]: https://github.com/apiflask/apiflask/issues/571
 
 
 ## Version 2.1.3
diff --git a/docs/openapi.md b/docs/openapi.md
index c686c1b..9550c26 100644
--- a/docs/openapi.md
+++ b/docs/openapi.md
@@ -1005,6 +1005,22 @@ app.config['AUTO_OPERATION_ID'] = True
 The auto-operationId will in the format of `{HTTP method}_{endpoint of the view}` (e.g. `get_hello`).
 
 
+### Operation spec extensions
+
+!!! warning "Version >= 2.2.0"
+
+    This feature was added in the [version 2.2.0](/changelog/#version-220).
+
+You can add custom fields to the operation object with the `extensions` parameter:
+
+```python
+@app.get('/')
+@app.doc(extensions={'x-foo': 'bar'})
+def hello():
+    return 'Hello'
+```
+
+
 ## Security information
 
 APIFlask will generate the `security` object and operation `security` field based on
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index b070443..d4dc3d9 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -1033,6 +1033,7 @@ class APIFlask(APIScaffold, Flask):
                 )
                 view_func_auth = view_func._spec.get('auth')
                 custom_security = view_func._spec.get('security')
+                operation_extensions = view_func._spec.get('extensions')
                 if self.config['AUTO_AUTH_ERROR_RESPONSE'] and (
                     has_bp_level_auth or view_func_auth or custom_security
                 ):
@@ -1137,6 +1138,10 @@ class APIFlask(APIScaffold, Flask):
 
                 operations[method.lower()] = operation
 
+                if operation_extensions:
+                    for extension, value in operation_extensions.items():
+                        operation[extension] = value
+
             # parameters
             path_arguments: t.Iterable = re.findall(r'<(([^<:]+:)?([^>]+))>', rule.rule)
             if path_arguments and not (
diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py
index c550b10..2323f9f 100644
--- a/src/apiflask/scaffold.py
+++ b/src/apiflask/scaffold.py
@@ -543,6 +543,7 @@ class APIScaffold:
         hide: bool | None = None,
         operation_id: str | None = None,
         security: str | list[str | dict[str, list]] | None = None,
+        extensions: dict[str, t.Any] | None = None,
     ) -> t.Callable[[DecoratedType], DecoratedType]:
         """Set up the OpenAPI Spec for view functions.
 
@@ -590,6 +591,14 @@ class APIScaffold:
                 the `SECURITY_SCHEMES` configuration. If you don't need specify the scopes, just
                 pass a security name (equals to `[{'foo': []}]`) or a list of security names (equals
                 to `[{'foo': []}, {'bar': []}]`).
+            extensions: The spec extensions of this endpoint (OpenAPI operation object). The fields
+                in this extensions dict should start with "x-" prefix. See more details in the
+                [Specification Extensions](https://spec.openapis.org/oas/v3.1.0#specification-extensions)
+                chapter of OpenAPI docs.
+
+        *Version changed: 2.2.0*
+
+        - Add `extensions` parameter to support setting spec extensions.
 
         *Version changed: 2.0.0*
 
@@ -637,6 +646,7 @@ class APIScaffold:
                 hide=hide,
                 operation_id=operation_id,
                 security=security,
+                extensions=extensions,
             )
             return f
 


Test patch:
diff --git a/tests/test_openapi_extensions.py b/tests/test_openapi_extensions.py
new file mode 100644
index 0000000..e0597bb
--- /dev/null
+++ b/tests/test_openapi_extensions.py
@@ -0,0 +1,13 @@
+from openapi_spec_validator import validate_spec
+
+
+def test_specification_extensions(app, client):
+    @app.get('/')
+    @app.doc(extensions={'x-foo': {'foo': 'bar'}})
+    def foo():
+        pass
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    validate_spec(rv.json)
+    assert rv.json['paths']['/']['get']['x-foo'] == {'foo': 'bar'}