Skip to content

feat: support msgspec as another model adapter#479

Merged
kemingy merged 9 commits into
0b01001001:mainfrom
kemingy:adapter_msgspec
May 11, 2026
Merged

feat: support msgspec as another model adapter#479
kemingy merged 9 commits into
0b01001001:mainfrom
kemingy:adapter_msgspec

Conversation

@kemingy
Copy link
Copy Markdown
Member

@kemingy kemingy commented Apr 24, 2026

kemingy added 5 commits April 25, 2026 01:25
Signed-off-by: Keming <kemingy94@gmail.com>
Signed-off-by: Keming <kemingy94@gmail.com>
Signed-off-by: Keming <kemingy94@gmail.com>
Signed-off-by: Keming <kemingy94@gmail.com>
Signed-off-by: Keming <kemingy94@gmail.com>
@alexted
Copy link
Copy Markdown

alexted commented May 1, 2026

Testing Report

Environment: Python 3.14.3, msgspec 0.21.1, Flask 3.1.3, Quart 0.20.0, spectree adapter_msgspec branch
Total tests: 574 passing (84 Flask comprehensive, 52 Quart comprehensive, 47 gap-coverage, 131 type-coverage, 19 upstream test_msgspec.py, remainder existing spectree tests)


✅ What Works Correctly

Request validation

  • All primitive types (int, float, str, bool, bytes) - validated correctly
  • Optional[T] / Union[T1, T2, ...] - accept any member type and null
  • Literal["a", "b"] and Literal[1, 2, 3] - out-of-range values return 422
  • str and int Enum - only declared values accepted
  • Collections: list[T], set[T], frozenset[T], dict[str, T], fixed-length tuple[A, B, C]
  • Nested Struct chains - tested 3 levels deep, all correct
  • list[Struct], dict[str, Struct] - nested errors reported with correct loc
  • datetime / date / time, UUID, Decimal - parsed from strings correctly
  • msgspec.Meta(ge, le, gt, lt, min_length, max_length, pattern) - constraints enforced at validation time
  • forbid_unknown_fields=True - extra fields rejected
  • rename="camel" / "pascal" - renaming applied to incoming JSON keys
  • default_factory - applied when field is absent
  • annotations=True mode - types read from function signature
  • Blueprint support - works when api.register(bp) precedes app.register_blueprint(bp)
  • before / after hooks - called correctly
  • Query param strict=False coercion - string "3"int 3

OpenAPI schema generation

  • All primitive types map correctly to OpenAPI types
  • Optional[T]anyOf: [T, null] with default: null
  • Union[T1, T2]anyOf: [T1, T2]
  • Literal[...]enum: [...]
  • str / int Enum → separate schemas with enum values
  • list[T]array + items; set+ uniqueItems: true
  • dict[str, T]additionalProperties
  • tuple[A, B, C]prefixItems with minItems / maxItems
  • Nested Struct → $ref chains, all levels appear in components/schemas
  • Generic structs Page[T] → specialized Page_T_ schemas
  • msgspec.Meta(description, examples, ge/le, gt/lt, min_length/max_length, pattern) → all attributes appear in OpenAPI schema
  • rename="camel" → properties renamed in schema (firstName not first_name)
  • bytes{ type: string, contentEncoding: base64 }
  • UUID{ type: string, format: uuid }
  • date{ type: string, format: date }
  • Decimal{ type: string, format: decimal }

❌ Bugs and Limitations Found

Bug #1 - Query parameters appear as name: null in OpenAPI spec

Location: parse_params() in spectree/utils.py
Severity: high

When a model is passed to query=, headers=, or cookies= in @api.validate(), the individual fields are not expanded into separate OpenAPI parameters. Instead, a single parameter entry with name: null is generated.

Root cause: parse_params() calls:

properties = model.get("properties", {model.get("title"): model})

The msgspec json_schema() always returns {"$ref": "...", "$defs": {...}} - no properties at the top level. The fallback produces {None: {"$ref": ...}}, so None becomes the parameter name.

Fix: parse_params() must dereference the $ref into $defs before extracting properties:

# pseudocode
if "$ref" in model and "$defs" in model:
    ref_name = model["$ref"].rsplit("/", 1)[-1]
    model = model["$defs"][ref_name]
properties = model.get("properties", {})

Bug #2 - Wrong Content-Type for a JSON endpoint causes an unhandled crash

Location: spectree/plugins/flask_plugin.py (and quart_plugin.py)
Severity: medium

