packages feed

acolyte-client-0.1.0.0: src/Acolyte/Client/Call.hs

-- | Type-safe endpoint calls derived from API types.
--
-- The key type family is 'CallArgs' which computes the argument type
-- from the endpoint type, and 'CallResult' which is the response type.
module Acolyte.Client.Call
  ( -- * Calling endpoints
    callEndpoint
    -- * Request building
  , EndpointRequest (..)
  , BuildPath (..)
  , ShowCapture (..)
  , buildUrl
  ) where

import Control.Exception (try)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as LBS
import Data.Kind (Type)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import qualified Data.Text as T
import GHC.TypeLits (KnownSymbol, symbolVal)
import Network.HTTP.Types (statusIsSuccessful, statusCode)

import qualified Data.Aeson as Aeson
import qualified Network.HTTP.Client as HC

import Acolyte.Core.Method (KnownMethod, methodVal)
import qualified Acolyte.Core.Method as Core
import Acolyte.Core.Path (PathSegment (..), Captures, CapturesTuple)
import Acolyte.Core.Endpoint (Endpoint, NoBody)
import Acolyte.Core.Effect (Requires)
import Acolyte.Core.Wrapper
  ( Named, Versioned, ApiVersion (..), Describe, Description
  , WithParams, WithHeaders, RespondsWith
  )
import Acolyte.Client.Core


-- | Build a request for an endpoint type.
class EndpointRequest endpoint where
  -- | The type of arguments needed (captures + optional body).
  type ReqArgs endpoint

  -- | The expected deserialized response type.
  type ReqResult endpoint

  -- | Build the HTTP method, URL segments, and optional body.
  buildReq :: Proxy endpoint -> ReqArgs endpoint -> (ByteString, [Text], Maybe LBS.ByteString)


