Skip to content

freckle/interpolated-data

Repository files navigation

interpolated-data

Hackage Stackage Nightly Stackage LTS CI

⚠️ Work in progress

There are many, many interpolation libraries on Hackage, but they are exclusively:

  1. For building interpolated strings at compile-time, through quasi-quotation
  2. For building only string (or string-like) types

This library is different. It aims to better support cases where:

  1. The interpolated data may be provided at runtime, such as from a configuration file or web request
  2. The interpolated data is structured, such as a record of fields of interpolated data
  3. You can state statically in the types what interpolation keys (called a "context") will be available, so we can validate the runtime input

Let's build a motivating example.

import Data.Interpolated

Let's say you're building some deployment tooling. Applications can describe some settings about how they're deployed in a configuration file. Deploys occur for some App and in an Environment. Therefore, we want to support interpolating those (and only those) runtime values into such settings.

An example configuration would look like:

stackName: "{env}-{app}"

repository:
  registry: "my-registry"
  name: "apps/{app}"

dockerfile: "./{app}.dockerfile"

The stackName key supports the env and app interpolations, while the fields of repository support only app (deployment images are reused from env to env, of course). One key feature of our library is making that safe through a combination of compile- and runtime validations.

InterpolationContext

To supply values for interpolations, we have to define types that are instances of InterpolationContext:

data AppEnvContext = AppEnvContext
  { app :: AppName
  , env :: Environment
  }

instance InterpolationContext AppEnvContext where

A valid InterpolationContext can say statically what keys it provides. It does this by defining interpolationVariables :: Proxy context -> Set Text:

  interpolationVariables _ = Set.fromList ["app", "env"]

This function operates on Proxy so that we can use it at construction-time, before we have any supplied context, to verify we're constructing an interpolation that can indeed be satisfied.

And when it comes time to provide the values, that would be through the interpolationValues :: context -> [(Text, Text)] member:

  interpolationValues AppEnvContext {..} =
    [ ("app", unAppName app)
    , ("env", unEnvironment env)
    ]

And to satisfy our hypothetical use-case, we'll make a second type for the context where only app is available:

newtype AppContext = AppContext
  { app :: AppName
  }

instance InterpolationContext AppContext where
  interpolationVariables _ = Set.singleton "app"
  interpolationValues AppContext {..} = [("app", unAppName app)]

ToInterpolated

The ToInterpolated class is used for input values that contain interpolations. If using a basic string-like type (e.g. Text) we provide that instance. GeneralizedNewtypeDeriving can be used to supply an instance for your own string-like types as well:

newtype StackName = StackName Text
  deriving stock (Eq, Show)
  deriving newtype (FromJSON, ToInterpolated)

newtype EcrRegistry = EcrRegistry Text
  deriving stock (Eq, Show)
  deriving newtype (FromJSON, ToInterpolated)

newtype Dockerfile = Dockerfile FilePath
  deriving stock (Eq, Show)
  deriving newtype (FromJSON, ToInterpolated)

EcrRepository is an example of structured data that supports interpolation.

data EcrRepository = EcrRepository
  { registry :: EcrRegistry
  , name :: Text
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass FromJSON

Since it's not a string-like type, we'll need to create an instance by hand:

instance ToInterpolated EcrRepository where

The parseVariables :: a -> Either String (Set Text) member says how to get all in-use interpolation variables from the given value. In this case, that means to take all the keys across its two fields by the same function:

  parseVariables EcrRepository {..} = (<>)
    <$> parseVariables registry
    <*> parseVariables name

The second member is runReplacement :: (Text -> Text) -> a -> a and it says how to replace the interpolations across the structure. In this case, that means to replace them in each field by the same mechanism:

  runReplacement f er = er
    { registry = runReplacement f $ registry er
    , name = runReplacement f $ name er
    }

InterpolatedBy

Defining a type as a `InterpolatedBy` context is how we ensure safe construction (and use). We validate that the context we specify supplies the variables a uses by calling the type-class functions described above.

data Settings = Settings
  { stackName :: StackName `InterpolatedBy` AppEnvContext
  , repository :: EcrRepository `InterpolatedBy` AppContext
  , dockerfile :: Maybe (Dockerfile `InterpolatedBy` AppContext)
  }
  deriving stock (Show, Generic)
  deriving anyclass FromJSON

Conveniently, construction via generic FromJSON will include this validation. Therefore, when we parse our user's configuration, they'll receive an informative error:

spec1 :: Spec
spec1 = do
  it "fails informatively" $ do
    let
      result =
        void
          $ first Yaml.prettyPrintParseException
          $ Yaml.decodeEither' @Settings $ mconcat
            [ "stackName: '{app}-{env}-{region}'\n"
            , "repository:\n"
            , "  registry: 'hub.docker.io'\n"
            , "  name: 'apps/{app}'\n"
            ]

    result `shouldBe` Left (mconcat
      [ "Aeson exception:\n"
      , "Error in $.stackName: Interpolation uses the variable region, "
      , "which is not available in the provided context (app, env)"
      ])

Type-safety

As authors of this deployment tool, we will be required to interpolate these values to get what we need to perform our logic. Since the values are tagged with context, if we make a mistake and provide the wrong one, that is a type-error. Providing the right context compiles and works as expected:

spec2 :: Spec
spec2 = do
  it "interpolates throughout" $ do
    let
      context1 = AppEnvContext { app = "my-app" , env="prod" }
      context2 = AppContext { app="my-app" }
      result =
        first Yaml.prettyPrintParseException
          $ Yaml.decodeEither' @Settings $ mconcat
            [ "stackName: '{app}-{env}'\n"
            , "repository:\n"
            , "  registry: 'hub.docker.io'\n"
            , "  name: 'apps/{app}'\n"
            ]

    -- Incorrectly using context2 here would fail to compile
    (interpolate context1 . stackName <$> result)
      `shouldBe` Right (StackName "my-app-prod")

    (interpolate context2 . repository <$> result)
      `shouldBe` Right (EcrRepository
        { registry = EcrRegistry "hub.docker.io"
        , name = "apps/my-app"
        })

LICENSE | CHANGELOG

About

No description or website provided.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 3

  •  
  •  
  •