Skip to content

Commit 4c22e9b

Browse files
committed
feat(Config): Add json config format option
In order to make it easier to generate gitit configuration, we add the ability for config files to be json values in addition to the custom config format parser. This should be 100% backward-compatible, with the error messages being reasonably clear about the fallback mechanism. Json strings, numbers and booleans are supported as-is, and the default section also works (sections are distinguished by their name being in `[brackets]`). Potentially a bit more documentation might be needed.
1 parent 8fd2c22 commit 4c22e9b

File tree

3 files changed

+141
-9
lines changed

3 files changed

+141
-9
lines changed

data/default.conf

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# gitit wiki configuration file
22

3+
# NOTE: you can also use a json syntax for configuration files. Create a toplevel object, and nest sections as objects within that object. For example:
4+
# {
5+
# "redirect": "yes",
6+
# "address": "0.0.0.0",
7+
# "[Github]": {
8+
# "oauthclientid": "01239456789abcdef012",
9+
# "oauthclientsecret": "01239456789abcdef01239456789abcdef012394",
10+
# }
11+
# }
12+
313
address: 0.0.0.0
414
# sets the IP address on which the web server will listen.
515

gitit.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ Library
157157
feed >= 1.0 && < 1.4,
158158
xml-types >= 0.3,
159159
xss-sanitize >= 0.3 && < 0.4,
160+
scientific >= 0.3 && < 0.4,
160161
tagsoup >= 0.13 && < 0.15,
161162
blaze-html >= 0.4 && < 0.10,
162163
json >= 0.4 && < 0.12,