-- GET (no body)
instance (BuildPath path, Aeson.FromJSON resp)
  => EndpointRequest (Endpoint 'Core.GET path NoBody resp) where
    type ReqArgs (Endpoint 'Core.GET path NoBody resp) = PathArgs path
    type ReqResult (Endpoint 'Core.GET path NoBody resp) = resp
    buildReq _ args = ("GET", buildPath @path args, Nothing)

-- DELETE (no body)
instance (BuildPath path, Aeson.FromJSON resp)
  => EndpointRequest (Endpoint 'Core.DELETE path NoBody resp) where
    type ReqArgs (Endpoint 'Core.DELETE path NoBody resp) = PathArgs path
    type ReqResult (Endpoint 'Core.DELETE path NoBody resp) = resp
    buildReq _ args = ("DELETE", buildPath @path args, Nothing)

-- HEAD (no body)
instance (BuildPath path, Aeson.FromJSON resp)
  => EndpointRequest (Endpoint 'Core.HEAD path NoBody resp) where
    type ReqArgs (Endpoint 'Core.HEAD path NoBody resp) = PathArgs path
    type ReqResult (Endpoint 'Core.HEAD path NoBody resp) = resp
    buildReq _ args = ("HEAD", buildPath @path args, Nothing)

-- POST with JSON body
instance (BuildPath path, Aeson.ToJSON req, Aeson.FromJSON resp)
  => EndpointRequest (Endpoint 'Core.POST path req resp) where
    type ReqArgs (Endpoint 'Core.POST path req resp) = (PathArgs path, req)
    type ReqResult (Endpoint 'Core.POST path req resp) = resp
    buildReq _ (args, body) = ("POST", buildPath @path args, Just (Aeson.encode body))

-- PUT with JSON body
instance (BuildPath path, Aeson.ToJSON req, Aeson.FromJSON resp)
  => EndpointRequest (Endpoint 'Core.PUT path req resp) where
    type ReqArgs (Endpoint 'Core.PUT path req resp) = (PathArgs path, req)
    type ReqResult (Endpoint 'Core.PUT path req resp) = resp
    buildReq _ (args, body) = ("PUT", buildPath @path args, Just (Aeson.encode body))

-- PATCH with JSON body
instance (BuildPath path, Aeson.ToJSON req, Aeson.FromJSON resp)
  => EndpointRequest (Endpoint 'Core.PATCH path req resp) where
    type ReqArgs (Endpoint 'Core.PATCH path req resp) = (PathArgs path, req)
    type ReqResult (Endpoint 'Core.PATCH path req resp) = resp
    buildReq _ (args, body) = ("PATCH", buildPath @path args, Just (Aeson.encode body))

-- Requires delegates to inner
instance EndpointRequest inner => EndpointRequest (Requires e inner) where
  type ReqArgs (Requires e inner) = ReqArgs inner
  type ReqResult (Requires e inner) = ReqResult inner
  buildReq _ = buildReq (Proxy @inner)

-- Named delegates to inner (name is irrelevant for client calls)
instance EndpointRequest inner => EndpointRequest (Named name inner) where
  type ReqArgs (Named name inner) = ReqArgs inner
  type ReqResult (Named name inner) = ReqResult inner
  buildReq _ = buildReq (Proxy @inner)

-- Versioned prepends the version prefix to URL segments
instance (ApiVersion v, EndpointRequest inner)
  => EndpointRequest (Versioned v inner) where
    type ReqArgs (Versioned v inner) = ReqArgs inner
    type ReqResult (Versioned v inner) = ReqResult inner
    buildReq _ args =
      let (method, segments, mBody) = buildReq (Proxy @inner) args
      in (method, versionPrefix @v : segments, mBody)

-- Describe delegates to inner (description is irrelevant for client calls)
instance EndpointRequest inner => EndpointRequest (Describe desc inner) where
  type ReqArgs (Describe desc inner) = ReqArgs inner
  type ReqResult (Describe desc inner) = ReqResult inner
  buildReq _ = buildReq (Proxy @inner)

-- Description delegates to inner (description is irrelevant for client calls)
instance EndpointRequest inner => EndpointRequest (Description desc inner) where
  type ReqArgs (Description desc inner) = ReqArgs inner
  type ReqResult (Description desc inner) = ReqResult inner
  buildReq _ = buildReq (Proxy @inner)

-- WithParams delegates to inner (query params are not path-level)
instance EndpointRequest inner => EndpointRequest (WithParams ps inner) where
  type ReqArgs (WithParams ps inner) = ReqArgs inner
  type ReqResult (WithParams ps inner) = ReqResult inner
  buildReq _ = buildReq (Proxy @inner)

-- WithHeaders delegates to inner (headers are separate from path/body)
instance EndpointRequest inner => EndpointRequest (WithHeaders hs inner) where
  type ReqArgs (WithHeaders hs inner) = ReqArgs inner
  type ReqResult (WithHeaders hs inner) = ReqResult inner
  buildReq _ = buildReq (Proxy @inner)

-- RespondsWith delegates to inner (status code is irrelevant for client calls)
instance EndpointRequest inner => EndpointRequest (RespondsWith s inner) where
  type ReqArgs (RespondsWith s inner) = ReqArgs inner
  type ReqResult (RespondsWith s inner) = ReqResult inner
  buildReq _ = buildReq (Proxy @inner)


-- ===================================================================
-- Path building
-- ===================================================================

-- | The path arguments type — matches CapturesTuple from the core.
type PathArgs :: [PathSegment] -> Type
type family PathArgs (path :: [PathSegment]) :: Type where
  PathArgs path = CapturesTuple (Captures path)


-- | Build URL segments from a type-level path and capture values.
class BuildPath (path :: [PathSegment]) where
  buildPath :: PathArgs path -> [Text]

instance BuildPath '[] where
  buildPath () = []

instance (KnownSymbol s, BuildPath rest, PathArgs ('Lit s ': rest) ~ PathArgs rest)
  => BuildPath ('Lit s ': rest) where
    buildPath args = T.pack (symbolVal (Proxy @s)) : buildPath @rest args

-- Single capture (no more captures after)
instance {-# OVERLAPPING #-} (ShowCapture t, KnownSymbol s, PathArgs ('Capture t ': '[ 'Lit s ]) ~ t)
  => BuildPath ('Capture t ': '[ 'Lit s ]) where
    buildPath cap = [showCapture cap, T.pack (symbolVal (Proxy @s))]

-- Single capture at end of path
instance {-# OVERLAPPING #-} (ShowCapture t)
  => BuildPath '[ 'Capture t ] where
    buildPath cap = [showCapture cap]

-- CaptureNamed followed by a literal (identical to Capture — name is cosmetic)
instance {-# OVERLAPPING #-} (ShowCapture t, KnownSymbol s, PathArgs ('CaptureNamed name t ': '[ 'Lit s ]) ~ t)
  => BuildPath ('CaptureNamed name t ': '[ 'Lit s ]) where
    buildPath cap = [showCapture cap, T.pack (symbolVal (Proxy @s))]

-- CaptureNamed at end of path
instance {-# OVERLAPPING #-} (ShowCapture t)
  => BuildPath '[ 'CaptureNamed name t ] where
    buildPath cap = [showCapture cap]


-- | Convert a capture value to a text URL segment.
class ShowCapture a where
  showCapture :: a -> Text

instance ShowCapture Int where
  showCapture = T.pack . show

instance ShowCapture Integer where
  showCapture = T.pack . show

instance ShowCapture Text where
  showCapture = id

instance ShowCapture String where
  showCapture = T.pack


-- ===================================================================
-- High-level call function
-- ===================================================================

-- | Call an endpoint on a client.
--
-- @
-- result <- callEndpoint @(Get UsersPath (Json [User])) client ()
-- result <- callEndpoint @(Get UserByIdPath (Json User)) client 42
-- result <- callEndpoint @(Post UsersPath (Json CreateUser) (Json User)) client ((), newUser)
-- @
callEndpoint
  :: forall endpoint
   . (EndpointRequest endpoint, Aeson.FromJSON (ReqResult endpoint))
  => Client
  -> ReqArgs endpoint
  -> IO (Either ClientError (ReqResult endpoint))
callEndpoint client args = do
  let (method, segments, mBody) = buildReq (Proxy @endpoint) args
      url = buildUrl (clientBaseUrl client) segments
      urlStr = T.unpack url

  initReq <- HC.parseRequest urlStr
  let req0 = initReq
        { HC.method = method
        , HC.requestHeaders =
            [("Accept", "application/json"), ("Content-Type", "application/json")]
        }
      req1 = case mBody of
        Nothing -> req0
        Just body -> req0 { HC.requestBody = HC.RequestBodyLBS body }

  req2 <- interceptRequest (clientInterceptor client) req1

  result <- try @HC.HttpException $ HC.httpLbs req2 (clientManager client)
  case result of
    Left ex -> pure (Left (HttpError ex))
    Right resp -> do
      let status = HC.responseStatus resp
          body = LBS.toStrict (HC.responseBody resp)
      if statusIsSuccessful status
        then case Aeson.eitherDecodeStrict' body of
          Right val -> pure (Right val)
          Left err  -> pure (Left (DecodeError (T.pack err)))
        else pure (Left (StatusError (statusCode status) body))


-- ===================================================================
-- Helpers
-- ===================================================================

-- | Join a base URL with path segments to form a complete URL.
buildUrl :: Text -> [Text] -> Text
buildUrl base segments = base <> "/" <> T.intercalate "/" segments

methodToBS :: Core.Method -> ByteString
methodToBS Core.GET     = "GET"
methodToBS Core.POST    = "POST"
methodToBS Core.PUT     = "PUT"
methodToBS Core.DELETE  = "DELETE"
methodToBS Core.PATCH   = "PATCH"
methodToBS Core.HEAD    = "HEAD"
methodToBS Core.OPTIONS = "OPTIONS"