packages feed

amazonka-core-2.0: src/Amazonka/Data/XML.hs

-- |
-- Module      : Amazonka.Data.XML
-- Copyright   : (c) 2013-2023 Brendan Hay
-- License     : Mozilla Public License, v. 2.0.
-- Maintainer  : Brendan Hay <brendan.g.hay+amazonka@gmail.com>
-- Stability   : provisional
-- Portability : non-portable (GHC extensions)
module Amazonka.Data.XML where

import Amazonka.Data.ByteString
import Amazonka.Data.Text
import Amazonka.Prelude
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Conduit as Conduit
import qualified Data.Conduit.Lazy as Conduit.Lazy
import qualified Data.Conduit.List as Conduit.List
import qualified Data.HashMap.Strict as HashMap
import qualified Data.List.NonEmpty as NonEmpty
import Data.XML.Types (Event (..))
import System.IO.Unsafe (unsafePerformIO)
import Text.XML
import qualified Text.XML.Stream.Render as XML.Stream
import qualified Text.XML.Unresolved as XML.Unresolved

infixl 7 .@, .@?

(.@) :: FromXML a => [Node] -> Text -> Either String a
ns .@ n = findElement n ns >>= parseXML

(.@?) :: FromXML a => [Node] -> Text -> Either String (Maybe a)
ns .@? n =
  case findElement n ns of
    Left _ -> Right Nothing
    Right xs -> parseXML xs

infixr 7 @=, @@=

(@=) :: ToXML a => Name -> a -> XML
n @= x =
  case toXML x of
    XNull -> XNull
    xs -> XOne . NodeElement $ mkElement n xs

(@@=) :: ToText a => Name -> a -> XML
n @@= x = XAttr n (toText x)

decodeXML :: FromXML a => ByteStringLazy -> Either String a
decodeXML lbs =
  bimap show documentRoot (parseLBS def lbs)
    >>= parseXML . childrenOf

-- The following is taken from xml-conduit.Text.XML which uses
-- unsafePerformIO anyway, with the following caveat:
--   'not generally safe, but we know that runResourceT
--    will not deallocate any of the resources being used
--    by the process.'
encodeXML :: ToElement a => a -> ByteStringLazy
encodeXML x =
  LBS.fromChunks . unsafePerformIO . Conduit.Lazy.lazyConsume $
    Conduit.List.sourceList (XML.Unresolved.toEvents doc)
      Conduit..| Conduit.List.map rename
      Conduit..| XML.Stream.renderBytes def
  where
    doc =
      toXMLDocument $
        Document
          { documentRoot = root,
            documentEpilogue = [],
            documentPrologue =
              Prologue
                { prologueBefore = [],
                  prologueDoctype = Nothing,
                  prologueAfter = []
                }
          }

    rename = \case
      EventBeginElement n xs -> EventBeginElement (f n) (map (first f) xs)
      EventEndElement n -> EventEndElement (f n)
      evt -> evt
      where
        f n
          | isNothing (nameNamespace n) = n {nameNamespace = ns}
          | otherwise = n

    ns = nameNamespace (elementName root)
    root = toElement x

class FromXML a where
  parseXML :: [Node] -> Either String a

instance FromXML [Node] where
  parseXML = pure

instance FromXML a => FromXML (Maybe a) where
  parseXML [] = pure Nothing
  parseXML ns = Just <$> parseXML ns

instance FromXML Text where
  parseXML = fmap (fromMaybe mempty) . withContent "Text"

instance FromXML Char where parseXML = parseXMLText "Char"

instance FromXML ByteString where parseXML = parseXMLText "ByteString"

instance FromXML Int where parseXML = parseXMLText "Int"

instance FromXML Integer where parseXML = parseXMLText "Integer"

instance FromXML Natural where parseXML = parseXMLText "Natural"

instance FromXML Double where parseXML = parseXMLText "Double"

instance FromXML Bool where parseXML = parseXMLText "Bool"

class ToElement a where
  toElement :: a -> Element

instance ToElement Element where
  toElement = id

-- | Convert to an 'Element', only if the resulting element contains @> 0@ nodes.
maybeElement :: ToElement a => a -> Maybe Element
maybeElement x =
  case toElement x of
    e@(Element _ _ ns)
      | null ns -> Nothing
      | otherwise -> Just e

-- | Provides a way to make the operators for ToXML instance
-- declaration be consistent WRT to single nodes or lists of nodes.
data XML
  = XNull
  | XAttr Name Text
  | XOne Node
  | XMany [(Name, Text)] [Node]
  deriving stock (Show)

instance Semigroup XML where
  XNull <> XNull = XNull
  a <> XNull = a
  XNull <> b = b
  a <> b =
    XMany
      (listXMLAttributes a <> listXMLAttributes b)
      (listXMLNodes a <> listXMLNodes b)

