11# SPDX-License-Identifier: Apache-2.0
2- # Copyright (c) 2025 Maurice Garcia
2+ # Copyright (c) 2025-2026 Maurice Garcia
33
44from __future__ import annotations
55
66import json
77from enum import Enum
88import logging
9- from typing import Any
9+
10+ from pydantic import BaseModel , ConfigDict , Field , field_serializer , field_validator
1011
1112from pypnm .api .routes .common .service .status_codes import ServiceStatusCode
1213from pypnm .config .pnm_config_manager import SystemConfigSettings
@@ -23,103 +24,132 @@ class MessageResponseType(Enum):
2324 PNM_FILE_SESSION = 2
2425 SNMP_DATA_RTN_SPEC_ANALYSIS = 10
2526
26- class MessageResponse :
27+ class MessagePayload ( BaseModel ) :
2728 """
28- Represents a structured response with a status and optional data payload.
29-
30- Attributes:
31- status (ServiceStatusCode): Status of the message.
32- payload (Optional[Any]): Associated payload (list, dict, etc.).
33-
34- Example:
35-
36- {
37- "status":"SUCCESS",
38- "payload":[
39- {
40- "status":"SUCCESS",
41- "message_type":"PNM_FILE_TRANSACTION",
42- "message":{
43- "transaction_id":"275de83146e904d7",
44- "filename":"ds_ofdm_rxmer_per_subcar_00:50:f1:12:e2:63_954000000_1746501260.bin"
45- }
46- }
47- ]
48- }
49-
29+ Typed payload entry for MessageResponse.
5030 """
31+ model_config = ConfigDict (extra = "allow" )
5132
52- def __init__ ( self , status : ServiceStatusCode , payload : Any | None = None ) -> None :
53- """
54- Initializes a MessageResponse instance.
33+ status : str = Field (..., description = "Status for this payload entry." )
34+ message_type : str | None = Field ( None , description = "Message type identifier." )
35+ message : object | None = Field ( None , description = "Message-specific content." )
5536
56- Args:
57- status (ServiceStatusCode): Status of the message.
58- payload (Optional[Any]): Optional message payload.
37+ def as_dict (self ) -> dict [str , object ]:
5938 """
60- self .status :ServiceStatusCode = status
61- self .payload :Any | None = payload
39+ Return this payload as a dictionary, preserving extra fields.
40+ """
41+ return self .model_dump ()
42+
6243
63- def get (self ) -> dict [str , Any ]:
44+ class MessageResponse (BaseModel ):
45+ """
46+ Represents a structured response with a status and optional data payload.
47+ """
48+ model_config = ConfigDict (arbitrary_types_allowed = True , validate_assignment = True )
49+
50+ status : ServiceStatusCode = Field (..., description = "Status of the message." )
51+ payload : list [MessagePayload ] | None = Field (None , description = "Associated payload entries." )
52+
53+ def __init__ (self , status : ServiceStatusCode , payload : list [MessagePayload ] | list [dict [str , object ]] | None = None ) -> None :
54+ super ().__init__ (status = status , payload = payload )
55+
56+ @field_validator ("payload" , mode = "before" )
57+ @classmethod
58+ def _coerce_payload (cls , value : object ) -> list [MessagePayload ] | None :
59+ if value is None :
60+ return None
61+ if isinstance (value , list ):
62+ items : list [MessagePayload ] = []
63+ for entry in value :
64+ if isinstance (entry , MessagePayload ):
65+ items .append (entry )
66+ continue
67+ if isinstance (entry , dict ):
68+ items .append (MessagePayload (** entry ))
69+ continue
70+ items .append (MessagePayload (status = "UNKNOWN" , message = entry ))
71+ return items
72+ raise ValueError ("payload must be a list or None" )
73+
74+ @field_serializer ("status" )
75+ def _serialize_status (self , status : ServiceStatusCode ) -> str :
76+ return status .name
77+
78+ def get (self ) -> dict [str , object ]:
6479 """
6580 Serializes the message response to a dictionary.
6681
6782 Returns:
68- Dict[str, Any ]: Dictionary with 'status' and 'data '.
83+ Dict[str, object ]: Dictionary with 'status' and 'payload '.
6984 """
7085 return {
7186 "status" : self .status .name ,
72- "payload" : self .payload
87+ "payload" : self ._payload_as_dict_list (),
7388 }
7489
7590 def __repr__ (self ) -> str :
76- return json .dumps ({
77- "status" : self .status .name ,
78- "payload" : self .payload
79- })
91+ return json .dumps (self .get ())
8092
8193 def __str__ (self ) -> str :
8294 return self .__repr__ ()
8395
84- def get_payload_msg (payload_element : dict [str , Any ]) -> tuple [str , str , Any ]:
96+ def get_payload_msg (payload_element : MessagePayload | dict [str , object ]) -> tuple [str , str , object | None ]:
8597 """
8698 Extracts 'status', 'message_type', and 'message' from a payload element.
8799
88100 Args:
89- payload_element (Dict[str, Any ]): The payload dictionary .
101+ payload_element (MessagePayload | Dict[str, object ]): The payload element .
90102
91103 Returns:
92- Tuple[str, str, Any ]: A tuple containing the status, message type, and message content.
104+ Tuple[str, str, object | None ]: A tuple containing the status, message type, and message content.
93105 """
94- status = payload_element .get ("status" , "UNKNOWN" )
95- message_type = payload_element .get ("message_type" , "UNKNOWN" )
96- message = payload_element .get ("message" , None )
106+ if isinstance (payload_element , MessagePayload ):
107+ payload_dict = payload_element .as_dict ()
108+ else :
109+ payload_dict = payload_element
110+ status = str (payload_dict .get ("status" , "UNKNOWN" ))
111+ message_type = str (payload_dict .get ("message_type" , "UNKNOWN" ))
112+ message = payload_dict .get ("message" , None )
97113 return status , message_type , message
98114
99- def payload_to_dict (self , key : int | str = "data" ) -> dict [int | str , Any ]:
115+ def payload_to_dict (self , key : int | str = "data" ) -> dict [int | str , object ]:
100116 """
101117 Wraps the internal payload in a dictionary under the specified key.
102118
103119 Args:
104120 key (int | str): The key under which the payload will be stored. Defaults to "data".
105121
106122 Returns:
107- Dict[Any, Any ]: A dictionary containing the payload under the given key.
123+ Dict[int | str, object ]: A dictionary containing the payload under the given key.
108124 """
109- return {key : self .payload }
125+ return {key : self ._payload_as_dict_list () }
110126
111- def log_payload (self , filename_prefix :str = "" ) -> None :
127+ def log_payload (self , filename_prefix : str = "" ) -> None :
112128 """
113129 Logs the payload content for debugging purposes.
114130 """
115- prefix :str = ""
131+ prefix : str = ""
116132 if filename_prefix :
117133 prefix = f'{ filename_prefix } _'
118134
119135 LogFile .write (f'{ prefix } payload_{ Generate .time_stamp (TimeUnit .MILLISECONDS )} .msgrsp' ,
120136 self .payload_to_dict (),
121137 log_dir = SystemConfigSettings .message_response_dir ())
122138
139+ def _payload_as_dict_list (self ) -> list [dict [str , object ]] | None :
140+ if self .payload is None :
141+ return None
142+ payload_list : list [dict [str , object ]] = []
143+ for entry in self .payload :
144+ if isinstance (entry , MessagePayload ):
145+ payload_list .append (entry .as_dict ())
146+ continue
147+ if isinstance (entry , dict ):
148+ payload_list .append (dict (entry ))
149+ continue
150+ payload_list .append ({"status" : "UNKNOWN" , "message" : entry })
151+ return payload_list
152+
123153
124154class CommonMessagingService :
125155 """
@@ -129,7 +159,7 @@ class CommonMessagingService:
129159 batch operations, chained service calls, and aggregating results for client APIs.
130160
131161 Attributes:
132- _messages (List[Tuple[ServiceStatusCode, Dict[str, Any ]]]): Queue of messages.
162+ _messages (List[Tuple[ServiceStatusCode, Dict[str, object ]]]): Queue of messages.
133163 _last_non_success_status (ServiceStatusCode): Most recent non-success status seen.
134164 """
135165
@@ -138,16 +168,16 @@ def __init__(self) -> None:
138168 Initializes an empty messaging service instance.
139169 """
140170 self .logger = logging .getLogger (self .__class__ .__name__ )
141- self ._messages : list [tuple [ServiceStatusCode , dict [str , Any ]]] = []
171+ self ._messages : list [tuple [ServiceStatusCode , dict [str , object ]]] = []
142172 self ._last_non_success_status = ServiceStatusCode .SUCCESS
143173
144- def build_msg (self , status : ServiceStatusCode , payload : dict [str , Any ] | None = None ) -> None :
174+ def build_msg (self , status : ServiceStatusCode , payload : dict [str , object ] | None = None ) -> None :
145175 """
146176 Queues a new message with status and optional data.
147177
148178 Args:
149179 status (ServiceStatusCode): Message status.
150- payload (Optional[Dict[str, Any ]]): Associated data for the message.
180+ payload (Optional[Dict[str, object ]]): Associated data for the message.
151181
152182 Returns:
153183 bool: Always returns True after storing the message.
@@ -175,17 +205,18 @@ def send_msg(self) -> MessageResponse:
175205 )
176206
177207 combined_data = [
178- {
179- "status" : status .name ,
180- ** data
181- } for status , data in self ._messages
208+ MessagePayload (
209+ status = status .name ,
210+ ** data ,
211+ )
212+ for status , data in self ._messages
182213 ]
183214
184215 self ._messages .clear ()
185216
186217 return MessageResponse (final_status , combined_data )
187218
188- def build_send_msg (self , status : ServiceStatusCode , data : dict [str , Any ] | None = None ) -> MessageResponse :
219+ def build_send_msg (self , status : ServiceStatusCode , data : dict [str , object ] | None = None ) -> MessageResponse :
189220 """
190221 Builds and immediately sends a single message.
191222
@@ -220,9 +251,9 @@ def build_transaction_msg(self, transaction_id: TransactionId, filename: FileNam
220251 }
221252 })
222253
223- def build_transaction_msg_extension (self , transaction_id : TransactionId ,
254+ def build_transaction_msg_extension (self , transaction_id : TransactionId ,
224255 filename : FileNameStr ,
225- extension : dict [Any , Any ],
256+ extension : dict [str , object ],
226257 status : ServiceStatusCode = ServiceStatusCode .SUCCESS ) -> None :
227258 """
228259 Adds a transaction message with an ID and filename to the message queue.
@@ -268,15 +299,15 @@ def build_session_msg( self,session_id: str,transaction_ids: list[TransactionId]
268299 },
269300 )
270301
271- def get_first_of_type (self , msg_type : MessageResponseType ) -> dict [str , Any ] | None :
302+ def get_first_of_type (self , msg_type : MessageResponseType ) -> dict [str , object ] | None :
272303 """
273304 Retrieves the first message of a specified type, if available.
274305
275306 Args:
276307 msg_type (MessageResponseType): The type to look for.
277308
278309 Returns:
279- Optional[Dict[str, Any ]]: The first message of the given type, or None.
310+ Optional[Dict[str, object ]]: The first message of the given type, or None.
280311 """
281312 for _ , data in self ._messages :
282313 if data .get ("message_type" ) == msg_type .name :
0 commit comments