There are many, many interpolation libraries on Hackage, but they are exclusively:
- For building interpolated strings at compile-time, through quasi-quotation
- For building only string (or string-like) types
This library is different. It aims to better support cases where:
- The interpolated data may be provided at runtime, such as from a configuration file or web request
- The interpolated data is structured, such as a record of fields of interpolated data
- 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.
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)]
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
}
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)"
])
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"
})