instance Monoid XML where
  mempty = XNull
  mappend = (<>)

listXMLNodes :: XML -> [Node]
listXMLNodes = \case
  XNull -> []
  XAttr {} -> []
  XOne n -> [n]
  XMany _ ns -> ns

listXMLAttributes :: XML -> [(Name, Text)]
listXMLAttributes = \case
  XNull -> []
  XAttr n t -> [(n, t)]
  XOne {} -> []
  XMany as _ -> as

class ToXML a where
  toXML :: a -> XML

instance ToXML XML where
  toXML = id

instance ToXML a => ToXML (Maybe a) where
  toXML (Just x) = toXML x
  toXML Nothing = XNull

instance ToXML Text where toXML = toXMLText

instance ToXML ByteString where toXML = toXMLText

instance ToXML Int where toXML = toXMLText

instance ToXML Integer where toXML = toXMLText

instance ToXML Natural where toXML = toXMLText

instance ToXML Double where toXML = toXMLText

instance ToXML Bool where toXML = toXMLText

parseXMLMap ::
  (Eq k, Hashable k, FromText k, FromXML v) =>
  Text ->
  Text ->
  Text ->
  [Node] ->
  Either String (HashMap k v)
parseXMLMap e k v =
  fmap HashMap.fromList . traverse f . mapMaybe (childNodesOf e)
  where
    f ns =
      (,)
        <$> (ns .@ k >>= fromText)
        <*> ns .@ v

parseXMLList1 ::
  FromXML a =>
  Text ->
  [Node] ->
  Either String (NonEmpty a)
parseXMLList1 n = parseXMLList n >=> parse
  where
    parse xs =
      maybe
        (Left $ "Error parsing empty List1 when expecting at least one element: " ++ show n)
        Right
        (NonEmpty.nonEmpty xs)

parseXMLList ::
  FromXML a =>
  Text ->
  [Node] ->
  Either String [a]
parseXMLList n = traverse parseXML . mapMaybe (childNodesOf n)

parseXMLText :: FromText a => String -> [Node] -> Either String a
parseXMLText n =
  withContent n
    >=> maybe
      (Left $ "empty node list, when expecting single node " ++ n)
      fromText

toXMLList :: (IsList a, ToXML (Item a)) => Name -> a -> XML
toXMLList n = XMany [] . map (NodeElement . mkElement n) . toList

toXMLText :: ToText a => a -> XML
toXMLText = XOne . NodeContent . toText

mkElement :: ToXML a => Name -> a -> Element
mkElement n (toXML -> x) =
  Element n (fromList (listXMLAttributes x)) (listXMLNodes x)

withContent :: String -> [Node] -> Either String (Maybe Text)
withContent k = \case
  [] -> Right Nothing
  [NodeContent x] -> Right (Just x)
  _ -> Left $ "encountered many nodes, when expecting text: " ++ k

-- | Find a specific named NodeElement, at the current depth in the node tree.
--
-- Fails if absent.
findElement :: Text -> [Node] -> Either String [Node]
findElement n ns =
  missingElement n ns
    . listToMaybe
    $ mapMaybe (childNodesOf n) ns

-- | Find the first specific named NodeElement, at any depth in the node tree.
--
-- Fails if absent.
firstElement :: Text -> [Node] -> Either String [Node]
firstElement n ns =
  missingElement n ns
    . listToMaybe
    $ mapMaybe go ns
  where
    go x = case x of
      NodeElement e
        | Just n == localName x -> Just (childrenOf e)
        | otherwise -> listToMaybe (mapMaybe go (elementNodes e))
      _ -> Nothing

childNodesOf :: Text -> Node -> Maybe [Node]
childNodesOf n x = case x of
  NodeElement e
    | Just n == localName x ->
        Just (childrenOf e)
  _ -> Nothing

childrenOf :: Element -> [Node]
childrenOf e = elementNodes e <> map node (toList (elementAttributes e))
  where
    node (k, v) = NodeElement (Element (name k) mempty [NodeContent v])

    name k =
      Name
        { nameLocalName = fromMaybe "" (namePrefix k) <> ":" <> nameLocalName k,
          nameNamespace = mempty,
          namePrefix = mempty
        }

localName :: Node -> Maybe Text
localName = \case
  NodeElement e -> Just (nameLocalName (elementName e))
  _ -> Nothing

-- | An inefficient mechanism for retreiving the root
-- element name of an XML document.
rootElementName :: ByteStringLazy -> Maybe Text
rootElementName bs =
  either
    (const Nothing)
    (Just . nameLocalName . elementName . documentRoot)
    (parseLBS def bs)

missingElement :: Text -> [Node] -> Maybe a -> Either String a
missingElement n ns = maybe (Left err) Right
  where
    err =
      "unable to find element "
        ++ show n
        ++ " in nodes "
        ++ show (mapMaybe localName ns)