src/Network/Gitit/Config.hs

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
{-# LANGUAGE CPP, FlexibleContexts, ScopedTypeVariables, OverloadedStrings #-}
2+
{-# LANGUAGE LambdaCase #-}
3+
{-# LANGUAGE ViewPatterns #-}
4+
{-# LANGUAGE TypeApplications #-}
5+
{-# LANGUAGE MultiWayIf #-}
26
{-
37
Copyright (C) 2009 John MacFarlane <[email protected]>
48
@@ -33,12 +37,23 @@ import Network.Gitit.Util (parsePageType, readFileUTF8)
3337
import System.Log.Logger (logM, Priority(..))
3438
import System.IO (hPutStrLn, stderr)
3539
import System.Exit (ExitCode(..), exitWith)
40+
import Data.Either (partitionEithers)
41+
import Data.Function ((&))
42+
import Data.Functor ((<&>))
3643
import qualified Data.Map as M
44+
import qualified Data.Aeson as Json
45+
import qualified Data.Aeson.Key as Key
46+
import qualified Data.Aeson.KeyMap as KeyMap
3747
import Data.List (intercalate, foldl')
3848
import Data.Char (toLower, toUpper, isAlphaNum)
49+
import qualified Data.Scientific as Scientific
3950
import qualified Data.Text as T
4051
import Data.Text (Text)
4152
import Paths_gitit (getDataFileName)
53+
import qualified Data.Text.Lazy.Builder as T.Builder
54+
import qualified Data.Text.Lazy as T.Lazy
55+
import qualified Data.Text.Lazy.Builder.Int as T.Builder.Int
56+
import qualified Data.Text.Lazy.Builder.Scientific as Scientific
4257
import System.FilePath ((</>))
4358
import Text.Pandoc hiding (ERROR, WARNING, MathJax, MathML, WebTeX, getDataFileName)
4459
import qualified Control.Exception as E
@@ -50,6 +65,7 @@ import Control.Monad
5065
import Control.Monad.Trans
5166
import Text.Parsec
5267
import Text.Read (readMaybe)
68+
import Control.Monad.Except (ExceptT(ExceptT))
5369

5470
-- | Get configuration from config file.
5571
getConfigFromFile :: FilePath -> IO Config
@@ -60,7 +76,7 @@ getConfigFromFiles :: [FilePath] -> IO Config
6076
getConfigFromFiles fnames = do
6177
-- we start with default values from the data file
6278
cp <- getDataFileName "data/default.conf"
63-
cfgmap <- foldM alterConfigMap mempty (cp : fnames)
79+
cfgmap <- foldM alterConfigMapByNewFile mempty (cp : fnames)
6480
res <- runExceptT $ extractConfig cfgmap
6581
case res of
6682
Right conf -> pure conf
@@ -70,18 +86,67 @@ getConfigFromFiles fnames = do
7086

7187
type ConfigMap = M.Map (Text, Text) Text
7288

73-
alterConfigMap :: ConfigMap -> FilePath -> IO ConfigMap
74-
alterConfigMap cfmap fname = do
89+
-- | Parse the config file and return a map from section/field to value.
90+
-- The config file is first checked to see if it’s a valid json value.
91+
--
92+
-- If yes, use the json parser, otherwise use the old style parser for backwards compatibility.
93+
--
94+
-- The JSON format mirrors the old style format.
95+
-- Any key that is in the outer object is put into the [DEFAULT] section.
96+
-- Any key that is surrounded by `[` and `]` is a section name, and the keys inside are the fields in the section.
97+
--
98+
-- Example:
99+
--
100+
-- @
101+
-- {
102+
-- "repository-type": "git",
103+
-- "[GitHub]": {
104+
-- "oauthclientid": "clientid",
105+
-- "oauthclientsecret": "client
106+
-- }
107+
-- }
108+
-- @
109+
110+
alterConfigMapByNewFile :: ConfigMap -> FilePath -> IO ConfigMap
111+
alterConfigMapByNewFile cfmap fname = do
112+
eJsonVal <- readJsonValueFromFile fname
113+
secs <- case eJsonVal of
114+
Right val ->
115+
case pSectionJson val of
116+
Left err -> do
117+
hPutStrLn stderr ("Error parsing gitit json config " <> fname <> ":\n" <> err)
118+
exitWith (ExitFailure 1)
119+
Right secs -> pure secs
120+
Left _jsonErr -> do
121+
eSecs <- readOldStyleConfig fname
122+
case eSecs of
123+
Left errOld -> do
124+
hPutStrLn stderr ("Cannot parse " <> fname <> " as valid json value; tried parsing it as old-style gitit config instead but failed:\n" <> errOld)
125+
exitWith (ExitFailure 1)
126+
Right secs -> pure secs
127+
pure $ alterConfigMap cfmap secs
128+
129+
alterConfigMap :: ConfigMap -> [Section] -> ConfigMap
130+
alterConfigMap = foldl' go
131+
where
132+
go cfmap' (Section name fields) = foldl' (go' name) cfmap' fields
133+
go' name cfmap' (k,v) = M.insert (name, k) v cfmap'
134+
135+
readOldStyleConfig :: FilePath -> IO (Either String [Section])
136+
readOldStyleConfig fname = do
75137
contents <- readFileUTF8 fname
76138
let contents' = "[DEFAULT]\n" <> contents
77139
case parseConfig fname contents' of
78140
Left msg -> do
79-
hPutStrLn stderr ("Error parsing config " <> fname <> ":\n" <> msg)
80-
exitWith (ExitFailure 1)
81-
Right secs -> pure $ foldl' go cfmap secs
82-
where
83-
go cfmap' (Section name fields) = foldl' (go' name) cfmap' fields
84-
go' name cfmap' (k,v) = M.insert (name, k) v cfmap'
141+
pure $ Left msg
142+
Right secs -> pure $ Right secs
143+
144+
readJsonValueFromFile :: FilePath -> IO (Either String Json.Value)
145+
readJsonValueFromFile fname = do
146+
mval <- Json.eitherDecodeFileStrict fname
147+
pure $ case mval of
148+
Left err -> Left $ "Could not parse file as syntactically valid json value: " <> err
149+
Right val -> Right val
85150

86151
-- | Returns the default gitit configuration.
87152
getDefaultConfig :: IO Config
@@ -92,6 +157,62 @@ data Section = Section Text [(Text, Text)]
92157

93158
parseConfig :: FilePath -> Text -> Either String [Section]
94159
parseConfig fname txt = either (Left . show) Right $ parse (many pSection) fname txt
160+
data SectionJson =
161+
DefaultSection Text Text
162+
| ThisSection Text [(Text, Text)]
163+
164+
pSectionJson :: Json.Value -> Either String [Section]
165+
pSectionJson (Json.Object obj) = obj & KeyMap.toList
166+
<&> (\case
167+
(asSectionKey -> Just k, v) -> ThisSection k <$> pSectionFields k v
168+
(k, asJsonScalarText -> Just t) -> Right $ DefaultSection (k & Key.toText) t
169+
(k, v) -> Left ["The value of field " <> (k & Key.toString) <> " has to be a string, but was: " <> show v]
170+
)
171+
& partitionEithers
172+
& \case
173+
([], secs) -> secs & foldr go M.empty & M.toList <&> (\(name, fields) -> Section name fields) & Right
174+
where
175+
go (ThisSection name fields) acc = M.insert name fields acc
176+
go (DefaultSection k v) acc = M.insertWith (++) "DEFAULT" [(k, v)] acc
177+
(errs, _) -> Left $ intercalate "\n" $ concat errs
178+
pSectionJson _ = Left "The toplevel json value has to be a json object."
179+
180+
-- section key starts with [ and ends with ]
181+
asSectionKey :: KeyMap.Key -> Maybe Text
182+
asSectionKey k = case Key.toString k of
183+
(x : (hasEnd ']' -> Just k')) | x == '[' -> Just $ k' & T.pack & T.toUpper
184+
_ -> Nothing
185+
hasEnd :: Eq a => a -> [a] -> Maybe [a]
186+
hasEnd _ [] = Nothing
187+
hasEnd e xs
188+
| last xs == e = Just (init xs)
189+
| otherwise = Nothing
190+
191+
asJsonScalarText :: Json.Value -> Maybe Text
192+
asJsonScalarText (Json.String t) = Just t
193+
asJsonScalarText (Json.Number n) = Just $ T.Lazy.toStrict $ T.Builder.toLazyText formatNumber
194+
where
195+
-- The scientific builder always adds a decimal point, which we don’t want for e.g. port numbers :)
196+
formatNumber = if
197+
| Scientific.isInteger n
198+
, Just i <- Scientific.toBoundedInteger @Int n -> T.Builder.Int.decimal i
199+
| otherwise -> n & Scientific.scientificBuilder
200+
asJsonScalarText (Json.Bool True) = Just "true"
201+
asJsonScalarText (Json.Bool False) = Just "false"
202+
asJsonScalarText _ = Nothing
203+
204+
pSectionFields :: Text -> Json.Value -> Either [String] [(Text, Text)]
205+
pSectionFields sec (Json.Object obj) = obj
206+
& KeyMap.toList
207+
<&> (\case
208+
(k, asJsonScalarText -> Just t) -> Right (k & Key.toText, t)
209+
(k, v) -> Left $ "In Section " <> (sec & T.unpack) <> ": The value of field " <> (k & Key.toString) <> " has to be a string, but was: " <> show v
210+
)
211+
& partitionEithers
212+
& \case
213+
([], fields) -> Right fields
214+
(errs, _) -> Left errs
215+
pSectionFields sec _ = Left [ "The section " <> (sec & T.unpack) <> " has to be a json object." ]
95216

96217
pSection :: Parsec Text () Section
97218
pSection = do

0 commit comments

Comments
 (0)