settei-formats (empty) → 0.2.0.0
raw patch · 12 files changed
+566/−0 lines, 12 filesdep +basedep +containersdep +generic-lens
Dependencies added: base, containers, generic-lens, optparse-applicative, settei, settei-dhall, settei-formats, settei-kdl, settei-optparse-applicative, settei-yaml, tasty, tasty-hunit, text
Files
- CHANGELOG.md +7/−0
- LICENSE +28/−0
- settei-formats.cabal +81/−0
- src/Settei/Formats.hs +179/−0
- src/Settei/Formats/Optparse.hs +34/−0
- test/Main.hs +15/−0
- test/Settei/FormatsOptparseTest.hs +47/−0
- test/Settei/FormatsTest.hs +168/−0
- test/fixtures/app.dhall +1/−0
- test/fixtures/app.kdl +3/−0
- test/fixtures/app.yaml +2/−0
- test/fixtures/imports.dhall +1/−0
+ CHANGELOG.md view
@@ -0,0 +1,7 @@+# Changelog for settei-formats++## 0.2.0.0 — 2026-07-19++- Initial experimental release.+- Add tagged `FORMAT:PATH` configuration inputs, a shared loader dispatching to the+ YAML, KDL, and Dhall adapters, and a reusable `--config FORMAT:PATH` option.
+ LICENSE view
@@ -0,0 +1,28 @@+Copyright (c) 2026, shinzui+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice,+ this list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+ this list of conditions and the following disclaimer in the documentation+ and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its contributors+ may be used to endorse or promote products derived from this software+ without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE+POSSIBILITY OF SUCH DAMAGE.
+ settei-formats.cabal view
@@ -0,0 +1,81 @@+cabal-version: 3.8+name: settei-formats+version: 0.2.0.0+synopsis: Tagged multi-format configuration loading for Settei+description:+ Parse tagged FORMAT:PATH configuration inputs and load them through the+ Settei YAML, KDL, and Dhall adapters with one shared dispatcher, so+ multi-format applications stop hand-rolling the same reader and loader.++homepage: https://github.com/shinzui/settei+bug-reports: https://github.com/shinzui/settei/issues+license: BSD-3-Clause+license-file: LICENSE+author: shinzui+maintainer: shinzui+category: Configuration+build-type: Simple+tested-with: GHC ==9.12.4+extra-doc-files: CHANGELOG.md+data-files:+ test/fixtures/*.dhall+ test/fixtures/*.kdl+ test/fixtures/*.yaml++source-repository head+ type: git+ location: https://github.com/shinzui/settei.git++common common+ default-language: GHC2024+ default-extensions:+ DeriveAnyClass+ DuplicateRecordFields+ OverloadedLabels+ OverloadedStrings++ ghc-options: -Wall -Wcompat++library+ import: common+ hs-source-dirs: src+ exposed-modules:+ Settei.Formats+ Settei.Formats.Optparse++ build-depends:+ , base >=4.21 && <5+ , containers >=0.6.8 && <0.8+ , generic-lens >=2.2 && <2.4+ , optparse-applicative >=0.19 && <0.20+ , settei ==0.2.0.0+ , settei-dhall ==0.2.0.0+ , settei-kdl ==0.2.0.0+ , settei-optparse-applicative ==0.2.0.0+ , settei-yaml ==0.2.0.0+ , text >=2.1 && <2.2++test-suite settei-formats-tests+ import: common+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Main.hs+ other-modules:+ Paths_settei_formats+ Settei.FormatsOptparseTest+ Settei.FormatsTest++ autogen-modules: Paths_settei_formats+ build-depends:+ , base >=4.21 && <5+ , containers >=0.6.8 && <0.8+ , generic-lens >=2.2 && <2.4+ , optparse-applicative >=0.19 && <0.20+ , settei ==0.2.0.0+ , settei-dhall ==0.2.0.0+ , settei-formats ==0.2.0.0+ , settei-kdl ==0.2.0.0+ , settei-yaml ==0.2.0.0+ , tasty >=1.5 && <1.6+ , tasty-hunit >=0.10.2 && <0.11+ , text >=2.1 && <2.2
+ src/Settei/Formats.hs view
@@ -0,0 +1,179 @@+{-# LANGUAGE ImportQualifiedPost #-}++-- |+-- Module: Settei.Formats+-- Description: Tagged multi-format configuration inputs and a shared adapter loader.+module Settei.Formats+ ( ConfigFormat (..),+ ConfigInput,+ FormatLoadError (..),+ LoadOptions,+ annotateLoadOptions,+ configInput,+ configInputFormat,+ configInputPath,+ defaultLoadOptions,+ fromKubernetesMountedFile,+ loadConfigInput,+ loadOptionsAnnotations,+ loadOptionsDhallImportPolicy,+ loadOptionsKubernetesRef,+ parseConfigInput,+ renderFormatLoadErrorText,+ withDhallImportPolicy,+ )+where++import Data.Generics.Labels ()+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Settei+import Settei.Dhall qualified as Dhall+import Settei.Kdl qualified as Kdl+import Settei.Prelude+import Settei.Yaml qualified as Yaml++-- | Explicit format tag required for each configuration input.+data ConfigFormat = YamlFormat | KdlFormat | DhallFormat+ deriving stock (Generic, Eq, Ord, Show)++-- | One ordered, explicitly tagged configuration file.+data ConfigInput = ConfigInput+ { format :: !ConfigFormat,+ path :: !FilePath+ }+ deriving stock (Generic, Eq, Show)++-- | Construct a tagged input programmatically.+configInput :: ConfigFormat -> FilePath -> ConfigInput+configInput format path = ConfigInput {format, path}++-- | Return an input's explicit adapter tag.+configInputFormat :: ConfigInput -> ConfigFormat+configInputFormat value = value ^. #format++-- | Return an input's filesystem path.+configInputPath :: ConfigInput -> FilePath+configInputPath value = value ^. #path++-- | Parse the @FORMAT:PATH@ grammar: @yaml:PATH@, @kdl:PATH@, or @dhall:PATH@.+--+-- A missing colon or an empty path fails with @expected FORMAT:PATH@. Any other+-- format name, including an empty one as in @:app.yaml@, fails with+-- @FORMAT must be yaml, kdl, or dhall@.+parseConfigInput :: String -> Either String ConfigInput+parseConfigInput input =+ let (formatName, separatorAndPath) = break (== ':') input+ filePath = drop 1 separatorAndPath+ in if null separatorAndPath || null filePath+ then Left "expected FORMAT:PATH"+ else case formatName of+ "yaml" -> Right (configInput YamlFormat filePath)+ "kdl" -> Right (configInput KdlFormat filePath)+ "dhall" -> Right (configInput DhallFormat filePath)+ _ -> Left "FORMAT must be yaml, kdl, or dhall"++-- | How loaded sources should be annotated and how Dhall imports are policed.+data LoadOptions = LoadOptions+ { kubernetesReference :: !(Maybe KubernetesRef),+ annotations :: !(Map Text Text),+ dhallImportPolicy :: !Dhall.DhallImportPolicy+ }+ deriving stock (Generic)++-- | No Kubernetes reference, no extra annotations, and @NoImports@ for Dhall.+--+-- The Dhall default is deliberately the most restrictive policy: a loader must+-- never follow filesystem imports unless the caller explicitly names an allowed+-- directory with 'withDhallImportPolicy'.+defaultLoadOptions :: LoadOptions+defaultLoadOptions =+ LoadOptions+ { kubernetesReference = Nothing,+ annotations = Map.empty,+ dhallImportPolicy = Dhall.NoImports+ }++-- | Attach a trusted Kubernetes reference for a mounted file. No cluster lookup occurs.+fromKubernetesMountedFile :: KubernetesRef -> LoadOptions -> LoadOptions+fromKubernetesMountedFile reference options =+ options & #kubernetesReference ?~ reference++-- | Add trusted, secret-safe annotations to every loaded source.+annotateLoadOptions :: Map Text Text -> LoadOptions -> LoadOptions+annotateLoadOptions extra options = options & #annotations %~ (extra <>)++-- | Replace the Dhall import policy (default 'Dhall.NoImports').+withDhallImportPolicy :: Dhall.DhallImportPolicy -> LoadOptions -> LoadOptions+withDhallImportPolicy policy options = options & #dhallImportPolicy .~ policy++-- | Return the optional mounted-file Kubernetes reference.+loadOptionsKubernetesRef :: LoadOptions -> Maybe KubernetesRef+loadOptionsKubernetesRef value = value ^. #kubernetesReference++-- | Return caller-supplied trusted annotations.+loadOptionsAnnotations :: LoadOptions -> Map Text Text+loadOptionsAnnotations value = value ^. #annotations++-- | Return the enforced Dhall import policy.+loadOptionsDhallImportPolicy :: LoadOptions -> Dhall.DhallImportPolicy+loadOptionsDhallImportPolicy value = value ^. #dhallImportPolicy++-- | One failed tagged load, wrapping the originating adapter's full error list.+data FormatLoadError+ = YamlLoadError !(NonEmpty Yaml.YamlSourceError)+ | KdlLoadError !(NonEmpty Kdl.KdlSourceError)+ | DhallLoadError !(NonEmpty Dhall.DhallSourceError)+ deriving stock (Generic, Eq, Show)++-- | Load one tagged input through the adapter its format names.+loadConfigInput ::+ LoadOptions ->+ ConfigInput ->+ IO (Either (NonEmpty FormatLoadError) Source)+loadConfigInput options input =+ let sourceLabel = Text.pack (input ^. #path)+ wrap wrapError = either (Left . NonEmpty.singleton . wrapError) Right+ kubernetesAnnotationsOf =+ maybe Map.empty kubernetesAnnotations (options ^. #kubernetesReference)+ in case input ^. #format of+ YamlFormat ->+ let yamlOptions =+ Yaml.annotateYamlSourceOptions+ (options ^. #annotations)+ ( maybe+ id+ Yaml.fromKubernetesMountedFile+ (options ^. #kubernetesReference)+ (Yaml.yamlSourceOptions sourceLabel)+ )+ in wrap YamlLoadError <$> Yaml.readYamlSource yamlOptions (input ^. #path)+ KdlFormat ->+ let kdlOptions =+ Kdl.annotateKdlSourceOptions+ (options ^. #annotations)+ ( maybe+ id+ Kdl.fromKubernetesMountedFile+ (options ^. #kubernetesReference)+ (Kdl.kdlSourceOptions sourceLabel)+ )+ in wrap KdlLoadError <$> Kdl.readKdlSource kdlOptions (input ^. #path)+ DhallFormat ->+ let dhallOptions =+ Dhall.annotateDhallSourceOptions+ (kubernetesAnnotationsOf <> options ^. #annotations)+ ( Dhall.dhallSourceOptions+ sourceLabel+ (options ^. #dhallImportPolicy)+ )+ in wrap DhallLoadError+ <$> Dhall.loadDhallSource dhallOptions (Dhall.DhallFile (input ^. #path))++-- | Render one adapter load failure for operator-facing output.+renderFormatLoadErrorText :: FormatLoadError -> Text+renderFormatLoadErrorText = \case+ YamlLoadError problems -> Yaml.renderYamlErrorsText problems+ KdlLoadError problems -> Kdl.renderKdlErrorsText problems+ DhallLoadError problems -> Dhall.renderDhallErrorsText problems
+ src/Settei/Formats/Optparse.hs view
@@ -0,0 +1,34 @@+{-# LANGUAGE ImportQualifiedPost #-}++-- |+-- Module: Settei.Formats.Optparse+-- Description: Reusable @--config FORMAT:PATH@ options for tagged multi-format inputs.+module Settei.Formats.Optparse+ ( configInputOption,+ configInputOptions,+ configInputReader,+ )+where++import Control.Applicative qualified as Applicative+import Options.Applicative (Mod, OptionFields, Parser)+import Options.Applicative qualified as Options+import Settei.Formats++-- | 'Options.ReadM' for the @FORMAT:PATH@ grammar of 'parseConfigInput'.+configInputReader :: Options.ReadM ConfigInput+configInputReader = Options.eitherReader parseConfigInput++-- | Parse zero or more tagged inputs using caller-supplied option metadata.+configInputOption :: Mod OptionFields ConfigInput -> Parser [ConfigInput]+configInputOption modifiers =+ Applicative.many (Options.option configInputReader modifiers)++-- | Parse zero or more default @--config FORMAT:PATH@ occurrences.+configInputOptions :: Parser [ConfigInput]+configInputOptions =+ configInputOption+ ( Options.long "config"+ <> Options.metavar "FORMAT:PATH"+ <> Options.help "Load yaml:PATH, kdl:PATH, or dhall:PATH in occurrence order"+ )
+ test/Main.hs view
@@ -0,0 +1,15 @@+module Main (main) where++import Settei.FormatsOptparseTest qualified+import Settei.FormatsTest qualified+import Test.Tasty (defaultMain, testGroup)++main :: IO ()+main =+ defaultMain+ ( testGroup+ "settei-formats"+ [ Settei.FormatsTest.tests,+ Settei.FormatsOptparseTest.tests+ ]+ )
+ test/Settei/FormatsOptparseTest.hs view
@@ -0,0 +1,47 @@+{-# LANGUAGE ImportQualifiedPost #-}++module Settei.FormatsOptparseTest (tests) where++import Options.Applicative qualified as Options+import Settei.Formats+import Settei.Formats.Optparse+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++tests :: TestTree+tests =+ testGroup+ "Settei.Formats.Optparse"+ [ testCase "repeated --config inputs preserve occurrence order" $ do+ parsed <-+ expectParse+ configInputOptions+ ["--config", "yaml:a.yaml", "--config", "kdl:b.kdl"]+ parsed+ @?= [ configInput YamlFormat "a.yaml",+ configInput KdlFormat "b.kdl"+ ],+ testCase "no --config occurrence produces an empty list" $ do+ parsed <- expectParse configInputOptions []+ parsed @?= [],+ testCase "an unsupported format fails option parsing" $ do+ let parsed = parse configInputOptions ["--config", "toml:x"]+ assertBool+ "unsupported format unexpectedly parsed"+ (Options.getParseResult parsed == Nothing),+ testCase "caller-supplied option metadata is honored" $ do+ parsed <-+ expectParse+ (configInputOption (Options.long "input" <> Options.metavar "FORMAT:PATH"))+ ["--input", "dhall:c.dhall"]+ parsed @?= [configInput DhallFormat "c.dhall"]+ ]++parse :: Options.Parser a -> [String] -> Options.ParserResult a+parse parser arguments =+ Options.execParserPure Options.defaultPrefs (Options.info parser mempty) arguments++expectParse :: Options.Parser a -> [String] -> IO a+expectParse parser arguments = case parse parser arguments of+ Options.Success value -> pure value+ _ -> fail "expected command-line parsing to succeed"
+ test/Settei/FormatsTest.hs view
@@ -0,0 +1,168 @@+{-# LANGUAGE ImportQualifiedPost #-}++module Settei.FormatsTest (tests) where++import Data.Generics.Labels ()+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Paths_settei_formats qualified as Paths+import Settei+import Settei.Dhall qualified as Dhall+import Settei.Formats+import Settei.Kdl qualified as Kdl+import Settei.Prelude+import Settei.Yaml qualified as Yaml+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++tests :: TestTree+tests =+ testGroup+ "Settei.Formats"+ [ testGroup+ "FORMAT:PATH grammar"+ [ testCase "accepts yaml, kdl, and dhall tags" $ do+ assertParsed "yaml:app.yaml" YamlFormat "app.yaml"+ assertParsed "kdl:app.kdl" KdlFormat "app.kdl"+ assertParsed "dhall:app.dhall" DhallFormat "app.dhall",+ testCase "splits only at the first colon" $+ assertParsed+ "yaml:dir:with:colons.yaml"+ YamlFormat+ "dir:with:colons.yaml",+ testCase "rejects missing separators and empty paths" $ do+ parseConfigInput "app.yaml" @?= Left "expected FORMAT:PATH"+ parseConfigInput "yaml:" @?= Left "expected FORMAT:PATH",+ testCase "rejects unsupported and empty format names" $ do+ parseConfigInput "toml:app.toml"+ @?= Left "FORMAT must be yaml, kdl, or dhall"+ parseConfigInput ":app.yaml"+ @?= Left "FORMAT must be yaml, kdl, or dhall"+ ],+ testGroup+ "adapter dispatch"+ [ testCase "loads YAML and resolves a typed value" $+ assertFixtureLoads YamlFormat "test/fixtures/app.yaml",+ testCase "loads KDL and resolves a typed value" $+ assertFixtureLoads KdlFormat "test/fixtures/app.kdl",+ testCase "loads Dhall and resolves a typed value" $+ assertFixtureLoads DhallFormat "test/fixtures/app.dhall"+ ],+ testCase "Kubernetes and caller annotations reach YAML and Dhall sources" $ do+ yamlPath <- Paths.getDataFileName "test/fixtures/app.yaml"+ dhallPath <- Paths.getDataFileName "test/fixtures/app.dhall"+ let reference =+ kubernetesRef+ ConfigMapObject+ Nothing+ "settei-formats-test"+ (Just "app.yaml")+ options =+ fromKubernetesMountedFile+ reference+ ( annotateLoadOptions+ (Map.singleton "team" "platform")+ defaultLoadOptions+ )+ yamlSource <- expectLoaded options (configInput YamlFormat yamlPath)+ assertAnnotations yamlSource+ dhallSource <- expectLoaded options (configInput DhallFormat dhallPath)+ assertAnnotations dhallSource,+ testCase "Dhall imports are denied by default and allowed explicitly" $ do+ importPath <- Paths.getDataFileName "test/fixtures/imports.dhall"+ fixturesDirectory <- Paths.getDataFileName "test/fixtures"+ loadOptionsDhallImportPolicy defaultLoadOptions @?= Dhall.NoImports+ denied <- loadConfigInput defaultLoadOptions (configInput DhallFormat importPath)+ case denied of+ Left outerErrors -> case NonEmpty.toList outerErrors of+ [DhallLoadError problems] ->+ fmap Dhall.dhallErrorCategory (NonEmpty.toList problems)+ @?= [Dhall.DhallImportPolicyError]+ _ -> fail "expected one Dhall load error"+ Right _ -> fail "expected the default Dhall policy to reject imports"+ imported <-+ expectLoaded+ ( withDhallImportPolicy+ (Dhall.LocalImportsWithin fixturesDirectory)+ defaultLoadOptions+ )+ (configInput DhallFormat importPath)+ assertResolvedEndpoint imported,+ testCase "a missing YAML file is a structured load error" $ do+ missing <-+ loadConfigInput+ defaultLoadOptions+ (configInput YamlFormat "settei-formats-missing.yaml")+ case missing of+ Left outerErrors -> case NonEmpty.toList outerErrors of+ [YamlLoadError problems] ->+ fmap Yaml.yamlErrorCategory (NonEmpty.toList problems)+ @?= [Yaml.YamlIoError]+ _ -> fail "expected one YAML load error"+ Right _ -> fail "expected a missing YAML file to fail",+ testCase "format load errors delegate to adapter renderers" $ do+ yamlError <- expectLoadError YamlFormat "settei-formats-missing.yaml"+ case yamlError of+ YamlLoadError problems ->+ renderFormatLoadErrorText yamlError @?= Yaml.renderYamlErrorsText problems+ _ -> fail "expected a YAML load error"+ kdlError <- expectLoadError KdlFormat "settei-formats-missing.kdl"+ case kdlError of+ KdlLoadError problems ->+ renderFormatLoadErrorText kdlError @?= Kdl.renderKdlErrorsText problems+ _ -> fail "expected a KDL load error"+ dhallError <- expectLoadError DhallFormat "settei-formats-missing.dhall"+ case dhallError of+ DhallLoadError problems ->+ renderFormatLoadErrorText dhallError @?= Dhall.renderDhallErrorsText problems+ _ -> fail "expected a Dhall load error"+ ]++assertParsed :: String -> ConfigFormat -> FilePath -> IO ()+assertParsed input expectedFormat expectedPath = case parseConfigInput input of+ Left problem -> fail problem+ Right parsed -> do+ configInputFormat parsed @?= expectedFormat+ configInputPath parsed @?= expectedPath++assertFixtureLoads :: ConfigFormat -> FilePath -> IO ()+assertFixtureLoads format relativePath = do+ fixturePath <- Paths.getDataFileName relativePath+ loaded <- expectLoaded defaultLoadOptions (configInput format fixturePath)+ assertResolvedEndpoint loaded++expectLoaded :: LoadOptions -> ConfigInput -> IO Source+expectLoaded options input = do+ result <- loadConfigInput options input+ case result of+ Left problems -> fail (show problems)+ Right loaded -> pure loaded++expectLoadError :: ConfigFormat -> FilePath -> IO FormatLoadError+expectLoadError format path = do+ result <- loadConfigInput defaultLoadOptions (configInput format path)+ case result of+ Left problems -> case NonEmpty.toList problems of+ [problem] -> pure problem+ _ -> fail "expected one format load error"+ Right _ -> fail "expected format loading to fail"++assertResolvedEndpoint :: Source -> IO ()+assertResolvedEndpoint input =+ case (resolve defaultResolveOptions [input] endpointConfig) ^. #answer of+ Left problems -> fail (show problems)+ Right endpoint -> endpoint @?= "https://example.test"++assertAnnotations :: Source -> IO ()+assertAnnotations input = do+ Map.lookup "kubernetes.object-kind" (sourceAnnotations input) @?= Just "ConfigMap"+ Map.lookup "kubernetes.object-name" (sourceAnnotations input)+ @?= Just "settei-formats-test"+ Map.lookup "team" (sourceAnnotations input) @?= Just "platform"++endpointConfig :: Config Text+endpointConfig =+ required (publicSetting endpointKey "Service endpoint" textDecoder)++endpointKey :: Key+endpointKey = either (error . show) id (parseKey "service.endpoint")
+ test/fixtures/app.dhall view
@@ -0,0 +1,1 @@+{ service = { endpoint = "https://example.test" } }
+ test/fixtures/app.kdl view
@@ -0,0 +1,3 @@+service {+ endpoint "https://example.test"+}
+ test/fixtures/app.yaml view
@@ -0,0 +1,2 @@+service:+ endpoint: https://example.test
+ test/fixtures/imports.dhall view
@@ -0,0 +1,1 @@+./app.dhall