Y# Substitutions
Overviewβ
At its core, substitutions are a powerful interpolation feature in Variant that allow string values in YAML to be dynamically replaced with either other strings or full object representations like JSON tokens (JTokens). Substitutions provide flexibility by enabling both static configuration and dynamic data-driven logic.
There are two primary substitution operators:
-
$[]: Compile-time substitutions. These are simple string replacements performed before YAML is parsed into JSON. They are resolved without invoking Variantβs substitution service. -
${}: Runtime substitutions. These provide advanced functionality and are resolved at execution time using the substitution service, which has access to the currentVariantMessage.
Runtime substitutions support:
-
Data extraction from messages or headers
-
Default value fallbacks using
?? -
Method calls (e.g.,
ToUpper(),AddDays()) -
Array indexing
-
Secure value lookups
Hereβs a sample API endpoint demonstrating substitutions:
- routeTemplate: api/test
routeMethod: GET
pipeline:
- pipe: Variant.Core.Simple.ModifyMessageStrategyPipe
NAMESPACE: Settings
TEMP_VALUES:
Name: Bobby Davro
DoesNotExist: null
children:
- name: Peppa
age: 5
- name: George
age: 3
VALUE:
# Compile-time and runtime substitutions
projectName: $[Project.Id]
projectName1: ${Project.Id}
projectDescription: $[Project.Description]
# Accessing in-message values
name: ${TempValues.Name}
questionMarkAllowsNulls: ${?DoesNotExist}
# Inbuilt substitutions
currentDir: ${System.CurrentDirectory}
currentDir: ${System.SiteConfig}
dateTimeNow: ${DateTimeOffset.Now}
newGuid: ${Guid.NewGuid}
# Extension methods
uppercaseName: ${TempValues.Name.ToUpper()}
questionMarkAllowsNullWithoutErrors: ${?DoesNotExist.ConvertStringToBase64()}
nameAsBase64: ${TempValues.Name.ConvertStringToBase64()}
nextMonth: ${DateTimeOffset.Now.AddDays(28).ToString("yyyy-MM-dd")}
nullCoalescingWithString: ${?DoesNotExist ?? "ItDoesNottExist"}
nullCoalescingWithHeader: ${?DoesNotExist ?? TempValues.Name}
# Working with arrays
usingIndex1: ${TempValues.children[0].name}
usingSelectTokens: ${TempValues.children.SelectTokens("$..name")}
usingSelectTokensWithJoin: ${TempValues.children.SelectTokens("$..name").Join(",")}
# Secure values
secureSetting: ${+CloudStorage-Name}
secureSettingUppercase: ${+CloudStorage-Name.ToUpper()}
- pipe: Variant.Core.SetResponse
RESPONSE:
status: success
settings: ${Settings}
This example produces structured output like:
{
"status": "success",
"settings": {
"projectName": "licensegenerator",
"projectDescription": null,
"projectName1": "licensegenerator",
"name": "Bobby Davro",
"questionMarkAllowsNulls": null,
"currentDir": "C:\\home\\site\\wwwroot",
"dateTimeNow": "2023-07-19T13:38:42.3728453+00:00",
"newGuid": "7e9e0957-0f51-4ba5-acf5-5b9c039bb6c0",
"uppercaseName": "BOBBY DAVRO",
"questionMarkAllowsNullWithoutErrors": null,
"nameAsBase64": "Qm9iYnkgRGF2cm8=",
"nextMonth": "2023-08-16",
"nullCoalescingWithString": "ItDoesNottExist",
"nullCoalescingWithHeader": "Bobby Davro",
"usingIndex1": "Peppa",
"usingSelectTokens": ["Peppa", "George"],
"usingSelectTokensWithJoin": "Peppa,George",
"secureSetting": "comdemosa",
"secureSettingUppercase": "COMDEMOSA"
}
}
Compile-Time / App Setting Substitutionsβ
Compile-time substitutions ($[]) are used for injecting static configuration values into YAML before the platform compiles it. For example, given the following app settings:
{
"Project.Name": "MyProject",
"Project.Description": "MyProject description",
"Varaint.Environment.Type": "dev"
}
We can substitute these directly:
info:
title: $[Project.Name]
description: $[Project.Description]
This is particularly helpful in project startup configurations:
- routeTemplate: openapi.json
routeMethod: GET
pipeline:
- pipe: Variant.Core.WebApi.GenerateSwaggerEndpointsStrategyPipe
OPERATIONS_NAMESPACE: Operations
IGNORE_IF_NO_DESCRIPTION: true
- pipe: Variant.Core.ModifyMessageStrategyPipe
VALUE:
openapi: 3.0.0
paths: ${Operations}
info:
version: 1.0.0
title: $[Project.Name]
description: $[Project.Description]
You can also use $[] in the IS_ENABLED property to conditionally include or exclude endpoints based on environment:
- routeTemplate: openapi.json
routeMethod: GET
isEnabled: $[Varaint.Environment.IsDevelopmentEnvironment]
pipeline:
- pipe: ...
Note: App settings can also be read from Azure AppService configurations, not just local config files. For example, setting
"Variant.Environment.Type": "prod"in Azure would disable the above dev-only endpoint.
Runtime Substitutions and the Substitution Serviceβ
This section outlines how Variant's substitution service enables powerful runtime value resolution and transformation. We'll cover:
-
Substitution syntax
-
Built-in value sources
-
Extension method support
-
External key-value repository integration
Substitution Syntaxβ
There are three substitution patterns supported at runtime:
-
${MyHeader}β Direct value substitution. If the value is missing or a chained method fails, an exception is thrown. -
${?MyHeader}β Nullable substitution. Returnsnullif the value is missing or method evaluation fails. -
${+MyHeader}β Secure substitution. Fetches the value from a secure setting store.
Built-in Value Sourcesβ
By default, the substitution service pulls values from three sources:
App Settingsβ
While app settings are commonly used at compile time with $[], they can also be used at runtime using ${AppSettingName} or ${?AppSettingName}. This provides flexibility to apply runtime logic or method chaining on configuration values.
VariantMessageβ
Most runtime substitutions reference the current VariantMessage. This includes headers, payload data, and deeply nested properties. These are accessed using ${} notation.
Default Replacementsβ
Variant defines a standard set of always-available substitutions:
Generalβ
${Guid.NewGuid}β Generates a new GUID.
File Systemβ
-
${Temp.Dir}β Temporary directory path. -
${System.CurrentDirectory}β Application's current working directory.
Date/Timeβ
Each can be formatted using .ToString("format"):
-
${DateTime.Now} -
${DateTime.UtcNow} -
${DateTimeOffset.Now}
Message Metadataβ
-
${Message.SpawnId}β Spawned message ID (if applicable). -
${Message.Id}β Current message ID.
Payload Conversionβ
If the payload implements IDisposable or IAsyncDisposable, it is automatically disposed after conversion:
-
${Payload.ToString}β Converts the payload to a string. -
${Payload.ToBase64}β Encodes the payload in Base64. -
${Payload.ToStream}β Converts or returns the payload as a stream.
These built-in capabilities form the foundation for dynamic data access and manipulation across Variant workflows.
Extension Methodsβ
Extension methods enable inline transformations and logic directly within substitution expressions. By appending a dot (.) and a method name to a substitution, values can be dynamically modified or extended before being injected into the YAML output.
These methods can be either synchronous or asynchronous, and are typically registered through Strategies assemblies loaded into the runtime.
Registering Extension Methods in Codeβ
Here's an example of how to define and register both sync and async substitution methods in a custom assembly:
using System.Text;
using Variant.Core;
using Variant.Core.Attributes;
using Variant.Core.Utilities;
using YamlDotNet.Serialization;
[assembly: UniteServerExtensionAssembly(InitialiserType = typeof(AssemblyInitializer), InitialiserMethod = nameof(AssemblyInitializer.InitialiseAsync))]
namespace Variant.Core;
internal static partial class AssemblyInitializer
{
public static Task InitialiseAsync(IAppServices appServices)
{
// Synchronous extension method
appServices.SubstitutionService.AddSubstitutionMethod("First", (enumerable, lokiMessage) =>
{
return ((IEnumerable<object>)enumerable!).First();
});
appServices.SubstitutionService.AddSubstitutionMethod("Skip", (enumerable, lokiMessage, skip) =>
{
return ((IEnumerable<object>)enumerable!).Skip(ParseInt(skip));
});
}
}
Usage Examplesβ
Once defined, extension methods can be used within substitution expressions like so:
-
${DateTime.Now.AddDays(2)} -
${number.AddNumber(2)} -
${jToken.SelectTokens("$..users")} -
${DateTime.Now.AddDays(2).ToString("yyyy-MM-dd")}
Chaining methods allows multiple transformations to be applied in sequence.
The Variant runtime includes the following extension methods.
- First (IEnumerable, lokiMessage) 72
- Last (IEnumerable, lokiMessage) 78
- Any (IEnumerable, lokiMessage) 84
- Take (IEnumerable, lokiMessage, take)
- Skip (IEnumerable, lokiMessage, skip)
- Contains (string, lokiMessage, item, caseInsensitive)
- Ternary (bool, lokiMessage, trueExp, falseExp)
- CreateHMACSHA256Signature (stringToSignObj, lokiMessage, keyObj)
- Base64ToByteArray (string, lokiMessage)
- UTF8StringToByteArray (string, lokiMessage)
- UriEscapeDataString (string, lokiMessage)
- Equals (object, lokiMessage, object)
- DoesNotEqual (object, lokiMessage, object)
- AddNumber (number, lokiMessage, number)
- SubtractNumber (number, lokiMessage, number)
- AsJson (object, lokiMessage, bool indented)
- AsJson (object, lokiMessage)
- SelectToken (JToken, lokiMessage, string)
- JArrayContains (JArray, lokiMessage, JValue)
- SelectTokens (JToken, lokiMessage, string)
- JTokenGetProperty (JToken, lokiMessage, string propName)
- JTokenDeepEquals (JToken, lokiMessage, jtokenObj)
- JsonOrderBy (JArray, lokiMessage)
- JsonOrderBy (JArray, lokiMessage, orderBy)
- JsonOrderByDesc (JArray, lokiMessage)
- JsonOrderByDesc (JArray, lokiMessage, orderBy)
- ConvertObjectToJToken (object, lokiMessage)
- ConvertStringToJToken (string, lokiMessage)
- ConvertYamlToJToken (string, lokiMessage)
- ParseDateTime (dateObj, lokiMessage)
- AddTimeSpan (DateTime, lokiMessage, timeSpan)
- AddDays (DateTime, lokiMessage, dateCount)
- AddHours (DateTime, lokiMessage, dateCount)
- AddMinutes (DateTime, lokiMessage, dateCount)
- IsNull (object, lokiMessage)
- IsNullOrWhiteSpace (string, lokiMessage)
- IsNullOrEmpty (string, lokiMessage)
- IsNotNull (string, lokiMessage)
- IsNotNullOrWhiteSpace (string, lokiMessage)
- IsNotNullOrEmpty (string, lokiMessage)
- ToTitle (string, lokiMessage)
- PadLeft (string, lokiMessage, count, padding)
- PadRight (string, lokiMessage, count, padding)
- Split (string, lokiMessage, separatorStr)
- SplitAndRemoveEmptyEntries (string, lokiMessage, separatorStr)
- Split (string, lokiMessage, separatorStr, count)
- Join (string, lokiMessage, separator)
- Trim (string, lokiMessage, trim)
- TrimStart (string, lokiMessage, trim)
- TrimEnd (string, lokiMessage, trim)
- StartsWith (string, lokiMessage, startsWithStr)
- StartsWith (string, lokiMessage, startsWithStr, caseInsensitive)
- EndsWith (string, lokiMessage, endsWithStr)
- Random (_, lokiMessage, to)
- Random (_, lokiMessage, from to)
Nested Substitutionsβ
The substitution service parses each substitution expression into a syntax tree, which means substitution values can be embedded inside other substitution calls.
For example:
- routeTemplate: api/substring/{mystring}/{from}
routeMethod: GET
pipeline:
- pipe: Variant.Core.SetResponse
RESPONSE: ${Request.Path.mystring.Substring(${Request.Path.from})}
Here, ${Request.Path.from} is resolved first and then passed to the Substring() method on Request.Path.mystring.
Reflected Extension Methodsβ
If a substitution method isnβt explicitly registered with the substitution service, the system will attempt to resolve it using .NET reflection.
This allows public instance methods and properties of common .NET types to be invoked at runtime, even if they arenβt defined as extension methods.
For properties, the syntax must use the get_ prefix:
- pipe: Variant.Core.SetResponse
# This will fail:
# RESPONSE: ${DateTimeOffset.Now.Date}
# This will succeed:
RESPONSE: ${DateTimeOffset.Now.get_Date}
This reflective support allows flexible access to native .NET APIs without requiring boilerplate wrappers.
Adding External Key-Value Repositoriesβ
The substitution service can be extended with additional data sources by implementing either:
-
IAdditionalSecureSettingsβ used with${+YourSecret}syntax (e.g., secrets from Azure Key Vault) -
IAdditionalSettingsβ used with${YourSetting}syntax (general key-value stores)
Note:
${+}substitutions cannot be used in URL paths, query strings, or request bodies due to security constraints.${}can be used in all locations but must be managed securely.
Registering Repositories in Service.yamlβ
To integrate external repositories, declare them in the dependencies section of your Service.yaml configuration:
dependencies:
- name:
interface: Variant.Core.IAdditionalSecureSettings, Variant.Core, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
implementation: Variant.Strategies.Azure.Vault.AzureVaultUsingClientSecret, Variant.Strategies.Azure.Vault, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
values:
CacheValues: true
TenantId: $[Vault_TenantId]
ClientId: $[Vault_ClientId]
ClientSecret: $[Vault_ClientSecret]
VaultUrl: $[Vault_Url]
- name: additionalVaultSetting
interface: Variant.Core.IAdditionalSecureSettings, Variant.Core, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
implementation: Variant.Strategies.Azure.Vault.AzureVaultUsingClientCertificate, Variant.Strategies.Azure.Vault, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
values:
CacheValues: true
TenantId: 2e793447-1377-44de-b149-aaaaaaaa
ClientId: db102636-b357-40a9-9b95-bbbbbbb
Thumbprint: AAAAAAAA83E66EAB840A9AAAAAAAAAA
VaultUrl: https://myKeyVault.vault.azure.net/
- name: AzureAppConfigurationSettings
interface: Variant.Core.IAdditionalSettings, Variant.Core, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
implementation: Variant.Azure.AppConfiguration.GetAzureAppConfigurationSettings, Variant.Strategies.Azure.AppConfiguration, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
values:
ConnectionString: ${AppConfigConnectionString}
LabelFilter: app-underwritingservice
KeyFilter: null
The first two entries connect to Azure Key Vault using client secret or certificate authentication. The third connects to Azure App Configuration for general key-value settings.
Tip: See the _Extending Variant documentation for guidance on implementing your own extension methods or repository interfaces.