Skip to content

Conversation

@v1gnesh
Copy link
Contributor

@v1gnesh v1gnesh commented Nov 23, 2025

Summary

Adds support for msgspec Struct types in Restate SDK Python handlers, following the same pattern as existing Pydantic support.
Closes #146

Accompanying docs PR: restatedev/docs-restate#98

Changes

Core Implementation

  • python/restate/serde.py - Added MsgspecJsonSerde class and msgspec type detection
  • python/restate/handler.py - Added automatic msgspec type detection and serde selection (priority: msgspec → Pydantic → dataclass → JSON)
  • python/restate/discovery.py - Added JSON schema generation for msgspec types using msgspec.json.schema()
  • pyproject.toml - Added msgspec to serde optional dependencies

Example

Added examples/msgspec_greeter.py demonstrating msgspec.Struct usage, mirroring the existing pydantic_greeter.py example.

Testing

Test Script

A standalone test script
# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "msgspec>=0.19.0",
#     "hypercorn>=0.18.0",
#     "restate-sdk @ file:///home/fdra/_dev/state/resources/sdk-python",
# ]
# ///
"""
Standalone test script for msgspec support in Restate SDK Python

"""

import asyncio

import msgspec
import restate
from restate import Context, Service


# Define msgspec models
class GreetingRequest(msgspec.Struct):
    """Request model using msgspec.Struct"""
    name: str
    title: str = "Mr/Ms"


class GreetingResponse(msgspec.Struct):
    """Response model using msgspec.Struct"""
    message: str
    timestamp: int = 0


class UserProfile(msgspec.Struct):
    """Complex nested model"""
    user_id: int
    username: str
    email: str
    active: bool = True


# Create service
greeter = Service("MsgspecGreeter")


@greeter.handler()
async def greet(ctx: Context, req: GreetingRequest) -> GreetingResponse:
    """Handler that uses msgspec.Struct for input and output"""
    import time
    message = f"Hello {req.title} {req.name}!"
    return GreetingResponse(message=message, timestamp=int(time.time()))


@greeter.handler()
async def simple_greet(ctx: Context, name: str) -> str:
    """Handler with simple string input/output"""
    return f"Hi {name}!"


@greeter.handler()
async def create_profile(ctx: Context, profile: UserProfile) -> UserProfile:
    """Handler that echoes back a complex msgspec.Struct"""
    # Just echo back the profile to test msgspec serialization/deserialization
    return profile


@greeter.handler()
async def get_profile(ctx: Context, user_id: int) -> UserProfile:
    """Handler that creates a profile based on user_id"""
    # Return a profile to test msgspec serialization
    return UserProfile(
        user_id=user_id,
        username=f"user_{user_id}",
        email=f"user{user_id}@example.com"
    )


if __name__ == "__main__":
    import msgspec
    import restate
    import hypercorn
    import hypercorn.asyncio

    app = restate.app([greeter])
    conf = hypercorn.Config()
    conf.bind = ["0.0.0.0:9080"]
    asyncio.run(hypercorn.asyncio.serve(app, conf))

Running Tests

Prerequisites:

# Start Restate server
podman run --name restate_dev --rm \
  -p 8080:8080 -p 9070:9070 -p 5122:5122 \
  --add-host=host.docker.internal:host-gateway \
  docker.restate.dev/restatedev/restate:1.5.3 --log-format=compact

Run the test service:

uv run test_msgspec_standalone.py

Register the service:

curl -X POST http://localhost:9070/deployments \
  -H 'Content-Type: application/json' \
  -d '{"uri": "http://host.docker.internal:9080"}'

Test Cases

# Test 1: Complex msgspec.Struct Input/Output
curl -X POST http://localhost:8080/MsgspecGreeter/greet \
  -H 'Content-Type: application/json' \
  -d '{"name": "Alice", "title": "Dr"}'
# Response: {"message":"Hello Dr Alice!","timestamp":1763887409}

# Test 2: Simple String Input/Output
curl -X POST http://localhost:8080/MsgspecGreeter/simple_greet \
  -H 'Content-Type: application/json' \
  -d '"Bob"'
# Response: "Hi Bob!"

# Test 3: Complex Nested msgspec.Struct Echo
curl -X POST http://localhost:8080/MsgspecGreeter/create_profile \
  -H 'Content-Type: application/json' \
  -d '{"user_id": 123, "username": "charlie", "email": "charlie@example.com", "active": true}'
# Response: {"user_id":123,"username":"charlie","email":"charlie@example.com","active":true}

# Test 4: Integer Input → msgspec.Struct Output
curl -X POST http://localhost:8080/MsgspecGreeter/get_profile \
  -H 'Content-Type: application/json' \
  -d '456'
# Response: {"user_id":456,"username":"user_456","email":"user456@example.com","active":true}

Copy link
Contributor

@igalshilman igalshilman left a comment

Choose a reason for hiding this comment

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

Hey @v1gnesh, thanks for opening the PR and adding support for msgspec. The library indeed looks interesting and useful!

I'd like to propose the following change to this PR:

Lets gather the msgspec API in a base class that we can conditionally import based on the availability of msgspec, otherwise default to a class that basically raises an error at runtime that says you need to add msgspec optional dependency. This is how we manage optional dependencies so far.

For example something like this:

class MsgspecAPI:
   
   def is_struct(annotation: Any) -> bool:
        return False     
    
   def json_encode(value: Any) -> Any:
              ...

   def json_decode(buf: bytes, struct: Any) -> Any
        ..
  
   def json_schema_from(annotation: Any):
      ...

And then,

def get_msgspec_impl() -> MsgspecAPI:

    try:
            import Struct
            import msgspec

            class MsgspecImpl(MsgspecAPI):
                          """implementation that uses msgspec """"
    
             ....
             return MsgspecImpl()
    
        expect ImportError:
            return MsgspecApi()


MsgSpec = try_import_msgspec()

"""
if not buf:
return None
import msgspec.json # type: ignore # pylint: disable=import-outside-toplevel
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's avoid these inline imports.

if not type_hint.annotation:
return None
if type_hint.is_msgspec:
import msgspec.json # type: ignore # pylint: disable=import-outside-toplevel
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's avoid this inner import. I'd rather to have all the conditional imports captured elsewhere.

@igalshilman
Copy link
Contributor

Probably it will be easier if I'll go trough with these changes on main.
I'd be happy to merge this as-is and followup.
Great job @v1gnesh LGTM.

@igalshilman igalshilman merged commit e572b2b into restatedev:main Nov 24, 2025
6 checks passed
@github-actions github-actions bot locked and limited conversation to collaborators Nov 24, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

msgspec support

2 participants