packages feed

plexus-synapse-3.17.3: src/Synapse/SchemaStore.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | SYNAPSE-28: what a call needs from a backend's schema, kept on disk so
-- a call sends first and checks only on failure.
--
-- Under @$XDG_CACHE_HOME/synapse/schemas@ (@~/.cache/synapse/schemas@):
--
-- @
-- routes/CONNECTION-POINT/BACKEND.json      where BACKEND is reached from there
-- ADDRESS/AGENT/BACKEND/info.json           its _info, as this agent was answered
-- ADDRESS/AGENT/BACKEND/PATH\@VERSION-HASH.json   an activation's schema (PATH: _root, tickets, tickets.inputs …)
-- ADDRESS/AGENT/BACKEND/PATH.pin            the file PATH resolves to now
-- ADDRESS/AGENT/BACKEND/plans/METHOD.json   one method's call plan: its schema, its
--                                           activation's deprecation, the IR its flags parse by
-- @
--
-- A schema is per agent because a caller's view of it is (AUTH-8). Old
-- versions stay beside the pin (SYNAPSE-29 adds pinning by version and
-- pruning); a plan is what a warm call reads — a few kilobytes, where
-- menger's @tickets@ schema is 2.6 MB.
module Synapse.SchemaStore
  ( Store(..)
  , storeFor
  , schemasRoot

    -- * Routes
  , readRoute
  , writeRoute

    -- * _info
  , readInfo
  , writeInfo

    -- * Schemas
  , readSchema
  , writeSchema

    -- * Plans
  , Plan(..)
  , planKey
  , readPlan
  , writePlan
  , planIR

    -- * Forgetting
  , forget
  ) where

import Control.Exception (SomeException, catch, try)
import Control.Monad (forM_, when)
import Data.Aeson
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Data.Char (isAlphaNum)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.Directory (doesDirectoryExist, getHomeDirectory, listDirectory, removeFile)
import System.Environment (lookupEnv)
import System.FilePath ((</>), takeExtension, takeFileName)

import Plexus.Auth (BackendInfo, parseBackendInfo)
import Plexus.Schema.Recursive (PluginSchema(..), MethodSchema, DeprecationInfo)
import Synapse.Endpoint (Endpoint(..), dirName)
import Synapse.Identity.Store (writePrivateFile)
import Synapse.IR.Types

-- | One backend's cache, as one agent sees it at one address.
newtype Store = Store { storeDir :: FilePath }
  deriving (Eq, Show)

-- | @$XDG_CACHE_HOME/synapse/schemas@, else @~/.cache/synapse/schemas@.
schemasRoot :: IO FilePath
schemasRoot = do
  x <- lookupEnv "XDG_CACHE_HOME"
  base <- case x of
    Just d | not (null d) -> pure d
    _ -> (</> ".cache") <$> getHomeDirectory
  pure (base </> "synapse" </> "schemas")

storeFor :: Endpoint -> Text -> Text -> IO Store
storeFor ep agent backend = do
  root <- schemasRoot
  pure (Store (root </> dirName ep </> safe agent </> safe backend))

-- | A name as a file name: what is not @[A-Za-z0-9._-]@ becomes @_@.
safe :: Text -> FilePath
safe t = case T.unpack (T.map (\c -> if isAlphaNum c || c `elem` ("._-" :: String) then c else '_') t) of
  "" -> "_"
  "." -> "_"
  ".." -> "__"
  s -> s

readJson :: FromJSON a => FilePath -> IO (Maybe a)
readJson p = do
  r <- try (BS.readFile p)
  pure $ case r of
    Left (_ :: SomeException) -> Nothing
    Right bs -> decodeStrict bs

-- | Written whole and renamed into place (0600), so a reader never sees
-- half a file; a cache that cannot be written is no failure of the call.
writeJson :: ToJSON a => FilePath -> a -> IO ()
writeJson p v = writePrivateFile p (LBS.toStrict (encode v)) `catch` \(_ :: SomeException) -> pure ()

-- ---------------------------------------------------------------------------
-- Routes

routePath :: Endpoint -> Text -> IO FilePath
routePath cp backend = do
  root <- schemasRoot
  pure (root </> "routes" </> dirName cp </> (safe backend <> ".json"))

