packages feed

link-canonical-0.1.0.0: src/Link/Canonical/Http.hs

module Link.Canonical.Http
  ( -- * HTTP Client typeclass
    HttpClient (..),

    -- * Response types
    HttpResponse (..),

    -- * Default implementation
    HttpClientIO (..),
    newHttpClientIO,
    runHttpClientIO,
  )
where

import Data.ByteString qualified as BS
import Data.CaseInsensitive qualified as CI
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Link.Canonical.Error (HttpClientError (..))
import Link.Canonical.Prelude
import Network.HTTP.Client qualified as HC
import Network.HTTP.Client.TLS qualified as TLS
import Network.HTTP.Types (Status)
import Text.URI qualified as URI

-- | HTTP response with only the data needed for redirect resolution
data HttpResponse = HttpResponse
  { -- | HTTP status code
    status :: Status,
    -- | Parsed Location header (for redirects)
    location :: Maybe URI,
    -- | Response headers as text pairs
    headers :: [(Text, Text)]
  }
  deriving stock (Generic, Eq, Show)

-- | Typeclass for HTTP clients
--
-- This abstraction allows for:
-- - Testing with mock clients
-- - Alternative HTTP backends
-- - Custom logging/metrics
class (Monad m) => HttpClient m where
  -- | Perform a HEAD request (efficient for redirect resolution)
  headRequest :: URI -> m (Either HttpClientError HttpResponse)

-- | Default HTTP client implementation using http-client-tls
newtype HttpClientIO = HttpClientIO
  { manager :: HC.Manager
  }
  deriving stock (Generic)

-- | Create a new HTTP client with TLS support
newHttpClientIO :: (MonadIO m) => m HttpClientIO
newHttpClientIO = liftIO $ HttpClientIO <$> TLS.newTlsManager

-- | Run an HTTP request with the given client
runHttpClientIO :: HttpClientIO -> URI -> IO (Either HttpClientError HttpResponse)
runHttpClientIO client uri = do
  let uriText = URI.render uri
      uriString = T.unpack uriText
  catchHttpException $ do
    request <- HC.parseRequest uriString
    let headRequest' = request {HC.method = "HEAD"}
    response <- HC.httpNoBody headRequest' (client ^. #manager)
    let respStatus = HC.responseStatus response
        respHeaders = HC.responseHeaders response
        locationHeader = lookup (CI.mk "Location") respHeaders
        parsedLocation = locationHeader >>= parseLocationHeader
        textHeaders = [(decodeHeader $ CI.original k, decodeHeader v) | (k, v) <- respHeaders]
    pure $
      Right $
        HttpResponse
          { status = respStatus,
            location = parsedLocation,
            headers = textHeaders
          }
  where
    parseLocationHeader bs =
      either (const Nothing) Just $ URI.mkURI $ decodeHeader bs

    decodeHeader :: BS.ByteString -> Text
    decodeHeader = TE.decodeUtf8Lenient

    catchHttpException :: IO (Either HttpClientError HttpResponse) -> IO (Either HttpClientError HttpResponse)
    catchHttpException action =
      action `catch` \case
        HC.HttpExceptionRequest _ (HC.ConnectionFailure _) ->
          pure $ Left $ ConnectionError "Connection failed"
        HC.HttpExceptionRequest _ HC.ConnectionTimeout ->
          pure $ Left TimeoutError
        HC.InvalidUrlException url reason ->
          pure $ Left $ InvalidUri $ "Invalid URL: " <> T.pack url <> " - " <> T.pack reason
        e ->
          pure $ Left $ ConnectionError $ "HTTP error: " <> T.pack (show e)

instance HttpClient IO where
  headRequest uri = do
    client <- newHttpClientIO
    runHttpClientIO client uri