Skip to content

Actions

Actions in SocketAPI implement a request-response pattern similar to REST API endpoints. When you decorate a function with @app.action(), it becomes an endpoint that clients can call and receive a response.

Basic Action Definition

from socketapi import SocketAPI

app = SocketAPI()

@app.action("greet")
async def greet(name: str):
    return {"message": f"Hello, {name}!"}

This creates an action named "greet" that clients can invoke.

Calling an Action

Clients call an action by sending a message with type: "action":

Send:

{
    "type": "action",
    "channel": "greet",
    "data": {"name": "Alice"}
}

Receive:

{
    "type": "action",
    "channel": "greet",
    "status": "completed",
    "data": {"message": "Hello, Alice!"}
}

Actions Without Parameters

You can define actions that don't require any parameters:

@app.action("ping")
async def ping():
    return {"status": "ok"}

Send:

{"type": "action", "channel": "ping"}

Receive:

{
    "type": "action",
    "channel": "ping",
    "status": "completed",
    "data": {"status": "ok"}
}

Actions Without Return Value

Actions can perform operations without returning data:

@app.action("log_event")
async def log_event(event: str):
    print(f"Event logged: {event}")
    # No return statement

Send:

{
    "type": "action",
    "channel": "log_event",
    "data": {"event": "user_login"}
}

Receive (no data field):

{
    "type": "action",
    "channel": "log_event",
    "status": "completed"
}

Multiple Parameters

Actions support multiple typed parameters:

@app.action("calculate")
async def calculate(a: int, b: int, operation: str):
    if operation == "add":
        result = a + b
    elif operation == "multiply":
        result = a * b
    else:
        result = 0
    return {"result": result}

Send:

{
    "type": "action",
    "channel": "calculate",
    "data": {"a": 5, "b": 3, "operation": "add"}
}

Receive:

{
    "type": "action",
    "channel": "calculate",
    "status": "completed",
    "data": {"result": 8}
}

Data Validation

SocketAPI uses Pydantic for automatic parameter validation. If parameters are missing or have incorrect types, an error is returned:

Missing parameter:

Send:

{
    "type": "action",
    "channel": "calculate",
    "data": {"a": 5, "b": 3}
}

Receive:

{
    "type": "error",
    "message": "Invalid parameters for action 'calculate'"
}

Incorrect type:

Send:

{
    "type": "action",
    "channel": "calculate",
    "data": {"a": "not_a_number", "b": 3, "operation": "add"}
}

Receive:

{
    "type": "error",
    "message": "Invalid parameters for action 'calculate'"
}

Note: Pydantic performs type coercion when possible. For example, "5" (string) can be converted to 5 (int).

Using Pydantic Models

For complex data structures, you can use Pydantic models:

from pydantic import BaseModel

class UserData(BaseModel):
    username: str
    email: str
    age: int

class UserResponse(BaseModel):
    id: int
    username: str
    created: bool

@app.action("create_user")
async def create_user(user: UserData) -> UserResponse:
    # Create user in database
    return UserResponse(id=1, username=user.username, created=True)

Send:

{
    "type": "action",
    "channel": "create_user",
    "data": {
        "user": {
            "username": "alice",
            "email": "alice@example.com",
            "age": 25
        }
    }
}

Receive:

{
    "type": "action",
    "channel": "create_user",
    "status": "completed",
    "data": {
        "id": 1,
        "username": "alice",
        "created": true
    }
}

Nested Pydantic Models

You can use nested models for more complex structures:

from pydantic import BaseModel

class Address(BaseModel):
    street: str
    city: str
    zipcode: str

class UserProfile(BaseModel):
    name: str
    email: str
    address: Address

@app.action("update_profile")
async def update_profile(profile: UserProfile):
    # Update user profile
    return {"success": True, "name": profile.name}

Send:

{
    "type": "action",
    "channel": "update_profile",
    "data": {
        "profile": {
            "name": "Alice",
            "email": "alice@example.com",
            "address": {
                "street": "123 Main St",
                "city": "Springfield",
                "zipcode": "12345"
            }
        }
    }
}

Returning Pydantic Models

When you return a Pydantic model, it's automatically serialized to JSON:

class Product(BaseModel):
    id: int
    name: str
    price: float

@app.action("get_product")
async def get_product(product_id: int) -> Product:
    return Product(id=product_id, name="Widget", price=9.99)

Receive:

{
    "type": "action",
    "channel": "get_product",
    "status": "completed",
    "data": {
        "id": 1,
        "name": "Widget",
        "price": 9.99
    }
}

Combining Actions with Channels

Actions are commonly used to broadcast data to channels:

@app.channel("chat")
async def chat_channel(message: str):
    return {"message": message}

@app.action("send_message")
async def send_message(text: str, user: str):
    # Broadcast to all chat subscribers
    await chat_channel(message=f"{user}: {text}")
    return {"sent": True}

When a client calls the action:

Send:

{
    "type": "action",
    "channel": "send_message",
    "data": {"text": "Hello everyone!", "user": "Alice"}
}

The calling client receives:

{
    "type": "action",
    "channel": "send_message",
    "status": "completed",
    "data": {"sent": true}
}

All subscribers to the "chat" channel receive:

{
    "type": "data",
    "channel": "chat",
    "data": {"message": "Alice: Hello everyone!"}
}

Error Handling

If a client tries to call a non-existent action:

Send:

{"type": "action", "channel": "nonexistent_action"}

Receive:

{
    "type": "error",
    "message": "Action 'nonexistent_action' not found."
}

Complete Example

from pydantic import BaseModel
from socketapi import SocketAPI

app = SocketAPI()

class Message(BaseModel):
    user: str
    text: str

class MessageResponse(BaseModel):
    id: int
    timestamp: int
    sent: bool

@app.channel("chat_room")
async def chat_room(message: Message):
    return message

@app.action("post_message")
async def post_message(message: Message) -> MessageResponse:
    # Save message to database
    import time
    message_id = 1
    timestamp = int(time.time())

    # Broadcast to all chat room subscribers
    await chat_room(message=message)

    # Return confirmation to sender
    return MessageResponse(id=message_id, timestamp=timestamp, sent=True)

@app.action("get_history")
async def get_history(limit: int = 10) -> dict[str, list]:
    # Fetch chat history from database
    return {"messages": []}

Client workflow:

  1. Call action to post a message:

    {
        "type": "action",
        "channel": "post_message",
        "data": {
            "message": {
                "user": "Alice",
                "text": "Hello!"
            }
        }
    }
    

  2. Receive confirmation:

    {
        "type": "action",
        "channel": "post_message",
        "status": "completed",
        "data": {
            "id": 1,
            "timestamp": 1701234567,
            "sent": true
        }
    }
    

  3. All subscribers to "chat_room" receive:

    {
        "type": "data",
        "channel": "chat_room",
        "data": {
            "user": "Alice",
            "text": "Hello!"
        }
    }
    

  4. Get chat history:

    {
        "type": "action",
        "channel": "get_history",
        "data": {"limit": 20}
    }