packages feed

jsonrpc-0.2.0.0: src/JSONRPC.hs

{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}

{- |
Module      : JSONRPC
Description : JSON-RPC 2.0 types and type classes
Copyright   : (c) 2026 DPella AB
License     : MPL-2.0
Maintainer  : matti@dpella.io, lobo@dpella.io
Stability   : experimental
Portability : GHC

An implementation of the [JSON-RPC 2.0](https://www.jsonrpc.org/specification)
protocol types for Haskell, with Aeson serialisation.

= Core types

The four message kinds defined by the specification:

* 'JSONRPCRequest'      — a method call expecting a response
* 'JSONRPCResponse'     — a successful response
* 'JSONRPCError'        — an error response
* 'JSONRPCNotification' — a one-way message (no response)

'JSONRPCMessage' is a tagged union of all four, useful when reading messages
off the wire without knowing the kind in advance.

= Deriving support

'IsJSONRPCRequest' and 'IsJSONRPCNotification' let you define strongly-typed
request\/notification types and derive their JSON-RPC encoding via
@DerivingVia@:

@
data PingRequest = PingRequest
    { id :: RequestId, params :: Maybe () }
    deriving (ToJSON, FromJSON) via ViaJSONRPCRequest PingRequest

instance IsJSONRPCRequest PingRequest where
    requestMethod _ = \"ping\"
@
-}
module JSONRPC (
    -- * Request identifiers
    RequestId (..),

    -- * Type classes
    IsJSONRPCRequest (..),
    IsJSONRPCNotification (..),

    -- * Deriving-via wrappers
    ViaJSONRPCRequest (..),
    ViaJSONRPCNotification (..),

    -- * JSON-RPC message types
    JSONRPCRequest (..),
    JSONRPCResponse (..),
    JSONRPCError (..),
    JSONRPCNotification (..),
    JSONRPCMessage (..),
    JSONRPCErrorInfo (..),

    -- * Helpers
    EmptyParams,

    -- * Standard JSON-RPC 2.0 error codes
    pARSE_ERROR,
    iNVALID_REQUEST,
    mETHOD_NOT_FOUND,
    iNVALID_PARAMS,
    iNTERNAL_ERROR,

    -- * Constants
    rPC_VERSION,
) where

import Control.Applicative ((<|>))
import Data.Aeson
import Data.Aeson qualified as Aeson
import Data.Data (Proxy (..), Typeable, typeRep)
import Data.Kind (Type)
import Data.Text (Text)
import GHC.Generics
import GHC.Records (HasField (getField))
import GHC.TypeError (ErrorMessage (..), TypeError)

-- ---------------------------------------------------------------------------
-- RequestId
-- ---------------------------------------------------------------------------

{- | A uniquely identifying ID for a request in JSON-RPC.

The JSON-RPC 2.0 specification requires that each request has a unique
identifier.  The ID can be a string, number, or null value.  Numbers
SHOULD NOT contain fractional parts.
-}
newtype RequestId = RequestId Value
    deriving stock (Show, Eq)
    deriving newtype (ToJSON, FromJSON)

-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------

{- | JSON-RPC protocol version string (@\"2.0\"@).

Every request, response, and notification includes a @jsonrpc@ field
that MUST be exactly this value.
-}
rPC_VERSION :: Text
rPC_VERSION = "2.0"

-- ---------------------------------------------------------------------------
-- EmptyParams
-- ---------------------------------------------------------------------------

{- | Placeholder type for methods that take no parameters.

'EmptyParams' serialises to JSON @null@ and always fails to parse
(it is only used as a type-level marker; the 'FromJSON' instance for
'JSONRPCRequest' defaults missing params to 'Null').
-}
data EmptyParams
    deriving stock (Generic)

instance Show EmptyParams where
    show :: EmptyParams -> String
    show _ = show Aeson.Null

instance Eq EmptyParams where
    (==) :: EmptyParams -> EmptyParams -> Bool
    _ == _ = True

instance ToJSON EmptyParams where
    toJSON :: EmptyParams -> Value
    toJSON _ = Aeson.Null

instance FromJSON EmptyParams where
    parseJSON _ = fail "EmptyParams can only be used as a placeholder for empty params"

-- ---------------------------------------------------------------------------
-- JSON-RPC error information
-- ---------------------------------------------------------------------------

{- | Structured error information inside a JSON-RPC error response.

@
{ "code": -32601, "message": "Method not found", "data": ... }
@

The @code@ and @message@ fields are mandatory; @data@ is optional and may
contain any additional information about the error.
-}
data JSONRPCErrorInfo = JSONRPCErrorInfo
    { code :: Int
    -- ^ A number indicating the error type that occurred.
    , message :: Text
    -- ^ A short description of the error.
    , errorData :: Maybe Value
    -- ^ Additional information about the error (serialised as @\"data\"@ in JSON).
    }
    deriving stock (Show, Eq, Generic)

-- | Custom 'ToJSON' that maps the Haskell field @errorData@ to the JSON key @\"data\"@
-- as required by the JSON-RPC 2.0 specification.
instance ToJSON JSONRPCErrorInfo where
    toJSON (JSONRPCErrorInfo c m d) =
        object $
            [ "code" .= c
            , "message" .= m
            ]
                <> maybe [] (\v -> ["data" .= v]) d

-- | Custom 'FromJSON' that reads the JSON key @\"data\"@ into the Haskell field @errorData@.
instance FromJSON JSONRPCErrorInfo where
    parseJSON = withObject "JSONRPCErrorInfo" $ \o ->
        JSONRPCErrorInfo
            <$> o .: "code"
            <*> o .: "message"
            <*> o .:? "data"

-- ---------------------------------------------------------------------------
-- Core message types
-- ---------------------------------------------------------------------------

{- | A JSON-RPC request that expects a response.

@
{ "jsonrpc": "2.0", "id": 1, "method": "foo", "params": { ... } }
@
-}
data JSONRPCRequest = JSONRPCRequest
    { jsonrpc :: Text
    -- ^ Always @\"2.0\"@.
    , id :: RequestId
    -- ^ A unique identifier for the request.
    , method :: Text
    -- ^ The method to invoke.
    , params :: Value
    -- ^ The method parameters (defaults to 'Null' if omitted).
    }
    deriving stock (Show, Eq, Generic)
    deriving anyclass (ToJSON)

-- | Custom 'FromJSON' instance that treats a missing @params@ key as 'Null'.
-- JSON-RPC 2.0 allows the @params@ field to be omitted.
instance FromJSON JSONRPCRequest where
    parseJSON = withObject "JSONRPCRequest" $ \o ->
        JSONRPCRequest
            <$> o .: "jsonrpc"
            <*> o .: "id"
            <*> o .: "method"
            <*> o .:? "params" .!= Null

{- | A successful (non-error) response to a request.

@
{ "jsonrpc": "2.0", "id": 1, "result": { ... } }
@
-}
data JSONRPCResponse = JSONRPCResponse
    { jsonrpc :: Text
    -- ^ Always @\"2.0\"@.
    , id :: RequestId
    -- ^ Must match the @id@ of the corresponding request.
    , result :: Value
    -- ^ The result of the method invocation.
    }
    deriving stock (Show, Eq, Generic)
    deriving anyclass (FromJSON, ToJSON)

{- | A response to a request that indicates an error occurred.

@
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "..." } }
@
-}
data JSONRPCError = JSONRPCError
    { jsonrpc :: Text
    -- ^ Always @\"2.0\"@.
    , id :: RequestId
    -- ^ Must match the @id@ of the corresponding request (or 'Null' for parse errors).
    , error :: JSONRPCErrorInfo
    -- ^ Structured error information.
    }
    deriving stock (Show, Eq, Generic)
    deriving anyclass (FromJSON, ToJSON)

{- | A notification which does not expect a response.

@
{ "jsonrpc": "2.0", "method": "update", "params": { ... } }
@

Notifications have no @id@ field.  The server MUST NOT reply to a notification.
-}
data JSONRPCNotification = JSONRPCNotification
    { jsonrpc :: Text
    -- ^ Always @\"2.0\"@.
    , method :: Text
    -- ^ The method name.
    , params :: Value
    -- ^ The method parameters (omitted from JSON when 'Null').
    }
    deriving stock (Show, Eq, Generic)

-- | Custom 'ToJSON' instance that omits @params@ when it is 'Null'.
instance ToJSON JSONRPCNotification where
    toJSON (JSONRPCNotification j m Null) =
        object ["jsonrpc" .= j, "method" .= m]
    toJSON (JSONRPCNotification j m p) =
        object ["jsonrpc" .= j, "method" .= m, "params" .= p]

-- | Custom 'FromJSON' instance that treats a missing @params@ key as 'Null'.
-- JSON-RPC 2.0 allows notifications to omit the @params@ field entirely.
instance FromJSON JSONRPCNotification where
    parseJSON = withObject "JSONRPCNotification" $ \o ->
        JSONRPCNotification
            <$> o .: "jsonrpc"
            <*> o .: "method"
            <*> o .:? "params" .!= Null

{- | Any valid JSON-RPC message that can be decoded off the wire.

Useful when reading from a transport where the message kind is unknown.
-}
data JSONRPCMessage
    = RequestMessage JSONRPCRequest
    | ResponseMessage JSONRPCResponse
    | ErrorMessage JSONRPCError
    | NotificationMessage JSONRPCNotification
    deriving stock (Show, Eq, Generic)

instance ToJSON JSONRPCMessage where
    toJSON (RequestMessage r) = toJSON r
    toJSON (ResponseMessage r) = toJSON r
    toJSON (ErrorMessage e) = toJSON e
    toJSON (NotificationMessage n) = toJSON n

instance FromJSON JSONRPCMessage where
    parseJSON v =
        (RequestMessage <$> parseJSON v)
            <|> (ResponseMessage <$> parseJSON v)
            <|> (ErrorMessage <$> parseJSON v)
            <|> (NotificationMessage <$> parseJSON v)

-- ---------------------------------------------------------------------------
-- IsJSONRPCRequest
-- ---------------------------------------------------------------------------

-- | Type family to extract the type of the @params@ field from a generic representation.
type family RequestParamType (rep :: Type -> Type) :: Type where
    RequestParamType (D1 _ (C1 _ (S1 _ (K1 _ RequestId) :*: S1 _ (K1 _ p)))) = p
    RequestParamType (D1 ('MetaData nm _ _ _) _) =
        TypeError
            ( ('Text "Error when defining IsJSONRPCRequest for " :<>: 'Text nm :<>: 'Text ":")
                :$$: 'Text "The request datatype must be a record with fields 'id' and 'params'"
            )

{- | Type class for Haskell types that represent a specific JSON-RPC request method.

Instances define the method name via 'requestMethod'.  Serialisation and
deserialisation are provided by default using GHC Generics: the type must
be a two-field record with @id :: RequestId@ and @params :: SomeParamsType@.

Use 'ViaJSONRPCRequest' for @DerivingVia@-based 'ToJSON'\/'FromJSON' instances.
-}
class
    ( HasField "id" a RequestId
    , HasField "params" a (RequestParams a)
    , Typeable a
    , ToJSON (RequestParams a)
    , FromJSON (RequestParams a)
    ) =>
    IsJSONRPCRequest a
    where
    -- | The type of the @params@ field.
    type RequestParams a
    type RequestParams a = RequestParamType (Rep a)

    -- | The JSON-RPC method name (e.g. @\"initialize\"@, @\"tools\/list\"@).
    requestMethod :: Proxy a -> Text

    -- | Convert a typed request to a raw 'JSONRPCRequest'.
    toJSONRPCRequest :: a -> JSONRPCRequest
    toJSONRPCRequest (req :: a) =
        JSONRPCRequest rPC_VERSION (getField @"id" req) (requestMethod (Proxy @a)) (toJSON (getField @"params" req))

    -- | Parse a raw 'JSONRPCRequest' into a typed request.
    fromJSONRPCRequest :: JSONRPCRequest -> Either String a
    default fromJSONRPCRequest ::
        ( Generic a
        , Rep a ~ D1 c0 (C1 i0 (S1 s0 (K1 i1 RequestId) :*: S1 s1 (K1 i30 (RequestParams a))))
        ) =>
        JSONRPCRequest ->
        Either String a
    fromJSONRPCRequest JSONRPCRequest{id = req_id, params = req_params} =
        case fromJSON @(RequestParams a) req_params of
            Success p -> Right $ to $ M1 (M1 (M1 (K1 req_id) :*: M1 (K1 p)))
            Aeson.Error err -> Left err

-- | Newtype wrapper for deriving 'ToJSON' and 'FromJSON' via 'IsJSONRPCRequest'.
newtype ViaJSONRPCRequest a = ViaJSONRPCRequest {unViaJSONRPCRequest :: a}

instance (IsJSONRPCRequest a) => ToJSON (ViaJSONRPCRequest a) where
    toJSON = toJSON . toJSONRPCRequest . unViaJSONRPCRequest

instance (IsJSONRPCRequest a) => FromJSON (ViaJSONRPCRequest a) where
    parseJSON = withObject (show $ typeRep (Proxy @a)) $ \o -> do
        m <- o .: "method"
        req_id <- o .: "id"
        if m == requestMethod (Proxy @a)
            then do
                p <- o .:? "params" .!= Null
                case fromJSONRPCRequest (JSONRPCRequest rPC_VERSION req_id m p) of
                    Right r -> return (ViaJSONRPCRequest r)
                    Left err -> fail $ "Failed to parse params for " <> show (typeRep (Proxy @a)) <> ": " <> err
            else fail $ "Expected method '" <> show (requestMethod (Proxy @a)) <> "'"

-- ---------------------------------------------------------------------------
-- IsJSONRPCNotification
-- ---------------------------------------------------------------------------

-- | Type family to extract the type of the @params@ field from a notification's generic representation.
type family NotificationParamType (rep :: Type -> Type) :: Type where
    NotificationParamType (D1 _ (C1 _ (S1 _ (K1 _ p)))) = p
    NotificationParamType (D1 ('MetaData nm _ _ _) _) =
        TypeError
            ( ('Text "Error when defining IsJSONRPCNotification for " :<>: 'Text nm :<>: 'Text ":")
                :$$: 'Text "The notification datatype must be a record with a single field 'params'"
            )

{- | Type class for Haskell types that represent a specific JSON-RPC notification method.

Similar to 'IsJSONRPCRequest' but for notifications (no @id@ field).
Use 'ViaJSONRPCNotification' for @DerivingVia@-based instances.
-}
class
    ( HasField "params" a (NotificationParams a)
    , Typeable a
    , ToJSON (NotificationParams a)
    , FromJSON (NotificationParams a)
    ) =>
    IsJSONRPCNotification a
    where
    -- | The type of the @params@ field.
    type NotificationParams a
    type NotificationParams a = NotificationParamType (Rep a)

    -- | The JSON-RPC method name.
    notificationsMethod :: Proxy a -> Text

    -- | Convert a typed notification to a raw 'JSONRPCNotification'.
    toJSONRPCNotification :: a -> JSONRPCNotification
    toJSONRPCNotification (req :: a) =
        JSONRPCNotification rPC_VERSION (notificationsMethod (Proxy @a)) (toJSON (getField @"params" req))

    -- | Parse a raw 'JSONRPCNotification' into a typed notification.
    fromJSONRPCNotification :: JSONRPCNotification -> Either String a
    default fromJSONRPCNotification ::
        ( Generic a
        , Rep a ~ D1 c0 (C1 i0 (S1 s1 (K1 i30 (NotificationParams a))))
        ) =>
        JSONRPCNotification ->
        Either String a
    fromJSONRPCNotification JSONRPCNotification{params = notification_params} =
        case fromJSON @(NotificationParams a) notification_params of
            Success p -> Right $ to $ M1 (M1 (M1 (K1 p)))
            Aeson.Error err -> Left err

-- | Newtype wrapper for deriving 'ToJSON' and 'FromJSON' via 'IsJSONRPCNotification'.
newtype ViaJSONRPCNotification a = ViaJSONRPCNotification {unViaJSONRPCNotification :: a}

instance (IsJSONRPCNotification a) => ToJSON (ViaJSONRPCNotification a) where
    toJSON = toJSON . toJSONRPCNotification . unViaJSONRPCNotification

instance (IsJSONRPCNotification a) => FromJSON (ViaJSONRPCNotification a) where
    parseJSON = withObject (show $ typeRep (Proxy @a)) $ \o -> do
        m <- o .: "method"
        if m == notificationsMethod (Proxy @a)
            then do
                p <- o .:? "params" .!= Null
                case fromJSONRPCNotification (JSONRPCNotification rPC_VERSION m p) of
                    Right r -> return (ViaJSONRPCNotification r)
                    Left err -> fail $ "Failed to parse params for " <> show (typeRep (Proxy @a)) <> ": " <> err
            else fail $ "Expected method '" <> show (notificationsMethod (Proxy @a)) <> "'"

-- ---------------------------------------------------------------------------
-- Standard JSON-RPC 2.0 error codes
-- ---------------------------------------------------------------------------

{- | Parse error (@-32700@).

Invalid JSON was received by the server.
-}
pARSE_ERROR :: Int
pARSE_ERROR = -32700

{- | Invalid request (@-32600@).

The JSON sent is not a valid Request object.
-}
iNVALID_REQUEST :: Int
iNVALID_REQUEST = -32600

{- | Method not found (@-32601@).

The method does not exist or is not available.
-}
mETHOD_NOT_FOUND :: Int
mETHOD_NOT_FOUND = -32601

{- | Invalid params (@-32602@).

Invalid method parameter(s).
-}
iNVALID_PARAMS :: Int
iNVALID_PARAMS = -32602

{- | Internal error (@-32603@).

Internal JSON-RPC error.
-}
iNTERNAL_ERROR :: Int
iNTERNAL_ERROR = -32603