-- | Where the backend was found from this connection point (itself, or a
-- registry's entry): the address calls go to.
readRoute :: Endpoint -> Text -> IO (Maybe Endpoint)
readRoute cp backend = do
  p <- routePath cp backend
  v <- readJson p
  pure $ case v of
    Just (Object o)
      | Just (String h) <- KM.lookup "host" o
      , Just (Number port) <- KM.lookup "port" o
      , Just (Bool tls) <- KM.lookup "tls" o
      , Just (String path) <- KM.lookup "path" o
      -> Just Endpoint { epHost = h, epPort = round port, epTls = tls, epPath = path
                       , epCaFiles = if h == epHost cp && round port == epPort cp then epCaFiles cp else [] }
    _ -> Nothing

writeRoute :: Endpoint -> Text -> Endpoint -> IO ()
writeRoute cp backend at = do
  p <- routePath cp backend
  writeJson p (object ["host" .= epHost at, "port" .= epPort at, "tls" .= epTls at, "path" .= epPath at])

-- ---------------------------------------------------------------------------
-- _info

readInfo :: Store -> IO (Maybe BackendInfo)
readInfo st = (>>= parseBackendInfo) <$> readJson (storeDir st </> "info.json")

writeInfo :: Store -> BackendInfo -> IO ()
writeInfo st = writeJson (storeDir st </> "info.json")

-- ---------------------------------------------------------------------------
-- Schemas

pathName :: [Text] -> FilePath
pathName [] = "_root"
pathName segs = safe (T.intercalate "." segs)

readSchema :: Store -> [Text] -> IO (Maybe PluginSchema)
readSchema st segs = do
  pin <- try (BS.readFile (storeDir st </> (pathName segs <> ".pin")))
  case pin of
    Left (_ :: SomeException) -> pure Nothing
    Right f -> readJson (storeDir st </> takeFileName (T.unpack (T.strip (TE.decodeUtf8 f))))

-- | Kept as @PATH\@VERSION-HASH.json@ and pinned.
writeSchema :: Store -> [Text] -> PluginSchema -> IO ()
writeSchema st segs schema = do
  let file = pathName segs <> "@" <> safe (psVersion schema) <> "-" <> safe (psHash schema) <> ".json"
  writeJson (storeDir st </> file) schema
  writePrivateFile (storeDir st </> (pathName segs <> ".pin")) (TE.encodeUtf8 (T.pack file))
    `catch` \(_ :: SomeException) -> pure ()

-- ---------------------------------------------------------------------------
-- Plans

-- | What a warm call reads instead of its activation's schema.
data Plan = Plan
  { plMethod      :: MethodSchema
  , plPath        :: [Text]                  -- ^ the method's path as navigation resolved it
  , plDeprecation :: Maybe DeprecationInfo   -- ^ its activation's
  , plIR          :: Maybe IR                -- ^ the method and the types its params name; Nothing when it takes none
  } deriving (Eq, Show)

instance ToJSON Plan where
  toJSON Plan{..} = object
    [ "method" .= plMethod, "path" .= plPath, "deprecation" .= plDeprecation, "ir" .= plIR ]

instance FromJSON Plan where
  parseJSON = withObject "Plan" $ \o -> Plan
    <$> o .: "method" <*> o .: "path" <*> o .:? "deprecation" <*> o .:? "ir"

-- | The plan's file name for the path as the command line gave it.
planKey :: [Text] -> FilePath
planKey segs = pathName segs <> ".json"

-- | SYNAPSE-CC-8: a plan whose IR another synapse wrote (an older IR
-- version: 3.16.0's 2.0) is not this one's; it is read as no plan, so the
-- call asks the server and writes a plan of its own.
readPlan :: Store -> [Text] -> IO (Maybe Plan)
readPlan st segs = do
  plan <- readJson (storeDir st </> "plans" </> planKey segs)
  pure $ case plan of
    Just p | maybe False ((/= currentIrVersion) . irVersion) (plIR p) -> Nothing
    other -> other

writePlan :: Store -> [Text] -> Plan -> IO ()
writePlan st segs = writeJson (storeDir st </> "plans" </> planKey segs)

-- | The IR a plan keeps: the one method, and the types its params reach.
planIR :: Text -> IR -> IR
planIR fullPath ir = ir
  { irMethods = Map.filterWithKey (\k _ -> k == fullPath) (irMethods ir)
  , irTypes = Map.restrictKeys (irTypes ir) reached
  , irPlugins = Map.empty
  }
  where
    roots = [pdType p | Just md <- [Map.lookup fullPath (irMethods ir)], p <- mdParams md]
    reached = go Set.empty (concatMap names roots)
    go seen [] = seen
    go seen (n : rest)
      | n `Set.member` seen = go seen rest
      | otherwise = go (Set.insert n seen) (more n ++ rest)
    more n = maybe [] (kindNames . tdKind) (Map.lookup n (irTypes ir))
    kindNames = \case
      KindStruct fs -> concatMap (names . fdType) fs
      KindEnum _ vs _ -> concatMap (\v -> concatMap (names . fdType) (vdFields v) ++ maybe [] names (vdRef v)) vs
      KindAlias t -> names t
      _ -> []
    names = \case
      RefNamed qn -> [qualifiedNameFull qn, qnLocalName qn]
      t -> concatMap names (typeRefChildren t)

-- ---------------------------------------------------------------------------
-- Forgetting

-- | A call the cache misled (a method renamed, a parameter changed): its
-- plans and pins go, so every call after it reads what the server says
-- now. The schema files stay (the versions SYNAPSE-29 pins between).
forget :: Store -> IO ()
forget st = do
  let dir = storeDir st
  ok <- doesDirectoryExist dir
  when ok $ do
    fs <- listDirectory dir `catch` \(_ :: SomeException) -> pure []
    forM_ [f | f <- fs, takeExtension f == ".pin"] $ \f -> rm (dir </> f)
    let plans = dir </> "plans"
    pk <- doesDirectoryExist plans
    when pk $ do
      ps <- listDirectory plans `catch` \(_ :: SomeException) -> pure []
      forM_ ps $ \f -> rm (plans </> f)
  where
    rm p = removeFile p `catch` \(_ :: SomeException) -> pure ()