packages feed

sofetch-0.1.0.0: examples/GitHubExplorer.hs

{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE RankNTypes #-}

-- | Concurrent exploration of the GitHub REST API.
--
-- This example demonstrates sofetch with an HTTP backend where the
-- value is concurrency, deduplication, and caching, not SQL batching.
--
-- Run it with:
--
-- @
-- stack run github-explorer
-- @
--
-- __Rate limit note:__ GitHub allows 60 unauthenticated requests per
-- hour. This example uses ~15 requests against a handful of stable
-- accounts. To raise the limit to 5,000/hour, set:
--
-- @
-- export GITHUB_TOKEN=ghp_your_token_here
-- @
module Main (main) where

import Fetch

import Control.Exception (SomeException)
import Control.Monad (when)
import Data.Aeson ((.:), (.:?), withObject, FromJSON(..), eitherDecode')
import Data.ByteString.Lazy (ByteString)
import Data.IORef
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import GHC.Generics (Generic)
import Network.HTTP.Client
  ( Manager
  , newManager, parseRequest, httpLbs, responseBody, responseStatus
  , requestHeaders
  )
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Types.Status (statusCode)
import System.Environment (lookupEnv)

-- ══════════════════════════════════════════════
-- Domain types (parsed from GitHub JSON)
-- ══════════════════════════════════════════════

data GitHubUser = GitHubUser
  { ghUserLogin      :: !Text
  , ghUserName       :: !(Maybe Text)
  , ghUserPublicRepos :: !Int
  , ghUserFollowers  :: !Int
  } deriving (Show, Eq, Generic)

instance FromJSON GitHubUser where
  parseJSON = withObject "GitHubUser" $ \o -> GitHubUser
    <$> o .: "login"
    <*> o .:? "name"
    <*> o .: "public_repos"
    <*> o .: "followers"

data GitHubRepo = GitHubRepo
  { ghRepoName       :: !Text
  , ghRepoStars      :: !Int
  , ghRepoLanguage   :: !(Maybe Text)
  , ghRepoFork       :: !Bool
  } deriving (Show, Eq, Generic)

instance FromJSON GitHubRepo where
  parseJSON = withObject "GitHubRepo" $ \o -> GitHubRepo
    <$> o .: "name"
    <*> o .: "stargazers_count"
    <*> o .:? "language"
    <*> o .: "fork"

-- ══════════════════════════════════════════════
-- FetchKey types
-- ══════════════════════════════════════════════

-- | Fetch a GitHub user profile by login name.
newtype UserLogin = UserLogin Text
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (Hashable)

instance FetchKey UserLogin where
  type Result UserLogin = GitHubUser

-- | Fetch the public repos for a GitHub user.
newtype UserRepos = UserRepos Text
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (Hashable)

instance FetchKey UserRepos where
  type Result UserRepos = [GitHubRepo]

-- ══════════════════════════════════════════════
-- Application monad
-- ══════════════════════════════════════════════

-- | Environment: HTTP manager + optional auth token.
data AppEnv = AppEnv
  { envManager :: !Manager
  , envToken   :: !(Maybe Text)
  }

newtype AppM a = AppM { unAppM :: AppEnv -> IO a }

instance Functor AppM where
  fmap f (AppM g) = AppM $ \e -> fmap f (g e)

instance Applicative AppM where
  pure a = AppM $ \_ -> pure a
  AppM ff <*> AppM fx = AppM $ \e -> ff e <*> fx e

instance Monad AppM where
  AppM ma >>= f = AppM $ \e -> do
    a <- ma e
    unAppM (f a) e

askEnv :: AppM AppEnv
askEnv = AppM pure

liftIO :: IO a -> AppM a
liftIO io = AppM $ \_ -> io

runAppM :: AppEnv -> AppM a -> IO a
runAppM env (AppM f) = f env

-- ══════════════════════════════════════════════
-- HTTP helpers
-- ══════════════════════════════════════════════

-- | Make an authenticated GET request to the GitHub API.
-- Returns Nothing on non-200 responses (so per-key errors don't
-- take down the entire batch).
githubGet :: String -> AppM (Maybe ByteString)
githubGet url = do
  env <- askEnv
  req <- Main.liftIO $ parseRequest url
  let authHeaders = case envToken env of
        Just tok -> [("Authorization", "token " <> TE.encodeUtf8 tok)]
        Nothing  -> []
      req' = req
        { requestHeaders =
            ("User-Agent", "sofetch-example/0.1")
          : ("Accept", "application/vnd.github.v3+json")
          : authHeaders ++ requestHeaders req
        }
  Main.liftIO $ putStrLn $ "  [HTTP] GET " <> url
  resp <- Main.liftIO $ httpLbs req' (envManager env)
  let code = statusCode (responseStatus resp)
  if code == 200
    then pure (Just (responseBody resp))
    else do
      Main.liftIO $ putStrLn $ "  [HTTP] " <> show code <> " " <> url
      pure Nothing

-- ══════════════════════════════════════════════
-- DataSource instances
-- ══════════════════════════════════════════════

-- | Each UserLogin key fires a separate HTTP request, but sofetch
-- runs them concurrently (default FetchStrategy = Concurrent) and
-- deduplicates across the computation.
--
-- Uses 'optionalBatchFetch': keys whose HTTP request fails are
-- omitted from the result map. The engine fills them with an error
-- so that 'tryFetch' returns @Left@ and 'fetch' throws, but
-- successful keys are unaffected.
instance DataSource AppM UserLogin where
  batchFetch = optionalBatchFetch $ \(UserLogin login) -> do
    mbs <- githubGet $ "https://api.github.com/users/" <> T.unpack login
    case mbs of
      Nothing -> pure Nothing
      Just bs -> case eitherDecode' bs of
        Right a  -> pure (Just a)
        Left err -> do
          Main.liftIO $ putStrLn $ "  [JSON] decode error: " <> err
          pure Nothing

instance DataSource AppM UserRepos where
  batchFetch = optionalBatchFetch $ \(UserRepos login) -> do
    mbs <- githubGet $ "https://api.github.com/users/" <> T.unpack login <> "/repos?per_page=5&sort=stars"
    case mbs of
      Nothing -> pure Nothing
      Just bs -> case eitherDecode' bs of
        Right a  -> pure (Just a)
        Left err -> do
          Main.liftIO $ putStrLn $ "  [JSON] decode error: " <> err
          pure Nothing

-- ══════════════════════════════════════════════
-- Instrumented runner
-- ══════════════════════════════════════════════

-- | Run a Fetch computation with detailed round-by-round logging.
runFetchIO :: AppEnv -> Fetch AppM a -> IO a
runFetchIO env action = do
  cRef <- newCacheRef
  totalRoundsRef <- newIORef (0 :: Int)
  totalKeysRef   <- newIORef (0 :: Int)
  totalHitsRef   <- newIORef (0 :: Int)

  let e = FetchEnv
        { fetchCache = cRef
        , fetchLower = runAppM env
        , fetchLift  = Main.liftIO
        }

      withRound n batches exec = do
        let pending  = batchSize batches
            sources  = batchSourceCount batches
        Main.liftIO $ putStrLn $ "  ┌─ Round " <> show n
          <> ": " <> show pending <> " key(s) across "
          <> show sources <> " source(s)"

        stats <- exec

        let dispatched = roundKeys stats - roundCacheHits stats
        Main.liftIO $ do
          when (roundCacheHits stats > 0) $
            putStrLn $ "  │  Cache: " <> show (roundCacheHits stats) <> " hit(s)"
          putStrLn $ "  │  Dispatched: " <> show dispatched <> " key(s) to data sources"
          putStrLn $ "  └─ Round " <> show n <> " complete"
          modifyIORef' totalRoundsRef (+ 1)
          modifyIORef' totalKeysRef (+ roundKeys stats)
          modifyIORef' totalHitsRef (+ roundCacheHits stats)

  a <- runAppM env $ runLoopWith e withRound action

  rounds <- readIORef totalRoundsRef
  keys   <- readIORef totalKeysRef
  hits   <- readIORef totalHitsRef
  putStrLn $ "  ── Summary: " <> show rounds <> " round(s), "
    <> show keys <> " key(s), "
    <> show hits <> " cache hit(s)"
  pure a

-- ══════════════════════════════════════════════
-- Scenarios
-- ══════════════════════════════════════════════

header :: String -> String -> IO ()
header num desc = do
  putStrLn ""
  putStrLn $ "━━━ Scenario " <> num <> ": " <> desc <> " ━━━"

showUser :: GitHubUser -> String
showUser u = T.unpack (ghUserLogin u)
  <> " (" <> maybe "?" T.unpack (ghUserName u)
  <> ", " <> show (ghUserPublicRepos u) <> " repos"
  <> ", " <> show (ghUserFollowers u) <> " followers)"

main :: IO ()
main = do
  mgr <- newManager tlsManagerSettings
  mToken <- lookupEnv "GITHUB_TOKEN"
  let env = AppEnv mgr (T.pack <$> mToken)

  putStrLn "sofetch GitHub Explorer"
  putStrLn $ "Auth: " <> maybe "none (60 req/hour limit)" (const "token (5000 req/hour)") mToken

  -- ── Scenario 1: Concurrent fetches ────────────
  header "1" "Concurrent fetches"
  putStrLn "Fetching two users in parallel (one round, two concurrent HTTP requests)."
  (u1, u2) <- runFetchIO env $
    (,) <$> fetch (UserLogin "haskell") <*> fetch (UserLogin "rust-lang")
  putStrLn $ "  => " <> showUser u1
  putStrLn $ "  => " <> showUser u2

  -- ── Scenario 2: Monadic chain ─────────────────
  header "2" "Monadic chain (2 rounds)"
  putStrLn "Fetching a user, then their repos (data dependency forces 2 rounds)."
  (user, repos) <- runFetchIO env $ do
    u <- fetch (UserLogin "haskell")                 -- round 1
    rs <- fetch (UserRepos (ghUserLogin u))          -- round 2
    pure (u, rs)
  putStrLn $ "  => " <> showUser user
  putStrLn $ "  => Top repos: " <> show (map ghRepoName (take 3 repos))

  -- ── Scenario 3: Fan-out ───────────────────────
  header "3" "Fan-out (fetch many, then fan out)"
  putStrLn "Fetching 3 users in round 1, then all their repos in round 2."
  let logins = [UserLogin "haskell", UserLogin "rust-lang", UserLogin "golang"]
  allRepos <- runFetchIO env $ do
    users <- fetchAll logins                                    -- round 1: 3 concurrent HTTP
    fetchAll (map (UserRepos . ghUserLogin) users)              -- round 2: 3 concurrent HTTP
  putStrLn $ "  => Repo counts: " <> show (map length allRepos)

  -- ── Scenario 4: Deduplication + caching ───────
  header "4" "Deduplication + caching"
  putStrLn "Fetching 'haskell' from TWO code paths. Only ONE HTTP request fires."
  putStrLn "Then fetching 'haskell' again in a later round: cache hit, no HTTP."
  result <- runFetchIO env $ do
    -- Both of these want UserLogin "haskell"; deduplicated into one request
    (a, b) <- (,) <$> fetch (UserLogin "haskell") <*> fetch (UserLogin "haskell")
    -- This monadic bind forces a new round, but the cache has the value
    _ <- fetch (UserLogin "haskell")  -- cache hit
    pure (a, b)
  putStrLn $ "  => Got same user twice: " <> show (fst result == snd result)

  -- ── Scenario 5: Error handling ────────────────
  header "5" "Error handling (tryFetch)"
  putStrLn "Fetching a nonexistent user alongside a real one."
  (good, bad) <- runFetchIO env $
    (,) <$> tryFetch (UserLogin "haskell")
        <*> tryFetch (UserLogin "this-user-definitely-does-not-exist-404-sofetch")
  case good of
    Right u -> putStrLn $ "  => Good: " <> showUser u
    Left  e -> putStrLn $ "  => Good failed: " <> show e
  case bad of
    Right u -> putStrLn $ "  => Bad: " <> showUser u
    Left  e -> putStrLn $ "  => Bad (expected): " <> show (e :: SomeException)

  -- ── Scenario 6: Combinators ───────────────────
  header "6" "Combinators (fetchThrough, fetchMap)"
  putStrLn "Using fetchThrough to pair logins with user profiles:"
  let loginTexts = ["haskell", "rust-lang", "golang"] :: [Text]
  paired <- runFetchIO env $
    fetchThrough UserLogin loginTexts
  mapM_ (\(login, u) -> putStrLn $ "  => " <> T.unpack login
    <> " -> " <> maybe "?" T.unpack (ghUserName u)) paired

  putStrLn ""
  putStrLn "Using fetchMap to extract just follower counts:"
  counts <- runFetchIO env $
    fetchMap UserLogin (\login u -> (login, ghUserFollowers u)) loginTexts
  mapM_ (\(login, n) -> putStrLn $ "  => " <> T.unpack login
    <> ": " <> show n <> " followers") counts

  putStrLn ""
  putStrLn "Done!"