substrate-synapse-0.1.0.0: src/Synapse/Algebra/TemplateGen.hs
-- | Template generation from schemas via recursion schemes
--
-- This module demonstrates two catamorphisms:
--
-- = Tree-level Catamorphism
--
-- We fold over the schema tree (SchemaF) to collect templates:
--
-- @
-- templateAlgebra :: SchemaF [GeneratedTemplate] -> [GeneratedTemplate]
-- @
--
-- This algebra is applied via hylomorphism:
--
-- @
-- generateAllTemplates = walkSchema templateAlgebraM
-- @
--
-- = Schema-level Catamorphism
--
-- We also fold over JSON Schema structure to generate mustache:
--
-- @
-- schemaToMustache :: Value -> Text
-- @
--
-- This is a catamorphism over the JSON Schema ADT, handling:
-- - oneOf/anyOf → collect all variant properties
-- - object → list properties as {{key}}
-- - array → generate {{#.}}...{{/.}} iteration
-- - primitives → {{.}}
module Synapse.Algebra.TemplateGen
( -- * Generation
generateAllTemplates
, generateAllTemplatesWithCallback
, generateTemplate
, generateTemplateFor
-- * Algebras
, templateAlgebra
, schemaToMustache
-- * Types
, GeneratedTemplate(..)
) where
import Data.Aeson (Value(..))
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import Data.Text (Text)
import qualified Data.Text as T
import Data.Maybe (fromMaybe, mapMaybe)
import qualified Data.Vector as V
import System.FilePath ((</>), (<.>))
import System.Directory (createDirectoryIfMissing)
import qualified Data.Text.IO as TIO
import Control.Monad.IO.Class (liftIO)
import Synapse.Schema.Types
import Synapse.Schema.Functor (SchemaF(..))
import Synapse.Monad
import Synapse.Transport (fetchMethodSchema)
import Synapse.Algebra.Walk
-- | A generated template with metadata
data GeneratedTemplate = GeneratedTemplate
{ gtNamespace :: Text -- ^ Plugin namespace
, gtMethod :: Text -- ^ Method name
, gtTemplate :: Text -- ^ Mustache template content
, gtPath :: FilePath -- ^ Relative path for template file
}
deriving stock (Show, Eq)
-- | Generate template for a method
-- Template named by method (matches content_type: namespace.method)
generateTemplate :: MethodInfo -> [GeneratedTemplate]
generateTemplate MethodInfo{..} =
[ GeneratedTemplate
{ gtNamespace = miNamespace
, gtMethod = methodName miMethod
, gtTemplate = "{{! " <> miNamespace <> "." <> methodName miMethod <> " }}\n" <> templateBody
, gtPath = T.unpack miNamespace </> T.unpack (methodName miMethod) <.> "mustache"
}
]
where
templateBody = case methodReturns miMethod of
Nothing -> "{{.}}"
Just schema -> schemaToMustache schema
-- | Extract event types from schema (oneOf/anyOf with event discriminator)
extractEventTypes :: Value -> [(Text, Value)]
extractEventTypes (Object o) = case KM.lookup "oneOf" o of
Just (Array variants) -> mapMaybe extractEvent (V.toList variants)
Nothing -> case KM.lookup "anyOf" o of
Just (Array variants) -> mapMaybe extractEvent (V.toList variants)
Nothing -> []
extractEventTypes _ = []
-- | Extract event name and schema from a variant
extractEvent :: Value -> Maybe (Text, Value)
extractEvent v@(Object o) = case KM.lookup "properties" o of
Just (Object props) -> case KM.lookup "event" props of
Just eventSchema -> case extractConstValue eventSchema of
"event" -> Nothing -- fallback value, no const found
evtName -> Just (evtName, v)
Nothing -> Nothing
Nothing -> Nothing
extractEvent _ = Nothing
-- | Extract const value from a schema (for discriminators)
extractConstValue :: Value -> Text
extractConstValue (Object o) = case KM.lookup "const" o of
Just (String s) -> s
_ -> "event"
extractConstValue _ = "event"
-- | Generate template for a specific method path
generateTemplateFor :: Path -> Text -> SynapseM GeneratedTemplate
generateTemplateFor path methodName' = do
schema <- fetchMethodSchema path methodName'
let namespace = if null path then "plexus" else T.intercalate "." path
pure $ GeneratedTemplate
{ gtNamespace = namespace
, gtMethod = methodName'
, gtTemplate = generateFromReturns namespace methodName' (methodReturns schema)
, gtPath = T.unpack namespace </> T.unpack methodName' <.> "mustache"
}
-- ============================================================================
-- Tree-Level Algebra (SchemaF)
-- ============================================================================
-- | Algebra for folding schema tree into templates
--
-- This is a proper F-algebra: @SchemaF [GeneratedTemplate] -> [GeneratedTemplate]@
--
-- At each node:
-- - PluginF: generate templates for local methods, combine with children
-- - MethodF: leaf case (not used since methods are inside plugins)
templateAlgebra :: SchemaF [GeneratedTemplate] -> [GeneratedTemplate]
templateAlgebra (PluginF schema path childResults) =
let namespace = psNamespace schema
localTemplates = concatMap (methodToTemplate namespace) (psMethods schema)
in localTemplates ++ concat childResults
templateAlgebra (MethodF method ns path) =
-- Methods appear as leaves when walked individually
methodToTemplate ns method
-- | Convert a method to template(s)
methodToTemplate :: Text -> MethodSchema -> [GeneratedTemplate]
methodToTemplate namespace method =
[ GeneratedTemplate
{ gtNamespace = namespace
, gtMethod = methodName method
, gtTemplate = "{{! " <> namespace <> "." <> methodName method <> " }}\n" <> body
, gtPath = T.unpack namespace </> T.unpack (methodName method) <.> "mustache"
}
]
where
body = case methodReturns method of
Nothing -> "{{.}}"
Just schema -> schemaToMustache schema
-- | Monadic version for use with hyloM
templateAlgebraM :: SchemaF [GeneratedTemplate] -> SynapseM [GeneratedTemplate]
templateAlgebraM = pure . templateAlgebra
-- | Generate all templates by walking the schema tree
--
-- This is a hylomorphism: unfold tree, fold with template algebra.
-- The walkSchema function applies hyloM under the hood.
generateAllTemplates :: Path -> SynapseM [GeneratedTemplate]
generateAllTemplates = walkSchema templateAlgebraM
-- | Generate templates with a callback for each one (for streaming output)
--
-- The callback is invoked as each template is generated during the walk.
generateAllTemplatesWithCallback
:: (GeneratedTemplate -> IO ()) -- ^ Called for each template
-> Path
-> SynapseM Int -- ^ Returns count
generateAllTemplatesWithCallback callback = walkSchema streamingAlgebra
where
streamingAlgebra :: SchemaF Int -> SynapseM Int
streamingAlgebra (PluginF schema path childCounts) = do
let namespace = psNamespace schema
templates = concatMap (methodToTemplate namespace) (psMethods schema)
liftIO $ mapM_ callback templates
pure $ length templates + sum childCounts
streamingAlgebra (MethodF method ns path) = do
let templates = methodToTemplate ns method
liftIO $ mapM_ callback templates
pure $ length templates
-- | Generate mustache template from a method's return schema
generateFromReturns :: Text -> Text -> Maybe Value -> Text
generateFromReturns namespace method Nothing =
"{{! " <> namespace <> "." <> method <> " - no return schema }}\n{{.}}"
generateFromReturns namespace method (Just schema) =
"{{! " <> namespace <> "." <> method <> " }}\n" <> schemaToMustache schema
-- ============================================================================
-- Schema-Level Algebra (JSON Schema)
-- ============================================================================
-- | Convert JSON Schema to mustache template
--
-- This is a catamorphism over the JSON Schema structure.
-- The Value type is the fixed point, and we fold with:
--
-- @
-- schemaAlg :: SchemaNode -> Text
-- @
--
-- where SchemaNode would be the base functor for JSON Schema.
-- Since JSON Schema isn't defined as Fix F, we pattern match directly,
-- but the structure is the same: recursively process children, combine.
schemaToMustache :: Value -> Text
schemaToMustache (Object o) = case KM.lookup "oneOf" o of
-- Handle oneOf (discriminated union by 'event' field)
Just (Array variants) -> generateVariants (V.toList variants)
Nothing -> case KM.lookup "anyOf" o of
Just (Array variants) -> generateVariants (V.toList variants)
Nothing -> case KM.lookup "type" o of
Just (String "object") -> generateObject o
Just (String "array") -> generateArray o
Just (String "string") -> "{{.}}"
Just (String "number") -> "{{.}}"
Just (String "integer") -> "{{.}}"
Just (String "boolean") -> "{{.}}"
_ -> generateObject o -- Default to object handling
schemaToMustache _ = "{{.}}"
-- | Generate template for oneOf/anyOf variants
-- Collect all unique properties across variants (flat template, no sections)
generateVariants :: [Value] -> Text
generateVariants variants =
let allProps = concatMap extractProps variants
uniqueKeys = dedupe $ map fst allProps
displayKeys = filter (not . isInternalField) uniqueKeys
in T.intercalate " " $ map (\k -> "{{" <> k <> "}}") displayKeys
where
extractProps :: Value -> [(Text, Value)]
extractProps (Object o) = case KM.lookup "properties" o of
Just (Object props) -> [(K.toText k, v) | (k, v) <- KM.toList props]
Nothing -> []
extractProps _ = []
dedupe :: Eq a => [a] -> [a]
dedupe [] = []
dedupe (x:xs) = x : dedupe (filter (/= x) xs)
-- | Generate template for object properties
generateObject :: KM.KeyMap Value -> Text
generateObject o = case KM.lookup "properties" o of
Just (Object props) -> generateProps props
Nothing -> "{{.}}"
-- | Generate output for properties
generateProps :: KM.KeyMap Value -> Text
generateProps props =
let keys = map K.toText $ KM.keys props
-- Filter out internal fields like 'event'
displayKeys = filter (not . isInternalField) keys
lines' = map (\k -> "{{" <> k <> "}}") displayKeys
in T.intercalate " " lines'
-- | Check if a field is internal (shouldn't be displayed)
isInternalField :: Text -> Bool
isInternalField "event" = True
isInternalField "type" = True
isInternalField _ = False
-- | Generate template for array
generateArray :: KM.KeyMap Value -> Text
generateArray o = case KM.lookup "items" o of
Just itemSchema ->
"{{#.}}\n" <> schemaToMustache itemSchema <> "\n{{/.}}"
Nothing -> "{{#.}}{{.}}{{/.}}"
-- | Write generated templates to disk
writeTemplates :: FilePath -> [GeneratedTemplate] -> IO ()
writeTemplates baseDir templates = do
mapM_ (writeTemplate baseDir) templates
-- | Write a single template to disk
writeTemplate :: FilePath -> GeneratedTemplate -> IO ()
writeTemplate baseDir gt = do
let fullPath = baseDir </> gtPath gt
dir = takeDirectory fullPath
createDirectoryIfMissing True dir
TIO.writeFile fullPath (gtTemplate gt)
where
takeDirectory = reverse . dropWhile (/= '/') . reverse