When a JSON endpoint receives application/x-www-form-urlencoded or multipart/form-data, spectree sets use_json=Falsecontext.json = None. A handler annotated with json: MyModel receives None and raises AttributeError on the first attribute access.

Expected behavior: return 422 with a clear error message such as "Expected JSON body with Content-Type: application/json".
Actual behavior: AttributeError propagates - TESTING=True re-raises as an exception, TESTING=False produces HTTP 500.

Minimal reproduction:

@app.post("/items")
@api.validate(json=MyModel)
def handler(json: MyModel):
    return {"name": json.name}  # crashes: json is None

client.post(
    "/items", 
    data={"name": "x"},
    content_type="application/x-www-form-urlencoded"
)  # → AttributeError: 'NoneType' object has no attribute 'name'

Bug #3 - BaseFile dec_hook broken in msgspec 0.21.1

Location: spectree/model_adapter/msgspec_adapter.py - _dec_hook
Severity: medium

_dec_hook(BaseFile, obj) is called and returns the file object, but msgspec then performs an isinstance(obj, BaseFile) check after the hook returns. Since BytesIO is not a subclass of BaseFile, this raises:

ValidationError: Expected `BaseFile`, got `_io.BytesIO`

Workaround: annotate file fields as Any - msgspec skips the type check entirely:

class UploadForm(msgspec.Struct):
    file: Any  # instead of BaseFile

Alternatively, register file-like types as virtual subclasses of BaseFile via ABC.register().


Bug #4 - Response validation is completely bypassed

Location: spectree/model_adapter/msgspec_adapter.py - is_model_instance()
Severity: critical

MsgspecModelAdapter.is_model_instance() unconditionally returns True for any value - dicts, ints, strings, everything:

def is_model_instance(self, value: Any, model) -> bool:
    """All kinds of types are treated the same."""
    return True

In validate_response() (from spectree/plugins/base.py) this triggers skip_validation = True, and dump_json(raw_value) is called directly on whatever the handler returned, with zero validation against the declared response schema.

Consequences - all of the following pass silently with HTTP 200:

# Handler declared as resp=Response(HTTP_200=MyModel(id: int, name: str))

def handler():
    return {"id": "not-an-int", "name": 999}    # wrong types → 200
    return {"id": 1}                            # missing 'name' → 200
    return {"id": 1, "name": "x", "extra": "!"} # extra field → 200, extra included

Comparison with pydantic adapter: checks isinstance(value, BaseModel); plain dicts go through the full validation path. The msgspec adapter never reaches that branch.

Fix: one-line change in is_model_instance():

def is_model_instance(self, value: Any, model) -> bool:
    return isinstance(value, msgspec.Struct)

Bug #5 - Sub-model schemas are duplicated in components/schemas

Location: spectree schema assembly logic
Severity: low (cosmetic)

When the same sub-Struct (e.g. Address) is referenced from multiple endpoints, it appears separately in components/schemas under a different hash-namespaced key for each endpoint:

UserCreate.a1b2c3d.Address    ← copy for POST /users
OrderCreate.a1b2c3d.Address   ← copy for POST /orders

$ref resolution is still correct within each endpoint's namespace - there is no functional breakage. But with N endpoints sharing M sub-models, the spec grows to N×M entries instead of M.

Root cause: each json_schema() call returns $defs that include all transitive dependencies. Spectree imports all of them under the top-level model's hash key without global deduplication by class identity.


Behavioral Differences vs Pydantic (not bugs, but noteworthy)

