packages feed

freckle-app (empty) → 1.0.0.1

raw patch · 31 files changed

+3075/−0 lines, 31 filesdep +Globdep +MonadRandomdep +aeson

Dependencies added: Glob, MonadRandom, aeson, ansi-terminal, base, bytestring, case-insensitive, conduit, data-default, datadog, directory, doctest, errors, exceptions, fast-logger, filepath, freckle-app, hspec, hspec-core, hspec-expectations-lifted, hspec-junit-formatter, http-client, http-conduit, http-link-header, http-types, immortal, iproute, lens, lens-aeson, load-env, monad-control, monad-logger, mtl, network, network-uri, persistent, persistent-postgresql, postgresql-simple, primitive, process, resource-pool, retry, rio, temporary, text, time, transformers, transformers-base, unliftio, unliftio-core, wai, wai-extra, yaml, yesod, yesod-core

Files

+ CHANGELOG.md view
@@ -0,0 +1,7 @@+## [v1.0.0.1](https://github.com/freckle/freckle-app/compare/v1.0.0.0...v1.0.0.1)++- Ensure `release` GitHub Action completes properly.++## [v1.0.0.0](https://github.com/freckle/freckle-app/tree/v1.0.0.0)++First tagged release.
+ LICENSE view
@@ -0,0 +1,21 @@+The MIT License (MIT)++Copyright (c) 2021 Renaissance Learning Inc++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,7 @@+# freckle-app++Haskell application toolkit used at Freckle.++---++[CHANGELOG](./CHANGELOG.md) | [LICENSE](./LICENSE)
+ doctest/Main.hs view
@@ -0,0 +1,11 @@+module Main+  ( main+  )+where++import Prelude++import Freckle.App.Test.DocTest++main :: IO ()+main = doctest "library/"
+ freckle-app.cabal view
@@ -0,0 +1,166 @@+cabal-version:      1.12+name:               freckle-app+version:            1.0.0.1+license:            MIT+license-file:       LICENSE+maintainer:         Freckle Education+homepage:           https://github.com/freckle/freckle-app#readme+bug-reports:        https://github.com/freckle/freckle-app/issues+synopsis:           Haskell application toolkit used at Freckle+description:        Please see README.md+category:           Utils+build-type:         Simple+extra-source-files:+    README.md+    CHANGELOG.md++source-repository head+    type:     git+    location: https://github.com/freckle/freckle-app++library+    exposed-modules:+        Freckle.App+        Freckle.App.Database+        Freckle.App.Datadog+        Freckle.App.Env+        Freckle.App.Env.Internal+        Freckle.App.Ghci+        Freckle.App.GlobalCache+        Freckle.App.Http+        Freckle.App.Http.Paginate+        Freckle.App.Http.Retry+        Freckle.App.Logging+        Freckle.App.RIO+        Freckle.App.Test+        Freckle.App.Test.DocTest+        Freckle.App.Test.Hspec.Runner+        Freckle.App.Time+        Freckle.App.Version+        Freckle.App.Wai+        Freckle.App.Yesod+        Network.HTTP.Link.Compat++    hs-source-dirs:     library+    other-modules:      Paths_freckle_app+    default-language:   Haskell2010+    default-extensions:+        BangPatterns DataKinds DeriveAnyClass DeriveFoldable DeriveFunctor+        DeriveGeneric DeriveLift DeriveTraversable DerivingStrategies+        FlexibleContexts FlexibleInstances GADTs GeneralizedNewtypeDeriving+        LambdaCase MultiParamTypeClasses NoImplicitPrelude+        NoMonomorphismRestriction OverloadedStrings RankNTypes+        RecordWildCards ScopedTypeVariables StandaloneDeriving+        TypeApplications TypeFamilies++    build-depends:+        Glob >=0.10.1,+        MonadRandom >=0.5.3,+        aeson >=1.5.6.0,+        ansi-terminal >=0.11,+        base >=4.14.1.0 && <5,+        bytestring >=0.10.12.0,+        case-insensitive >=1.2.1.0,+        conduit >=1.3.4.1,+        data-default >=0.7.1.1,+        datadog >=0.2.5.0,+        doctest >=0.17,+        errors >=2.3.0,+        exceptions >=0.10.4,+        fast-logger >=3.0.5,+        filepath >=1.4.2.1,+        hspec >=2.7.10,+        hspec-core >=2.7.10,+        hspec-expectations-lifted >=0.10.0,+        hspec-junit-formatter >=1.0.0.5,+        http-client >=0.6.4.1,+        http-conduit >=2.3.8,+        http-link-header >=1.2.0,+        http-types >=0.12.3,+        immortal >=0.3,+        iproute >=1.7.11,+        lens >=4.19.2,+        load-env >=0.2.1.0,+        monad-control >=1.0.2.3,+        monad-logger >=0.3.36,+        mtl >=2.2.2,+        network >=3.1.1.1,+        network-uri >=2.6.4.1,+        persistent >=2.13.1.1,+        persistent-postgresql >=2.13.0.3,+        postgresql-simple >=0.6.4,+        primitive >=0.7.1.0,+        process >=1.6.9.0,+        resource-pool >=0.2.3.2,+        retry >=0.8.1.2,+        rio >=0.1.20.0,+        text >=1.2.4.1,+        time >=1.9.3,+        transformers >=0.5.6.2,+        transformers-base >=0.4.5.2,+        unliftio >=0.2.18,+        unliftio-core >=0.2.0.1,+        wai >=3.2.3,+        wai-extra >=3.1.6,+        yaml >=0.11.5.0,+        yesod >=1.6.1.2,+        yesod-core >=1.6.20.2++test-suite doctest+    type:               exitcode-stdio-1.0+    main-is:            Main.hs+    hs-source-dirs:     doctest+    other-modules:      Paths_freckle_app+    default-language:   Haskell2010+    default-extensions:+        BangPatterns DataKinds DeriveAnyClass DeriveFoldable DeriveFunctor+        DeriveGeneric DeriveLift DeriveTraversable DerivingStrategies+        FlexibleContexts FlexibleInstances GADTs GeneralizedNewtypeDeriving+        LambdaCase MultiParamTypeClasses NoImplicitPrelude+        NoMonomorphismRestriction OverloadedStrings RankNTypes+        RecordWildCards ScopedTypeVariables StandaloneDeriving+        TypeApplications TypeFamilies++    build-depends:+        base >=4.14.1.0 && <5,+        freckle-app -any++test-suite spec+    type:               exitcode-stdio-1.0+    main-is:            Main.hs+    hs-source-dirs:     tests+    other-modules:+        Freckle.App.Env.InternalSpec+        Freckle.App.HttpSpec+        Freckle.App.VersionSpec+        Freckle.App.WaiSpec+        Spec+        Paths_freckle_app++    default-language:   Haskell2010+    default-extensions:+        BangPatterns DataKinds DeriveAnyClass DeriveFoldable DeriveFunctor+        DeriveGeneric DeriveLift DeriveTraversable DerivingStrategies+        FlexibleContexts FlexibleInstances GADTs GeneralizedNewtypeDeriving+        LambdaCase MultiParamTypeClasses NoImplicitPrelude+        NoMonomorphismRestriction OverloadedStrings RankNTypes+        RecordWildCards ScopedTypeVariables StandaloneDeriving+        TypeApplications TypeFamilies++    ghc-options:        -threaded -rtsopts -with-rtsopts=-N+    build-depends:+        aeson >=1.5.6.0,+        base >=4.14.1.0 && <5,+        bytestring >=0.10.12.0,+        directory >=1.3.6.0,+        freckle-app -any,+        hspec >=2.7.10,+        http-types >=0.12.3,+        lens >=4.19.2,+        lens-aeson >=1.1.1,+        process >=1.6.9.0,+        temporary >=1.3,+        text >=1.2.4.1,+        time >=1.9.3,+        wai >=3.2.3,+        wai-extra >=3.1.6
+ library/Freckle/App.hs view
@@ -0,0 +1,190 @@+-- | Micro-framework for building a non-web application+--+-- This is a version of the /ReaderT Design Pattern/.+--+-- <https://www.fpcomplete.com/blog/2017/06/readert-design-pattern>+--+-- == Basic Usage+--+-- Start by defining a type to hold your global application state:+--+-- > data App = App+-- >   { appDryRun :: Bool+-- >   , appLogLevel :: LogLevel+-- >   }+--+-- This type can be as complex or simple as you want. It might hold a separate+-- @Config@ attribute or may keep everything as one level of properties. It+-- could even hold an @'IORef'@ if you need mutable application state.+--+-- The only requirements are some notion of a @'LogLevel'@:+--+-- > instance HasLogging App where+-- >   getLogLevel = appLogLevel+-- >   getLogFormat _ = FormatTerminal+-- >   getLogLocation _ = LogStdout+--+-- and a way to build a value:+--+-- > loadApp :: IO App+--+-- It's likely you'll want to use @"Freckle.App.Env"@ to load your @App@:+--+-- > import qualified Freckle.App.Env as Env+-- >+-- > loadApp = Env.parse $ App+-- >   <$> Env.switch "DRY_RUN" mempty+-- >   <*> Env.flag LevelInfo LevelDebug "DEBUG" mempty+--+-- Though not required, a type synonym can make things throughout your+-- application a bit more readable:+--+-- > type AppM = ReaderT App (LoggingT IO)+--+-- Now you have application-specific actions that can do IO, log, and access+-- your state:+--+-- > myAppAction :: AppM ()+-- > myAppAction = do+-- >   isDryRun <- asks appDryRun+-- >+-- >   if isDryRun+-- >     then $logWarn "Skipping due to dry-run"+-- >     else liftIO $ fireTheMissles+--+-- These actions can be (composed of course, or) invoked by a @main@ that+-- handles the reader context and evaluating the logging action.+--+-- > main :: IO ()+-- > main = do+-- >   app <- loadApp+-- >   runApp app $ do+-- >     myAppAction+-- >     myOtherAppAction+--+-- == Database+--+-- Adding Database access requires an instance of @'HasSqlPool'@ on your @App@+-- type. Most often, this will be easiest if you indeed separate a @Config@+-- attribute:+--+-- > data Config = Config+-- >   { configDbPoolSize :: Int+-- >   , configLogLevel :: LogLevel+-- >   }+--+-- So you can isolate Env-related concerns+--+-- > loadConfig :: IO Config+-- > loadConfig = Env.parse $ Config+-- >   <$> Env.var Env.auto "PGPOOLSIZE" (Env.def 1)+-- >   <*> Env.flag LevelInfo LevelDebug "DEBUG" mempty+--+-- from the runtime application state:+--+-- > data App = App+-- >   { appConfig :: Config+-- >   , appSqlPool :: SqlPool+-- >   }+-- >+-- > instance HasLogging App where+-- >   getLogLevel = configLogLevel . appConfig+-- >   getLogFormat _ = FormatTerminal+-- >   getLogLocation _ = LogStdout+--+-- The @"Freckle.App.Database"@ module provides @'makePostgresPool'@ for+-- building a Pool given this (limited) config data:+--+-- > loadApp :: IO App+-- > loadApp = do+-- >   appConfig{..} <- loadConfig+-- >   appSqlPool <- makePostgresPool configDbPoolSize+-- >   pure App{..}+-- >+-- > instance HasSqlPool App where+-- >   getSqlPool = appSqlPool+--+-- /Note/: the actual database connection parameters (host, user, etc) are+-- (currently) parsed from conventional environment variables by the underlying+-- driver directly. Our application configuration is only involved in declaring+-- the pool size.+--+-- This unlocks @'runDB'@ for your application:+--+-- > myAppAction :: AppM [Entity Something]+-- > myAppAction = runDB $ selectList [] []+--+-- == Testing+--+-- @"Freckle.App.Test"@ exposes an @'AppExample'@ type for examples in a+-- @'SpecWith' App@ spec. The can be run by giving your @loadApp@ function to+-- Hspec's @'before'@.+--+-- Our @Test@ module also exposes @'runAppTest'@ for running @AppM@ actions and+-- lifted expectations for use within such an example.+--+-- > spec :: Spec+-- > spec = before loadApp $ do+-- >   describe "myAppAction" $ do+-- >     it "works" $ do+-- >       result <- runAppTest myAppAction+-- >       result `shouldBe` "as expected"+--+-- If your application makes use of the database, a few things will have to be+-- different:+--+-- First, we want to have a specialized @'runDB'@ in tests to avoid excessive+-- annotations because of the generalized type of @'runDB'@ itself:+--+-- > import Database.Persist.Sql+-- > import qualified Freckle.App.Database as DB+-- >+-- > runDB :: SqlPersistM IO a -> AppExample App a+-- > runDB = DB.runDB+--+-- Second, we'll probably want a conventional @withApp@ function so that we can+-- truncate tables as part of spec setup:+--+-- > import Freckle.App (runApp)+-- > import Freckle.App.Test hiding (withApp)+-- > import Test.Hspec+-- >+-- > withApp :: SpecWith App -> Spec+-- > withApp = before $ do+-- >   app <- loadApp+-- >   runSqlPool truncateTables $ appSqlPool app+-- >   pure app+--+-- And now you can write specs that also use the database:+--+-- > spec :: Spec+-- > spec = withApp $ do+-- >   describe "myAppAction" $ do+-- >     it "works" . withGraph runDB do+-- >       nodeWith -- ...+-- >       nodeWith -- ...+-- >       nodeWith -- ...+-- >+-- >       result <- lift $ runAppTest myAppAction+-- >       result `shouldBe` "as expected"+--+module Freckle.App+  ( runApp+  , module X+  )+where++import Prelude++import Control.Monad.Logger as X+import Control.Monad.Reader as X+import Freckle.App.Database as X+import Freckle.App.Logging as X+import System.IO (BufferMode(..), hSetBuffering, stderr, stdout)++runApp :: HasLogging app => app -> ReaderT app (LoggingT IO) a -> IO a+runApp app action = do+  -- Ensure output is streamed if in a Docker container+  hSetBuffering stdout LineBuffering+  hSetBuffering stderr LineBuffering+  runAppLoggerT app $ runReaderT action app
+ library/Freckle/App/Database.hs view
@@ -0,0 +1,217 @@+{-# LANGUAGE ApplicativeDo #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE StrictData #-}++-- | Database access for your @App@+module Freckle.App.Database+  (+  -- * Abstract over access to a sql database+    HasSqlPool(..)+  , SqlPool+  , Seconds(..)+  , makePostgresPool+  , makePostgresPoolWith+  , runDB+  , PostgresConnectionConf(..)+  , PostgresPasswordSource(..)+  , PostgresPassword(..)+  , envParseDatabaseConf+  , envPostgresPasswordSource+  ) where++import Prelude++import Control.Concurrent+import qualified Control.Immortal as Immortal+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Control.Monad.Logger (runNoLoggingT)+import Control.Monad.Reader+import Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as BS8+import Data.IORef+import Data.Pool+import qualified Data.Text as T+import Data.Time (UTCTime, getCurrentTime)+import Database.Persist.Postgresql+import Database.PostgreSQL.Simple+  (Connection, Only(..), connectPostgreSQL, execute)+import Database.PostgreSQL.Simple.SqlQQ (sql)+import qualified Freckle.App.Env as Env+import Freckle.App.Time (Seconds(..))+import System.Process (readProcess)++type SqlPool = Pool SqlBackend++class HasSqlPool app where+  getSqlPool :: app -> SqlPool++instance HasSqlPool SqlPool where+  getSqlPool = id++makePostgresPool :: IO SqlPool+makePostgresPool = do+  postgresPasswordSource <- Env.parse envPostgresPasswordSource+  conf <- Env.parse (envParseDatabaseConf postgresPasswordSource)+  makePostgresPoolWith conf++runDB+  :: (HasSqlPool app, MonadUnliftIO m, MonadReader app m)+  => SqlPersistT m a+  -> m a+runDB action = do+  pool <- asks getSqlPool+  runSqlPool action pool++data PostgresConnectionConf = PostgresConnectionConf+  { pccHost :: String+  , pccPort :: Int+  , pccUser :: String+  , pccPassword :: PostgresPassword+  , pccDatabase :: String+  , pccPoolSize :: Int+  , pccStatementTimeout :: Seconds+  }+  deriving stock (Show, Eq)++data PostgresPasswordSource+  = PostgresPasswordSourceIamAuth+  | PostgresPasswordSourceEnv+  deriving stock (Show, Eq)++data PostgresPassword+  = PostgresPasswordIamAuth+  | PostgresPasswordStatic String+  deriving stock (Show, Eq)++envPostgresPasswordSource :: Env.Parser PostgresPasswordSource+envPostgresPasswordSource = do+  useIam <- Env.switch "USE_RDS_IAM_AUTH" $ Env.def False+  pure $ if useIam+    then PostgresPasswordSourceIamAuth+    else PostgresPasswordSourceEnv++envParseDatabaseConf+  :: PostgresPasswordSource -> Env.Parser PostgresConnectionConf+envParseDatabaseConf source = do+  user <- Env.var Env.str "PGUSER" Env.nonEmpty+  password <- case source of+    PostgresPasswordSourceIamAuth -> pure PostgresPasswordIamAuth+    PostgresPasswordSourceEnv ->+      PostgresPasswordStatic <$> Env.var Env.str "PGPASSWORD" Env.nonEmpty+  host <- Env.var Env.str "PGHOST" Env.nonEmpty+  database <- Env.var Env.str "PGDATABASE" Env.nonEmpty+  port <- Env.var Env.auto "PGPORT" Env.nonEmpty+  poolSize <- Env.var Env.auto "PGPOOLSIZE" $ Env.def 1+  statementTimeout <- Env.var Env.auto "PGSTATEMENTTIMEOUT" $ Env.def 120+  pure PostgresConnectionConf+    { pccHost = host+    , pccPort = port+    , pccUser = user+    , pccPassword = password+    , pccDatabase = database+    , pccPoolSize = poolSize+    , pccStatementTimeout = statementTimeout+    }++data AuroraIamToken = AuroraIamToken+  { aitToken :: String+  , aitCreatedAt :: UTCTime+  , aitPostgresConnectionConf :: PostgresConnectionConf+  }+  deriving stock (Show, Eq)++createAuroraIamToken :: PostgresConnectionConf -> IO AuroraIamToken+createAuroraIamToken aitPostgresConnectionConf@PostgresConnectionConf {..} = do+  -- TODO: Consider recording how long creating an auth token takes+  -- somewhere, even if it is just in the logs, so we get an idea of how long+  -- it takes in prod.+  aitToken <- T.unpack . T.strip . T.pack <$> readProcess+    "aws"+    [ "rds"+    , "generate-db-auth-token"+    , "--hostname"+    , pccHost+    , "--port"+    , show pccPort+    , "--region"+    , "us-east-1"+    , "--username"+    , pccUser+    ]+    ""+  aitCreatedAt <- getCurrentTime+  pure AuroraIamToken { .. }++-- | Spawns a thread that refreshes the IAM auth token every minute+--+-- The IAM auth token lasts 15 minutes, but we refresh it every minute just to+-- be super safe.+--+spawnIamTokenRefreshThread+  :: PostgresConnectionConf -> IO (IORef AuroraIamToken)+spawnIamTokenRefreshThread conf = do+  tokenIORef <- newIORef =<< createAuroraIamToken conf+  void $ Immortal.create $ \_ -> Immortal.onFinish onFinishCallback $ do+    refreshIamToken conf tokenIORef+    threadDelay oneMinuteInMicroseconds+  pure tokenIORef+ where+  oneMinuteInMicroseconds = 60 * 1000000++  onFinishCallback (Left ex) =+    -- TODO: Somehow get MonadLogger-style error log message in here+    putStrLn $ "Error refreshing IAM auth token: " ++ show ex+  onFinishCallback (Right ()) = pure ()++refreshIamToken :: PostgresConnectionConf -> IORef AuroraIamToken -> IO ()+refreshIamToken conf tokenIORef = do+  token' <- createAuroraIamToken conf+  writeIORef tokenIORef token'++-- isAuroraIamTokenExpired :: AuroraIamToken -> IO Bool+-- isAuroraIamTokenExpired AuroraIamToken {..} = do+--   now <- getCurrentTime+--   let tenMinutesInSeconds = 60 * 15+--   pure $ now `diffUTCTime` aitCreatedAt > tenMinutesInSeconds++setTimeout :: PostgresConnectionConf -> Connection -> IO ()+setTimeout PostgresConnectionConf {..} conn =+  let timeoutMillis = unSeconds pccStatementTimeout * 1000+  in void $ execute conn [sql| SET statement_timeout = ? |] (Only timeoutMillis)++makePostgresPoolWith :: PostgresConnectionConf -> IO SqlPool+makePostgresPoolWith conf@PostgresConnectionConf {..} = case pccPassword of+  PostgresPasswordIamAuth -> makePostgresPoolWithIamAuth conf+  PostgresPasswordStatic password ->+    runNoLoggingT $ createPostgresqlPoolModified+      (setTimeout conf)+      (postgresConnectionString conf password)+      pccPoolSize++-- | Creates a PostgreSQL pool using IAM auth for the password.+makePostgresPoolWithIamAuth :: PostgresConnectionConf -> IO SqlPool+makePostgresPoolWithIamAuth conf@PostgresConnectionConf {..} = do+  tokenIORef <- spawnIamTokenRefreshThread conf+  runNoLoggingT $ createSqlPool (mkConn tokenIORef) pccPoolSize+ where+  -- TODO: Instead of refreshing the token before creating a connection, we+  -- could spawn a separate thread to refresh it on a timer. That way we don't+  -- waste time refreshing it when we want to make a new connection.+  mkConn tokenIORef logFunc = do+    token <- readIORef tokenIORef+    let connStr = postgresConnectionString conf (aitToken token)+    conn <- connectPostgreSQL connStr+    setTimeout conf conn+    openSimpleConn logFunc conn++postgresConnectionString :: PostgresConnectionConf -> String -> ByteString+postgresConnectionString PostgresConnectionConf {..} password =+  BS8.pack $ unwords+    [ "host=" <> pccHost+    , "port=" <> show pccPort+    , "user=" <> pccUser+    , "password=" <> password+    , "dbname=" <> pccDatabase+    ]
+ library/Freckle/App/Datadog.hs view
@@ -0,0 +1,196 @@+{-# LANGUAGE ApplicativeDo #-}+{-# LANGUAGE NamedFieldPuns #-}++-- | Datadog access for your @App@+module Freckle.App.Datadog+  (+  -- * Reader environment interface+    HasDogStatsClient(..)+  , HasDogStatsTags(..)+  , StatsClient+  , Tag++  -- * Lower-level operations+  , sendAppMetricWithTags++  -- * Higher-level operations+  , increment+  , counter+  , guage+  , histogram+  , histogramSince+  , histogramSinceMs++  -- * Reading settings at startup+  , DogStatsSettings(..)+  , envParseDogStatsEnabled+  , envParseDogStatsSettings+  , envParseDogStatsTags+  , mkStatsClient+  )+where++import Prelude++import Control.Lens (set)+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Control.Monad.Reader+import Data.Foldable (for_)+import Data.Text (Text)+import Data.Time (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)+import qualified Freckle.App.Env as Env+import Network.StatsD.Datadog hiding (metric, name, tags)+import qualified Network.StatsD.Datadog as Datadog+import Yesod.Core.Types (HandlerData, handlerEnv, rheSite)++class HasDogStatsClient app where+  getDogStatsClient :: app -> Maybe StatsClient++instance HasDogStatsClient site =>  HasDogStatsClient (HandlerData child site) where+  getDogStatsClient = getDogStatsClient . rheSite . handlerEnv++class HasDogStatsTags app where+  getDogStatsTags :: app -> [Tag]++instance HasDogStatsTags site =>  HasDogStatsTags (HandlerData child site) where+  getDogStatsTags = getDogStatsTags . rheSite . handlerEnv++increment+  :: ( MonadUnliftIO m+     , MonadReader env m+     , HasDogStatsClient env+     , HasDogStatsTags env+     )+  => Text+  -> [(Text, Text)]+  -> m ()+increment name tags = counter name tags 1++counter+  :: ( MonadUnliftIO m+     , MonadReader env m+     , HasDogStatsClient env+     , HasDogStatsTags env+     )+  => Text+  -> [(Text, Text)]+  -> Int+  -> m ()+counter name tags = sendAppMetricWithTags name tags Counter++guage+  :: ( MonadUnliftIO m+     , MonadReader env m+     , HasDogStatsClient env+     , HasDogStatsTags env+     )+  => Text+  -> [(Text, Text)]+  -> Double+  -> m ()+guage name tags = sendAppMetricWithTags name tags Gauge++histogram+  :: ( MonadUnliftIO m+     , MonadReader env m+     , HasDogStatsClient env+     , HasDogStatsTags env+     , ToMetricValue n+     )+  => Text+  -> [(Text, Text)]+  -> n+  -> m ()+histogram name tags metricValue =+  sendAppMetricWithTags name tags Histogram metricValue++histogramSince+  :: ( MonadUnliftIO m+     , MonadReader env m+     , HasDogStatsClient env+     , HasDogStatsTags env+     )+  => Text+  -> [(Text, Text)]+  -> UTCTime+  -> m ()+histogramSince = histogramSinceBy toSeconds+  where+  -- N.B. NominalDiffTime is treated as seconds when using round. Replace round+  -- with nominalDiffTimeToSeconds once we upgrade our version of the time+  -- library.+        toSeconds = round @_ @Int++histogramSinceMs+  :: ( MonadUnliftIO m+     , MonadReader env m+     , HasDogStatsClient env+     , HasDogStatsTags env+     )+  => Text+  -> [(Text, Text)]+  -> UTCTime+  -> m ()+histogramSinceMs = histogramSinceBy toMilliseconds+  where toMilliseconds = (* 1000) . realToFrac @_ @Double++histogramSinceBy+  :: ( MonadUnliftIO m+     , MonadReader env m+     , HasDogStatsClient env+     , HasDogStatsTags env+     , ToMetricValue n+     )+  => (NominalDiffTime -> n)+  -> Text+  -> [(Text, Text)]+  -> UTCTime+  -> m ()+histogramSinceBy f name tags time = do+  now <- liftIO getCurrentTime+  let delta = f $ now `diffUTCTime` time+  sendAppMetricWithTags name tags Histogram delta++sendAppMetricWithTags+  :: ( MonadUnliftIO m+     , MonadReader env m+     , HasDogStatsClient env+     , HasDogStatsTags env+     , ToMetricValue v+     )+  => Text+  -> [(Text, Text)]+  -> MetricType+  -> v+  -> m ()+sendAppMetricWithTags name tags metricType metricValue = do+  mClient <- asks getDogStatsClient++  for_ mClient $ \client -> do+    appTags <- asks getDogStatsTags++    let+      ddTags = appTags <> map (uncurry tag) tags+      ddMetric = set Datadog.tags ddTags+        $ Datadog.metric (MetricName name) metricType metricValue++    send client ddMetric++envParseDogStatsEnabled :: Env.Parser Bool+envParseDogStatsEnabled = Env.switch "DOGSTATSD_ENABLED" $ Env.def False++envParseDogStatsSettings :: Env.Parser DogStatsSettings+envParseDogStatsSettings = do+  dogStatsSettingsHost <- Env.var Env.str "DOGSTATSD_HOST" $ Env.def "127.0.0.1"+  dogStatsSettingsPort <- Env.var Env.auto "DOGSTATSD_PORT" $ Env.def 8125+  dogStatsSettingsMaxDelay <-+    Env.var Env.auto "DOGSTATSD_MAX_DELAY_MICROSECONDS" $ Env.def 1000000+  pure defaultSettings+    { dogStatsSettingsHost+    , dogStatsSettingsPort+    , dogStatsSettingsMaxDelay+    }++envParseDogStatsTags :: Env.Parser [Tag]+envParseDogStatsTags =+  Env.var (map (uncurry tag) <$> Env.keyValues) "DOGSTATSD_TAGS" $ Env.def []
+ library/Freckle/App/Env.hs view
@@ -0,0 +1,280 @@+-- | Parse the shell environment for configuration+--+-- Usage:+--+-- > import Freckle.App.Env+-- >+-- > data Config = Config -- Example+-- >   { cBatchSize :: Natural+-- >   , cDryRun :: Bool+-- >   , cLogLevel :: LogLevel+-- >   }+-- >+-- > loadConfig :: IO Config+-- > loadConfig = parse $ Config+-- >   <$> var auto "BATCH_SIZE" (def 1)+-- >   <*> switch "DRY_RUN" mempty+-- >   <*> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty+--+-- N.B. Usage is meant to mimic envparse, but the implementation is greatly+-- simplified (at loss of some features) and some bugs have been fixed.+--+-- <http://hackage.haskell.org/package/envparse>+--+module Freckle.App.Env+  (+  -- * Parsing+    Parser+  , Off(..)+  , On(..)+  , parse+  , var+  , flag+  , switch+  , handleEither++  -- * Readers+  , str+  , auto+  , time+  , keyValues+  , eitherReader++  -- * Modifiers+  , def+  , nonEmpty+  )+where++import Prelude++import Control.Error.Util (note)+import Data.Bifunctor (first, second)+import Data.String+import Data.Text (Text, pack, unpack)+import qualified Data.Text as T+import Data.Time+import Freckle.App.Env.Internal+import System.Environment (getEnvironment)+import System.Exit (die)+import Text.Read (readEither)++-- | Designates the value of a parameter when a flag is not provided.+newtype Off a = Off a++-- | Designates the value of a parameter when a flag is provided.+newtype On a = On a++-- $setup+-- >>> :{+--  let+--    exampleParse :: [(String, String)] -> Parser a -> Either [(String, Error)] a+--    exampleParse env = ($ env) . unParser+-- :}++-- | Parse the current environment in @'IO'@+--+-- The process will exit non-zero after printing any errors.+--+parse :: Parser a -> IO a+parse p = do+  env <- getEnvironment+  either (die . prettyErrors) pure $ unParser p env+ where+  prettyErrors = unlines . map (uncurry prettyError)+  prettyError name UnsetError = name <> " must be set"+  prettyError name (InvalidError msg) = name <> " is invalid:\n  " <> msg++-- | Parse a variable by name, using the given Reader and options+--+-- >>> exampleParse @String [("EDITOR", "vim")] $ var str "EDITOR" (def "vi")+-- Right "vim"+--+-- >>> exampleParse @String [] $ var str "EDITOR" (def "vi")+-- Right "vi"+--+-- Parsers are instances of @'Alternative'@, which means you can use combinators+-- like @'optional'@ or @'<|>'@.+--+-- >>> import Control.Applicative+--+-- >>> exampleParse @(Maybe String) [] $ optional $ var str "EDITOR" nonEmpty+-- Right Nothing+--+-- The above will no longer fail if the environment variable is missing, but it+-- will still validate it if it is present:+--+-- >>> exampleParse @(Maybe String) [("EDITOR", "")] $ optional $ var str "EDITOR" nonEmpty+-- Left [("EDITOR",InvalidError "value cannot be empty")]+--+-- >>> exampleParse @(Maybe String) [("EDITOR", "vim")] $ optional $ var str "EDITOR" nonEmpty+-- Right (Just "vim")+--+-- >>> let p = var str "VISUAL" nonEmpty <|> var str "EDITOR" nonEmpty <|> pure "vi"+-- >>> exampleParse @String [("VISUAL", "vim"), ("EDITOR", "ed")] p+-- Right "vim"+--+-- >>> exampleParse @String [("EDITOR", "ed")] p+-- Right "ed"+--+-- >>> exampleParse @String [] p+-- Right "vi"+--+-- Again, values that /are/ present are still validated:+--+-- >>> exampleParse @String [("VISUAL", ""), ("EDITOR", "ed")] p+-- Left [("VISUAL",InvalidError "value cannot be empty")]+--+var :: Reader a -> String -> Mod a -> Parser a+var r n (Mod m) =+  varParser $ m Var { varName = n, varReader = r, varDefault = Nothing }++-- | Parse a simple flag+--+-- If the variable is present and non-empty in the environment, the active value+-- is returned, otherwise the default is used.+--+-- >>> import Control.Monad.Logger+--+-- >>> exampleParse [("DEBUG", "1")] $ flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty+-- Right LevelDebug+--+-- >>> exampleParse [("DEBUG", "")] $ flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty+-- Right LevelInfo+--+-- >>> exampleParse [] $ flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty+-- Right LevelInfo+--+-- N.B. only the empty string is falsey:+--+-- >>> exampleParse [("DEBUG", "false")] $ flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty+-- Right LevelDebug+--+-- >>> exampleParse [("DEBUG", "no")] $ flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty+-- Right LevelDebug+--+flag :: Off a -> On a -> String -> Mod a -> Parser a+flag (Off f) (On t) n (Mod m) = varParser $ m Var+  { varName = n+  , varReader = Reader $ \case+    "" -> Right f+    _ -> Right t+  , varDefault = Just f+  }++-- | A simplified version of @'flag'@ for @'Bool'@ values+--+-- >>> exampleParse [("VERBOSE", "1")] $ switch "VERBOSE" mempty+-- Right True+--+-- >>> exampleParse [] $ switch "VERBOSE" mempty+-- Right False+--+switch :: String -> Mod Bool -> Parser Bool+switch = flag (Off False) (On True)++-- | Create a @'Reader'@ from a simple parser function+--+-- This is a building-block for other @'Reader'@s+--+eitherReader :: (String -> Either String a) -> Reader a+eitherReader f =+  Reader $ \s -> first (InvalidError . (<> (": \"" <> s <> "\""))) $ f s++-- | Use a value's @'Read'@ instance+--+-- >>> import Numeric.Natural+--+-- >>> exampleParse @Natural [("SIZE", "1")] $ var auto "SIZE" mempty+-- Right 1+--+-- >>> exampleParse @Natural [("SIZE", "-1")] $ var auto "SIZE" mempty+-- Left [("SIZE",InvalidError "Prelude.read: no parse: \"-1\"")]+--+auto :: Read a => Reader a+auto = eitherReader readEither++-- | Read a time value using the given format+--+-- >>> exampleParse [("TIME", "1985-02-12")] $ var (time "%Y-%m-%d") "TIME" mempty+-- Right 1985-02-12 00:00:00 UTC+--+-- >>> exampleParse [("TIME", "10:00PM")] $ var (time "%Y-%m-%d") "TIME" mempty+-- Left [("TIME",InvalidError "unable to parse time as %Y-%m-%d: \"10:00PM\"")]+--+time :: String -> Reader UTCTime+time fmt =+  eitherReader+    $ note ("unable to parse time as " <> fmt)+    . parseTimeM True defaultTimeLocale fmt++-- | Read key-value pairs+--+-- >>> exampleParse [("TAGS", "foo:bar,baz:bat")] $ var keyValues "TAGS" mempty+-- Right [("foo","bar"),("baz","bat")]+--+-- Value-less keys are not supported:+--+-- >>> exampleParse [("TAGS", "foo,baz:bat")] $ var keyValues "TAGS" mempty+-- Left [("TAGS",InvalidError "Key foo has no value: \"foo,baz:bat\"")]+--+-- Nor are key-less values:+--+-- >>> exampleParse [("TAGS", "foo:bar,:bat")] $ var keyValues "TAGS" mempty+-- Left [("TAGS",InvalidError "Value bat has no key: \"foo:bar,:bat\"")]+--+keyValues :: Reader [(Text, Text)]+keyValues = eitherReader $ traverse keyValue . T.splitOn "," . pack+ where+  keyValue :: Text -> Either String (Text, Text)+  keyValue t = case second (T.drop 1) $ T.breakOn ":" t of+    (k, v) | T.null v -> Left $ "Key " <> unpack k <> " has no value"+    (k, v) | T.null k -> Left $ "Value " <> unpack v <> " has no key"+    (k, v) -> Right (k, v)++-- | Use a value's @'IsString'@ instance+--+-- >>> import Data.Text (Text)+--+-- >>> exampleParse @Text [("FOO", "foo")] $ var str "FOO" mempty+-- Right "foo"+--+-- Take note: if this fails, it's basically @'error'@.+--+str :: IsString a => Reader a+str = Reader $ pure . fromString++-- | Modify parsing to fail on empty strings+--+-- >>> exampleParse @String [("FOO", "")] $ var str "FOO" nonEmpty+-- Left [("FOO",InvalidError "value cannot be empty")]+--+nonEmpty :: Mod a+nonEmpty = Mod $ \v -> v+  { varReader = Reader $ \case+    [] -> Left $ InvalidError "value cannot be empty"+    xs -> unReader (varReader v) xs+  }++-- | Declare a default value for the parser+def :: a -> Mod a+def d = Mod $ \v -> v { varDefault = Just d }++-- | Handle parsers that may fail+--+-- Handling @'Either'@ parser results causes short circuiting in the parser+-- results.+--+-- >>> exampleParse @String [("FOO", "")] $ handleEither "CONTEXT" $ pure $ Left "failed"+-- Left [("CONTEXT",InvalidError "failed")]+--+-- >>> exampleParse @String [("FOO", "")] $ handleEither "CONTEXT" $ pure $ Right "stuff"+-- Right "stuff"+--+handleEither+  :: String -- ^ Parser context reported on error+  -> Parser (Either String a)+  -> Parser a+handleEither context p = bindParser p $ \case+  Left err -> Parser $ \_ -> Left [(context, InvalidError err)]+  Right x -> pure x
+ library/Freckle/App/Env/Internal.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE TupleSections #-}++-- | Internal Env machinery exposed for testing+module Freckle.App.Env.Internal+  ( Error(..)+  , Parser(..)+  , bindParser+  , Reader(..)+  , Mod(..)+  , Var(..)+  , varParser+  )+where++import Prelude++import Control.Applicative+import Data.Bifunctor (first)++-- | Environment parsing errors+data Error+  = UnsetError+  -- ^ A variable was not found, and no default was specified+  | InvalidError String+  -- ^ A variable was found, but it failed to parse+  deriving stock (Eq, Show)++isUnsetError :: Error -> Bool+isUnsetError UnsetError = True+isUnsetError (InvalidError _) = False++-- | Parse an Environment+--+-- Errors are accumulated into tuples mapping name to error.+--+newtype Parser a = Parser+  { unParser :: [(String, String)] -> Either [(String, Error)] a+  }+  deriving stock Functor++instance Applicative Parser where+  pure a = Parser . const $ Right a+  Parser f <*> Parser a = Parser $ \env -> case (f env, a env) of+    (Right f', Right a') -> Right $ f' a'++    -- Accumulate errors+    (Left e1, Left e2) -> Left $ e1 ++ e2+    (Left e, _) -> Left e+    (_, Left e) -> Left e++instance Alternative Parser where+  empty = Parser $ const $ Left []+  Parser f <|> Parser g = Parser $ \env -> case f env of+    Left ferrs | all (isUnsetError . snd) ferrs -> case g env of+      Left gerrs -> Left (ferrs ++ gerrs)+      y -> y+    x -> x++-- | Monadic bind for @'Parser'@+--+-- This short-circuits all parsing and is not ideal for an applicative style+-- parser, which ideally reports all errors instead of short-circuiting. As such+-- a `Monad` instance is not exposed for @'Parser'@.+--+bindParser :: Parser a -> (a -> Parser b) -> Parser b+bindParser (Parser f) g = Parser $ \envs -> do+  x <- f envs+  let h = unParser $ g x+  h envs++-- | Read a single environment variable's value+--+-- This will only ever fail with @'InvalidError'@, since @'UnsetError'@ is+-- handled before invoking any @'Reader'@.+--+newtype Reader a = Reader+  { unReader :: String -> Either Error a+  }+  deriving stock (Functor)++newtype Mod a = Mod (Var a -> Var a)++instance Semigroup (Mod a) where+  Mod f <> Mod g = Mod $ f . g++instance Monoid (Mod a) where+  mempty = Mod id++data Var a = Var+  { varName :: String+  , varReader :: Reader a+  , varDefault :: Maybe a+  }++varParser :: Var a -> Parser a+varParser Var {..} = Parser $ \env -> case (lookup varName env, varDefault) of+  (Nothing, Just d) -> Right d+  (Nothing, _) -> Left [(varName, UnsetError)]+  (Just v, _) -> first (pure . (varName, )) $ unReader varReader v
+ library/Freckle/App/Ghci.hs view
@@ -0,0 +1,28 @@+module Freckle.App.Ghci+  ( runDB+  , runDB'+  , loadEnv+  , loadEnvTest+  )+where++import Prelude++import Control.Monad.Reader (ReaderT)+import Database.Persist.Postgresql (runSqlPool)+import Database.Persist.Sql (SqlBackend)+import Freckle.App.Database (makePostgresPool)+import LoadEnv (loadEnv, loadEnvFrom)++-- | Run a db action against .env+runDB :: ReaderT SqlBackend IO b -> IO b+runDB f = loadEnv *> runDB' f++-- | Run a db action+runDB' :: ReaderT SqlBackend IO b -> IO b+runDB' f = do+  pool <- makePostgresPool+  runSqlPool f pool++loadEnvTest :: IO ()+loadEnvTest = loadEnvFrom ".env.test"
+ library/Freckle/App/GlobalCache.hs view
@@ -0,0 +1,88 @@+-- | Facility for purely creating an 'IORef' in which to stash a value+--+-- Storing a truly global value can be useful for performance (e.g. to speed up+-- tests by caching a constructed App) or necessary to prevent contention (e.g.+-- by caching a single 'LoggerSet' which holds access to a log file).+--+-- In some cases, it's not possible to create an 'IORef' safely in an 'IO'+-- context to use for this purpose. Either because a library prevents it (e.g.+-- the test runner provides no such hook before triggering its parallel+-- execution) or because the current application architecture cannot allow it+-- without a high effort re-organization.+--+-- For these cases, we use this module.+--+-- == Usage+--+-- Given some function,+--+-- @+-- makeLogger :: HasLogging a => a -> IO Logger+-- makeLogger app = makeYesodLogger =<< newLoggerSet defaultBufSize+--  where+--   newLoggerSet = case getLogLocation app of+--     LogStdout -> newStdoutLoggerSet+--     LogStderr -> newStderrLoggerSet+--     LogFile f -> flip newFileLoggerSet f+-- @+--+-- Update it to cache the construction in a new top-level value,+--+-- @+-- loggerSetVar :: GlobalCache LoggerSet+-- loggerSetVar = unsafePerformIO newGlobalCache+-- {-# NOINLINE loggerSetVar #-}+--+-- makeLogger :: HasLogging a => a -> IO Logger+-- makeLogger app = makeYesodLogger+--   =<< globallyCache loggerSetVar (newLoggerSet defaultBufSize)+--  where+--   newLoggerSet = case getLogLocation app of+--     LogStdout -> newStdoutLoggerSet+--     LogStderr -> newStderrLoggerSet+--     LogFile f -> flip newFileLoggerSet f+-- @+--+module Freckle.App.GlobalCache+  ( GlobalCache+  , newGlobalCache+  , globallyCache+  , withGlobalCacheCleanup+  )+where++import Prelude++import Control.Concurrent.MVar (mkWeakMVar, modifyMVar_, newMVar)+import Control.Monad (void)+import Data.IORef++newtype GlobalCache a = GlobalCache+  { _unGlobalCache :: IORef (Maybe a)+  }++newGlobalCache :: IO (GlobalCache a)+newGlobalCache = GlobalCache <$> newIORef Nothing++globallyCache :: GlobalCache a -> IO a -> IO a+globallyCache (GlobalCache var) construct = do+  mv <- readIORef var+  maybe (cache =<< construct) pure mv+  where cache v = atomicModifyIORef' var $ const (Just v, v)++-- | Garbage collect one of our 'IORef's after an action has run+--+-- Global 'IORef's are problematic for @ghci@. @ghci@ cannot garbage collect+-- them, so any state they hold will persist for an entire @ghci@ session. This+-- causes a varietyof issues.+--+-- To avoid garbage collection issues, we can leverage a "System.Mem.Weak" to+-- add a finalizer. When that 'MVar' gets garbage collected we can clear the+-- global 'IORef'. This maintains the status quo, with minimal plumbing.+--+withGlobalCacheCleanup :: GlobalCache a -> IO b -> IO ()+withGlobalCacheCleanup (GlobalCache var) action = do+  cleanup <- newMVar ()+  void $ mkWeakMVar cleanup $ writeIORef var Nothing+  void action+  modifyMVar_ cleanup (const $ pure ())
+ library/Freckle/App/Http.hs view
@@ -0,0 +1,243 @@+-- | Centralized module for making HTTP requests from the backend+--+-- These functions:+--+-- - Do not throw exceptions on non-200+-- - May throw for other 'HttpException' cases (e.g. 'ConnectionTimeout')+-- - Handle 429-@Retry-In@ for you+-- - Capture decoding failures with 'Either' values as the 'Response' body+--+-- == Examples+--+-- Make request, retry on 429s, and parse the body as JSON.+--+-- @+-- -- Throws, but only on a complete failure to perform the request+-- resp <- 'httpJson' $ 'parseRequest_' "https://example.com"+--+-- -- Safe access+-- 'getResponseBody' resp :: Either 'HttpDecodeError' a+--+-- -- Unsafe access (throws on Left)+-- 'getResponseBodyUnsafe' resp :: m a+-- @+--+-- 'httpLbs' can be used to get a raw response (without risk of decoding+-- errors), and 'httpDecode' can be used to supply your own decoding function+-- (e.g. for CSV).+--+-- Interact with a paginated endpoint that uses @Link@, combining all the pages+-- monoidally (e.g. concat) and throwing on any decoding errors.+--+-- @+-- 'httpPaginated' 'httpJson' 'getResponseBodyUnsafe' $ 'parseRequest_' "https://..."+-- @+--+-- Decoding errors can be handled differently by adjusting what 'Monoid' you+-- convert each page's response into:+--+-- @+-- 'httpPaginated' 'httpJson' fromResponseLenient $ 'parseRequest_' "https://..."+--+-- fromResponseLenient+--   :: MonadLogger m+--   => Response (Either e [MyJsonThing])+--   -> m [MyJsonThing]+-- fromResponseLenient r = case getResponseBody r of+--      Left _ -> [] <$ logWarn "..."+--      Right a -> pure a+-- @+--+-- See "Freckle.Http.App.Paginate" to process requested pages in a streaming+-- fashion, or perform pagination based on somethign other than @Link@.+--+module Freckle.App.Http+  ( httpJson+  , HttpDecodeError(..)+  , httpDecode+  , httpLbs+  , httpNoBody+  , httpPaginated+  , sourcePaginated++  -- * Request builders+  , Request+  , parseRequest+  , parseRequest_++  -- * Request modifiers+  , addRequestHeader+  , addAcceptHeader+  , addBearerAuthorizationHeader+  , addToRequestQueryString+  , setRequestBasicAuth+  , setRequestBodyJSON+  , setRequestBodyURLEncoded+  , setRequestCheckStatus+  , setRequestPath++  -- * Response accessors+  , Response+  , getResponseStatus+  , getResponseBody++  -- ** Unsafe access+  , getResponseBodyUnsafe++  -- * Exceptions+  , HttpException(..)++  -- **+  -- | Predicates useful for handling 'HttpException's+  --+  -- For example, given a function 'guarded', which returns 'Just' a given value+  -- when a predicate holds for it (otherwise 'Nothing'), you can add+  -- error-handling specific to exceptions caused by 4XX responses:+  --+  -- @+  -- 'handleJust' (guarded 'httpExceptionIsClientError') handle4XXError $ do+  --   resp <- 'httpJson' $ 'setRequestCheckStatus' $ parseRequest_ "http://..."+  --   body <- 'getResponseBodyUnsafe' resp+  --+  --   -- ...+  -- @+  --+  , httpExceptionIsInformational+  , httpExceptionIsRedirection+  , httpExceptionIsClientError+  , httpExceptionIsServerError++  -- * "Network.HTTP.Types" re-exports+  , Status+  , statusCode+  , statusIsInformational+  , statusIsSuccessful+  , statusIsRedirection+  , statusIsClientError+  , statusIsServerError+  ) where++import Prelude++import Conduit (foldC, mapMC, runConduit, (.|))+import Control.Monad.IO.Class (MonadIO)+import Data.Aeson (FromJSON)+import qualified Data.Aeson as Aeson+import Data.Bifunctor (first)+import qualified Data.ByteString as BS+import Data.ByteString.Lazy (ByteString)+import qualified Data.ByteString.Lazy.Char8 as BSL8+import Data.List.NonEmpty (NonEmpty(..))+import qualified Data.List.NonEmpty as NE+import Freckle.App.Http.Paginate+import Freckle.App.Http.Retry+import Network.HTTP.Conduit (HttpExceptionContent(..))+import Network.HTTP.Simple hiding (httpLbs, httpNoBody)+import qualified Network.HTTP.Simple as HTTP+import Network.HTTP.Types.Header (hAccept, hAuthorization)+import Network.HTTP.Types.Status+  ( Status+  , statusCode+  , statusIsClientError+  , statusIsInformational+  , statusIsRedirection+  , statusIsServerError+  , statusIsSuccessful+  )+import UnliftIO.Exception (Exception(..), throwIO)++data HttpDecodeError = HttpDecodeError+  { hdeBody :: ByteString+  , hdeErrors :: NonEmpty String+  }+  deriving stock (Eq, Show)++instance Exception HttpDecodeError where+  displayException HttpDecodeError {..} =+    unlines+      $ ["Error decoding HTTP Response:", "Raw body:", BSL8.unpack hdeBody]+      <> fromErrors hdeErrors+   where+    fromErrors = \case+      err :| [] -> ["Error:", err]+      errs -> "Errors:" : map bullet (NE.toList errs)+    bullet = (" • " <>)++-- | Request and decode a response as JSON+httpJson+  :: (MonadIO m, FromJSON a)+  => Request+  -> m (Response (Either HttpDecodeError a))+httpJson = httpDecode (first pure . Aeson.eitherDecode)+  . addAcceptHeader "application/json"++-- | Request and decode a response+httpDecode+  :: MonadIO m+  => (ByteString -> Either (NonEmpty String) a)+  -> Request+  -> m (Response (Either HttpDecodeError a))+httpDecode decode req = do+  resp <- httpLbs req+  let body = getResponseBody resp+  pure $ first (HttpDecodeError body) . decode <$> resp++-- | Request a lazy 'ByteString', handling 429 retries+httpLbs :: MonadIO m => Request -> m (Response ByteString)+httpLbs = rateLimited httpLBS++-- | Make a Request ignoring the response, but handling 429 retries+httpNoBody :: MonadIO m => Request -> m (Response ())+httpNoBody = rateLimited HTTP.httpNoBody++-- | Request all pages of a paginated endpoint into a big list+--+-- This uses 'sourcePaginated', and so reads a @Link@ header. To do otherwise,+-- drop down to 'sourcePaginatedBy' directly.+--+-- The second argument is used to extract the data to combine out of the+-- response. This is particularly useful for 'Either' values, like you may get+-- from 'httpJson'. It lives in @m@ to support functions such as 'getResponseBodyUnsafe'.+--+httpPaginated+  :: (MonadIO m, Monoid b)+  => (Request -> m (Response a))+  -> (Response a -> m b)+  -> Request+  -> m b+httpPaginated runRequest getBody req =+  runConduit $ sourcePaginated runRequest req .| mapMC getBody .| foldC++addAcceptHeader :: BS.ByteString -> Request -> Request+addAcceptHeader = addRequestHeader hAccept++addBearerAuthorizationHeader :: BS.ByteString -> Request -> Request+addBearerAuthorizationHeader = addRequestHeader hAuthorization . ("Bearer " <>)++-- | Read an 'Either' response body, throwing any 'Left' as an exception+--+-- If you plan to use this function, and haven't built your decoding to handle+-- error response bodies too, you'll want to use 'setRequestCheckStatus' so that+-- you see status-code exceptions before 'HttpDecodeError's.+--+getResponseBodyUnsafe+  :: (MonadIO m, Exception e) => Response (Either e a) -> m a+getResponseBodyUnsafe = either throwIO pure . getResponseBody++httpExceptionIsInformational :: HttpException -> Bool+httpExceptionIsInformational = filterStatusException statusIsInformational++httpExceptionIsRedirection :: HttpException -> Bool+httpExceptionIsRedirection = filterStatusException statusIsRedirection++httpExceptionIsClientError :: HttpException -> Bool+httpExceptionIsClientError = filterStatusException statusIsClientError++httpExceptionIsServerError :: HttpException -> Bool+httpExceptionIsServerError = filterStatusException statusIsServerError++filterStatusException :: (Status -> Bool) -> HttpException -> Bool+filterStatusException predicate = \case+  HttpExceptionRequest _ (StatusCodeException resp _) ->+    predicate $ getResponseStatus resp+  _ -> False
+ library/Freckle/App/Http/Paginate.hs view
@@ -0,0 +1,102 @@+-- | Streaming interface for paginated HTTP APIs+--+-- == Examples+--+-- Take an action on each page as it is requested:+--+-- @+-- let req = parseRequest_ "https://..."+--+-- runConduit+--   $ sourcePaginated httpJson req+--   .| mapM_C onEachPage+--+-- onEachPage :: Response (Either HttpDecodeError [MyJsonThing]) -> m ()+-- onEachPage = undefined+-- @+--+-- Take and action /and/ collect:+--+-- @+-- allPages <- runConduit+--   $ 'sourcePaginated' httpJson req+--   .| iterM onEachPage+--   .| sinkList+-- @+--+-- For APIs that do pagination not via @Link@, you can use 'sourcePaginatedBy'+--+-- @+-- data Page a = Page+--   { pData :: [a]+--   , pNext :: Int+--   }+--+-- instance FromJSON a => FromJSON (Item a) where+--   parseJSON = withObject "Page" $ \o -> Page+--     <$> o .: "data"+--     <*> o .: "next"+--+-- runConduit+--   $ 'sourcePaginatedBy' nextPage httpJson req+--   .| mapMC (fmap pData . 'getResponseBodyUnsafe')+--   .| foldC+--+-- nextPage+--   :: Request+--   -> Response (Either ('HttpDecodeError' String) (Page a))+--   -> Maybe Request+-- nextPage req resp = do+--   body <- hush $ getResponseBody resp+--   let next = C8.pack $ show $ pNext body+--   pure $ addToRequestQueryString [("next", Just next)] req+-- @+--+module Freckle.App.Http.Paginate+  ( sourcePaginated+  , sourcePaginatedBy+  ) where++import Prelude++import Conduit+import Control.Error.Util (hush)+import Data.Foldable (traverse_)+import Data.List (find)+import Data.Maybe (listToMaybe)+import Data.Text.Encoding (decodeUtf8)+import Network.HTTP.Link.Compat hiding (linkHeader)+import Network.HTTP.Simple++-- | Stream pages of a paginated response, using @Link@ to find next pages+--+sourcePaginated+  :: MonadIO m+  => (Request -> m (Response body))+  -- ^ Run one request+  -> Request+  -- ^ Initial request+  -> ConduitT i (Response body) m ()+sourcePaginated = sourcePaginatedBy linkHeader++-- | Stream pages of a paginated response, using a custom /find next/+sourcePaginatedBy+  :: MonadIO m+  => (Request -> Response body -> Maybe Request)+  -- ^ How to get the next page from each request+  -> (Request -> m (Response body))+  -- ^ Run one request+  -> Request+  -- ^ Initial request+  -> ConduitT i (Response body) m ()+sourcePaginatedBy mNextRequest runRequest req = do+  resp <- lift $ runRequest req+  yield resp+  traverse_ (sourcePaginatedBy mNextRequest runRequest) $ mNextRequest req resp++linkHeader :: Request -> Response body -> Maybe Request+linkHeader _req resp = do+  header <- listToMaybe $ getResponseHeader "Link" resp+  links <- hush $ parseLinkURI $ decodeUtf8 header+  uri <- href <$> find (((Rel, "next") `elem`) . linkParams) links+  parseRequest $ show uri
+ library/Freckle/App/Http/Retry.hs view
@@ -0,0 +1,95 @@+module Freckle.App.Http.Retry+  ( RetriesExhausted(..)+  , rateLimited+  , rateLimited'+  ) where++import Prelude++import Control.Monad (guard, unless, void)+import Control.Monad.IO.Class (MonadIO)+import Control.Retry+import qualified Data.ByteString.Char8 as BS8+import Data.Maybe (listToMaybe)+import Network.HTTP.Client (Request(..))+import Network.HTTP.Simple+import Network.HTTP.Types.Status (status429)+import Text.Read (readMaybe)+import UnliftIO.Exception (Exception(..), throwIO)++-- | Thrown if we exhaust our retries limit and still see a @429@+--+-- This typically means the API is not sending back accurate @Retry-In@ values+-- with 429 responses.+--+-- __Rationale__:+--+-- In order for 'rateLimited' to function in the case when the 'Request' is+-- using 'throwErrorStatusCodes' for 'checkResponse', we have to modify it to+-- not throw on 429s specifically. Otherwise, the first response would just+-- throw due to 4XX and never retry. However, in that case of someone expecting+-- invalid statuses to throw an exception, if we exhaust our retries and still+-- see a 429 at the end, an exception should be thrown.+--+-- Unfortunately, it's not possible to reuse the user-defined 'checkResponse' in+-- order to throw a uniform 'HttpException' in this case; so we throw this+-- ourselves instead.+--+data RetriesExhausted = RetriesExhausted+  { reLimit :: Int+  , reResponse :: Response ()+  }+  deriving stock Show++instance Exception RetriesExhausted where+  displayException RetriesExhausted {..} =+    "Retries exhaused after "+      <> show reLimit+      <> " attempts. Final response:\n"+      <> show reResponse++rateLimited+  :: MonadIO m => (Request -> m (Response body)) -> Request -> m (Response body)+rateLimited = rateLimited' 10++-- | 'rateLimited' but with configurable retry limit+rateLimited'+  :: MonadIO m+  => Int+  -> (Request -> m (Response body))+  -> Request+  -> m (Response body)+rateLimited' retryLimit f req = do+  resp <- retryingDynamic+    (limitRetries retryLimit)+    (\_ ->+      pure+        . maybe DontRetry (ConsultPolicyOverrideDelay . microseconds)+        . getRetryAfter+    )+    (\_ -> f $ suppressRetryStatusError req)++  checkRetriesExhausted retryLimit resp++suppressRetryStatusError :: Request -> Request+suppressRetryStatusError req = req+  { checkResponse = \req' resp ->+    unless (getResponseStatus resp == status429)+      $ originalCheckResponse req' resp+  }+  where originalCheckResponse = checkResponse req++checkRetriesExhausted :: MonadIO m => Int -> Response body -> m (Response body)+checkRetriesExhausted retryLimit resp+  | getResponseStatus resp == status429 = throwIO+  $ RetriesExhausted { reLimit = retryLimit, reResponse = void resp }+  | otherwise = pure resp++getRetryAfter :: Response body -> Maybe Int+getRetryAfter resp = do+  guard $ getResponseStatus resp == status429+  header <- listToMaybe $ getResponseHeader "Retry-After" resp+  readMaybe $ BS8.unpack header++microseconds :: Int -> Int+microseconds = (* 1000000)
+ library/Freckle/App/Logging.hs view
@@ -0,0 +1,197 @@+{-# LANGUAGE TupleSections #-}++module Freckle.App.Logging+  (+  -- * Logging settings+    HasLogging(..)+  , getLogDefaultANSI+  , getLogBehaviors+  , LogLevel+  , LogFormat(..)+  , LogLocation(..)++  -- ** Loading+  , parseEnvLogFormat+  , parseEnvLogLevel+  , parseEnvLogLocation++  -- * 'MonadLogger'-style running+  , runAppLoggerT++  -- * Formats, for use from other Logging libraries+  , formatJsonLogStr+  , formatJsonNoLoc+  , formatJson+  , formatTerminal+  )+where++import Prelude++import Control.Monad.Logger+import Data.Aeson (ToJSON, encode, object, (.=))+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy as BSL+import Data.Text (Text)+import Data.Text.Encoding (decodeUtf8)+import qualified Freckle.App.Env as Env+import System.Console.ANSI+  ( Color(Blue, Magenta, Red, Yellow)+  , ColorIntensity(Dull)+  , ConsoleLayer(Foreground)+  , SGR(Reset, SetColor)+  , hSupportsANSI+  , setSGRCode+  )+import System.IO (stderr, stdout)++data LogFormat+  = FormatJSON+  -- ^ Emit @{"level": "{level}", "message": "{message}"}@+  | FormatTerminal+  -- ^ Emit @[{level}] @{message}@, possibly colorized++data LogLocation+  = LogStdout+  | LogStderr+  | LogFile FilePath++-- | Class for getting Logging settings from your @app@ type+class HasLogging a where+    getLogLevel :: a -> LogLevel++    getLogFormat :: a -> LogFormat++    getLogLocation :: a -> LogLocation++-- | Provide a pure decision for colorizing output+--+-- This is useful in a context where actively checking for ANSI terminal support+-- is either not possible or too expensive. Given that we support 'LogFile', and+-- so are unlikely to be redirecting terminal output to a file, it is relatively+-- safe to use this determination.+--+getLogDefaultANSI :: HasLogging a => a -> Bool+getLogDefaultANSI app = case (getLogLocation app, getLogFormat app) of+  (LogStdout, FormatTerminal) -> True+  (LogStdout, FormatJSON) -> False+  (LogStderr, FormatTerminal) -> True+  (LogStderr, FormatJSON) -> False+  (LogFile _, FormatTerminal) -> False+  (LogFile _, FormatJSON) -> False++getLogBehaviors :: HasLogging a => a -> IO (ByteString -> IO (), Bool)+getLogBehaviors app = case getLogLocation app of+  LogStdout -> (BS8.hPutStr stdout, ) <$> hSupportsANSI stdout+  LogStderr -> (BS8.hPutStr stderr, ) <$> hSupportsANSI stderr+  LogFile path -> pure (BS8.appendFile path, False)++parseEnvLogLevel :: Env.Parser LogLevel+parseEnvLogLevel = Env.var parse "LOG_LEVEL" $ Env.def LevelWarn+ where+  parse = Env.eitherReader $ \case+    "warn" -> Right LevelWarn+    "error" -> Right LevelError+    "debug" -> Right LevelDebug+    "info" -> Right LevelInfo+    level -> Left $ "unexpected log level: " <> level++parseEnvLogFormat :: Env.Parser LogFormat+parseEnvLogFormat = Env.var parse "LOG_FORMAT" $ Env.def FormatTerminal+ where+  parse = Env.eitherReader $ \case+    "json" -> Right FormatJSON+    "terminal" -> Right FormatTerminal+    format -> Left $ "unexpected format: " <> format++parseEnvLogLocation :: Env.Parser LogLocation+parseEnvLogLocation = Env.var parse "LOG_LOCATION" $ Env.def LogStdout+ where+  parse = Env.eitherReader $ \case+    "stdout" -> Right LogStdout+    "stderr" -> Right LogStderr+    "file" -> Right $ LogFile "fancy.log"+    file -> Right $ LogFile file++runAppLoggerT :: HasLogging a => a -> LoggingT IO b -> IO b+runAppLoggerT app f = do+  (putLogLine, isANSI) <- getLogBehaviors app++  let+    logger = case getLogFormat app of+      FormatJSON -> jsonLogger putLogLine+      FormatTerminal -> ansiLogger putLogLine isANSI++  flip runLoggingT logger+    $ filterLogger (\_ level -> level >= getLogLevel app) f+ where+  jsonLogger putLogLine loc src level str =+    putLogLine $ formatJsonLogStr loc src level str++  ansiLogger putLogLine isANSI loc src level str =+    putLogLine $ formatTerminal isANSI loc src level str++formatJsonLogStr :: Loc -> LogSource -> LogLevel -> LogStr -> ByteString+formatJsonLogStr loc src level =+  formatJson (Just loc) (Just src) level . decodeUtf8 . fromLogStr++formatJsonNoLoc :: ToJSON a => LogLevel -> a -> ByteString+formatJsonNoLoc = formatJson Nothing Nothing++formatJson+  :: ToJSON a => Maybe Loc -> Maybe LogSource -> LogLevel -> a -> ByteString+formatJson loc src level msg = (<> "\n") $ BSL.toStrict $ encode $ object+  [ "loc" .= (locJson <$> loc)+  , "src" .= src+  , "level" .= levelText level+  , "message" .= msg+  ]+ where+  locJson Loc {..} = object+    [ "filename" .= loc_filename+    , "package" .= loc_package+    , "module" .= loc_module+    , "start" .= loc_start+    , "end" .= loc_end+    ]++formatTerminal+  :: ToLogStr a+  => Bool -- ^ Supports escapes?+  -> Loc+  -> LogSource+  -> LogLevel+  -> a+  -> ByteString+formatTerminal isANSI loc src level str = mconcat+  [ esc $ style level+  , BS.snoc levelStr labelEnd+  , esc Reset+  , BS.intercalate (BS.singleton labelEnd) logStr+  , esc Reset+  ]+ where+  labelEnd = fromIntegral $ fromEnum ']'++  (levelStr : logStr) =+    BS.split labelEnd . fromLogStr $ defaultLogStr loc src level $ toLogStr str++  esc x = if isANSI then BS8.pack $ setSGRCode [x] else ""++style :: LogLevel -> SGR+style = \case+  LevelDebug -> SetColor Foreground Dull Magenta+  LevelInfo -> SetColor Foreground Dull Blue+  LevelWarn -> SetColor Foreground Dull Yellow+  LevelError -> SetColor Foreground Dull Red+  LevelOther _ -> Reset++levelText :: LogLevel -> Text+levelText = \case+  LevelDebug -> "Debug"+  LevelInfo -> "Info"+  LevelWarn -> "Warn"+  LevelError -> "Error"+  LevelOther x -> x
+ library/Freckle/App/RIO.hs view
@@ -0,0 +1,74 @@+-- | Compatibility between "Freckle.App" and "RIO"+--+-- "Freckle.App" was created before "RIO" existed. We need to decide if we're+-- going to move to using "RIO" without "Freckle.App" (and port the things+-- we've added to be "RIO"-based), or not.+--+-- As part of that decisions, some Apps are using "RIO". These should still be+-- able to make use of "Freckle.App", by using this module.+--+module Freckle.App.RIO+  ( toAppLogLevel+  , fromAppLogLevel+  , makeLogFunc+  )+where++import Prelude++import Control.Monad (when)+import Control.Monad.Logger (Loc(..), LogLevel(..))+import Data.Maybe (fromMaybe, listToMaybe)+import Freckle.App.Logging+import GHC.Exception (CallStack, SrcLoc(..), getCallStack)+import qualified RIO++toAppLogLevel :: RIO.LogLevel -> LogLevel+toAppLogLevel = \case+  RIO.LevelDebug -> LevelDebug+  RIO.LevelInfo -> LevelInfo+  RIO.LevelWarn -> LevelWarn+  RIO.LevelError -> LevelError+  RIO.LevelOther x -> LevelOther x++fromAppLogLevel :: LogLevel -> RIO.LogLevel+fromAppLogLevel = \case+  LevelDebug -> RIO.LevelDebug+  LevelInfo -> RIO.LevelInfo+  LevelWarn -> RIO.LevelWarn+  LevelError -> RIO.LevelError+  LevelOther x -> RIO.LevelOther x++makeLogFunc :: HasLogging a => a -> IO RIO.LogFunc+makeLogFunc app = do+  (putLogLine, isANSI) <- getLogBehaviors app++  pure $ RIO.mkLogFunc $ \cs src rioLevel builder -> do+    let+      level = toAppLogLevel rioLevel+      msg = RIO.utf8BuilderToText builder++    when (level >= getLogLevel app) $ putLogLine $ case getLogFormat app of+      FormatJSON -> formatJson (Just $ callStackToLoc cs) (Just src) level msg+      FormatTerminal -> formatTerminal isANSI (callStackToLoc cs) src level msg++callStackToLoc :: CallStack -> Loc+callStackToLoc cs = fromMaybe unknownLoc $ do+  (_, SrcLoc {..}) <- listToMaybe $ getCallStack cs++  pure $ Loc+    { loc_filename = srcLocFile+    , loc_package = srcLocPackage+    , loc_module = srcLocModule+    , loc_start = (srcLocStartLine, srcLocStartCol)+    , loc_end = (srcLocEndLine, srcLocEndCol)+    }++unknownLoc :: Loc+unknownLoc = Loc+  { loc_filename = "<unknown>"+  , loc_package = "<unknown>"+  , loc_module = "unknown"+  , loc_start = (0, 0)+  , loc_end = (0, 0)+  }
+ library/Freckle/App/Test.hs view
@@ -0,0 +1,104 @@+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE UndecidableInstances #-}+{-# OPTIONS_GHC -fno-warn-deprecations #-}++module Freckle.App.Test+  ( AppExample+  , withApp+  , withAppSql+  , runAppTest+  , module X+  ) where++import Prelude++import Control.Monad.Base+import Control.Monad.Catch+import Control.Monad.IO.Unlift+import Control.Monad.Logger+import Control.Monad.Primitive+import Control.Monad.Random (MonadRandom(..))+import Control.Monad.Reader+import Control.Monad.Trans.Control+import Data.Pool as X+import Database.Persist.Sql (SqlPersistT, runSqlPool)+import Freckle.App.Database as X+import LoadEnv+import Test.Hspec as X+  (Spec, beforeAll, beforeWith, context, describe, example, fit, it, xit)+import Test.Hspec.Core.Spec (Arg, Example, SpecWith, evaluateExample)+import Test.Hspec.Expectations.Lifted as X++-- | An Hspec example over some @App@ value+newtype AppExample app a = AppExample (NoLoggingT (ReaderT app IO) a)+  deriving newtype+    ( Applicative+    , Functor+    , Monad+    , MonadBase IO+    , MonadBaseControl IO+    , MonadCatch+    , MonadIO+    , MonadReader app+    , MonadThrow+    , MonadUnliftIO+    , MonadFail+    , MonadLogger+    )++instance MonadRandom (AppExample app) where+  getRandomR = liftIO . getRandomR+  getRandom = liftIO getRandom+  getRandomRs = liftIO . getRandomRs+  getRandoms = liftIO getRandoms++instance PrimMonad (AppExample app) where+  type PrimState (AppExample app) = PrimState IO+  primitive = liftIO . primitive++instance Example (AppExample app a) where+  type Arg (AppExample app a) = app++  evaluateExample (AppExample ex) params action = evaluateExample+    (action $ \app -> void $ runReaderT (runNoLoggingT ex) app)+    params+    ($ ())++-- | Spec before helper+--+-- @+-- spec :: Spec+-- spec = 'withApp' loadApp $ do+-- @+--+-- Reads @.env.test@, then @.env@, then loads the application. Examples within+-- this spec can use 'runAppTest' (and 'runDB', if the app 'HasSqlPool').+--+withApp :: IO app -> SpecWith app -> Spec+withApp load = beforeAll (loadEnvTest *> load)++-- | 'withApp', with custom DB 'Pool' initialization+--+-- Runs the given function on the pool before every spec item. For example, to+-- truncate tables.+--+withAppSql+  :: HasSqlPool app => SqlPersistT IO a -> IO app -> SpecWith app -> Spec+withAppSql f load = beforeAll (loadEnvTest *> load) . beforeWith setup+  where setup app = app <$ runSqlPool f (getSqlPool app)++loadEnvTest :: IO ()+loadEnvTest = loadEnvFrom ".env.test" >> loadEnv++-- | Run an action with the test @App@+--+-- Like @'runApp'@, but without exception handling or logging+--+runAppTest :: ReaderT app (LoggingT IO) a -> AppExample app a+runAppTest action = do+  app <- ask++  liftIO $ runStderrLoggingT $ filterLogger (\_ _ -> False) $ runReaderT+    action+    app
+ library/Freckle/App/Test/DocTest.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE PackageImports #-}++module Freckle.App.Test.DocTest+  ( doctest+  , doctestWith++  -- * Lower-level, for site-specific use+  , findPackageFlags+  , findDocTestedFiles+  )+where++import Prelude++import Control.Monad (filterM)+import Data.Aeson+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as T+import Data.Yaml (decodeFileThrow)+import "Glob" System.FilePath.Glob (globDir1)+import qualified Test.DocTest as DocTest++-- | Run doctest on files in the given directory+doctest :: FilePath -> IO ()+doctest = doctestWith []++-- | Run doctest on files in the given directory and with additional flags+doctestWith :: [String] -> FilePath -> IO ()+doctestWith flags dir = do+  packageFlags <- findPackageFlags+  sourceFiles <- findDocTestedFiles dir+  DocTest.doctest $ packageFlags <> flags <> sourceFiles++-- | Representation of only the information we need in a @package.yaml@+data PackageYaml+  = PackageYaml+  { defaultExtensions :: [String]+  , name :: String+  }++instance FromJSON PackageYaml where+  parseJSON = withObject "PackageYaml"+    $ \o -> PackageYaml <$> o .: "default-extensions" <*> o .: "name"++-- Parse @default-extensions@ and @name& out of @package.yaml@+--+-- NB. This won't find target-specific extensions. If your package does this+-- (consider not, then) add them via the direct argument to @'doctestWith'@.+--+findPackageFlags :: IO [String]+findPackageFlags = do+  PackageYaml {..} <- decodeFileThrow "package.yaml"+  pure $ ("-package " <> name) : map ("-X" <>) defaultExtensions++-- | Find any source files with doctest comments+--+-- Doctest with a lot of files is really slow. Like /really/ slow:+--+-- <https://github.com/sol/doctest/issues/141>+--+-- Also, some suites won't actually work on a lot of our files because of some+-- instance-import-related debt that we don't have the time to clean up at this+-- time:+--+-- <https://freckleinc.slack.com/archives/C459XJBGR/p1519220418000125>+--+-- So we want to only run doctest for files that need it. This function finds+-- such files by /naively/ looking for the @^-- >>>@ pattern.+--+findDocTestedFiles :: FilePath -> IO [FilePath]+findDocTestedFiles dir = do+  paths <- globDir1 "**/*.hs" dir+  filterM (fmap hasDocTests . T.readFile) paths++hasDocTests :: Text -> Bool+hasDocTests = any ("-- >>>" `T.isInfixOf`) . T.lines
+ library/Freckle/App/Test/Hspec/Runner.hs view
@@ -0,0 +1,101 @@+module Freckle.App.Test.Hspec.Runner+  ( run+  , runParConfig+  , runWith+  , makeParallelConfig+  )+where++import Prelude++import Control.Applicative ((<|>))+import Control.Concurrent (getNumCapabilities, setNumCapabilities)+import Control.Monad (void)+import Control.Monad.Trans.Maybe (MaybeT(MaybeT), runMaybeT)+import Data.List (isInfixOf)+import Data.Maybe (fromMaybe, isJust)+import System.Environment (getArgs, lookupEnv)+import Test.HSpec.JUnit (runJUnitSpec)+import Test.Hspec (Spec)+import Test.Hspec.Runner+  ( Config+  , Path+  , configConcurrentJobs+  , configSkipPredicate+  , defaultConfig+  , evaluateSummary+  , readConfig+  , runSpec+  )++run :: String -> Spec -> IO ()+run = runWith defaultConfig++runParConfig :: String -> Spec -> IO ()+runParConfig name spec = do+  config <- makeParallelConfig defaultConfig+  runWith config name spec++runWith :: Config -> String -> Spec -> IO ()+runWith config name spec = do+  args <- getArgs+  isCircle <- isJust <$> lookupEnv "CIRCLECI"+  let runner = if isCircle then junit else hspec++  -- Run unreliable tests first, so local dev errors are reported for reliable+  -- specs at the end+  putStrLn "Running UNRELIABLE tests; failures here should not fail the build"+  void $ runner ("unreliable-" <> name) id =<< load+    args+    (skip reliableTests config)++  putStrLn "Running RELIABLE"+  reliableSummary <- runner name id+    =<< load args (skip (anys [unreliableTests, isolatedTests]) config)++  putStrLn "Running ISOLATED"+  isolatedSummary <- runner ("isolated-" <> name) noConcurrency+    =<< load args (skip (not . isolatedTests) config)++  evaluateSummary $ reliableSummary <> isolatedSummary+ where+  load = flip readConfig+  junit filename changeConfig =+    (spec `runJUnitSpec` ("/tmp/junit", filename)) . changeConfig+  hspec _ changeConfig = runSpec spec . changeConfig+  noConcurrency x = x { configConcurrentJobs = Just 1 }++makeParallelConfig :: Config -> IO Config+makeParallelConfig config = do+  jobCores <- fromMaybe 1 <$> runMaybeT+    (MaybeT lookupTestCapabilities <|> MaybeT lookupHostCapabilities)+  putStrLn $ "Running spec with " <> show jobCores <> " cores"+  setNumCapabilities jobCores+  -- Api specs are IO bound, having more jobs than cores allows for more+  -- cooperative IO from green thread interleaving.+  pure config { configConcurrentJobs = Just $ jobCores * 4 }++lookupTestCapabilities :: IO (Maybe Int)+lookupTestCapabilities = fmap read <$> lookupEnv "TEST_CAPABILITIES"++lookupHostCapabilities :: IO (Maybe Int)+lookupHostCapabilities = Just . reduceCapabilities <$> getNumCapabilities++-- Reduce capabilities to avoid contention with postgres+reduceCapabilities :: Int -> Int+reduceCapabilities = max 1 . (`div` 2)++skip :: (Path -> Bool) -> Config -> Config+skip predicate config = config { configSkipPredicate = Just predicate }++unreliableTests :: Path -> Bool+unreliableTests = ("UNRELIABLE" `isInfixOf`) . snd++reliableTests :: Path -> Bool+reliableTests = not . unreliableTests++isolatedTests :: Path -> Bool+isolatedTests = ("ISOLATED" `isInfixOf`) . snd++anys :: [a -> Bool] -> a -> Bool+anys xs a = or $ fmap (\f -> f a) xs
+ library/Freckle/App/Time.hs view
@@ -0,0 +1,10 @@+-- | Time types and functions for @App@+module Freckle.App.Time+  ( Seconds(..)+  ) where++import Prelude++newtype Seconds = Seconds { unSeconds :: Int }+  deriving stock (Show, Read)+  deriving newtype (Eq, Num)
+ library/Freckle/App/Version.hs view
@@ -0,0 +1,102 @@+-- | Facilities for inferring an application version+--+-- Various inputs are checked: files written during a docker build, git+-- information, or falling back to an unknown version. This is useful for+-- Bugsnag reports, client age comparison, etc.+--+module Freckle.App.Version+  ( AppVersion(..)+  , getAppVersion+  , tryGetAppVersion+  )+where++import Prelude++import Control.Applicative ((<|>))+import Control.Error.Util (hoistEither, note)+import Control.Monad.IO.Class (MonadIO(..))+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Control.Monad.Trans.Except+import Data.Bifunctor (first)+import Data.Char (isSpace)+import Data.List (dropWhileEnd)+import Data.Text (Text, pack)+import qualified Data.Text as T+import Data.Time (UTCTime, getCurrentTime)+import Data.Time.Format (defaultTimeLocale, parseTimeM)+import System.Exit (ExitCode(..))+import System.FilePath ((</>))+import System.Process (readProcessWithExitCode)+import UnliftIO.Exception (tryIO)++data AppVersion = AppVersion+  { avName :: Text+  , avCreatedAt :: UTCTime+  }+  deriving stock (Eq, Show)++-- | Attempt to infer an @'AppVersion'@+--+-- - If files exist under @\/app-version@ they ar read, otherwise+-- - If we're in a Git repository commit information is used, otherwise+-- - An /Unknown/ version as of the current time is returned+--+getAppVersion :: MonadUnliftIO m => m AppVersion+getAppVersion =+  either (const getAppVersionUnknown) pure =<< tryGetAppVersion "/app-version"++-- | A more testable version of @'getAppVersion'@+--+-- - Reports what didn't work in @'Left'@+-- - Accepts a parent path, for file-system version information+--+tryGetAppVersion+  :: MonadUnliftIO m => FilePath -> m (Either [String] AppVersion)+tryGetAppVersion parent =+  runExceptT+    $ withExceptT pure (getAppVersionFiles parent)+    <|> withExceptT pure getAppVersionGit++getAppVersionFiles :: MonadIO m => FilePath -> ExceptT String m AppVersion+getAppVersionFiles parent = do+  name <- readFileExceptT $ parent </> "name"+  seconds <- readFileExceptT $ parent </> "created-at"+  hoistEither $ toAppVersion name seconds++getAppVersionGit :: MonadIO m => ExceptT String m AppVersion+getAppVersionGit = do+  name <- git ["rev-parse", "HEAD"]+  seconds <- git ["show", "--no-patch", "--no-notes", "--pretty=%at"]+  hoistEither $ toAppVersion name seconds++toAppVersion :: String -> String -> Either String AppVersion+toAppVersion name seconds = do+  createdAt <- parseUnixSeconds $ strip seconds+  pure AppVersion { avName = T.strip $ pack name, avCreatedAt = createdAt }++parseUnixSeconds :: String -> Either String UTCTime+parseUnixSeconds x = note err $ parseTimeM True defaultTimeLocale "%s" x+  where err = x <> " does not parse as UTCTime with format %s"++getAppVersionUnknown :: MonadIO m => m AppVersion+getAppVersionUnknown = AppVersion "Unknown" <$> liftIO getCurrentTime++readFileExceptT :: MonadIO m => FilePath -> ExceptT String m String+readFileExceptT path = ExceptT $ liftIO $ first err <$> tryIO (readFile path)+  where err ex = "readFile: " <> show ex++git :: MonadIO m => [String] -> ExceptT String m String+git args = do+  (ec, stdout, stderr) <- exceptIO $ readProcessWithExitCode "git" args []++  case ec of+    ExitSuccess -> pure stdout+    ExitFailure n ->+      throwE $ "[" <> show n <> "] git " <> unwords args <> ": " <> stderr++exceptIO :: MonadIO m => IO a -> ExceptT String m a+exceptIO = withExceptT show . ExceptT . liftIO . tryIO++strip :: String -> String+strip = dropWhile isSpace . dropWhileEnd isSpace
+ library/Freckle/App/Wai.hs view
@@ -0,0 +1,217 @@+-- | Integration of "Freckle.App" tooling with "Network.Wai"+module Freckle.App.Wai+  ( RouteName(..)+  , TraceId(..)+  , makeLoggingMiddleware+  , noCacheMiddleware+  , corsMiddleware+  )+where++import Prelude++import Control.Applicative ((<|>))+import Control.Monad (guard)+import Control.Monad.Logger (LogLevel(..))+import Data.Aeson+import Data.Bifunctor (first)+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import Data.ByteString.Builder (toLazyByteString)+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy as BSL+import qualified Data.CaseInsensitive as CI+import Data.Default (def)+import Data.IP (fromHostAddress, fromHostAddress6)+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text.Encoding (decodeUtf8With)+import Data.Text.Encoding.Error (lenientDecode)+import Freckle.App.Logging+import Network.HTTP.Types (QueryItem, ResponseHeaders)+import Network.HTTP.Types.Status (Status, status200, statusCode)+import Network.Socket+import Network.Wai+import Network.Wai.Middleware.AddHeaders (addHeaders)+import Network.Wai.Middleware.RequestLogger+  ( Destination(Logger)+  , OutputFormat(..)+  , OutputFormatterWithDetails+  , destination+  , mkRequestLogger+  , outputFormat+  )+import System.Log.FastLogger (LoggerSet, toLogStr)++newtype RouteName = RouteName+  { unRouteName :: Text+  }+  deriving newtype ToJSON++newtype TraceId = TraceId+  { unTraceId :: Text+  }+  deriving newtype ToJSON++makeLoggingMiddleware+  :: HasLogging app+  => app+  -> (Request -> Maybe RouteName)+  -> (Request -> Maybe TraceId)+  -> LoggerSet+  -> IO Middleware+makeLoggingMiddleware app getRouteName getTraceId ls = case getLogFormat app of+  FormatJSON ->+    makeWith+      $ CustomOutputFormatWithDetails+      $ suppressByStatus (getLogLevel app)+      $ jsonOutputFormatter getRouteName getTraceId+  FormatTerminal -> makeWith $ Detailed $ getLogDefaultANSI app+ where+  makeWith format =+    mkRequestLogger def { outputFormat = format, destination = Logger ls }++suppressByStatus+  :: LogLevel -> OutputFormatterWithDetails -> OutputFormatterWithDetails+suppressByStatus minLevel f date req status responseSize duration reqBody response+  | statusLevel status >= minLevel+  = f date req status responseSize duration reqBody response+  | otherwise+  = ""++jsonOutputFormatter+  :: (Request -> Maybe RouteName)+  -> (Request -> Maybe TraceId)+  -> OutputFormatterWithDetails+jsonOutputFormatter getRouteName getTraceId date req status responseSize duration _reqBody response+  = toLogStr $ formatJsonNoLoc (statusLevel status) $ object+    [ "time" .= decodeUtf8 date+    , "method" .= decodeUtf8 (requestMethod req)+    , "route" .= getRouteName req+    , "path" .= decodeUtf8 (rawPathInfo req)+    , "query_string" .= map queryItemToJSON (queryString req)+    , "status" .= statusCode status+    , "duration_ms" .= (duration * 1000)+    , "request_size" .= requestBodyLengthToJSON (requestBodyLength req)+    , "response_size" .= responseSize+    , "response_body" .= do+      guard $ statusCode status >= 400+      Just $ maybeDecodeToValue $ toLazyByteString response+    , "trace_id" .= getTraceId req+    , "client_ip" .= (decodeUtf8 <$> clientIp)+    ]+  where clientIp = requestRealIp req <|> Just (sockAddrToIp $ remoteHost req)++statusLevel :: Status -> LogLevel+statusLevel status = case statusCode status of+  404 -> LevelInfo -- Special case+  code | code >= 500 -> LevelError+  code | code >= 400 -> LevelWarn+  code | code >= 300 -> LevelInfo+  _ -> LevelDebug++decodeUtf8 :: ByteString -> Text+decodeUtf8 = decodeUtf8With lenientDecode++requestBodyLengthToJSON :: RequestBodyLength -> Value+requestBodyLengthToJSON ChunkedBody = String "Unknown"+requestBodyLengthToJSON (KnownLength l) = toJSON l++queryItemToJSON :: QueryItem -> Value+queryItemToJSON (name, mValue) =+  toJSON (decodeUtf8 name, decodeUtf8 <$> mValue)++-- Try to decode as a 'Value'. Otherwise make a JSON string.+maybeDecodeToValue :: BSL.ByteString -> Value+maybeDecodeToValue str =+  fromMaybe (toJSON . decodeUtf8With lenientDecode . BSL.toStrict $ str)+    . decode @Value+    $ str++-- Copied from bugnag-haskell++requestRealIp :: Request -> Maybe ByteString+requestRealIp request =+  requestForwardedFor request <|> lookup "X-Real-IP" (requestHeaders request)++requestForwardedFor :: Request -> Maybe ByteString+requestForwardedFor request =+  readForwardedFor =<< lookup "X-Forwarded-For" (requestHeaders request)++-- |+--+-- >>> readForwardedFor ""+-- Nothing+--+-- >>> readForwardedFor "123.123.123"+-- Just "123.123.123"+--+-- >>> readForwardedFor "123.123.123, 45.45.45"+-- Just "123.123.123"+--+readForwardedFor :: ByteString -> Maybe ByteString+readForwardedFor bs+  | BS8.null bs = Nothing+  | otherwise = Just $ fst $ BS8.break (== ',') bs++sockAddrToIp :: SockAddr -> ByteString+sockAddrToIp (SockAddrInet _ h) = BS8.pack $ show $ fromHostAddress h+sockAddrToIp (SockAddrInet6 _ _ h _) = BS8.pack $ show $ fromHostAddress6 h+sockAddrToIp (SockAddrUnix _) = "<socket>"++noCacheMiddleware :: Middleware+noCacheMiddleware = addHeaders [cacheControlHeader]+ where+  cacheControlHeader =+    ("Cache-Control", "no-cache, no-store, max-age=0, private")++corsMiddleware+  :: (ByteString -> Bool)+  -- ^ Predicate that returns 'True' for valid @Origin@ values+  -> [ByteString]+  -- ^ Extra headers to add to @Expose-Headers@+  -> Middleware+corsMiddleware validateOrigin extraExposedHeaders =+  handleOptions validateOrigin extraExposedHeaders+    . addCORSHeaders validateOrigin extraExposedHeaders++handleOptions :: (ByteString -> Bool) -> [ByteString] -> Middleware+handleOptions validateOrigin extraExposedHeaders app req sendResponse =+  case (requestMethod req, lookup "Origin" (requestHeaders req)) of+    ("OPTIONS", Just origin) -> sendResponse $ responseLBS+      status200+      (toHeaders $ corsResponseHeaders validateOrigin extraExposedHeaders origin+      )+      mempty+    _ -> app req sendResponse+ where+  toHeaders :: [(ByteString, ByteString)] -> ResponseHeaders+  toHeaders = map (first CI.mk)++addCORSHeaders :: (ByteString -> Bool) -> [ByteString] -> Middleware+addCORSHeaders validateOrigin extraExposedHeaders app req sendResponse =+  case lookup "Origin" (requestHeaders req) of+    Nothing -> app req sendResponse+    Just origin -> addHeaders+      (corsResponseHeaders validateOrigin extraExposedHeaders origin)+      app+      req+      sendResponse++corsResponseHeaders+  :: (ByteString -> Bool)+  -> [ByteString]+  -> ByteString+  -> [(ByteString, ByteString)]+corsResponseHeaders validateOrigin extraExposedHeaders origin =+  [ ("Access-Control-Allow-Origin", validatedOrigin)+  , ("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, PATCH")+  , ("Access-Control-Allow-Credentials", "true")+  , ("Access-Control-Allow-Headers", "Content-Type, *")+  , ("Access-Control-Expose-Headers", BS.intercalate ", " exposedHeaders)+  ]+ where+  validatedOrigin = if validateOrigin origin then origin else "BADORIGIN"++  exposedHeaders =+    ["Set-Cookie", "Content-Disposition", "Link"] <> extraExposedHeaders
+ library/Freckle/App/Yesod.hs view
@@ -0,0 +1,55 @@+-- | Integration of "Freckle.App" tooling with "Yesod"+module Freckle.App.Yesod+  ( makeLogger+  , messageLoggerSource+  )+where++import Prelude++import Control.Monad (when)+import Control.Monad.Logger+import Freckle.App.GlobalCache+import Freckle.App.Logging+import System.IO.Unsafe (unsafePerformIO)+import System.Log.FastLogger+  ( LoggerSet+  , defaultBufSize+  , newFileLoggerSet+  , newStderrLoggerSet+  , newStdoutLoggerSet+  )+import Yesod.Core.Types (Logger, loggerPutStr)+import Yesod.Default.Config2 (makeYesodLogger)++loggerSetVar :: GlobalCache LoggerSet+loggerSetVar = unsafePerformIO newGlobalCache+{-# NOINLINE loggerSetVar #-}+{-# ANN loggerSetVar ("HLint: ignore Avoid restricted function" :: String) #-}++makeLogger :: HasLogging a => a -> IO Logger+makeLogger app = makeYesodLogger+  =<< globallyCache loggerSetVar (newLoggerSet defaultBufSize)+ where+  newLoggerSet = case getLogLocation app of+    LogStdout -> newStdoutLoggerSet+    LogStderr -> newStderrLoggerSet+    LogFile f -> flip newFileLoggerSet f++messageLoggerSource+  :: HasLogging a+  => a+  -> Logger+  -> Loc+  -> LogSource+  -> LogLevel+  -> LogStr+  -> IO ()+messageLoggerSource app logger loc src level str =+  when (level >= getLogLevel app)+    $ loggerPutStr logger+    $ toLogStr+    $ case getLogFormat app of+        FormatJSON -> formatJsonLogStr loc src level str+        FormatTerminal ->+          formatTerminal (getLogDefaultANSI app) loc src level str
+ library/Network/HTTP/Link/Compat.hs view
@@ -0,0 +1,27 @@+{-# LANGUAGE CPP #-}+module Network.HTTP.Link.Compat+    ( Link+    , linkURI+    , parseLinkURI+    , module Network.HTTP.Link+    )+where++import Prelude++import Data.Text (Text)+import Network.HTTP.Link hiding (Link)+import qualified Network.HTTP.Link as HTTP+import Network.URI (URI)++#if MIN_VERSION_http_link_header(1,2,0)+type Link = HTTP.Link URI+#else+type Link = HTTP.Link+#endif++linkURI :: URI -> [(LinkParam, Text)] -> Link+linkURI = HTTP.Link++parseLinkURI :: Text -> Either String [Link]+parseLinkURI = parseLinkHeader'
+ tests/Freckle/App/Env/InternalSpec.hs view
@@ -0,0 +1,125 @@+module Freckle.App.Env.InternalSpec+  ( spec+  )+where++import Prelude++import Control.Applicative+import Freckle.App.Env+import Freckle.App.Env.Internal+import Numeric.Natural+import Test.Hspec++spec :: Spec+spec = do+  describe "Parser" $ do+    context "Alternative" $ do+      let+        run :: Parser a -> Either [(String, Error)] a+        run p = unParser p [("PRESENT", "present"), ("INVALID_NAT", "-1")]++      -- Left identity: 'empty <|> x == x'+      it "satisfies left identity" $ do+        run @String (empty <|> var str "PRESENT" mempty)+          `shouldBe` Right "present"++        run @Natural (empty <|> var auto "INVALID_NAT" mempty) `shouldBe` Left+          [("INVALID_NAT", InvalidError "Prelude.read: no parse: \"-1\"")]++        run @String (empty <|> var str "MISSING" mempty)+          `shouldBe` Left [("MISSING", UnsetError)]++      -- Right identity: 'x <|> empty == x'+      it "satisfies right identity" $ do+        run @String (var str "PRESENT" mempty <|> empty)+          `shouldBe` Right "present"++        run @Natural (var auto "INVALID_NAT" mempty <|> empty) `shouldBe` Left+          [("INVALID_NAT", InvalidError "Prelude.read: no parse: \"-1\"")]++        run @String (var str "MISSING" mempty <|> empty)+          `shouldBe` Left [("MISSING", UnsetError)]++      -- Annihilation: 'f <$> empty == empty'+      it "satisfies annihilation" $ do+        run @Natural ((+ 1) <$> empty) `shouldBe` Left []+        run @String ((++ "!") <$> empty) `shouldBe` Left []++      -- Associativity: 'x <|> (y <|> z) == (x <|> y) <|> z'+      it "satisfies associativity" $ do+        run @String (empty <|> (empty <|> empty)) `shouldBe` Left []+        run @String ((empty <|> empty) <|> empty) `shouldBe` Left []++        run @Natural+            (empty+            <|> (var auto "INVALID_NAT" mempty <|> var auto "MISSING" mempty)+            )+          `shouldBe` Left+                       [ ( "INVALID_NAT"+                         , InvalidError "Prelude.read: no parse: \"-1\""+                         )+                       ]+        run @Natural+            ((empty <|> var auto "INVALID_NAT" mempty)+            <|> var auto "MISSING" mempty+            )+          `shouldBe` Left+                       [ ( "INVALID_NAT"+                         , InvalidError "Prelude.read: no parse: \"-1\""+                         )+                       ]++        run @String+            (empty <|> (var str "MISSING" mempty <|> var str "PRESENT" mempty))+          `shouldBe` Right "present"+        run @String+            ((empty <|> var str "MISSING" mempty) <|> var str "PRESENT" mempty)+          `shouldBe` Right "present"++      -- Distributivity: 'f <$> (x <|> y) == (f <$> x) <|> (f <$> y)'+      it "satisfies distributivity" $ do+        run @Natural+            ((+ 1)+            <$> (var auto "NOT_PRESENT" mempty+                <|> var auto "INVALID_NAT" mempty+                )+            )+          `shouldBe` Left+                       [ ("NOT_PRESENT", UnsetError)+                       , ( "INVALID_NAT"+                         , InvalidError "Prelude.read: no parse: \"-1\""+                         )+                       ]++        run @Natural+            ((+ 1)+            <$> var auto "NOT_PRESENT" mempty+            <|> (+ 1)+            <$> var auto "INVALID_NAT" mempty+            )+          `shouldBe` Left+                       [ ("NOT_PRESENT", UnsetError)+                       , ( "INVALID_NAT"+                         , InvalidError "Prelude.read: no parse: \"-1\""+                         )+                       ]++        run @String+            ((++ "!")+            <$> (var str "NOT_PRESENT" mempty <|> var str "PRESENT" mempty)+            )+          `shouldBe` Right "present!"++        run @String+            ((++ "!")+            <$> var str "NOT_PRESENT" mempty+            <|> (++ "!")+            <$> var str "PRESENT" mempty+            )+          `shouldBe` Right "present!"++      -- Left catch: 'pure x <|> empty = pure x'+      it "satisfies left catch"+        $ run (pure True <|> empty)+        `shouldBe` Right True
+ tests/Freckle/App/HttpSpec.hs view
@@ -0,0 +1,43 @@+module Freckle.App.HttpSpec+  ( spec+  ) where++import Prelude++import Control.Lens (_Left, _Right, to, (^?))+import Data.Aeson+import Data.Aeson.Lens+import qualified Data.List.NonEmpty as NE+import Freckle.App.Http+import Network.HTTP.Types.Status (status200)+import Test.Hspec++spec :: Spec+spec = do+  describe "httpJson" $ do+    it "fetches JSON via HTTP" $ do+      resp <- httpJson @_ @Value+        $ parseRequest_ "https://www.stackage.org/lts-17.10"++      getResponseStatus resp `shouldBe` status200+      getResponseBody resp+        ^? _Right+        . key "snapshot"+        . key "ghc"+        . _String+        `shouldBe` Just "8.10.4"++    it "places JSON parse errors in a Left body" $ do+      resp <- httpJson @_ @[()]+        $ parseRequest_ "https://www.stackage.org/lts-17.10"++      let+        expectedErrorMessage+          = "Error in $: parsing [] failed, expected Array, but encountered Object"++      getResponseStatus resp `shouldBe` status200+      getResponseBody resp+        ^? _Left+        . to hdeErrors+        . to NE.toList+        `shouldBe` Just [expectedErrorMessage]
+ tests/Freckle/App/VersionSpec.hs view
@@ -0,0 +1,59 @@+module Freckle.App.VersionSpec+  ( spec+  )+where++import Prelude++import Data.Bifunctor (first)+import Data.Maybe (fromMaybe)+import Data.Text (unpack)+import Data.Time (UTCTime)+import Data.Time.Format (defaultTimeLocale, parseTimeM)+import Freckle.App.Version+import System.Directory (withCurrentDirectory)+import System.IO.Temp (withSystemTempDirectory)+import System.Process (callProcess, readProcess)+import Test.Hspec++spec :: Spec+spec = do+  describe "getAppVersion" $ do+    -- NB. This is a single test case because the temporary directory and git+    -- stuff steps all over itself when run in parallel.+    --+    -- This could be a bug in withSystemTempDirectory or withCurrentDirectory?+    --+    it "tries files, then git, and records failed attempts" $ do+      inTemporaryDirectory $ \tmp -> do+        eVersion1 <- tryGetAppVersion tmp+        first (map $ unwords . take 6 . words) eVersion1 `shouldBe` Left+          [ "readFile: " <> tmp <> "/name: openFile: does not exist"+          , "[128] git rev-parse HEAD: fatal: not"+          ]++        callProcess "git" ["init", "--quiet"]+        callProcess "git" ["commit", "--quiet", "--allow-empty", "-m", "x"]+        sha <- readProcess "git" ["rev-parse", "HEAD"] ""++        eVersion2 <- tryGetAppVersion tmp+        fmap (unpack . (<> "\n") . avName) eVersion2 `shouldBe` Right sha++        let seconds = "1582301740"+        writeFile "name" "a version"+        writeFile "created-at" seconds++        eVersion3 <- tryGetAppVersion tmp+        eVersion3 `shouldBe` Right AppVersion+          { avName = "a version"+          , avCreatedAt = parseTimeUnsafe "%s" seconds+          }++inTemporaryDirectory :: (FilePath -> IO a) -> IO a+inTemporaryDirectory f = withSystemTempDirectory "freckle-app-tests"+  $ \tmp -> withCurrentDirectory tmp $ f tmp++parseTimeUnsafe :: String -> String -> UTCTime+parseTimeUnsafe fmt str = fromMaybe err+  $ parseTimeM True defaultTimeLocale fmt str+  where err = error $ str <> " did not parse as UTCTime format " <> fmt
+ tests/Freckle/App/WaiSpec.hs view
@@ -0,0 +1,120 @@+module Freckle.App.WaiSpec+  ( spec+  )+where++import Prelude++import Data.ByteString (ByteString)+import Data.Function (on)+import Data.List (deleteBy)+import Freckle.App.Wai (corsMiddleware, noCacheMiddleware)+import Network.HTTP.Types.Method (Method)+import Network.HTTP.Types.Status (status200)+import Network.Wai+import Network.Wai.Test+import Test.Hspec++spec :: Spec+spec = do+  describe "noCacheMiddleware" $ do+    let+      runTestSession :: Session a -> IO a+      runTestSession f = runSession f $ noCacheMiddleware app++    it "adds an appropriate Cache-Control header" $ runTestSession $ do+      response <- request defaultRequest++      assertHeader+        "Cache-Control"+        "no-cache, no-store, max-age=0, private"+        response+      assertBody "Test" response++  describe "corsMiddleware" $ do+    let+      runTestSession :: Session a -> IO a+      runTestSession = runTestSessionWith (const False) []++      runTestSessionWith+        :: (ByteString -> Bool) -> [ByteString] -> Session a -> IO a+      runTestSessionWith validateOrigin extraExposedHeaders f =+        runSession f $ corsMiddleware validateOrigin extraExposedHeaders app++    it "adds CORS headers to responses for non-OPTIONS" $ runTestSession $ do+      response <- request $ setMethod "GET" $ setOriginHeader+        "unimportant"+        defaultRequest++      assertAccessControlHeaders "BADORIGIN" response+      assertBody "Test" response++    it "responds itself, with CORS headers, for OPTIONS" $ runTestSession $ do+      response <- request $ setMethod "OPTIONS" $ setOriginHeader+        "unimportant"+        defaultRequest++      assertAccessControlHeaders "BADORIGIN" response+      assertBody mempty response++    it "doesn't operate on requests without Origin" $ runTestSession $ do+      response1 <- request defaultRequest+      response2 <- request $ setMethod "OPTIONS" defaultRequest++      assertBody "Test" response1+      assertNoAccessControlHeaders response1+      assertBody "Test" response2+      assertNoAccessControlHeaders response2++    it "accepts only valid Origins" $ runTestSessionWith (== "A") [] $ do+      responseA <- request $ setOriginHeader "A" defaultRequest+      responseB <- request $ setOriginHeader "B" defaultRequest++      assertAccessControlHeaders "A" responseA+      assertAccessControlHeaders "BADORIGIN" responseB+      assertBody "Test" responseA+      assertBody "Test" responseB++    it "adds extra Exposed-Headers"+      $ runTestSessionWith (const False) ["X-Foo"]+      $ do+          response <- request $ setOriginHeader "unimportant" defaultRequest+          assertHeader+            "Access-Control-Expose-Headers"+            "Set-Cookie, Content-Disposition, Link, X-Foo"+            response++app :: Application+app _req respond = respond $ responseLBS status200 [] "Test"++setMethod :: Method -> Request -> Request+setMethod method req = req { requestMethod = method }++setOriginHeader :: ByteString -> Request -> Request+setOriginHeader origin req =+  let+    header = ("Origin", origin)+    others = deleteBy ((==) `on` fst) header $ requestHeaders req+  in req { requestHeaders = others <> [header] }++assertAccessControlHeaders :: ByteString -> SResponse -> Session ()+assertAccessControlHeaders origin response = do+  assertHeader "Access-Control-Allow-Origin" origin response+  assertHeader+    "Access-Control-Allow-Methods"+    "POST, GET, OPTIONS, PUT, DELETE, PATCH"+    response+  assertHeader "Access-Control-Allow-Credentials" "true" response+  assertHeader "Access-Control-Allow-Headers" "Content-Type, *" response+  assertHeader+    "Access-Control-Expose-Headers"+    "Set-Cookie, Content-Disposition, Link"+    response++assertNoAccessControlHeaders :: SResponse -> Session ()+assertNoAccessControlHeaders response = do+  assertNoHeader "Access-Control-Allow-Origin" response+  assertNoHeader "Access-Control-Allow-Methods" response+  assertNoHeader "Access-Control-Allow-Credentials" response+  assertNoHeader "Access-Control-Allow-Headers" response+  assertNoHeader "Access-Control-Expose-Headers" response
+ tests/Main.hs view
@@ -0,0 +1,13 @@+module Main+  ( main+  )+where++import Prelude++import Freckle.App.Test.Hspec.Runner (runParConfig)+import qualified Spec+import Test.Hspec++main :: IO ()+main = "freckle-app" `runParConfig` parallel Spec.spec
+ tests/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --module-name=Spec -optF --no-main #-}