plexus-synapse-3.10.1: src/Synapse/Self/Protocol/Validator.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Synapse.Self.Protocol.Validator
( -- * Types
ProtocolViolation(..)
, Severity(..)
, Location(..)
, ValidationContext(..)
, ValidationConfig(..)
-- * Validation functions
, validateStreamItem
, validateMetadata
, validateFieldNames
, validateDataItem
, validateProgressItem
, validateErrorItem
, validateDoneItem
) where
import Data.Aeson (Value(..), Object)
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import Data.Char (isDigit, isHexDigit, isLower)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Scientific (isInteger, floatingOrInteger)
import Data.Vector (Vector)
import qualified Data.Vector as V
-- | Protocol violation with precise location and suggested fix
data ProtocolViolation = ProtocolViolation
{ pvMessage :: !Text
, pvSeverity :: !Severity
, pvLocation :: !Location
, pvExpected :: !(Maybe Text)
, pvActual :: !(Maybe Text)
, pvFix :: !(Maybe Text) -- ^ Suggested fix
}
deriving (Show, Eq)
data Severity = Error | Warning | Info
deriving (Show, Eq, Ord)
data Location
= InMessage
{ lmSubscriptionId :: !(Maybe Int)
, lmMessageIndex :: !Int
, lmField :: !(Maybe Text)
}
| InStream
{ lsMethod :: !Text
, lsMessageCount :: !Int
}
| InConnection
deriving (Show, Eq)
-- | Validation context tracking stream state
data ValidationContext = ValidationContext
{ vcViolations :: ![ProtocolViolation]
, vcConfig :: !ValidationConfig
}
deriving (Show, Eq)
data ValidationConfig = ValidationConfig
{ vcCheckMetadata :: !Bool
, vcCheckStreamDone :: !Bool
, vcCheckProvenance :: !Bool
, vcCheckTypes :: !Bool
, vcStrictMode :: !Bool -- ^ Fail on warnings
}
deriving (Show, Eq)
-- | Default validation config (check everything)
defaultValidationConfig :: ValidationConfig
defaultValidationConfig = ValidationConfig
{ vcCheckMetadata = True
, vcCheckStreamDone = True
, vcCheckProvenance = True
, vcCheckTypes = True
, vcStrictMode = False
}
-- ============================================================================
-- Main Validation Function
-- ============================================================================
-- | Validate a stream item
--
-- Checks:
-- - Message structure (type field)
-- - Metadata structure (provenance, plexus_hash, timestamp)
-- - Field naming (snake_case for protocol fields)
-- - Type-specific validation (data, error, progress, done)
validateStreamItem :: Value -> IO [ProtocolViolation]
validateStreamItem item = pure $ case item of
Object obj ->
let typeViolations = validateType obj
metadataViolations = validateMetadata item
fieldNameViolations = validateFieldNames item
typeSpecificViolations = validateByType obj
in concat [typeViolations, metadataViolations, fieldNameViolations, typeSpecificViolations]
_ ->
[ ProtocolViolation
{ pvMessage = "Stream item must be a JSON object"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "JSON object with 'type' field"
, pvActual = Just $ T.pack $ show item
, pvFix = Just "Ensure stream items are JSON objects"
}
]
-- ============================================================================
-- Type Field Validation
-- ============================================================================
-- | Validate that the type field exists and is valid
validateType :: Object -> [ProtocolViolation]
validateType obj = case KM.lookup "type" obj of
Nothing ->
[ ProtocolViolation
{ pvMessage = "Missing 'type' field in stream item"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "type field with value: data, error, progress, done, or request"
, pvActual = Just "missing"
, pvFix = Just "Add 'type' field to stream item"
}
]
Just (String typeStr) ->
if typeStr `elem` ["data", "error", "progress", "done", "request"]
then []
else
[ ProtocolViolation
{ pvMessage = "Invalid stream item type: " <> typeStr
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "data, error, progress, done, or request"
, pvActual = Just typeStr
, pvFix = Just "Use one of the valid stream item types"
}
]
Just other ->
[ ProtocolViolation
{ pvMessage = "Stream item 'type' must be a string"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "string"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change type value to a string"
}
]
-- ============================================================================
-- Metadata Validation
-- ============================================================================
-- | Validate stream metadata structure
--
-- Checks:
-- - Provenance is array of strings
-- - plexus_hash is 16-character hex string
-- - Timestamp is valid (integer, Unix seconds or ISO 8601)
validateMetadata :: Value -> [ProtocolViolation]
validateMetadata (Object obj) =
-- Check if this item type should have metadata
case KM.lookup "type" obj of
Just (String "request") ->
-- Request items don't require metadata
[]
_ ->
-- All other types require metadata
case KM.lookup "metadata" obj of
Nothing ->
[ ProtocolViolation
{ pvMessage = "Missing 'metadata' field (required for data, error, progress, done items)"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "metadata object with provenance, plexus_hash, timestamp"
, pvActual = Just "missing"
, pvFix = Just "Add metadata field to stream item"
}
]
Just (Object metaObj) ->
concat
[ validateProvenance metaObj
, validatePlexusHash metaObj
, validateTimestamp metaObj
]
Just other ->
[ ProtocolViolation
{ pvMessage = "Metadata must be a JSON object"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "object"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change metadata to an object"
}
]
validateMetadata _ = []
-- | Validate provenance field (must be non-empty array of strings)
validateProvenance :: Object -> [ProtocolViolation]
validateProvenance obj = case KM.lookup "provenance" obj of
Nothing ->
[ ProtocolViolation
{ pvMessage = "Missing 'provenance' field in metadata"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "non-empty array of strings"
, pvActual = Just "missing"
, pvFix = Just "Add provenance field with server names"
}
]
Just (Array arr) ->
if V.null arr
then
[ ProtocolViolation
{ pvMessage = "Provenance array cannot be empty"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "non-empty array of strings"
, pvActual = Just "empty array"
, pvFix = Just "Add at least one server name to provenance"
}
]
else
-- Check each element is a string
concatMap (validateProvenanceElement arr) (zip [0..] (V.toList arr))
Just other ->
[ ProtocolViolation
{ pvMessage = "Provenance must be an array"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "array of strings"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change provenance to an array of server names"
}
]
-- | Validate a single provenance element
validateProvenanceElement :: Vector Value -> (Int, Value) -> [ProtocolViolation]
validateProvenanceElement _arr (idx, String _) = []
validateProvenanceElement _arr (idx, other) =
[ ProtocolViolation
{ pvMessage = "Provenance element at index " <> T.pack (show idx) <> " must be a string"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "string"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Ensure all provenance elements are strings"
}
]
-- | Validate plexus_hash field (must be exactly 16 hex characters)
validatePlexusHash :: Object -> [ProtocolViolation]
validatePlexusHash obj = case KM.lookup "plexus_hash" obj of
Nothing ->
[ ProtocolViolation
{ pvMessage = "Missing 'plexus_hash' field in metadata"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "16-character lowercase hex string"
, pvActual = Just "missing"
, pvFix = Just "Add plexus_hash field with schema version hash"
}
]
Just (String hashStr) ->
let len = T.length hashStr
isAllHex = T.all (\c -> isHexDigit c && (isDigit c || isLower c)) hashStr
in concat
[ if len /= 16
then
[ ProtocolViolation
{ pvMessage = "plexus_hash must be exactly 16 characters (got " <> T.pack (show len) <> ")"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "16 characters"
, pvActual = Just $ T.pack (show len) <> " characters: " <> hashStr
, pvFix = Just "Use 16-character hex hash (SHA-256 truncated to 64 bits)"
}
]
else []
, if not isAllHex
then
[ ProtocolViolation
{ pvMessage = "plexus_hash must be lowercase hexadecimal"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "lowercase hex characters (0-9, a-f)"
, pvActual = Just hashStr
, pvFix = Just "Use only lowercase hexadecimal characters"
}
]
else []
]
Just other ->
[ ProtocolViolation
{ pvMessage = "plexus_hash must be a string"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "string"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change plexus_hash to a string"
}
]
-- | Validate timestamp field (must be positive integer)
validateTimestamp :: Object -> [ProtocolViolation]
validateTimestamp obj = case KM.lookup "timestamp" obj of
Nothing ->
[ ProtocolViolation
{ pvMessage = "Missing 'timestamp' field in metadata"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "positive integer (Unix seconds)"
, pvActual = Just "missing"
, pvFix = Just "Add timestamp field with Unix epoch seconds"
}
]
Just (Number n) ->
case floatingOrInteger n of
Left (_ :: Double) ->
[ ProtocolViolation
{ pvMessage = "Timestamp must be an integer (not float)"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "integer (use Math.floor(Date.now() / 1000) in JavaScript)"
, pvActual = Just $ T.pack $ show n
, pvFix = Just "Convert timestamp to integer: Math.floor(Date.now() / 1000)"
}
]
Right (ts :: Integer) ->
if ts <= 0
then
[ ProtocolViolation
{ pvMessage = "Timestamp must be positive"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "positive integer"
, pvActual = Just $ T.pack $ show ts
, pvFix = Just "Use positive Unix timestamp"
}
]
else []
Just other ->
[ ProtocolViolation
{ pvMessage = "Timestamp must be a number"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "integer"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change timestamp to an integer"
}
]
-- ============================================================================
-- Field Naming Validation
-- ============================================================================
-- | Validate field naming conventions (snake_case for protocol fields)
validateFieldNames :: Value -> [ProtocolViolation]
validateFieldNames (Object obj) =
let protocolFields = ["jsonrpc", "subscription_id", "method", "params", "result", "id"]
camelCaseFields = ["subscriptionId", "plexusHash", "contentType", "requestId"]
violations = concatMap (checkFieldNaming obj) (KM.keys obj)
in violations
validateFieldNames _ = []
-- | Check if a field name violates naming conventions
checkFieldNaming :: Object -> K.Key -> [ProtocolViolation]
checkFieldNaming obj key =
let keyText = K.toText key
isCamelCase = T.any (\c -> c >= 'A' && c <= 'Z') keyText
isProtocolField = keyText `elem` ["type", "metadata", "content_type", "content",
"message", "percentage", "code", "plexus_hash",
"provenance", "timestamp"]
in if isCamelCase && isProtocolField
then
[ ProtocolViolation
{ pvMessage = "Protocol field '" <> keyText <> "' uses camelCase (should be snake_case)"
, pvSeverity = Warning
, pvLocation = InConnection
, pvExpected = Just $ toSnakeCase keyText
, pvActual = Just keyText
, pvFix = Just $ "Rename to: " <> toSnakeCase keyText
}
]
else []
-- | Convert camelCase to snake_case (simple version)
toSnakeCase :: Text -> Text
toSnakeCase txt = T.pack $ go (T.unpack txt)
where
go [] = []
go (c:cs)
| c >= 'A' && c <= 'Z' = '_' : toLowerChar c : go cs -- Convert to lowercase and prepend underscore
| otherwise = c : go cs
toLowerChar c = toEnum (fromEnum c + 32)
-- ============================================================================
-- Type-Specific Validation
-- ============================================================================
-- | Validate based on stream item type
validateByType :: Object -> [ProtocolViolation]
validateByType obj = case KM.lookup "type" obj of
Just (String "data") -> validateDataItem (Object obj)
Just (String "progress") -> validateProgressItem (Object obj)
Just (String "error") -> validateErrorItem (Object obj)
Just (String "done") -> validateDoneItem (Object obj)
Just (String "request") -> [] -- Request validation could be added
_ -> []
-- | Validate data item structure
validateDataItem :: Value -> [ProtocolViolation]
validateDataItem (Object obj) = concat
[ case KM.lookup "content_type" obj of
Nothing ->
[ ProtocolViolation
{ pvMessage = "Missing 'content_type' field in data item"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "content_type string (e.g., 'echo.response')"
, pvActual = Just "missing"
, pvFix = Just "Add content_type field"
}
]
Just (String ct) ->
if T.null ct
then
[ ProtocolViolation
{ pvMessage = "content_type cannot be empty"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "non-empty string"
, pvActual = Just "empty string"
, pvFix = Just "Provide valid content_type"
}
]
else []
Just other ->
[ ProtocolViolation
{ pvMessage = "content_type must be a string"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "string"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change content_type to string"
}
]
, case KM.lookup "content" obj of
Nothing ->
[ ProtocolViolation
{ pvMessage = "Missing 'content' field in data item"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "content object with domain data"
, pvActual = Just "missing"
, pvFix = Just "Add content field"
}
]
Just _ -> [] -- Any JSON value is valid for content
]
validateDataItem _ = []
-- | Validate progress item structure
validateProgressItem :: Value -> [ProtocolViolation]
validateProgressItem (Object obj) = concat
[ case KM.lookup "message" obj of
Nothing ->
[ ProtocolViolation
{ pvMessage = "Missing 'message' field in progress item"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "message string"
, pvActual = Just "missing"
, pvFix = Just "Add message field"
}
]
Just (String _) -> []
Just other ->
[ ProtocolViolation
{ pvMessage = "Progress message must be a string"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "string"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change message to string"
}
]
, case KM.lookup "percentage" obj of
Nothing -> [] -- percentage is optional
Just Null -> [] -- null is allowed
Just (Number n) ->
case floatingOrInteger n of
Left (_ :: Double) -> [] -- Allow float percentages
Right (pct :: Integer) ->
if pct < 0 || pct > 100
then
[ ProtocolViolation
{ pvMessage = "Progress percentage must be between 0 and 100"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "0-100"
, pvActual = Just $ T.pack $ show pct
, pvFix = Just "Use percentage value between 0 and 100"
}
]
else []
Just other ->
[ ProtocolViolation
{ pvMessage = "Progress percentage must be a number or null"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "number or null"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change percentage to number or null"
}
]
]
validateProgressItem _ = []
-- | Validate error item structure
validateErrorItem :: Value -> [ProtocolViolation]
validateErrorItem (Object obj) = concat
[ case KM.lookup "message" obj of
Nothing ->
[ ProtocolViolation
{ pvMessage = "Missing 'message' field in error item"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "message string"
, pvActual = Just "missing"
, pvFix = Just "Add message field with error description"
}
]
Just (String msg) ->
if T.null msg
then
[ ProtocolViolation
{ pvMessage = "Error message cannot be empty"
, pvSeverity = Warning
, pvLocation = InConnection
, pvExpected = Just "non-empty error message"
, pvActual = Just "empty string"
, pvFix = Just "Provide descriptive error message"
}
]
else []
Just other ->
[ ProtocolViolation
{ pvMessage = "Error message must be a string"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "string"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change message to string"
}
]
, case KM.lookup "code" obj of
Nothing -> [] -- code is optional
Just Null -> [] -- null is allowed
Just (String _) -> [] -- string code is valid
Just other ->
[ ProtocolViolation
{ pvMessage = "Error code must be a string or null"
, pvSeverity = Error
, pvLocation = InConnection
, pvExpected = Just "string or null"
, pvActual = Just $ T.pack $ show other
, pvFix = Just "Change code to string or null"
}
]
]
validateErrorItem _ = []
-- | Validate done item structure (only needs metadata)
validateDoneItem :: Value -> [ProtocolViolation]
validateDoneItem _item = [] -- Done items only need metadata, which is validated separately