core-data-0.3.0.0: lib/Core/Encoding/Json.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
--
-- As currently implemented this module, in conjunction with
-- Core.Text, is the opposite of efficient. The idea right now is to
-- experiment with the surface API. If it stabilizes, then the fact
-- that our string objects are already in UTF-8 will make for a very
-- efficient emitter.
--
{- |
Encoding and decoding UTF-8 JSON content.
This module is a thin wrapper around the most excellent __aeson__ library,
which has rich and powerful facilities for encoding Haskell types into JSON.
Quite often, however, you find yourself having to create a Haskell type /just/
to read some JSON coming from an external web service or API. This can be
challenging when the source of the JSON is complex or varying its schema over
time. For ease of exploration this module simply defines an easy to use
intermediate type representing JSON as a format.
Often you'll be working with literals directly in your code. While you can
write:
@
j = 'JsonObject' ('intoMap' [('JsonKey' "answer", 'JsonNumber' 42)])
@
and it would be correct, enabling:
@
\{\-\# LANGUAGE OverloadedStrings \#\-\}
\{\-\# LANGUAGE OverloadedLists \#\-\}
@
allows you to write:
@
j = 'JsonObject' [("answer", 42)]
@
which you is somewhat less cumbersome in declaration-heavy code. You're
certainly welcome to use the constructors if you find it makes for more
readable code or if you need the type annotations.
-}
module Core.Encoding.Json (
-- * Encoding and Decoding
encodeToUTF8,
encodeToRope,
decodeFromUTF8,
decodeFromRope,
JsonValue (..),
JsonKey (..),
-- * Syntax highlighting
JsonToken (..),
colourizeJson,
prettyKey,
prettyValue,
) where
import Core.Data.Structures (Key, Map, fromMap, intoMap)
import Core.Text.Bytes (Bytes, fromBytes, intoBytes)
import Core.Text.Colour (
AnsiColour,
brightBlue,
brightGrey,
brightMagenta,
dullBlue,
dullCyan,
dullGreen,
dullYellow,
pureGrey,
)
import Core.Text.Rope (
Rope,
Textual,
fromRope,
intoRope,
singletonRope,
)
import Core.Text.Utilities (
Render (Token, colourize, highlight),
breakPieces,
)
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as Aeson
import qualified Data.Aeson.KeyMap as Aeson
import Data.Coerce
import Data.Hashable (Hashable)
import qualified Data.List as List
import Data.Scientific (
FPFormat (..),
Scientific,
formatScientific,
isFloating,
)
import Data.String (IsString (..))
import qualified Data.Text as T
import qualified Data.Vector as V
import GHC.Generics
import Prettyprinter (
Doc,
Pretty (..),
annotate,
comma,
dquote,
group,
hcat,
indent,
lbrace,
lbracket,
line,
line',
nest,
punctuate,
rbrace,
rbracket,
sep,
unAnnotate,
viaShow,
vsep,
(<+>),
)
{- |
Given a JSON value, encode it to UTF-8 bytes
I know we're not /supposed/ to rely on types to document functions, but
really, this one does what it says on the tin.
-}
encodeToUTF8 :: JsonValue -> Bytes
encodeToUTF8 = intoBytes . encodeToRope
{- |
Given a JSON value, encode it to a Rope (which, by definition, is UTF-8
internally).
-}
encodeToRope :: JsonValue -> Rope
encodeToRope value = case value of
JsonObject xm ->
let kvs = fromMap xm
members = fmap (\((JsonKey k), v) -> doublequote <> escapeString k <> doublequote <> colonspace <> encodeToRope v) kvs
in openbrace <> mconcat (List.intersperse commaspace members) <> closebrace
JsonArray xs ->
openbracket <> mconcat (List.intersperse commaspace (fmap encodeToRope xs)) <> closebracket
JsonString x ->
doublequote <> escapeString x <> doublequote
JsonNumber x -> case isFloating x of
True -> intoRope (formatScientific Generic Nothing x)
False -> intoRope (formatScientific Fixed (Just 0) x)
JsonBool x -> case x of
True -> "true"
False -> "false"
JsonNull -> "null"
where
commaspace = singletonRope ','
colonspace = singletonRope ':'
doublequote = singletonRope '"'
openbrace = singletonRope '{'
closebrace = singletonRope '}'
openbracket = singletonRope '['
closebracket = singletonRope ']'
{- |
-- Escape any quotes in a JsonString.
-}
escapeString :: Rope -> Rope
escapeString text =
let pieces = breakPieces (== '"') text
in mconcat (List.intersperse "\\\"" pieces)
{-# INLINEABLE escapeString #-}
{- |
Given an array of bytes, attempt to decode it as a JSON value.
-}
decodeFromUTF8 :: Bytes -> Maybe JsonValue
decodeFromUTF8 b =
let x :: Maybe Aeson.Value
x = Aeson.decodeStrict' (fromBytes b)
in fmap fromAeson x
{- |
Given an string that is full of a bunch of JSON, attempt to decode
it.
-}
decodeFromRope :: Rope -> Maybe JsonValue
decodeFromRope text =
let x :: Maybe Aeson.Value
x = Aeson.decodeStrict' (fromRope text)
in fmap fromAeson x
{- |
A JSON value.
-}
data JsonValue
= JsonObject (Map JsonKey JsonValue)
| JsonArray [JsonValue]
| JsonString Rope
| JsonNumber Scientific
| JsonBool Bool
| JsonNull
deriving (Eq, Show, Generic)
--
-- Overloads so that Haskell code literals can be interpreted as JSON
-- values. Obviously these are a lot on the partial side, but what else are
-- you supposed to do? This is all Haskell gives us for getting at
-- literals.
--
instance IsString JsonValue where
fromString :: String -> JsonValue
fromString = JsonString . intoRope
instance Num JsonValue where
fromInteger = JsonNumber . fromInteger
(+) = error "Sorry, you can't add JsonValues"
(-) = error "Sorry, you can't negate JsonValues"
(*) = error "Sorry, you can't multiply JsonValues"
abs = error "Sorry, not applicable for JsonValues"
signum = error "Sorry, not applicable for JsonValues"
instance Fractional JsonValue where
fromRational :: Rational -> JsonValue
fromRational = JsonNumber . fromRational
(/) = error "Sorry, you can't do division on JsonValues"
{- |
Keys in a JSON object.
-}
newtype JsonKey
= JsonKey Rope
deriving (Eq, Show, Generic, IsString, Ord)
instance Hashable JsonKey
instance Key JsonKey
instance Textual JsonKey where
fromRope t = coerce t
intoRope x = coerce x
fromAeson :: Aeson.Value -> JsonValue
fromAeson value = case value of
Aeson.Object o ->
let tvs = Aeson.toList o
kvs =
fmap
( \(k, v) ->
( JsonKey
(intoRope (Aeson.toText k))
, fromAeson v
)
)
tvs
kvm :: Map JsonKey JsonValue
kvm = intoMap kvs
in JsonObject kvm
Aeson.Array v -> JsonArray (fmap fromAeson (V.toList v))
Aeson.String t -> JsonString (intoRope t)
Aeson.Number n -> JsonNumber n
Aeson.Bool x -> JsonBool x
Aeson.Null -> JsonNull
--
-- Pretty printing
--
{- |
Support for pretty-printing JSON values with syntax highlighting using the
__prettyprinter__ library. To output a JSON structure to terminal
colourized with ANSI escape codes you can use the 'Render' instance:
@
debug "j" (render j)
@
will get you:
@
23:46:04Z (00.007) j =
{
"answer": 42
}
@
-}
data JsonToken
= SymbolToken
| QuoteToken
| KeyToken
| StringToken
| EscapeToken
| NumberToken
| BooleanToken
| LiteralToken
instance Render JsonValue where
type Token JsonValue = JsonToken
colourize = colourizeJson
highlight = prettyValue
instance Render JsonKey where
type Token JsonKey = JsonToken
colourize = colourizeJson
highlight = prettyKey
--
-- Ugh. If you want to experiment with narrower output, then:
--
-- . layoutPretty (LayoutOptions {layoutPageWidth = AvailablePerLine 15 1.0}) . prettyValue
--
{- |
Used by the 'Render' instance to turn symbolic annotations into ANSI colours annotations.
If you're curious, the render pipeline looks like:
@
render = 'intoText' . 'renderStrict' . 'reAnnotateS' 'colourize'
. 'layoutPretty' 'defaultLayoutOptions' . 'prettyValue'
@
-}
colourizeJson :: JsonToken -> AnsiColour
colourizeJson token = case token of
SymbolToken -> pureGrey
QuoteToken -> brightGrey
KeyToken -> brightBlue
StringToken -> dullCyan
EscapeToken -> dullYellow
NumberToken -> dullGreen
BooleanToken -> brightMagenta
LiteralToken -> dullBlue
instance Pretty JsonKey where
pretty = unAnnotate . prettyKey
prettyKey :: JsonKey -> Doc JsonToken
prettyKey (JsonKey t) =
annotate QuoteToken dquote
<> annotate KeyToken (pretty (fromRope t :: T.Text))
<> annotate QuoteToken dquote
instance Pretty JsonValue where
pretty = unAnnotate . prettyValue
prettyValue :: JsonValue -> Doc JsonToken
prettyValue value = case value of
JsonObject xm ->
let pairs = fromMap xm
entries = fmap (\(k, v) -> (prettyKey k) <> annotate SymbolToken ":" <+> clear v (prettyValue v)) pairs
clear v doc = case v of
(JsonObject _) -> line <> doc
(JsonArray _) -> group doc
_ -> doc
in if length entries == 0
then annotate SymbolToken (lbrace <> rbrace)
else annotate SymbolToken lbrace <> line <> indent 4 (vsep (punctuate (annotate SymbolToken comma) entries)) <> line <> annotate SymbolToken rbrace
JsonArray xs ->
let entries = fmap prettyValue xs
in line'
<> nest
4
( annotate SymbolToken lbracket
<> line' -- first line not indented
<> sep (punctuate (annotate SymbolToken comma) entries)
)
<> line'
<> annotate SymbolToken rbracket
JsonString x ->
annotate QuoteToken dquote
<> annotate StringToken (escapeText x)
<> annotate QuoteToken dquote
JsonNumber x -> annotate NumberToken (viaShow x)
JsonBool x -> case x of
True -> annotate BooleanToken "true"
False -> annotate BooleanToken "false"
JsonNull -> annotate LiteralToken "null"
{-# INLINEABLE prettyValue #-}
escapeText :: Rope -> Doc JsonToken
escapeText text =
let t = fromRope text :: T.Text
ts = T.split (== '"') t
ds = fmap pretty ts
in hcat (punctuate (annotate EscapeToken "\\\"") ds)
{-# INLINEABLE escapeText #-}