Skip to content

feat: replace check_expected_keys with pydantic models (#1034)#1065

Open
reachsridhard wants to merge 6 commits into
taverntesting:masterfrom
reachsridhard:feat/replace-check-expected-keys-with-pydantic
Open

feat: replace check_expected_keys with pydantic models (#1034)#1065
reachsridhard wants to merge 6 commits into
taverntesting:masterfrom
reachsridhard:feat/replace-check-expected-keys-with-pydantic

Conversation

@reachsridhard

@reachsridhard reachsridhard commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Replace the 9-year-old check_expected_keys pattern with pydantic models that use extra='forbid' for key validation. This provides the same behavior (rejecting unexpected keys) with better error messages and type safety.

Changes:

  • Add tavern/_core/pydantic_models.py with pydantic models for each request/client spec (REST, MQTT, gRPC)
  • Replace all check_expected_keys calls with .validate_keys() calls in rest/request.py, mqtt/request.py, mqtt/client.py, grpc/request.py, grpc/response.py, grpc/client.py
  • Remove now-unused expected_blocks dicts from MQTT and gRPC clients
  • Add tests/unit/test_pydantic_models.py with 27 tests covering all new models
  • Keep check_expected_keys in dict_util.py for backward compatibility

Closes #1034

Summary by CodeRabbit

  • New Features

    • Added stricter validation for REST, MQTT, and gRPC request/client settings to reject unexpected fields.
    • Improved handling of nested configuration blocks, including TLS and connection settings.
  • Bug Fixes

    • Validation errors now return clearer messages when invalid keys are supplied.
  • Tests

    • Added coverage for valid and invalid configuration inputs across REST, MQTT, and gRPC.

…#1034)

Replace the 9-year-old check_expected_keys pattern with pydantic models
that use extra='forbid' for key validation. This provides the same
behavior (rejecting unexpected keys) with better error messages and
type safety.

Changes:
- Add tavern/_core/pydantic_models.py with pydantic models for each
  request/client spec (REST, MQTT, gRPC)
- Replace all check_expected_keys calls with .validate_keys() calls
  in rest/request.py, mqtt/request.py, mqtt/client.py,
  grpc/request.py, grpc/response.py, grpc/client.py
- Remove now-unused expected_blocks dicts from MQTT and gRPC clients
- Add tests/unit/test_pydantic_models.py with 27 tests covering
  all new models
- Keep check_expected_keys in dict_util.py for backward compatibility

Closes taverntesting#1034
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough B[Pydantic model construction]
B --> C{ValidationError?}
C -- No --> D[Return model_dump]
C -- Yes --> E{extra_forbidden errors?}
E -- Yes --> F[Raise UnexpectedKeysError with key names]
E -- No --> G[Raise UnexpectedKeysError with Pydantic message]
</diagram>
</layer>
<layer id="rest_plugin" title="REST plugin wired to RestRequestSpec">
<summary>Removes check_expected_keys import and manual whitelist from rest/request.py, replacing them with RestRequestSpec.validate_keys.</summary>
<ranges>
range_79cda24e8b63
range_3ed1406cccc5
range_741386e6c540
</ranges>
</layer>
<layer id="mqtt_plugin" title="MQTT client and request wired to MQTT spec models">
<summary>Removes check_expected_keys and expected_blocks from mqtt/client.py and mqtt/request.py, replacing them with MQTTClientTopLevel, MQTTClientArgs, MQTTConnectArgs, MQTTAuthArgs, MQTTTLSArgs, MQTTSSLContextArgs, and MQTTRequestSpec validate_keys calls.</summary>
<ranges>
range_68b8184cbd3c
range_e50b787744f0
range_09c3747ad8f0
range_e4864da5f6a5
range_7cf29a9cfc1b
range_e7c1c319bd82
</ranges>
</layer>
<layer id="grpc_plugin" title="gRPC client, request, and response wired to gRPC spec models">
<summary>Removes check_expected_keys and expected_blocks from grpc/client.py, grpc/request.py, and grpc/response.py, replacing them with GRPCClientTopLevel, GRPCConnectArgs, GRPCProtoArgs, GRPCRequestSpec, and GRPCResponseSpec validate_keys calls.</summary>
<ranges>
range_5d5773e9bfdc
range_ab25d232d4c2
range_bf284c2417e6
range_83c4db251286
range_65b37c9e15f5
range_4eb0b358ee75
</ranges>
</layer>
<layer id="tests" title="Unit tests for all spec models">
<summary>Adds tests/unit/test_pydantic_models.py covering validate_keys success and UnexpectedKeysError cases for all REST, MQTT, and gRPC models.</summary>
<ranges>
range_41416930ad2a
</ranges>
</layer>
</cohort>
<unassigned_ranges>
</unassigned_ranges>
-->

## Walkthrough

Introduces a `_BaseKeyValidator` Pydantic base model with a `validate_keys` classmethod that raises `UnexpectedKeysError` on unexpected keys. Thirteen protocol-specific spec models (REST, MQTT, gRPC) are defined on top of this base, and all existing `check_expected_keys` calls across the REST, MQTT, and gRPC plugins are replaced with calls to the relevant model's `validate_keys`. Unit tests cover all models.

## Changes

**Pydantic-based key validation**

| Layer / File(s) | Summary |
|---|---|
| **`_BaseKeyValidator` and all spec models** <br> `tavern/_core/pydantic_models.py` | Adds the `_BaseKeyValidator` base model with `extra="forbid"` and a `validate_keys` classmethod that raises `UnexpectedKeysError`. Defines `RestRequestSpec`, `MQTTRequestSpec`, `MQTTClientArgs`, `MQTTConnectArgs`, `MQTTAuthArgs`, `MQTTTLSArgs`, `MQTTSSLContextArgs`, `MQTTClientTopLevel`, `GRPCRequestSpec`, `GRPCResponseSpec`, `GRPCConnectArgs`, `GRPCProtoArgs`, and `GRPCClientTopLevel` on top of it. |
| **REST plugin wired to `RestRequestSpec`** <br> `tavern/_plugins/rest/request.py` | Removes `check_expected_keys` import and manual allowed-key set; replaces with `RestRequestSpec.validate_keys(rspec)`. |
| **MQTT client and request wired to MQTT spec models** <br> `tavern/_plugins/mqtt/client.py`, `tavern/_plugins/mqtt/request.py` | Removes `expected_blocks` whitelist and `check_expected_keys` calls; replaces top-level and per-block validation with `MQTTClientTopLevel`, `MQTTClientArgs`, `MQTTConnectArgs`, `MQTTAuthArgs`, `MQTTTLSArgs`, `MQTTSSLContextArgs`, and `MQTTRequestSpec` validate_keys calls. |
| **gRPC client, request, and response wired to gRPC spec models** <br> `tavern/_plugins/grpc/client.py`, `tavern/_plugins/grpc/request.py`, `tavern/_plugins/grpc/response.py` | Removes `check_expected_keys` and `expected_blocks` from all three gRPC plugin files; replaces with `GRPCClientTopLevel`, `GRPCConnectArgs`, `GRPCProtoArgs`, `GRPCRequestSpec`, and `GRPCResponseSpec` validate_keys calls. |
| **Unit tests** <br> `tests/unit/test_pydantic_models.py` | Adds pytest tests covering valid-input acceptance and `UnexpectedKeysError` rejection for all REST, MQTT, and gRPC spec models. |

## Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

</details>

<!-- walkthrough_end -->
<!-- pre_merge_checks_walkthrough_start -->

<details>
<summary>🚥 Pre-merge checks | ✅ 4 | ❌ 1</summary>

### ❌ Failed checks (1 warning)

|     Check name     | Status     | Explanation                                                                          | Resolution                                                                         |
| :----------------: | :--------- | :----------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- |
| Docstring Coverage | ⚠️ Warning | Docstring coverage is 5.88% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. |

<details>
<summary>✅ Passed checks (4 passed)</summary>

|         Check name         | Status   | Explanation                                                                                                                                 |
| :------------------------: | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
|      Description Check     | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled.                                                                                 |
|         Title check        | ✅ Passed | The title clearly states the main change: replacing check_expected_keys with Pydantic models.                                               |
|     Linked Issues check    | ✅ Passed | The PR implements the requested switch from check_expected_keys to Pydantic-based key validation while preserving unexpected-key rejection. |
| Out of Scope Changes check | ✅ Passed | All changes support the validation refactor, including models, updated callers, and tests; no unrelated code stands out.                    |

</details>

</details>

<!-- pre_merge_checks_walkthrough_end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

<details>
<summary>🧪 Generate unit tests (beta)</summary>

- [ ] <!-- {"checkboxId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} -->   Create PR with unit tests

</details>

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=taverntesting/tavern&utm_content=1065)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>


<sub>Comment `@coderabbitai help` to get the list of available commands.</sub>

<!-- tips_end -->

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/unit/test_pydantic_models.py (2)

31-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the offending key in the failure cases.

A core goal of this change is better unexpected-key diagnostics, but these tests only check the exception type. If the implementation regressed to a generic UnexpectedKeysError, this suite would still pass.

Suggested assertion pattern
     def test_unexpected_key(self):
         data = {"method": "GET", "url": "http://example.com", "bad_key": "value"}
-        with pytest.raises(exceptions.UnexpectedKeysError):
+        with pytest.raises(exceptions.UnexpectedKeysError) as exc_info:
             RestRequestSpec.validate_keys(data)
+        assert "bad_key" in str(exc_info.value)

Also applies to: 47-50, 59-62, 69-72, 79-82, 89-92, 99-102, 109-112, 121-124, 131-134, 141-144, 151-154, 161-164

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_pydantic_models.py` around lines 31 - 34, The unexpected-key
tests only assert the exception type, so they won’t catch regressions in the
actual offending-key diagnostics. Update the affected cases in
test_pydantic_models.py to assert the specific bad key is present in the
UnexpectedKeysError message or payload when calling
RestRequestSpec.validate_keys and the other similar validation paths. Use the
existing test methods (for example, test_unexpected_key and the other listed
cases) to verify the exception includes the exact unexpected field name, not
just that an error was raised.

24-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the happy-path assertions.

Most of these tests only prove that one field survives validation. They would still pass if validate_keys() dropped sibling keys or changed the returned shape, even though callers consume the full validated dict. Comparing against the full expected payload would lock down the round-trip contract much better.

Suggested test shape
     def test_valid_keys(self):
         data = {"method": "GET", "url": "http://example.com", "json": {"a": 1}}
         result = RestRequestSpec.validate_keys(data)
-        assert "method" in result
-        assert "url" in result
-        assert "json" in result
+        assert result == data

Also applies to: 42-45, 54-57, 64-67, 74-77, 84-87, 94-97, 104-107, 116-119, 126-129, 136-139, 146-149, 156-159

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_pydantic_models.py` around lines 24 - 29, The happy-path
tests for RestRequestSpec.validate_keys are too loose because they only assert
individual keys exist, so tighten them to compare the returned dict against the
full expected payload. Update test_valid_keys and the other similar cases in
test_pydantic_models to verify the complete validated structure preserves all
fields and values, not just one surviving key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tavern/_core/pydantic_models.py`:
- Around line 107-114: The MQTT SSL-context args model currently blocks a key
that MQTTClient expects to read for certificate verification. Update
MQTTSSLContextArgs so cert_reqs is accepted alongside the existing SSL fields,
and make sure the validation inherited from _BaseKeyValidator allows that key to
pass through to MQTTClient’s SSL context handling.

---

Nitpick comments:
In `@tests/unit/test_pydantic_models.py`:
- Around line 31-34: The unexpected-key tests only assert the exception type, so
they won’t catch regressions in the actual offending-key diagnostics. Update the
affected cases in test_pydantic_models.py to assert the specific bad key is
present in the UnexpectedKeysError message or payload when calling
RestRequestSpec.validate_keys and the other similar validation paths. Use the
existing test methods (for example, test_unexpected_key and the other listed
cases) to verify the exception includes the exact unexpected field name, not
just that an error was raised.
- Around line 24-29: The happy-path tests for RestRequestSpec.validate_keys are
too loose because they only assert individual keys exist, so tighten them to
compare the returned dict against the full expected payload. Update
test_valid_keys and the other similar cases in test_pydantic_models to verify
the complete validated structure preserves all fields and values, not just one
surviving key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4b6ffa7c-de81-4e50-8e2c-1019e7c776b6

📥 Commits

Reviewing files that changed from the base of the PR and between 202337b and ad05d70.

📒 Files selected for processing (8)
  • tavern/_core/pydantic_models.py
  • tavern/_plugins/grpc/client.py
  • tavern/_plugins/grpc/request.py
  • tavern/_plugins/grpc/response.py
  • tavern/_plugins/mqtt/client.py
  • tavern/_plugins/mqtt/request.py
  • tavern/_plugins/rest/request.py
  • tests/unit/test_pydantic_models.py

Comment on lines +107 to +114
class MQTTSSLContextArgs(_BaseKeyValidator):
ca_certs: Optional[Any] = None
certfile: Optional[Any] = None
keyfile: Optional[Any] = None
password: Optional[Any] = None
tls_version: Optional[Any] = None
ciphers: Optional[Any] = None
alpn_protocols: Optional[Any] = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Allow cert_reqs in SSL-context config.

MQTTClient reads self._ssl_context_args.get("cert_reqs") to set verify_mode and tls_insecure_set, but this model forbids that key, so users cannot reach that path.

🐛 Proposed fix
 class MQTTSSLContextArgs(_BaseKeyValidator):
     ca_certs: Optional[Any] = None
+    cert_reqs: Optional[Any] = None
     certfile: Optional[Any] = None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class MQTTSSLContextArgs(_BaseKeyValidator):
ca_certs: Optional[Any] = None
certfile: Optional[Any] = None
keyfile: Optional[Any] = None
password: Optional[Any] = None
tls_version: Optional[Any] = None
ciphers: Optional[Any] = None
alpn_protocols: Optional[Any] = None
class MQTTSSLContextArgs(_BaseKeyValidator):
ca_certs: Optional[Any] = None
cert_reqs: Optional[Any] = None
certfile: Optional[Any] = None
keyfile: Optional[Any] = None
password: Optional[Any] = None
tls_version: Optional[Any] = None
ciphers: Optional[Any] = None
alpn_protocols: Optional[Any] = None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tavern/_core/pydantic_models.py` around lines 107 - 114, The MQTT SSL-context
args model currently blocks a key that MQTTClient expects to read for
certificate verification. Update MQTTSSLContextArgs so cert_reqs is accepted
alongside the existing SSL fields, and make sure the validation inherited from
_BaseKeyValidator allows that key to pass through to MQTTClient’s SSL context
handling.

grpc/response.py passes _GRPCExpected | Mapping to validate_keys,
which mypy rejects when the parameter is typed as dict. Changed to
Mapping and added dict() conversion before ** unpacking.
pydantic_models.py is imported by core plugin modules (rest, mqtt,
grpc), so pydantic must be a runtime dependency, not just a dev
dependency. Moved from optional-dependencies to dependencies.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace 'check_expected_keys' with pydantic or something

1 participant