{-# LANGUAGE UndecidableInstances #-}

{-|

Copyright:

  This file is part of the package openid-connect.  It is subject to
  the license terms in the LICENSE file found in the top-level
  directory of this distribution and at:

    https://code.devalot.com/sthenauth/openid-connect

  No part of this package, including this file, may be copied,
  modified, propagated, or distributed except according to the terms
  contained in the LICENSE file.

License: BSD-2-Clause

-}
module OpenID.Connect.JSON
  ( GenericJSON(..)
  , ErrorResponse(..)
  , (:*:)(..)
  , Words(..)
  , fromWords
  , toWords
  , URI(..)
  , Aeson.ToJSON
  , Aeson.FromJSON
  ) where

--------------------------------------------------------------------------------
import Control.Category ((>>>))
import Control.Monad (MonadPlus(..))
import Data.Aeson as Aeson
import Data.Aeson.Encoding as Aeson
import Data.Bifunctor (bimap)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as NonEmpty
import Data.Text (Text)
import qualified Data.Text as Text
import GHC.Generics (Generic, Rep)
import qualified Network.URI as Network

--------------------------------------------------------------------------------
-- | Type wrapper for automatic JSON deriving.
newtype GenericJSON a = GenericJSON
  { genericJSON :: a }

--------------------------------------------------------------------------------
-- | Default JSON decoding/encoding options.
aesonOptions :: Aeson.Options
aesonOptions = Aeson.defaultOptions
    { Aeson.fieldLabelModifier     = snakeCase
    , Aeson.constructorTagModifier = snakeCase
    , Aeson.allNullaryToStringTag  = True
    , Aeson.omitNothingFields      = True
    }
  where
    snakeCase = Aeson.camelTo2 '_' . dropWhile (== '_')

instance ( Generic a
         , Aeson.GToJSON Aeson.Zero (Rep a)
         , Aeson.GToEncoding Aeson.Zero (Rep a)
         ) =>
  ToJSON (GenericJSON a) where
    toJSON     = Aeson.genericToJSON aesonOptions     . genericJSON
    toEncoding = Aeson.genericToEncoding aesonOptions . genericJSON

instance ( Generic a
         , Aeson.GFromJSON Aeson.Zero (Rep a)
         ) =>
  FromJSON (GenericJSON a) where
    parseJSON = fmap GenericJSON . Aeson.genericParseJSON aesonOptions

--------------------------------------------------------------------------------
-- | A provider response that indicates an error as described in OAuth
-- 2.0 Bearer Token Usage (RFC 6750).
--
-- @since 0.1.0.0
data ErrorResponse = ErrorResponse
  { errorCode        :: Text
  , errorDescription :: Maybe Text
  }
  deriving stock Show

instance ToJSON ErrorResponse where
  toJSON ErrorResponse{..} = Aeson.object
    [ "error" .= errorCode
    , "error_description" .= errorDescription
    ]
  toEncoding ErrorResponse{..} = Aeson.pairs
    ( "error" .= errorCode <> "error_description" .= errorDescription)

instance FromJSON ErrorResponse where
  parseJSON = Aeson.withObject "Error Response" $ \v ->
    ErrorResponse
      <$> v .:  "error"
      <*> v .:? "error_description"

--------------------------------------------------------------------------------
-- | Join two types together so they work with the same JSON document.
newtype (:*:) a b = Join
  { getProduct :: (a, b) }

instance (ToJSON a, ToJSON b) => ToJSON (a :*: b) where
  toJSON prod =
    case bimap toJSON toJSON (getProduct prod) of
      (Aeson.Object x, Aeson.Object y) -> Aeson.Object (x <> y)
      (x, _)                           -> x

instance (FromJSON a, FromJSON b) => FromJSON (a :*: b) where
  parseJSON v = fmap Join ((,) <$> parseJSON v <*> parseJSON v)

--------------------------------------------------------------------------------
-- | Space separated list of words.
--
-- @since 0.1.0.0
newtype Words = Words
  { toWordList :: NonEmpty Text
  }
  deriving stock (Generic, Show)
  deriving newtype Semigroup

instance ToJSON Words where
  toJSON = fromWords >>> toJSON
  toEncoding = fromWords >>> toEncoding

instance FromJSON Words where
  parseJSON = Aeson.withText "Space separated words" toWords

--------------------------------------------------------------------------------
-- | Encode a list of words into 'Text'.
--
-- @since 0.1.0.0
fromWords :: Words -> Text
fromWords = toWordList
        >>> NonEmpty.nub
        >>> NonEmpty.toList
        >>> Text.unwords

--------------------------------------------------------------------------------
-- | Decode a list of words from 'Text'.
--
-- @since 0.1.0.0
toWords :: MonadPlus m => Text -> m Words
toWords = Text.words >>> \case
  [] -> mzero
  xs -> pure (Words $ NonEmpty.fromList xs)

--------------------------------------------------------------------------------
-- | A wrapper around the "Network.URI" type that supports 'ToJSON'
-- and 'FromJSON'.
--
-- @since 0.1.0.0
newtype URI = URI
  { getURI :: Network.URI }
  deriving newtype (Show, Eq)

instance ToJSON URI where
  toJSON u = toJSON (Network.uriToString id (getURI u) [])
  toEncoding u = Aeson.string (Network.uriToString id (getURI u) [])

instance FromJSON URI where
  parseJSON = Aeson.withText "URI" go
    where
      go = maybe mzero (pure . URI) .
             Network.parseURI .
               Text.unpack