aeson-yaml 1.0.0.0 → 1.0.1.0
raw patch · 5 files changed
+193/−41 lines, 5 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
+ Data.Aeson.Yaml: encodeQuoted :: ToJSON a => a -> ByteString
+ Data.Aeson.Yaml: encodeQuotedDocuments :: ToJSON a => [a] -> ByteString
Files
- CHANGELOG.md +18/−1
- README.md +9/−3
- aeson-yaml.cabal +2/−2
- src/Data/Aeson/Yaml.hs +95/−19
- test/Test/Data/Aeson/Yaml.hs +69/−16
CHANGELOG.md view
@@ -1,3 +1,20 @@-# 1.0.0.0+# Changelog++## 1.0.1.0 - 2019-10-15++### Added++ - `encodeQuoted`: Encodes with all keys/strings quoted+ - `encodeQuotedDocuments`: Encodes documents with all keys/strings quoted++### Changed++ - Simple strings (scalars) are now written unquoted.++ - Multi-line strings (with trailing newlines) are now written as literal+ block scalars.+++## 1.0.0.0 - 2019-09-30 Initial version
README.md view
@@ -1,8 +1,6 @@ # aeson-yaml -BSD3-licensed library to encode any Aeson value as YAML, without a-dependency on an external YAML library like `yaml` (libyaml C FFI) or-`HsYaml` (GPL).+BSD3-licensed, pure Haskell library to encode any Aeson value as YAML. ## Usage @@ -30,3 +28,11 @@ ## License [BSD3](LICENSE)++## Motivation++This library does not depend on any external YAML library with C bindings,+like `yaml`, or a restrictive license, like `HsYaml` (GPLv3). Note, though,+that this library can only be used for encoding, not decoding.++This library also works with GHCJS and Eta.
aeson-yaml.cabal view
@@ -1,7 +1,7 @@ cabal-version: 1.12 name: aeson-yaml-version: 1.0.0.0+version: 1.0.1.0 homepage: https://github.com/clovyr/aeson-yaml bug-reports: https://github.com/clovyr/aeson-yaml/issues author: Patrick Nielsen@@ -38,7 +38,7 @@ Data.Aeson.Yaml build-depends: aeson >= 0.4.0.0 && < 1.5- , base >= 4.5.0.0 && < 4.13+ , base >= 4.8.2.0 && < 5 , bytestring >= 0.10.4.0 && < 0.11 , text >= 0.1 && < 1.3 , unordered-containers >= 0.1.0.0 && < 0.3
src/Data/Aeson/Yaml.hs view
@@ -8,12 +8,13 @@ This module is meant to be imported qualified. -}- {-# LANGUAGE OverloadedStrings #-} module Data.Aeson.Yaml ( encode , encodeDocuments+ , encodeQuoted+ , encodeQuotedDocuments ) where import Data.Aeson hiding (encode)@@ -23,10 +24,12 @@ import qualified Data.ByteString.Builder as ByteString.Builder import qualified Data.ByteString.Lazy as ByteString.Lazy import qualified Data.ByteString.Short as ByteString.Short+import Data.Char (isDigit) import qualified Data.HashMap.Strict as HashMap-import Data.List (sortOn)-import Data.List (intersperse)+import Data.List (intersperse, sortOn) import Data.Monoid ((<>), mconcat, mempty)+import qualified Data.Text as Text+import Data.Text (Text) import qualified Data.Text.Encoding as Text.Encoding import qualified Data.Vector as Vector @@ -43,23 +46,41 @@ indent 0 = mempty indent n = bs " " <> (indent $! n - 1) -enc :: ToJSON a => a -> ByteString.Lazy.ByteString-enc = Data.Aeson.encode- -- | Encode a value as YAML (lazy bytestring). encode :: ToJSON a => a -> ByteString.Lazy.ByteString-encode = ByteString.Builder.toLazyByteString . encodeBuilder False 0 . toJSON+encode v =+ ByteString.Builder.toLazyByteString $+ encodeBuilder False False 0 (toJSON v) <> bs "\n" --- | Encode multiple values separated by '---'. To encode values of different+-- | Encode multiple values separated by '\n---\n'. To encode values of different -- types, @import Data.Aeson(ToJSON(toJSON))@ and do -- @encodeDocuments [toJSON x, toJSON y, toJSON z]@. encodeDocuments :: ToJSON a => [a] -> ByteString.Lazy.ByteString-encodeDocuments =- ByteString.Builder.toLazyByteString .- mconcat . intersperse (bs "\n---\n") . map ((encodeBuilder False 0) . toJSON)+encodeDocuments vs = ByteString.Builder.toLazyByteString $ output <> bs "\n"+ where+ output =+ mconcat $+ intersperse (bs "\n---\n") $ map (encodeBuilder False False 0 . toJSON) vs -encodeBuilder :: Bool -> Int -> Data.Aeson.Value -> Builder-encodeBuilder newlineBeforeObject level value =+-- | Encode a value as YAML (lazy bytestring). Keys and strings are always+-- quoted.+encodeQuoted :: ToJSON a => a -> ByteString.Lazy.ByteString+encodeQuoted v =+ ByteString.Builder.toLazyByteString $+ encodeBuilder True False 0 (toJSON v) <> bs "\n"++-- | Encode multiple values separated by '\n---\n'. Keys and strings are always+-- quoted.+encodeQuotedDocuments :: ToJSON a => [a] -> ByteString.Lazy.ByteString+encodeQuotedDocuments vs =+ ByteString.Builder.toLazyByteString $ output <> bs "\n"+ where+ output =+ mconcat $+ intersperse (bs "\n---\n") $ map (encodeBuilder True False 0 . toJSON) vs++encodeBuilder :: Bool -> Bool -> Int -> Data.Aeson.Value -> Builder+encodeBuilder alwaysQuote newlineBeforeObject level value = case value of Object hm -> mconcat $@@ -72,20 +93,75 @@ mconcat $ (prefix :) $ intersperse prefix $- map (encodeBuilder False (level + 1)) (Vector.toList vec)+ map (encodeBuilder alwaysQuote False (level + 1)) (Vector.toList vec) where prefix = bs "\n" <> indent level <> bs "- "- String s -> bl (enc s)- Number n -> bl (enc n)- Bool bool -> bl (enc bool)+ String s -> encodeText True alwaysQuote level s+ Number n -> bl (Data.Aeson.encode n)+ Bool bool -> bl (Data.Aeson.encode bool) Null -> bs "null" where keyValue level' (k, v) = mconcat- [ b (Text.Encoding.encodeUtf8 k)+ [ encodeText False alwaysQuote level k , ":" , case v of Object _ -> "" Array _ -> "" _ -> " "- , encodeBuilder True (level' + 1) v+ , encodeBuilder alwaysQuote True (level' + 1) v ]++encodeText :: Bool -> Bool -> Int -> Text -> Builder+encodeText canMultiline alwaysQuote level s+ | canMultiline && "\n" `Text.isSuffixOf` s = encodeLines level (Text.lines s)+ | alwaysQuote && unquotable =+ bs "'" <> b (Text.Encoding.encodeUtf8 s) <> bs "'"+ | alwaysQuote || not unquotable = bl $ Data.Aeson.encode s+ | otherwise = b (Text.Encoding.encodeUtf8 s)+ where+ unquotable =+ s /= "" &&+ (not $ isSpecial s) &&+ isSafeAscii (Text.head s) &&+ not (Text.all isNumberRelated s) && Text.all isAllowed s+ isSpecial s'+ | Text.length s > 5 = False+ | otherwise =+ case Text.toLower s' of+ "true" -> True+ "false" -> True+ "on" -> True+ "off" -> True+ "y" -> True+ "yes" -> True+ "n" -> True+ "no" -> True+ _ -> False+ isSafeAscii c =+ (c >= 'a' && c <= 'z') ||+ (c >= 'A' && c <= 'Z') ||+ (c >= '0' && c <= '9') || c == '/' || c == '_' || c == '.' || c == '='+ isNumberRelated c = isDigit c || c == '.' || c == 'e'+ isAllowed c+ -- We don't include ' ' here to avoid sequences like " -" and ": "+ = isSafeAscii c || c == '-' || c == ':'++encodeLines :: Int -> [Text] -> Builder+encodeLines level ls =+ mconcat $+ (prefix :) $+ intersperse (bs "\n" <> indent level) $ map (b . Text.Encoding.encodeUtf8) ls+ where+ prefix =+ mconcat+ [ bs "|"+ , if needsIndicator+ then bs "2"+ else mempty+ , "\n"+ , indent level+ ]+ needsIndicator =+ case ls of+ (line:_) -> " " `Text.isPrefixOf` line+ _ -> False
test/Test/Data/Aeson/Yaml.hs view
@@ -18,13 +18,14 @@ import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertEqual, testCase) -import qualified Data.Aeson.Yaml+import Data.Aeson.Yaml data TestCase = TestCase { tcName :: String , tcInput :: Data.ByteString.Lazy.ByteString , tcOutput :: Data.ByteString.Lazy.ByteString+ , tcAlwaysQuote :: Bool } testCases :: [TestCase]@@ -34,6 +35,18 @@ , tcInput = [s| {+ "nullValue": null,+ "isTrue": true,+ "isFalse": false,+ "numberString": "12345",+ "quoted ! key": true,+ "ON": "ON",+ "off": "off",+ "piString": "3.14",+ "expString": "1e3",+ "multiLine": "The first line is followed by the\nsecond line\n",+ "multiLineWithSpaces": " This has\nextra spaces at the beginning\n",+ "notMultiline": "This won't be\nmulti-lined", "apiVersion": "apps/v1", "kind": "Deployment", "metadata": {@@ -70,6 +83,7 @@ "containerPort": 7654 } ],+ "script": "#!/bin/bash\necho hello world\n", "volumeMounts": [ { "mountPath": "/data/mount1",@@ -88,37 +102,67 @@ } |] , tcOutput =- [s|apiVersion: "apps/v1"-kind: "Deployment"+ [s|"ON": "ON"+apiVersion: apps/v1+expString: "1e3"+isFalse: false+isTrue: true+kind: Deployment metadata: labels:- app: "foo"+ app: foo name: "{{ .Release.Name }}-deployment"+multiLine: |+ The first line is followed by the+ second line+multiLineWithSpaces: |2+ This has+ extra spaces at the beginning+notMultiline: "This won't be\nmulti-lined"+nullValue: null+numberString: "12345"+"off": "off"+piString: "3.14"+"quoted ! key": true spec: replicas: 1 selector: matchLabels:- app: "foo"+ app: foo template: metadata: labels:- app: "foo"+ app: foo name: "{{ .Release.Name }}-pod" spec: containers: - command:- - "/data/bin/foo"+ - /data/bin/foo - "--port=7654"- image: "ubuntu:latest"+ image: ubuntu:latest name: "{{ .Release.Name }}-container" ports: - containerPort: 7654+ script: |+ #!/bin/bash+ echo hello world volumeMounts:- - mountPath: "/data/mount1"+ - mountPath: /data/mount1 name: "{{ .Release.Name }}-volume-mount1"- - mountPath: "/data/mount2"- name: "{{ .Release.Name }}-volume-mount2"|]+ - mountPath: /data/mount2+ name: "{{ .Release.Name }}-volume-mount2"+|]+ , tcAlwaysQuote = False }+ , TestCase+ { tcName = "Quoted"+ , tcInput = [s|{"foo": "bar", "baz": "quux"}|]+ , tcOutput =+ [s|'baz': 'quux'+'foo': 'bar'+|]+ , tcAlwaysQuote = True+ } ] foo :: Data.Aeson.Value@@ -134,12 +178,21 @@ "libyaml decodes the original value" decodedInput decodedYaml- assertEqual- "Expected documents output"- ("foo: \"bar\"" <> "\n---\n" <> output)- (Data.Aeson.Yaml.encodeDocuments [foo, decodedInput])+ if tcAlwaysQuote+ then assertEqual+ "Expected documents output"+ ("'foo': 'bar'" <> "\n---\n" <> output)+ (encodeQuotedDocuments [foo, decodedInput])+ else assertEqual+ "Expected documents output"+ ("foo: bar" <> "\n---\n" <> output)+ (encodeDocuments [foo, decodedInput]) where- output = Data.Aeson.Yaml.encode decodedInput+ output =+ (if tcAlwaysQuote+ then encodeQuoted+ else encode)+ decodedInput decodedInput :: Data.Aeson.Value decodedInput = fromRight (error "couldn't decode JSON") $