Skip to content

Commit 75f5f51

Browse files
authored
Added type validation for websocket messages (#20)
1 parent 6bbf17e commit 75f5f51

9 files changed

Lines changed: 298 additions & 50 deletions

File tree

src/refast/components/shadcn/charts/funnel.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,7 @@ def __init__(
111111
# Auto-inject fill colors by index when data items have no fill
112112
if data and not any("fill" in item for item in data):
113113
self.data = [
114-
{**item, "fill": f"hsl(var(--chart-{(i % 8) + 1}))"}
115-
for i, item in enumerate(data)
114+
{**item, "fill": f"hsl(var(--chart-{(i % 8) + 1}))"} for i, item in enumerate(data)
116115
]
117116
else:
118117
self.data = list(data)

src/refast/components/shadcn/charts/pie.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,7 @@ def __init__(
131131
# Auto-inject fill colors by index when data items have no fill
132132
if data and not any("fill" in item for item in data):
133133
self.data = [
134-
{**item, "fill": f"hsl(var(--chart-{(i % 8) + 1}))"}
135-
for i, item in enumerate(data)
134+
{**item, "fill": f"hsl(var(--chart-{(i % 8) + 1}))"} for i, item in enumerate(data)
136135
]
137136
else:
138137
self.data = list(data)

src/refast/components/shadcn/charts/radial.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,7 @@ def __init__(
5050
]
5151
# Remap name_key → "name" so Recharts legend/tooltip can read entry.name
5252
if name_key and name_key != "name":
53-
processed = [
54-
{**item, "name": item[name_key]}
55-
for item in processed
56-
]
53+
processed = [{**item, "name": item[name_key]} for item in processed]
5754
self.data = processed
5855
self.margin = margin or {"top": 0, "right": 0, "left": 0, "bottom": 0}
5956
self.cx = cx

src/refast/components/shadcn/charts/scatter.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,7 @@ def render(self) -> dict[str, Any]:
187187
# erases color/fill from scatter tooltip dimension entries, so the only
188188
# reliable source is the data-point payload itself.
189189
data_with_fill = (
190-
[{**item, "fill": self.fill} for item in self.data]
191-
if self.data is not None
192-
else None
190+
[{**item, "fill": self.fill} for item in self.data] if self.data is not None else None
193191
)
194192
return {
195193
"type": self.component_type,

src/refast/components/shadcn/typography.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,8 @@ def _parse_custom_tags(self) -> str:
303303

304304
# Innermost first parsing loop
305305
# We search for self-closing or container tags matching <[A-Z]
306-
self_closing_rx = re.compile(r'<([A-Z][a-zA-Z0-9_]*)(?:\s+([^>]*?))?\s*\/>')
307-
container_rx = re.compile(r'<([A-Z][a-zA-Z0-9_]*)(?:\s+([^>]*?))?\s*>(.*?)</\1>', re.DOTALL)
306+
self_closing_rx = re.compile(r"<([A-Z][a-zA-Z0-9_]*)(?:\s+([^>]*?))?\s*\/>")
307+
container_rx = re.compile(r"<([A-Z][a-zA-Z0-9_]*)(?:\s+([^>]*?))?\s*>(.*?)</\1>", re.DOTALL)
308308

309309
def parse_attributes(attrs_str: str) -> dict[str, Any]:
310310
if not attrs_str:
@@ -316,7 +316,7 @@ def parse_attributes(attrs_str: str) -> dict[str, Any]:
316316
for match in attr_rx.finditer(attrs_str):
317317
name = match.group(1)
318318
val_group = match.group(0)
319-
if '=' in val_group:
319+
if "=" in val_group:
320320
val = match.group(2) or match.group(3) or match.group(4) or ""
321321
else:
322322
val = True
@@ -338,16 +338,16 @@ def parse_attributes(attrs_str: str) -> dict[str, Any]:
338338
comp_id = f"{tag_name}_{tag_counts[tag_name]}"
339339
self.custom_components[comp_id] = instance
340340
replacement = f"![{tag_name}](/refast-component/{comp_id})"
341-
content = content[:match.start()] + replacement + content[match.end():]
341+
content = content[: match.start()] + replacement + content[match.end() :]
342342
except Exception:
343343
# Validation failed (or other error), leave tag text
344344
# as is (mark temporarily)
345345
failed_marker = f"<__FAILED_SELF_{tag_name} {attrs_str or ''} />"
346-
content = content[:match.start()] + failed_marker + content[match.end():]
346+
content = content[: match.start()] + failed_marker + content[match.end() :]
347347
else:
348348
# Tag name not registered in custom_tags, leave it as is
349349
failed_marker = f"<__FAILED_SELF_{tag_name} {attrs_str or ''} />"
350-
content = content[:match.start()] + failed_marker + content[match.end():]
350+
content = content[: match.start()] + failed_marker + content[match.end() :]
351351
continue
352352

353353
# 2. Try to find the first container tag with no nested un-processed tags.
@@ -358,7 +358,7 @@ def parse_attributes(attrs_str: str) -> dict[str, Any]:
358358
# If inner_text contains '<[A-Z]', there is an inner un-processed tag. Skip for now.
359359
# Note: we exclude '<__FAILED_' markers as they are already processed/failed.
360360
# So we search for '<' followed by an uppercase letter: '<[A-Z]'
361-
if re.search(r'<[A-Z]', inner_text):
361+
if re.search(r"<[A-Z]", inner_text):
362362
continue
363363
container_match = m
364364
break
@@ -376,17 +376,17 @@ def parse_attributes(attrs_str: str) -> dict[str, Any]:
376376
if isinstance(callable_obj, type):
377377
sig = inspect.signature(callable_obj.__init__)
378378
params = list(sig.parameters.keys())
379-
if 'self' in params:
380-
params.remove('self')
379+
if "self" in params:
380+
params.remove("self")
381381
else:
382382
sig = inspect.signature(callable_obj)
383383
params = list(sig.parameters.keys())
384384

385385
param_name = None
386-
if 'children' in params:
387-
param_name = 'children'
388-
elif 'content' in params:
389-
param_name = 'content'
386+
if "children" in params:
387+
param_name = "children"
388+
elif "content" in params:
389+
param_name = "content"
390390

391391
try:
392392
if param_name:
@@ -416,29 +416,29 @@ def parse_attributes(attrs_str: str) -> dict[str, Any]:
416416
self.custom_components[comp_id] = instance
417417
replacement = f"![{tag_name}](/refast-component/{comp_id})"
418418
content = (
419-
content[:container_match.start()]
419+
content[: container_match.start()]
420420
+ replacement
421-
+ content[container_match.end():]
421+
+ content[container_match.end() :]
422422
)
423423
except Exception:
424424
failed_marker = (
425425
f"<__FAILED_CONT_{tag_name} {attrs_str or ''}>"
426426
f"{inner_content}</__FAILED_CONT_{tag_name}>"
427427
)
428428
content = (
429-
content[:container_match.start()]
429+
content[: container_match.start()]
430430
+ failed_marker
431-
+ content[container_match.end():]
431+
+ content[container_match.end() :]
432432
)
433433
else:
434434
failed_marker = (
435435
f"<__FAILED_CONT_{tag_name} {attrs_str or ''}>"
436436
f"{inner_content}</__FAILED_CONT_{tag_name}>"
437437
)
438438
content = (
439-
content[:container_match.start()]
439+
content[: container_match.start()]
440440
+ failed_marker
441-
+ content[container_match.end():]
441+
+ content[container_match.end() :]
442442
)
443443
continue
444444

@@ -455,7 +455,7 @@ def parse_attributes(attrs_str: str) -> dict[str, Any]:
455455

456456
def _traversal_children(self) -> "list[Component]":
457457
children = super()._traversal_children()
458-
if hasattr(self, 'custom_components') and self.custom_components:
458+
if hasattr(self, "custom_components") and self.custom_components:
459459
for comp in self.custom_components.values():
460460
if isinstance(comp, Component):
461461
children.append(comp)

src/refast/models/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Pydantic models for Refast."""
2+
3+
from refast.models.messages import (
4+
CallbackMessage,
5+
ClientMessage,
6+
EventMessage,
7+
NavigateMessage,
8+
StoreInitMessage,
9+
StoreSyncMessage,
10+
client_message_adapter,
11+
)
12+
13+
__all__ = [
14+
"CallbackMessage",
15+
"ClientMessage",
16+
"EventMessage",
17+
"NavigateMessage",
18+
"StoreInitMessage",
19+
"StoreSyncMessage",
20+
"client_message_adapter",
21+
]

src/refast/models/messages.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Pydantic models for incoming WebSocket messages."""
2+
3+
from typing import Annotated, Any, Literal
4+
5+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
6+
from pydantic.alias_generators import to_camel
7+
8+
9+
class BaseMessage(BaseModel):
10+
"""Base model for WebSocket messages with camelCase conversion."""
11+
12+
model_config = ConfigDict(
13+
alias_generator=to_camel,
14+
populate_by_name=True,
15+
)
16+
17+
18+
class CallbackMessage(BaseMessage):
19+
"""Payload for callback invocation."""
20+
21+
type: Literal["callback"]
22+
callback_id: str
23+
data: dict[str, Any] = Field(default_factory=dict)
24+
event_data: Any = Field(default_factory=dict)
25+
26+
27+
class StoreInitMessage(BaseMessage):
28+
"""Payload for store initialization."""
29+
30+
type: Literal["store_init"]
31+
data: dict[str, Any] = Field(default_factory=dict)
32+
path: str = "/"
33+
34+
35+
class NavigateMessage(BaseMessage):
36+
"""Payload for navigation."""
37+
38+
type: Literal["navigate"]
39+
path: str = "/"
40+
41+
42+
class EventMessage(BaseMessage):
43+
"""Payload for custom event."""
44+
45+
type: Literal["event"]
46+
event_type: str
47+
data: dict[str, Any] = Field(default_factory=dict)
48+
49+
50+
class StoreSyncMessage(BaseMessage):
51+
"""Payload for store synchronization."""
52+
53+
type: Literal["store_sync"]
54+
data: dict[str, Any] = Field(default_factory=dict)
55+
56+
57+
ClientMessage = Annotated[
58+
CallbackMessage | StoreInitMessage | NavigateMessage | EventMessage | StoreSyncMessage,
59+
Field(discriminator="type"),
60+
]
61+
62+
client_message_adapter = TypeAdapter(ClientMessage)

0 commit comments

Comments
 (0)