Behavior msgspec adapter pydantic adapter
bool for int field (strict=False) Rejected (422) - bool and int are distinct Accepted (True → 1)
Array index in error loc String "1" Integer 1
Union[StructA, StructB] without tagging TypeError at decoration time (app won't start) Works via try/match semantics
Literal["a", 1, True] (mixed types) TypeError at schema gen time Supported
Shared sub-model across endpoints Duplicated in components/schemas Deduplicated
Response validation with dict return Bypassed (Bug #4) Enforced

Notes on Union[StructA, StructB]

msgspec requires all Struct types in a union to be tagged. Without tagging, msgspec.json.schema() raises TypeError at @api.validate() decoration time, meaning the application fails to start. Solution:

class TextBlock(msgspec.Struct, tag=True):
    content: str

class ImageBlock(msgspec.Struct, tag=True):
    url: str

class Response(msgspec.Struct):
    blocks: list[Union[TextBlock, ImageBlock]]  # now valid

Notes on Literal type constraints

Literal["a", 1, True] (mixing str, int, bool in one Literal) is not supported by msgspec.json.schema(). Only homogeneous Literal of None, int, or str values is allowed.


Test Files Produced

File Tests Description
tests/test_flask_msgspec_comprehensive.py 84 Full Flask integration: all validation modes, blueprints, hooks, spec
tests/test_quart_msgspec_comprehensive.py 52 Full Quart async integration
tests/test_msgspec_supplemental.py 47 Gap coverage vs independent review findings
tests/test_msgspec_type_coverage.py 131 Exhaustive type coverage: all types, schema accuracy, bugs documented

Overall Assessment

The integration is fully functional for the primary use case: validating incoming requests and generating an OpenAPI specification for a wide range of types and schemas.

Critical: Bug #4 (response validation bypassed) - breaks the contract of resp=Response(...). One-line fix.
High: Bug #1 (query/header/cookie params → name: null) - makes parameters invisible in generated docs. Requires updating parse_params() in utils.py.
Medium: Bug #2 (wrong Content-Type → crash instead of 422) and Bug #3 (BaseFile dec_hook incompatible with msgspec 0.21.1 post-hook isinstance check).
Low: Bug #5 (schema duplication) - cosmetic, no functional impact.

Tests that I used:
test_flask_spectree_msgspec.py
test_msgspec_supplemental.py
test_msgspec_type_coverage.py
test_quart_spectree_msgspec.py

@kemingy
Copy link
Copy Markdown
Member Author

kemingy commented May 8, 2026

Hi @alexted thanks so much. Recently I'm quite busy. I will fix those bugs as soon as I get some free time.

@alexted
Copy link
Copy Markdown

alexted commented May 8, 2026

@kemingy Thanks for the update. No pressure - I understand you are busy. The adapter looks promising, and the bugs I found seem fixable. I am happy to help verify the changes when you get back to it.

Signed-off-by: Keming <kemingy94@gmail.com>
@kemingy
Copy link
Copy Markdown
Member Author

kemingy commented May 10, 2026

  • Bug 1 & 5 are fixed by additional check on msgspec json schema generation
  • Bug 2 is a known issue, for now it requires form & json to be annotated, I have added a test for this
  • Bug 3 BaseFile now works
  • Bug 4 is fixed by restrict it as the instance of msgspec.Struct

@alexted Thanks for your help

@alexted
Copy link
Copy Markdown

alexted commented May 10, 2026

@kemingy Thank you very much, you are a golden person!

Signed-off-by: Keming <kemingy94@gmail.com>
@kemingy kemingy marked this pull request as ready for review May 11, 2026 05:15
Copilot AI review requested due to automatic review settings May 11, 2026 05:15
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a model-adapter abstraction that enables using msgspec in addition to pydantic for request/response validation and OpenAPI schema generation (addressing issue #329). It also refactors internal configuration/OpenAPI helper models to be adapter-backed dataclasses and makes pydantic/msgspec installable via extras.

Changes:

  • Add a MsgspecModelAdapter and expose get_msgspec_model_adapter() alongside get_pydantic_model_adapter().
  • Refactor internal models/config serialization/validation to be adapter-backed (dataclasses) and routed through the configured adapter.
  • Update packaging/CI/tests to make pydantic optional and to conditionally run msgspec-dependent tests.

Reviewed changes

Copilot reviewed 42 out of 43 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
uv.lock Adds msgspec and moves pydantic to an optional extra in the lock metadata.
pyproject.toml Makes dependencies empty and introduces pydantic/msgspec optional-dependency extras; adds pytest marker.
README.md Updates install instructions to use extras and documents selecting an adapter.
Makefile Adjusts import/test targets to install pydantic extra explicitly; adds msgspec-aware test target.
.github/workflows/pythonpackage.yml Splits CI to run msgspec tests on CPython and “without msgspec” on PyPy.
spectree/model_adapter/protocol.py Expands adapter protocol (adds basefile, updates is_model_instance signature).
spectree/model_adapter/init.py Adds get_msgspec_model_adapter() and renames/removes default-adapter accessor.
spectree/model_adapter/pydantic_adapter.py Enhances pydantic adapter to support more model shapes and schema generation changes.
spectree/model_adapter/msgspec_adapter.py Introduces msgspec adapter implementation and schema/error shaping.
spectree/_types.py Introduces ModelAdapterType alias and updates hook/JSON types to builtin generics.
spectree/utils.py Updates typing and hook signatures to use ModelAdapterType; tweaks validation logging.
spectree/spec.py Threads the adapter through config validation; updates schema lifting and Tag/server/security dumping.
spectree/response.py Refactors typing to builtin generics and updates adapter binding/types.
spectree/plugins/base.py Updates response-validation flow to use adapter-provided is_model_instance.
spectree/plugins/falcon_plugin.py Minor typing modernizations.
spectree/plugins/werkzeug_utils.py Minor typing modernizations for response unpacking.
spectree/models.py Refactors OpenAPI helper models to adapter-backed dataclasses; changes validation rules/messages.
spectree/config.py Refactors configuration to adapter-backed dataclasses and updates serialization helpers.
spectree/errors.py Adds new internal exception types for validation/field errors.
spectree/dataclass_model.py Adds adapter-backed dataclass normalization/validation/serialization utilities.
spectree/dataclass_validator.py Removes the previous custom dataclass validator implementation.
spectree/init.py Exposes adapter getters from package root and removes exported BaseFile.
docs/source/adapter.rst Documents the msgspec adapter module in Sphinx docs.
examples/common.py Updates BaseFile import path for pydantic adapter.
examples/falcon_msgspec_demo.py Adds a new example demonstrating msgspec models with Falcon.
tests/conftest.py Skips msgspec tests when msgspec isn’t installed.
tests/common.py Updates tests to use the pydantic adapter getter and new BaseFile import.
tests/test_utils.py Updates adapter getter usage and validation-error model references.
tests/test_spec.py Updates validation-error model references; adds tests for naming strategies in refs/components.
tests/test_response.py Updates adapter getter usage and validation-error model used in response spec tests.
tests/test_pydantic.py Updates adapter usage and adapts to protocol changes.
tests/test_base_plugin.py Updates adapter getter usage and response validation invocation.
tests/test_plugin_flask.py Minor decorator formatting simplification.
tests/test_config.py Updates to adapter-backed configuration validation and new exception types.
tests/test_msgspec.py Adds msgspec adapter unit tests and internal config validation tests.
tests/test_plugin_with_msgspec.py Adds Flask plugin integration tests using msgspec models (incl. file upload).
tests/test_plugin_falcon_msgspec.py Adds Falcon plugin integration tests using msgspec models.
tests/import_module/test_msgspec_plugin.py Adds import smoke-test for msgspec adapter/plugin wiring.
tests/snapshots/test_plugin/test_plugin_spec[starlette][full_spec].json Updates snapshots for schema/name changes due to adapter refactor.
tests/snapshots/test_plugin/test_plugin_spec[flask][full_spec].json Updates snapshots for schema/name changes due to adapter refactor.
tests/snapshots/test_plugin/test_plugin_spec[flask_view][full_spec].json Updates snapshots for schema/name changes due to adapter refactor.
tests/snapshots/test_plugin/test_plugin_spec[flask_blueprint][full_spec].json Updates snapshots for schema/name changes due to adapter refactor.
tests/snapshots/test_plugin/test_plugin_spec[falcon][full_spec].json Updates snapshots for schema/name changes due to adapter refactor.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread spectree/model_adapter/msgspec_adapter.py
Comment thread spectree/model_adapter/msgspec_adapter.py Outdated
Comment thread spectree/model_adapter/msgspec_adapter.py
Comment thread spectree/models.py Outdated
Comment thread spectree/models.py
Comment thread spectree/dataclass_model.py Outdated
Comment thread tests/test_config.py
Comment thread spectree/spec.py Outdated
Comment thread spectree/dataclass_model.py
kemingy added 2 commits May 11, 2026 22:30
- fix the pydantic json_schema_extra
- fix is_model_instance, check if value is instance of model, and model
  is subclass of the model adapter
- fix get_model_key when it's annotated or a list

Signed-off-by: Keming <kemingy94@gmail.com>
Signed-off-by: Keming <kemingy94@gmail.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 45 out of 46 changed files in this pull request and generated 1 comment.

Comment thread spectree/model_adapter/pydantic_adapter.py
@kemingy kemingy added this pull request to the merge queue May 11, 2026
Merged via the queue into 0b01001001:main with commit 9b5574b May 11, 2026
14 checks passed
@kemingy kemingy deleted the adapter_msgspec branch May 11, 2026 14:48
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.

3 participants