@@ -274,56 +274,186 @@ def validate(
274274 root_id : Optional [str ] = None ,
275275 strict_integrity : bool = True ,
276276 ) -> None :
277- """Validates an A2UI messages against the schema.
278-
279- Args:
280- a2ui_json: The A2UI message(s) to validate.
281- root_id: Optional root component ID.
282- strict_integrity: If True, performs full topology and integrity checks.
283- If False, only performs schema validation and basic syntax checks.
284- """
277+ """Validates an A2UI messages against the schema."""
285278 messages = a2ui_json if isinstance (a2ui_json , list ) else [a2ui_json ]
286279
287- # Basic schema validation
288- errors = list (self ._validator .iter_errors (messages ))
289- if errors :
290- error = errors [0 ]
291- msg = f"Validation failed: { error .message } "
292- if error .context :
293- msg += "\n Context failures:"
294- for sub_error in error .context :
295- msg += f"\n - { sub_error .message } "
280+ if self .version == VERSION_0_9 :
281+ self ._validate_0_9_custom (messages , root_id , strict_integrity )
282+ else :
283+ # Fallback to old behavior for v0.8
284+ errors = list (self ._validator .iter_errors (messages ))
285+ if errors :
286+ error = errors [0 ]
287+ msg = f"Validation failed: { error .message } "
288+ if error .context :
289+ msg += "\n Context failures:"
290+ for sub_error in error .context :
291+ msg += f"\n - { sub_error .message } "
292+ raise ValueError (msg )
293+
294+ for message in messages :
295+ if not isinstance (message , dict ):
296+ continue
297+
298+ components = None
299+ surface_id = None
300+ if "surfaceUpdate" in message : # v0.8
301+ components = message ["surfaceUpdate" ].get (COMPONENTS )
302+ surface_id = message ["surfaceUpdate" ].get ("surfaceId" )
303+
304+ if components :
305+ ref_map = extract_component_ref_fields (self ._catalog )
306+ root_id = _find_root_id (messages , surface_id )
307+ _validate_component_integrity (
308+ root_id , components , ref_map , skip_root_check = not strict_integrity
309+ )
310+ analyze_topology (
311+ root_id , components , ref_map , raise_on_orphans = strict_integrity
312+ )
313+
314+ _validate_recursion_and_paths (message )
315+
316+ def _validate_0_9_custom (
317+ self ,
318+ messages : List [Dict [str , Any ]],
319+ root_id : Optional [str ] = None ,
320+ strict_integrity : bool = True ,
321+ ) -> None :
322+ all_errors = []
323+ for idx , message in enumerate (messages ):
324+ if not isinstance (message , dict ):
325+ all_errors .append (f"messages[{ idx } ]: Is not an object" )
326+ continue
327+
328+ if "createSurface" in message :
329+ val = self ._get_sub_validator ("CreateSurfaceMessage" )
330+ all_errors .extend (self ._get_formatted_errors (val , message , f"messages[{ idx } ]" ))
331+ elif "updateComponents" in message :
332+ all_errors .extend (
333+ self ._get_update_components_errors (message , f"messages[{ idx } ]" )
334+ )
335+ elif "updateDataModel" in message :
336+ val = self ._get_sub_validator ("UpdateDataModelMessage" )
337+ all_errors .extend (self ._get_formatted_errors (val , message , f"messages[{ idx } ]" ))
338+ elif "deleteSurface" in message :
339+ val = self ._get_sub_validator ("DeleteSurfaceMessage" )
340+ all_errors .extend (self ._get_formatted_errors (val , message , f"messages[{ idx } ]" ))
341+ else :
342+ keys = list (message .keys ())
343+ all_errors .append (f"messages[{ idx } ]: Unknown message type with keys { keys } " )
344+
345+ if all_errors :
346+ msg = "Validation failed:\n " + "\n " .join (f" - { err } " for err in all_errors )
296347 raise ValueError (msg )
297348
349+ # Integrity checks
298350 for message in messages :
299351 if not isinstance (message , dict ):
300352 continue
301-
302353 components = None
303354 surface_id = None
304- if "surfaceUpdate" in message : # v0.8
305- components = message ["surfaceUpdate" ].get (COMPONENTS )
306- surface_id = message ["surfaceUpdate" ].get ("surfaceId" )
307- elif "updateComponents" in message and isinstance (
355+ if "updateComponents" in message and isinstance (
308356 message ["updateComponents" ], dict
309- ): # v0.9
357+ ):
310358 components = message ["updateComponents" ].get (COMPONENTS )
311359 surface_id = message ["updateComponents" ].get ("surfaceId" )
312360
313361 if components :
314362 ref_map = extract_component_ref_fields (self ._catalog )
315363 root_id = _find_root_id (messages , surface_id )
316- # Always check for basic integrity (duplicates)
317364 _validate_component_integrity (
318365 root_id , components , ref_map , skip_root_check = not strict_integrity
319366 )
320- # Always check topology (cycles), but only raise on orphans if strict_integrity is True
321367 analyze_topology (
322368 root_id , components , ref_map , raise_on_orphans = strict_integrity
323369 )
324370
325371 _validate_recursion_and_paths (message )
326372
373+ def _get_sub_validator (self , def_name : str ) -> Draft202012Validator :
374+ sub_schema = self ._catalog .s2c_schema .get ("$defs" , {}).get (def_name )
375+ if not sub_schema :
376+ raise ValueError (f"Definition { def_name } not found in schema" )
377+ return Draft202012Validator (sub_schema , registry = self ._validator ._registry )
378+
379+ def _get_formatted_errors (
380+ self , validator : Draft202012Validator , instance : Any , base_path : str
381+ ) -> List [str ]:
382+ errors = list (validator .iter_errors (instance ))
383+ formatted = []
384+ for err in errors :
385+ path_str = "." .join (str (p ) for p in err .path )
386+ full_path = f"{ base_path } .{ path_str } " if path_str else base_path
387+
388+ message = err .message
389+ if (
390+ (
391+ "Unevaluated properties are not allowed" in message
392+ or "Additional properties are not allowed" in message
393+ )
394+ and "(" in message
395+ and ")" in message
396+ ):
397+ message = message [message .find ("(" ) + 1 : message .rfind (")" )]
398+
399+ formatted .append (f"{ full_path } : { message } " )
400+ return formatted
401+
402+ def _get_update_components_errors (
403+ self , message : Dict [str , Any ], path : str
404+ ) -> List [str ]:
405+ errors = []
406+ if "version" not in message or message ["version" ] != "v0.9" :
407+ errors .append (f"{ path } : Invalid version, expected 'v0.9'" )
408+
409+ uc = message .get ("updateComponents" )
410+ if not isinstance (uc , dict ):
411+ errors .append (f"{ path } : Expected updateComponents to be an object" )
412+ return errors
413+
414+ if "surfaceId" not in uc or not isinstance (uc ["surfaceId" ], str ):
415+ errors .append (f"{ path } .updateComponents: Invalid or missing surfaceId" )
416+
417+ components = uc .get ("components" )
418+ if not isinstance (components , list ):
419+ errors .append (f"{ path } .updateComponents: Expected components to be an array" )
420+ return errors
421+
422+ for idx , comp in enumerate (components ):
423+ comp_id = comp .get ("id" )
424+ comp_path = (
425+ f"{ path } .updateComponents.components[id='{ comp_id } ']"
426+ if comp_id
427+ else f"{ path } .updateComponents.components[{ idx } ]"
428+ )
429+ errors .extend (self ._get_single_component_errors (comp , comp_path ))
430+
431+ return errors
432+
433+ def _get_single_component_errors (self , comp : Dict [str , Any ], path : str ) -> List [str ]:
434+ if not isinstance (comp , dict ):
435+ return [f"{ path } : Component is not an object" ]
436+
437+ comp_type = comp .get ("component" )
438+ if not comp_type :
439+ return [f"{ path } : Missing 'component' field" ]
440+
441+ catalog = self ._catalog .catalog_schema
442+ if not catalog or "components" not in catalog :
443+ return [f"{ path } : Catalog schema or components missing" ]
444+
445+ comp_schema = catalog ["components" ].get (comp_type )
446+ if not comp_schema :
447+ return [f"{ path } : Unknown component: { comp_type } " ]
448+
449+ temp_schema = {
450+ "$schema" : "https://json-schema.org/draft/2020-12/schema" ,
451+ "$ref" : f"catalog.json#/components/{ comp_type } " ,
452+ }
453+
454+ validator = Draft202012Validator (temp_schema , registry = self ._validator ._registry )
455+ return self ._get_formatted_errors (validator , comp , path )
456+
327457
328458def _find_root_id (
329459 messages : List [Dict [str , Any ]], surface_id : Optional [str ] = None
0 commit comments