packages feed

hinagata-core-0.1.0.0: src/Hinagata/Connection.hs

module Hinagata.Connection
  ( Host,
    socketDirectory,
    tcpHost,
    hostText,
    Secret,
    secretText,
    mkSecret,
    ConnectionTarget,
    mkConnectionTarget,
    connectionHost,
    connectionPort,
    connectionUser,
    connectionDatabase,
    connectionPassword,
    ConnectionString,
    connectionStringText,
    renderConnectionString,
  )
where

import Data.Text qualified as Text
import Hinagata.Prelude
import Hinagata.Types
import System.FilePath (isAbsolute)

data Host = SocketDirectory !Text | TcpHost !Text
  deriving stock (Eq, Show)

hostText :: Host -> Text
hostText (SocketDirectory value) = value
hostText (TcpHost value) = value

socketDirectory :: Text -> Either Text Host
socketDirectory value
  | Text.null value = Left "socket directory is empty"
  | Text.any (== '\0') value = Left "socket directory contains NUL"
  | not (isAbsolute (Text.unpack value)) = Left "socket directory must be absolute"
  | otherwise = Right (SocketDirectory value)

tcpHost :: Text -> Either Text Host
tcpHost value
  | Text.null value = Left "TCP host is empty"
  | Text.any (== '\0') value = Left "TCP host contains NUL"
  | Text.any (== '/') value = Left "TCP host contains '/'"
  | otherwise = Right (TcpHost value)

newtype Secret = Secret Text
  deriving stock (Eq)

instance Show Secret where
  show _ = "<redacted>"

mkSecret :: Text -> Either Text Secret
mkSecret value
  | Text.any (== '\0') value = Left "credential contains NUL"
  | otherwise = Right (Secret value)

secretText :: Secret -> Text
secretText (Secret value) = value

data ConnectionTarget = ConnectionTarget
  { host :: !Host,
    port :: !Port,
    user :: !Text,
    database :: !DatabaseName,
    password :: !(Maybe Secret)
  }
  deriving stock (Eq)

instance Show ConnectionTarget where
  show ConnectionTarget {host, port, user, database} =
    "ConnectionTarget {host = "
      ++ show host
      ++ ", port = "
      ++ show port
      ++ ", user = "
      ++ show user
      ++ ", database = "
      ++ show database
      ++ ", password = <redacted>}"

mkConnectionTarget :: Host -> Port -> Text -> DatabaseName -> Maybe Secret -> Either Text ConnectionTarget
mkConnectionTarget host port user database password
  | Text.null user = Left "database user is empty"
  | Text.any (== '\0') user = Left "database user contains NUL"
  | otherwise = Right ConnectionTarget {host, port, user, database, password}

connectionHost :: ConnectionTarget -> Host
connectionHost ConnectionTarget {host} = host

connectionPort :: ConnectionTarget -> Port
connectionPort ConnectionTarget {port} = port

connectionUser :: ConnectionTarget -> Text
connectionUser ConnectionTarget {user} = user

connectionDatabase :: ConnectionTarget -> DatabaseName
connectionDatabase ConnectionTarget {database} = database

connectionPassword :: ConnectionTarget -> Maybe Secret
connectionPassword ConnectionTarget {password} = password

newtype ConnectionString = ConnectionString Text
  deriving stock (Eq)

instance Show ConnectionString where
  show _ = "<redacted connection string>"

connectionStringText :: ConnectionString -> Text
connectionStringText (ConnectionString value) = value

-- | Render explicit endpoint fields, clearing ambient hostaddr overrides.
-- Other libpq defaults (including authentication and TLS) remain available.
renderConnectionString :: ConnectionTarget -> ConnectionString
renderConnectionString ConnectionTarget {host, port, user, database, password} =
  ConnectionString $
    Text.intercalate " " $
      [ field "host" (hostText host),
        field "hostaddr" "",
        field "port" (Text.pack (show (portNumber port))),
        field "user" user,
        field "dbname" (databaseNameText database)
      ]
        ++ maybe [] (\credential -> [field "password" (secretText credential)]) password
  where
    field key value = key <> "='" <> Text.concatMap escape value <> "'"
    escape '\'' = "\\'"
    escape '\\' = "\\\\"
    escape character = Text.singleton character