htsn 0.0.3 → 0.0.4
raw patch · 14 files changed
+242/−398 lines, 14 filesdep +htsn-commondep −ansi-terminaldep −transformersdep ~tasty
Dependencies added: htsn-common
Dependencies removed: ansi-terminal, transformers
Dependency ranges changed: tasty
Files
- doc/htsnrc.example +5/−2
- doc/man1/htsn.1 +4/−2
- htsn.cabal +11/−13
- src/Configuration.hs +1/−1
- src/FeedHosts.hs +57/−0
- src/Logging.hs +0/−81
- src/Main.hs +25/−53
- src/OptionalConfiguration.hs +4/−7
- src/TSN/FeedHosts.hs +0/−57
- src/TSN/Xml.hs +0/−88
- src/Terminal.hs +0/−79
- src/Unix.hs +46/−14
- src/Xml.hs +88/−0
- test/TestSuite.hs +1/−1
doc/htsnrc.example view
@@ -67,8 +67,11 @@ # (Daemon mode only) Create a PID file in the given location. This is # used by the init system on Unix to keep track of the running-# daemon. Its parent directory must be writable by the user/group that-# we will run as!+# daemon.+#+# If necessary, its parent directory will be created with owner/group+# set to the appropriate user/group, but at most one directory will be+# created (that is, we won't create an entire directory tree). # # Default: /run/htsn/htsn.pid #
doc/man1/htsn.1 view
@@ -99,8 +99,10 @@ .IP \fB\-\-pidfile\fR (Daemon mode only) Create a PID file in the given location. This is used by the init system on Unix to keep track of the running daemon.-Its parent directory must be writable by the user/group that we will-run as!++If necessary, its parent directory will be created with owner/group+set to the appropriate user/group, but at most one directory will be+created (that is, we won't create an entire directory tree). Default: /run/htsn/htsn.pid
htsn.cabal view
@@ -1,5 +1,5 @@ name: htsn-version: 0.0.3+version: 0.0.4 cabal-version: >= 1.8 author: Michael Orlitzky maintainer: Michael Orlitzky <michael@orlitzky.com>@@ -128,9 +128,11 @@ . (Daemon mode only) Create a PID file in the given location. This is used by the init system on Unix to keep track of the running daemon.- Its parent directory must be writable by the user/group that we will- run as! .+ If necessary, its parent directory will be created with owner/group+ set to the appropriate user/group, but at most one directory will+ be created (that is, we won't create an entire directory tree).+ . Default: \/run\/htsn\/htsn.pid . @@@ -203,7 +205,6 @@ executable htsn build-depends:- ansi-terminal == 0.6.*, base == 4.*, cmdargs >= 0.10.6, configurator == 0.2.*,@@ -211,12 +212,12 @@ filepath == 1.3.*, hdaemonize == 0.4.*, hslogger == 1.2.*,+ htsn-common == 0.0.1, hxt == 9.3.*, MissingH == 1.2.*, network == 2.4.*,- tasty == 0.6.*,+ tasty == 0.7.*, tasty-hunit == 0.4.*,- transformers == 0.3.*, unix == 2.6.* main-is:@@ -229,12 +230,10 @@ CommandLine Configuration ExitCodes- Logging+ FeedHosts OptionalConfiguration- Terminal- TSN.FeedHosts- TSN.Xml Unix+ Xml ghc-options: -Wall@@ -265,7 +264,6 @@ hs-source-dirs: src test main-is: TestSuite.hs build-depends:- ansi-terminal == 0.6.*, base == 4.*, cmdargs >= 0.10.6, configurator == 0.2.*,@@ -273,12 +271,12 @@ filepath == 1.3.*, hdaemonize == 0.4.*, hslogger == 1.2.*,+ htsn-common == 0.0.1, hxt == 9.3.*, MissingH == 1.2.*, network == 2.4.*,- tasty == 0.6.*,+ tasty == 0.7.*, tasty-hunit == 0.4.*,- transformers == 0.3.*, unix == 2.6.* -- It's not entirely clear to me why I have to reproduce all of this.
src/Configuration.hs view
@@ -13,7 +13,7 @@ import qualified OptionalConfiguration as OC ( OptionalConfiguration(..), merge_maybes )-import TSN.FeedHosts (FeedHosts(..))+import FeedHosts (FeedHosts(..)) -- | The main configuration data type. This will be passed to most of -- the important functions once it has been created.
+ src/FeedHosts.hs view
@@ -0,0 +1,57 @@+{-# LANGUAGE DeriveDataTypeable #-}++-- | A newtype around a list of Strings which represent the feed+-- hosts. This is all to avoid an orphan instance of Configured for+-- [String] if we had defined one in e.g. OptionalConfiguration.+--+-- This was placed under the "TSN" namespace because its Default+-- instance is specific to TSN, even though otherwise it's just a+-- list of strings.+--+module FeedHosts+where++-- DC is needed only for the DCT.Configured instance of String.+import qualified Data.Configurator as DC()+import qualified Data.Configurator.Types as DCT (+ Configured,+ Value( List ),+ convert )+import Data.Data (Data)+import System.Console.CmdArgs.Default (Default(..))+import Data.Typeable (Typeable)+++-- | A (wrapper around a) list of hostnames that supply the XML feed.+--+newtype FeedHosts =+ FeedHosts { get_feed_hosts :: [String] }+ deriving (Data, Show, Typeable)+++instance Default FeedHosts where+ -- | The default list of feed hosts. These were found by checking+ -- PTR records in the neighborhood of the IP address in use. There+ -- is a feed4.sportsnetwork.com, but it was not operational when+ -- this was written.+ def = FeedHosts ["feed1.sportsnetwork.com",+ "feed2.sportsnetwork.com",+ "feed3.sportsnetwork.com"]+++instance DCT.Configured FeedHosts where+ -- | This allows us to read a FeedHosts object out of a Configurator+ -- config file. By default Configurator wouldn't know what to do,+ -- so we have to tell it that we expect a list, and if that list+ -- has strings in it, we can apply the FeedHosts constructor to+ -- it.+ convert (DCT.List xs) =+ -- mapM gives us a Maybe [String] here.+ fmap FeedHosts (mapM convert_string xs)+ where+ convert_string :: DCT.Value -> Maybe String+ convert_string = DCT.convert++ -- If we read anything other than a list of values out of the file,+ -- fail.+ convert _ = Nothing
− src/Logging.hs
@@ -1,81 +0,0 @@-module Logging (- init_logging,- log_debug,- log_error,- log_info,- log_warning )-where--import Control.Monad ( when )-import System.Log.Formatter ( simpleLogFormatter )-import System.Log.Handler ( setFormatter )-import System.Log.Handler.Simple ( GenericHandler, fileHandler )-import System.Log.Handler.Syslog (- Facility ( USER ),- openlog )-import System.Log.Logger (- Priority ( INFO ),- addHandler,- debugM,- errorM,- infoM,- rootLoggerName,- setHandlers,- setLevel,- updateGlobalLogger,- warningM )----- | Log a message at the DEBUG level.-log_debug :: String -> IO ()-log_debug = debugM rootLoggerName---- | Log a message at the ERROR level.-log_error :: String -> IO ()-log_error = errorM rootLoggerName---- | Log a message at the INFO level.-log_info :: String -> IO ()-log_info = infoM rootLoggerName---- | Log a message at the WARNING level.-log_warning :: String -> IO ()-log_warning = warningM rootLoggerName----- | Set up the logging. All logs are handled by the global "root"--- logger provided by HSLogger. We remove all of its handlers so--- that it does nothing; then we conditionally add back two handlers--- -- one for syslog, and one for a normal file -- dependent upon--- the 'syslog' and 'log_file' configuration items.------ Why don't we take a Configuration as an argument? Because it--- would create circular imports!-init_logging :: Maybe FilePath -> Priority -> Bool -> IO ()-init_logging log_file log_level syslog = do- -- First set the global log level and clear the default handler.- let no_handlers = [] :: [GenericHandler a]- updateGlobalLogger rootLoggerName (setLevel log_level .- setHandlers no_handlers)-- when syslog $ do- let min_level = INFO- let sl_level = if log_level < min_level then min_level else log_level-- -- The syslog handle gets its own level which will cowardly refuse- -- to log all debug info (i.e. the entire feed) to syslog.- sl_handler' <- openlog rootLoggerName [] USER sl_level-- -- Syslog should output the date by itself.- let sl_formatter = simpleLogFormatter "htsn[$pid] $prio: $msg"- let sl_handler = setFormatter sl_handler' sl_formatter-- updateGlobalLogger rootLoggerName (addHandler sl_handler)-- case log_file of- Nothing -> return ()- Just lf -> do- lf_handler' <- fileHandler lf log_level- let lf_formatter = simpleLogFormatter "$time: htsn[$pid] $prio: $msg"- let lf_handler = setFormatter lf_handler' lf_formatter- updateGlobalLogger rootLoggerName (addHandler lf_handler)
src/Main.hs view
@@ -5,7 +5,7 @@ where import Control.Concurrent ( threadDelay )-import Control.Exception.Base ( bracket )+import Control.Exception ( bracket, throw ) import Control.Monad ( when ) import Data.List ( isPrefixOf ) import Data.Maybe ( isNothing )@@ -37,62 +37,21 @@ exit_no_password, exit_no_username, exit_pidfile_exists )-import Logging (- init_logging,- log_debug,- log_error,- log_info,- log_warning )+import FeedHosts ( FeedHosts(..) )+import Network.Services.TSN.Logging ( init_logging ) import qualified OptionalConfiguration as OC ( OptionalConfiguration(..), from_rc )-import Terminal (- display_debug,- display_error,- display_info,- display_sent,- display_warning )-import TSN.FeedHosts ( FeedHosts(..) )-import TSN.Xml ( parse_xmlfid )+import Network.Services.TSN.Report (+ report_debug,+ report_info,+ report_warning,+ report_error )+import Network.Services.TSN.Terminal ( display_sent )+import Xml ( parse_xmlfid ) import Unix ( full_daemonize ) --- | Display and log debug information. WARNING! This does not--- automatically append a newline. The output is displayed/logged--- as-is, for, you know, debug purposes.-report_debug :: String -> IO ()-report_debug s = do- display_debug s- log_debug s ---- | Display and log an error condition. This will prefix the error--- with "ERROR: " when displaying (but not logging) it so that it--- stands out.----report_error :: String -> IO ()-report_error s = do- display_error $ "ERROR: " ++ s- log_error s----- | Display and log an informational (status) message.----report_info :: String -> IO ()-report_info s = do- display_info s- log_info s----- | Display and log a warning. This will prefix the warning with--- "WARNING: " when displaying (but not logging) it so that it--- stands out.----report_warning :: String -> IO ()-report_warning s = do- display_warning $ "WARNING: " ++ s- log_warning s-- -- | Receive a single line of text from a Handle, and send it to the -- debug log. --@@ -297,7 +256,7 @@ -- logging before the missing parameter checks below so that we can -- log the errors. let cfg = (def :: Configuration) `merge_optional` opt_config- init_logging (log_file cfg) (log_level cfg) (syslog cfg)+ init_logging (log_level cfg) (log_file cfg) (syslog cfg) -- Check the optional config for missing required options. This is -- necessary because if the user specifies an empty list of@@ -337,7 +296,7 @@ -- If we were asked to daemonize, do that; otherwise just run the thing. if (daemonize cfg)- then full_daemonize cfg run_program+ then try_daemonize cfg run_program else run_program where@@ -354,3 +313,16 @@ catchIOError (connect_and_parse cfg host) (report_error . show) thread_sleep 5 -- Wait 5s before attempting to reconnect. round_robin cfg $ (feed_host_idx + 1) `mod` (length hosts)+++ -- | A exception handler around full_daemonize. If full_daemonize+ -- doesn't work, we report the error and crash. This is fine; we+ -- only need the program to be resilient once it actually starts.+ --+ try_daemonize :: Configuration -> IO () -> IO ()+ try_daemonize cfg program =+ catchIOError+ (full_daemonize cfg program)+ (\e -> do+ report_error (show e)+ throw e)
src/OptionalConfiguration.hs view
@@ -34,9 +34,8 @@ import System.IO.Error ( catchIOError ) import System.Log ( Priority(..) ) -import Logging ( log_error ) -- Can't import report_error from Main-import Terminal ( display_error ) -- 'cause of circular imports.-import TSN.FeedHosts ( FeedHosts(..) )+import FeedHosts ( FeedHosts(..) )+import Network.Services.TSN.Report ( report_error ) -- Derive standalone instances of Data and Typeable for Priority. This@@ -145,12 +144,10 @@ from_rc :: IO OptionalConfiguration from_rc = do etc <- catchIOError getSysconfDir (\e -> do- display_error (show e)- log_error (show e)+ report_error (show e) return "/etc") home <- catchIOError getHomeDirectory (\e -> do- display_error (show e)- log_error (show e)+ report_error (show e) return "$(HOME)") let global_config_path = etc </> "htsnrc" let user_config_path = home </> ".htsnrc"
− src/TSN/FeedHosts.hs
@@ -1,57 +0,0 @@-{-# LANGUAGE DeriveDataTypeable #-}---- | A newtype around a list of Strings which represent the feed--- hosts. This is all to avoid an orphan instance of Configured for--- [String] if we had defined one in e.g. OptionalConfiguration.------ This was placed under the "TSN" namespace because its Default--- instance is specific to TSN, even though otherwise it's just a--- list of strings.----module TSN.FeedHosts-where---- DC is needed only for the DCT.Configured instance of String.-import qualified Data.Configurator as DC()-import qualified Data.Configurator.Types as DCT (- Configured,- Value( List ),- convert )-import Data.Data (Data)-import System.Console.CmdArgs.Default (Default(..))-import Data.Typeable (Typeable)----- | A (wrapper around a) list of hostnames that supply the XML feed.----newtype FeedHosts =- FeedHosts { get_feed_hosts :: [String] }- deriving (Data, Show, Typeable)---instance Default FeedHosts where- -- | The default list of feed hosts. These were found by checking- -- PTR records in the neighborhood of the IP address in use. There- -- is a feed4.sportsnetwork.com, but it was not operational when- -- this was written.- def = FeedHosts ["feed1.sportsnetwork.com",- "feed2.sportsnetwork.com",- "feed3.sportsnetwork.com"]---instance DCT.Configured FeedHosts where- -- | This allows us to read a FeedHosts object out of a Configurator- -- config file. By default Configurator wouldn't know what to do,- -- so we have to tell it that we expect a list, and if that list- -- has strings in it, we can apply the FeedHosts constructor to- -- it.- convert (DCT.List xs) =- -- mapM gives us a Maybe [String] here.- fmap FeedHosts (mapM convert_string xs)- where- convert_string :: DCT.Value -> Maybe String- convert_string = DCT.convert-- -- If we read anything other than a list of values out of the file,- -- fail.- convert _ = Nothing
− src/TSN/Xml.hs
@@ -1,88 +0,0 @@--- | Minimal XML functionality needed to parse each document's--- XML_File_ID.----module TSN.Xml (- parse_xmlfid,- xml_tests )-where--import Data.Either.Utils ( maybeToEither )-import Test.Tasty ( TestTree, testGroup )-import Test.Tasty.HUnit ( (@?=), Assertion, testCase )-import Text.Read ( readMaybe )-import Text.XML.HXT.Core (- (>>>),- (/>),- getChildren,- getText,- hasName,- runLA,- xreadDoc )----- | A tiny parser written in HXT to extract the "XML_File_ID" element--- from a document. If we fail to parse an XML_File_ID, we return--- the reason wrapped in a 'Left' constructor. The reason should be--- one of two things:------ 1. No XML_File_ID elements were found.------ 2. An XML_File_ID element was found, but it could not be read--- into an Integer.------ We use an Either rather than a Maybe because we do expect some--- non-integer XML_File_IDs. In the examples, you will see--- NHL_DepthChart_XML.XML with an XML_File_ID of "49618.61" and--- CFL_Boxscore_XML1.xml with an XML_File_ID of "R28916". According--- to Brijesh Patel of TSN, these are special category files and not--- part of the usual feed.------ We want to report them differently, "just in case."----parse_xmlfid :: String -- ^ The XML Document- -> Either String Integer-parse_xmlfid doc =- case parse_results of- [] -> Left "No XML_File_ID elements found."- (x:_) -> x- where- parse :: String -> [String]- parse =- runLA (xreadDoc- >>> hasName "message"- /> hasName "XML_File_ID"- >>> getChildren- >>> getText)-- read_either_integer :: String -> Either String Integer- read_either_integer s =- let msg = "Could not parse XML_File_ID " ++ s ++ " as an integer."- in- maybeToEither msg (readMaybe s)-- elements = parse doc- parse_results = map read_either_integer elements----- * Tasty Tests-xml_tests :: TestTree-xml_tests =- testGroup- "XML tests"- [ xml_file_id_tests ]---xml_file_id_tests :: TestTree-xml_file_id_tests =- testCase "XML_File_ID is parsed correctly" $ do- let xmlfids = ["19908216", "19908216", "19908245", "19908246", "19908247"]- mapM_ check xmlfids- where- check :: String -> Assertion- check xmlfid = do- xml <- readFile ("test/xml/" ++ xmlfid ++ ".xml")- let actual = parse_xmlfid xml- -- The maybeToEither should always succeed here, so the error- -- message goes unused.- let expected = maybeToEither "derp" (readMaybe xmlfid)- actual @?= expected
− src/Terminal.hs
@@ -1,79 +0,0 @@-module Terminal (- display_debug,- display_error,- display_info,- display_sent,- display_warning )-where--import Control.Monad.IO.Class (MonadIO(..))-import System.Console.ANSI (- SGR( SetColor ),- Color(..),- ColorIntensity( Vivid ),- ConsoleLayer( Foreground ),- hSetSGR )-import System.IO ( Handle, hPutStr, stderr, stdout )---- | Perform a computation (anything in MonadIO) with the given--- graphics mode(s) enabled. Revert to the previous graphics mode--- after the computation has finished.-with_sgr :: (MonadIO m) => Handle -> [SGR] -> m a -> m a-with_sgr h sgrs computation = do- liftIO $ hSetSGR h sgrs- x <- computation- liftIO $ hSetSGR h []- return x---- | Perform a computation (anything in MonadIO) with the output set--- to a certain color. Reset to the default color after the--- computation has finished.-with_color :: (MonadIO m) => Handle -> Color -> m a -> m a-with_color h color =- with_sgr h [SetColor Foreground Vivid color]----- | Write the given String to a handle in color. The funnyCaps are--- for synergy with putstrLn and friends.----hPutStrColor :: Handle -> Color -> String -> IO ()-hPutStrColor h c = with_color h c . hPutStr h----- | Write the given line to a handle in color. The funnyCaps are for--- synergy with putstrLn and friends.----hPutStrColorLn :: Handle -> Color -> String -> IO ()-hPutStrColorLn h c s = hPutStrColor h c (s ++ "\n")----- | Display text sent to the feed on the console. Don't automatically--- append a newline.----display_sent :: String -> IO ()-display_sent = hPutStrColor stdout Green----- | Display debug text on the console. Don't automatically append a--- newline in case the raw text is needed for, uh, debugging.----display_debug :: String -> IO ()-display_debug = putStr----- | Display an informational message on the console.----display_info :: String -> IO ()-display_info = hPutStrColorLn stdout Cyan----- | Display a warning on the console. Uses stderr instead of stdout.----display_warning :: String -> IO ()-display_warning = hPutStrColorLn stderr Yellow----- | Display an error on the console. Uses stderr instead of stdout.----display_error :: String -> IO ()-display_error = hPutStrColorLn stderr Red
src/Unix.hs view
@@ -5,7 +5,10 @@ import Control.Concurrent ( ThreadId, myThreadId ) import Control.Exception ( throwTo )+import Control.Monad ( unless )+import System.Directory ( createDirectory, doesDirectoryExist ) import System.Exit ( ExitCode( ExitSuccess ) )+import System.FilePath ( dropFileName, dropTrailingPathSeparator ) import System.IO.Error ( catchIOError ) import System.Posix ( GroupEntry ( groupID ),@@ -23,6 +26,7 @@ removeLink, setFileCreationMask, setGroupID,+ setOwnerAndGroup, setUserID, sigTERM ) import System.Posix.Daemonize ( daemonize )@@ -31,8 +35,7 @@ Configuration( pidfile, run_as_group, run_as_user ))-import Logging ( log_info, log_error )-import Terminal ( display_error )+import Network.Services.TSN.Report ( report_error, report_info ) -- | Retrieve the uid associated with the given system user name. We -- take a Maybe String as an argument so the user name can be passed@@ -60,10 +63,9 @@ -- graceful_shutdown :: Configuration -> ThreadId -> IO () graceful_shutdown cfg main_thread_id = do- log_info "SIGTERM received, removing PID file and shutting down."+ report_info "SIGTERM received, removing PID file and shutting down." catchIOError try_nicely (\e -> do- display_error (show e)- log_error (show e)+ report_error (show e) exitImmediately ExitSuccess ) where try_nicely = do@@ -71,25 +73,55 @@ throwTo main_thread_id ExitSuccess +-- | Create the directory in which we intend to store the PID+-- file. This will *not* create any parent directories. The PID+-- directory will have its owner/group changed to the user/group+-- under which we'll be running. No permissions will be set; the+-- system's umask must allow owner-write.+--+-- This is intended to create one level beneath either /var/run or+-- /run which often do not survive a reboot.+--+-- If the directory already exists, it is left alone; that is, we+-- don't change its owner/group.+--+create_pid_directory :: FilePath -- ^ The directory to contain the PID file.+ -> UserID -- ^ Owner of the new directory if created.+ -> GroupID -- ^ Group of the new directory if created.+ -> IO ()+create_pid_directory pid_directory uid gid = do+ it_exists <- doesDirectoryExist pid_directory+ unless it_exists $ do+ report_info $ "Creating PID directory " ++ pid_directory+ createDirectory pid_directory+ report_info $ "Changing owner/group of " ++ pid_directory +++ " to " ++ (show uid) ++ "/" ++ (show gid)+ setOwnerAndGroup pid_directory uid gid+ -- | Write a PID file, install a SIGTERM handler, drop privileges, and -- finally do the daemonization dance. -- full_daemonize :: Configuration -> IO () -> IO () full_daemonize cfg program = do+ uid <- get_user_id (run_as_user cfg)+ gid <- get_group_id (run_as_group cfg)++ -- This will have to be done as root and the result chowned to our+ -- user/group, so it must happen before daemonizing.+ let pid_directory = dropTrailingPathSeparator $ dropFileName $ pidfile cfg+ create_pid_directory pid_directory uid gid+ -- The call to 'daemonize' will set the umask to zero, but we want -- to retain it. So, we set the umask to zero before 'daemonize' -- can, so that we can record the previous umask value (returned by -- setFileCreationMask). orig_umask <- setFileCreationMask 0- -- This is the 'daemonize' from System.Posix.Daemonize. If it- -- doesn't work, we report the error and do not much else.- catchIOError (daemonize (program' orig_umask))- (\e -> do- display_error (show e)- log_error (show e))++ -- This is the 'daemonize' from System.Posix.Daemonize.+ daemonize (program' orig_umask uid gid) where -- We need to do all this stuff *after* we daemonize.- program' orig_umask = do+ program' orig_umask uid gid = do -- First we install a signal handler for sigTERM. We need to -- pass the thread ID to the signal handler so it knows which -- process to "exit."@@ -98,8 +130,8 @@ -- Next we drop privileges. Group ID has to go first, otherwise -- you ain't root to change groups.- get_group_id (run_as_group cfg) >>= setGroupID- get_user_id (run_as_user cfg) >>= setUserID+ setGroupID gid+ setUserID uid -- Now we create the PID file. pid <- getProcessID
+ src/Xml.hs view
@@ -0,0 +1,88 @@+-- | Minimal XML functionality needed to parse each document's+-- XML_File_ID.+--+module Xml (+ parse_xmlfid,+ xml_tests )+where++import Data.Either.Utils ( maybeToEither )+import Test.Tasty ( TestTree, testGroup )+import Test.Tasty.HUnit ( (@?=), Assertion, testCase )+import Text.Read ( readMaybe )+import Text.XML.HXT.Core (+ (>>>),+ (/>),+ getChildren,+ getText,+ hasName,+ runLA,+ xreadDoc )+++-- | A tiny parser written in HXT to extract the "XML_File_ID" element+-- from a document. If we fail to parse an XML_File_ID, we return+-- the reason wrapped in a 'Left' constructor. The reason should be+-- one of two things:+--+-- 1. No XML_File_ID elements were found.+--+-- 2. An XML_File_ID element was found, but it could not be read+-- into an Integer.+--+-- We use an Either rather than a Maybe because we do expect some+-- non-integer XML_File_IDs. In the examples, you will see+-- NHL_DepthChart_XML.XML with an XML_File_ID of "49618.61" and+-- CFL_Boxscore_XML1.xml with an XML_File_ID of "R28916". According+-- to Brijesh Patel of TSN, these are special category files and not+-- part of the usual feed.+--+-- We want to report them differently, "just in case."+--+parse_xmlfid :: String -- ^ The XML Document+ -> Either String Integer+parse_xmlfid doc =+ case parse_results of+ [] -> Left "No XML_File_ID elements found."+ (x:_) -> x+ where+ parse :: String -> [String]+ parse =+ runLA (xreadDoc+ >>> hasName "message"+ /> hasName "XML_File_ID"+ >>> getChildren+ >>> getText)++ read_either_integer :: String -> Either String Integer+ read_either_integer s =+ let msg = "Could not parse XML_File_ID " ++ s ++ " as an integer."+ in+ maybeToEither msg (readMaybe s)++ elements = parse doc+ parse_results = map read_either_integer elements+++-- * Tasty Tests+xml_tests :: TestTree+xml_tests =+ testGroup+ "XML tests"+ [ xml_file_id_tests ]+++xml_file_id_tests :: TestTree+xml_file_id_tests =+ testCase "XML_File_ID is parsed correctly" $ do+ let xmlfids = ["19908216", "19908216", "19908245", "19908246", "19908247"]+ mapM_ check xmlfids+ where+ check :: String -> Assertion+ check xmlfid = do+ xml <- readFile ("test/xml/" ++ xmlfid ++ ".xml")+ let actual = parse_xmlfid xml+ -- The maybeToEither should always succeed here, so the error+ -- message goes unused.+ let expected = maybeToEither "derp" (readMaybe xmlfid)+ actual @?= expected
test/TestSuite.hs view
@@ -1,6 +1,6 @@ import Test.Tasty ( TestTree, defaultMain ) -import TSN.Xml ( xml_tests )+import Xml ( xml_tests ) tests :: TestTree tests = xml_tests