diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2a58503 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +# If this file is renamed, the incrementing run attempt number will be reset. + +name: CI + +on: + push: + branches: [ "dev", "main" ] + pull_request: + branches: [ "dev", "main" ] + +env: + CI_BUILD_NUMBER_BASE: ${{ github.run_number }} + CI_TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + +jobs: + build: + + runs-on: ubuntu-24.04 + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Setup + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Compute build number + run: | + echo "CI_BUILD_NUMBER=$(($CI_BUILD_NUMBER_BASE+90))" >> $GITHUB_ENV + - name: Build and Publish + env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + ./Build.ps1 diff --git a/Build.ps1 b/Build.ps1 index 64a5e37..61448e8 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -1,63 +1,55 @@ -# This script originally (c) 2016 Serilog Contributors - license Apache 2.0 - -echo "build: Build started" +Write-Output "build: Build started" Push-Location $PSScriptRoot +Write-Output "build: Tool versions follow" + +dotnet --version +dotnet --list-sdks + if(Test-Path .\artifacts) { - echo "build: Cleaning .\artifacts" - Remove-Item .\artifacts -Force -Recurse + Write-Output "build: Cleaning ./artifacts" + Remove-Item ./artifacts -Force -Recurse } & dotnet restore --no-cache -if($LASTEXITCODE -ne 0) { exit 1 } -$branch = @{ $true = $env:APPVEYOR_REPO_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$env:APPVEYOR_REPO_BRANCH -ne $NULL]; -$revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:APPVEYOR_BUILD_NUMBER, 10); $false = "local" }[$env:APPVEYOR_BUILD_NUMBER -ne $NULL]; -$suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)))-$revision"}[$branch -eq "main" -and $revision -ne "local"] +$dbp = [Xml] (Get-Content .\Directory.Build.props) +$versionPrefix = $dbp.Project.PropertyGroup.VersionPrefix -echo "build: Version suffix is $suffix" +Write-Output "build: Package version prefix is $versionPrefix" -foreach ($src in ls src/Seq.App.*) { +$branch = @{ $true = $env:CI_TARGET_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$NULL -ne $env:CI_TARGET_BRANCH]; +$revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:CI_BUILD_NUMBER, 10); $false = "local" }[$NULL -ne $env:CI_BUILD_NUMBER]; +$suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)) -replace '([^a-zA-Z0-9\-]*)', '')-$revision"}[$branch -eq "main" -and $revision -ne "local"] + +Write-Output "build: Package version suffix is $suffix" + +foreach ($src in Get-ChildItem src/*) { Push-Location $src - echo "build: Packaging app project in $src" + Write-Output "build: Packaging project in $src" - if (Test-Path ./obj/publish) { - Remove-Item -Recurse -Force ./obj/publish - } - if ($suffix) { & dotnet publish -c Release -o ./obj/publish --version-suffix=$suffix - & dotnet pack -c Release -o ..\..\artifacts --no-build --version-suffix=$suffix + & dotnet pack -c Release -o ../../artifacts --no-build --version-suffix=$suffix } else { & dotnet publish -c Release -o ./obj/publish - & dotnet pack -c Release -o ..\..\artifacts --no-build + & dotnet pack -c Release -o ../../artifacts --no-build } - if($LASTEXITCODE -ne 0) { throw "Build failed" } + if($LASTEXITCODE -ne 0) { throw "Packaging failed" } Pop-Location } -foreach ($src in @("src/Seq.Syntax", "src/Seq.Mail", "src/Seq.Apps.Testing")) { - Push-Location $src - - echo "build: Packaging library in $src" - - if ($suffix) { - & dotnet pack -c Release -o ..\..\artifacts --version-suffix=$suffix - } else { - & dotnet pack -c Release -o ..\..\artifacts - } - if($LASTEXITCODE -ne 0) { throw "Build failed" } - - Pop-Location -} +Write-Output "build: Checking complete solution builds" +& dotnet build +if($LASTEXITCODE -ne 0) { throw "Solution build failed" } -foreach ($test in ls test/*.Tests) { +foreach ($test in Get-ChildItem test/*.Tests) { Push-Location $test - echo "build: Testing project in $test" + Write-Output "build: Testing project in $test" & dotnet test -c Release if($LASTEXITCODE -ne 0) { throw "Testing failed" } @@ -65,4 +57,22 @@ foreach ($test in ls test/*.Tests) { Pop-Location } -Pop-Location \ No newline at end of file +Pop-Location + +if ($env:NUGET_API_KEY) { + # GitHub Actions will only supply this to branch builds and not PRs. We publish + # builds from any branch this action targets (i.e. main and dev). + + Write-Output "build: Publishing NuGet packages" + + foreach ($nupkg in Get-ChildItem artifacts/*.nupkg) { + & dotnet nuget push -k $env:NUGET_API_KEY -s https://api.nuget.org/v3/index.json "$nupkg" + if($LASTEXITCODE -ne 0) { throw "Publishing failed" } + } + + if (!($suffix)) { + Write-Output "build: Creating release for version $versionPrefix" + + iex "gh release create v$versionPrefix --title v$versionPrefix --generate-notes $(get-item ./artifacts/*.nupkg) $(get-item ./artifacts/*.snupkg)" + } +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e348342 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + 1.1.0 + + \ No newline at end of file diff --git a/README.md b/README.md index fb8ea9e..d1cabf6 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ -# Seq Mail Apps [![Build status](https://ci.appveyor.com/api/projects/status/6jo5xhyfans07msl/branch/dev?svg=true)](https://ci.appveyor.com/project/datalust/seq-app-mail/branch/dev) +# Seq Mail Apps This repository contains the Seq output apps for various email services, built on a shared email templating system. | Package id | Description | |-------------------------------------------------------------------------------------|-----------------------------------------------------------------| +| [`Seq.App.Mail.AmazonSes`](https://nuget.org/packages/seq.app.mail.amazonses) | Send email using the Amazon Simple Email Service (SES) API. | | [`Seq.App.Mail.Microsoft365`](https://nuget.org/packages/seq.app.mail.microsoft365) | Send email through Microsoft 365 using the Microsoft Graph API. | | [`Seq.App.Mail.Smtp`](https://nuget.org/packages/seq.app.mail.smtp) | Send email using the SMTP protocol. | > Need to send email using a protocol or API not listed here? Let us know! - ## Getting started Install the app under _Settings > Apps_, using one of the package ids from the table above. @@ -30,9 +30,16 @@ When starting an instance of the app in Seq, the following parameters can be sup | **Body** | The email body. | Yes | See `src/Seq.Mail/Resources` | | **Body is plain text** | If checked, the body template will be interpreted as plain text, rather than HTML. | | | +### `Seq.App.Mail.AmazonSes` + +| Property | Description | Template? | Default | +|-------------------|------------------------------|---|---| +| **Access key id** | An Amazon SES access key id. | | | +| **Client id** | An Amazon SES secret key. | | | + ### `Seq.App.Mail.Microsoft365` -To send mail using the Microsoft 365 app, first create an app registration in Azure. The app must have the `Mail.Send` permission +To send mail using the Microsoft 365 app, first create an app registration in Azure. The app must have the `Mail.Send` permission for the Microsoft Graph API. | Property | Description | Template? | Default | @@ -54,6 +61,8 @@ for the Microsoft Graph API. ## Templates +The Seq mail apps support the [Seq template language](https://docs.datalust.co/docs/template-syntax). + Event and notification properties can be inserted dynamically into many of the settings listed above, by surrounding them with braces: diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 96f0571..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: '{build}' -skip_tags: true -image: Visual Studio 2022 -build_script: - - pwsh: ./Build.ps1 -test: off -artifacts: - - path: artifacts/Seq.*.nupkg -deploy: - - provider: NuGet - api_key: - secure: yA6yDHx8xQNbvvX+L4Aqc1yVcd77wEzrgeEsqF+UaBQqMXdvhJT236wEUX2voTjq - skip_symbols: true - artifact: /Seq.*\.nupkg/ - on: - branch: /^(main|dev)$/ - - provider: GitHub - auth_token: - secure: hX+cZmW+9BCXy7vyH8myWsYdtQHyzzil9K5yvjJv7dK9XmyrGYYDj/DPzMqsXSjo - artifact: /Seq.*\.nupkg/ - tag: v$(appveyor_build_version) - on: - branch: main diff --git a/harness/Seq.Mail.TestHarness/Seq.Mail.TestHarness.csproj b/harness/Seq.Mail.TestHarness/Seq.Mail.TestHarness.csproj index 2f966a6..49801ad 100644 --- a/harness/Seq.Mail.TestHarness/Seq.Mail.TestHarness.csproj +++ b/harness/Seq.Mail.TestHarness/Seq.Mail.TestHarness.csproj @@ -8,7 +8,7 @@ - + diff --git a/seq-app-mail.sln b/seq-app-mail.sln index c17f7fb..e555869 100644 --- a/seq-app-mail.sln +++ b/seq-app-mail.sln @@ -11,11 +11,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sln", "sln", "{0F8E84A5-952 ProjectSection(SolutionItems) = preProject .gitattributes = .gitattributes .gitignore = .gitignore - appveyor.yml = appveyor.yml - Build.ps1 = Build.ps1 LICENSE = LICENSE README.md = README.md RunLocalSmtp.ps1 = RunLocalSmtp.ps1 + Directory.Build.props = Directory.Build.props + Build.ps1 = Build.ps1 EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "asset", "asset", "{AC60371C-2888-45BB-9770-2A690DEA80FA}" @@ -43,6 +43,15 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "harness", "harness", "{E684 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seq.Mail.TestHarness", "harness\Seq.Mail.TestHarness\Seq.Mail.TestHarness.csproj", "{53B48FC5-E76D-4442-9259-DB57CDB53232}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seq.App.Mail.AmazonSes", "src\Seq.App.Mail.AmazonSes\Seq.App.Mail.AmazonSes.csproj", "{D124E851-B1CE-4DAD-8C1A-45697701D190}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{A5CC96EF-1E35-4836-9EE6-11F114D3E41D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{6D4A81AD-7209-4848-AFF5-232E4E3FDF41}" + ProjectSection(SolutionItems) = preProject + .github\workflows\ci.yml = .github\workflows\ci.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,6 +90,10 @@ Global {53B48FC5-E76D-4442-9259-DB57CDB53232}.Debug|Any CPU.Build.0 = Debug|Any CPU {53B48FC5-E76D-4442-9259-DB57CDB53232}.Release|Any CPU.ActiveCfg = Release|Any CPU {53B48FC5-E76D-4442-9259-DB57CDB53232}.Release|Any CPU.Build.0 = Release|Any CPU + {D124E851-B1CE-4DAD-8C1A-45697701D190}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D124E851-B1CE-4DAD-8C1A-45697701D190}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D124E851-B1CE-4DAD-8C1A-45697701D190}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D124E851-B1CE-4DAD-8C1A-45697701D190}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -94,6 +107,8 @@ Global {A836BDA9-335E-49E1-AA68-DA3D8B6DABA0} = {B03B3086-D197-4B32-9AE2-8536C345EA2D} {7E46171B-8E2F-42EB-8C10-526578F9BED0} = {91E482DE-E1E7-4CE1-9511-C0AF07F3648A} {53B48FC5-E76D-4442-9259-DB57CDB53232} = {E684AE47-A861-44F3-A648-A512652ED9C5} + {D124E851-B1CE-4DAD-8C1A-45697701D190} = {91E482DE-E1E7-4CE1-9511-C0AF07F3648A} + {6D4A81AD-7209-4848-AFF5-232E4E3FDF41} = {A5CC96EF-1E35-4836-9EE6-11F114D3E41D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EB6672D6-318E-493E-8B60-77F5A7A90E66} diff --git a/seq-app-mail.sln.DotSettings b/seq-app-mail.sln.DotSettings index e40a888..0dfd52c 100644 --- a/seq-app-mail.sln.DotSettings +++ b/seq-app-mail.sln.DotSettings @@ -1,5 +1,6 @@  True True + True True True \ No newline at end of file diff --git a/src/Seq.App.Mail.AmazonSes/AmazonSesMailApp.cs b/src/Seq.App.Mail.AmazonSes/AmazonSesMailApp.cs new file mode 100644 index 0000000..f15cf91 --- /dev/null +++ b/src/Seq.App.Mail.AmazonSes/AmazonSesMailApp.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Amazon.SimpleEmail.Model; +using MimeKit; +using Seq.Apps; +using Seq.Mail; + +// ReSharper disable UnusedAutoPropertyAccessor.Global + +namespace Seq.App.Mail.AmazonSes; + +[SeqApp("Amazon Simple Email Service (SES) Mail", + Description = "Send events and notifications by email, using the Amazon Simple Email Service (SES) API.")] +public class AmazonSesMailApp: MailApp +{ + readonly IAmazonSesMailGateway _mailGateway; + AmazonSesOptions? _options; + + internal AmazonSesMailApp(IAmazonSesMailGateway mailGateway) + { + _mailGateway = mailGateway; + } + + public AmazonSesMailApp() + : this (new AmazonSimpleEmailServiceClientMailGateway()) + { + } + + [SeqAppSetting( + DisplayName = "Access key id", + HelpText = "An AWS access key id.")] + public string? AccessKeyId { get; set; } + + [SeqAppSetting( + DisplayName = "Secret key", + HelpText = "An AWS secret key.", + InputType = SettingInputType.Password)] + public string? SecretKey { get; set; } + + protected override void OnAttached() + { + base.OnAttached(); + + _options = new AmazonSesOptions( + NormalizeOption(AccessKeyId) ?? throw new InvalidOperationException("An access key id is required."), + NormalizeOption(SecretKey) ?? throw new InvalidOperationException("A secret key is required.")); + } + + protected override async Task SendAsync(MimeMessage message, CancellationToken cancel) + { + const string charset = "UTF-8"; + var subject = new Content { Data = message.Subject, Charset = charset }; + + var body = new Body + { + Html = message.HtmlBody != null ? new Content { Data = message.HtmlBody, Charset = charset } : null, + Text = message.TextBody != null ? new Content { Data = message.TextBody, Charset = charset } : null + }; + + var sesMessage = new Message + { + Subject = subject, + Body = body + }; + + var destination = new Destination { ToAddresses = message.To.Select(addr => addr.ToString()).ToList() }; + + var request = new SendEmailRequest + { + Source = message.From.Single().ToString(), + Destination = destination, + Message = sesMessage + }; + + await _mailGateway.SendAsync(_options!, request, cancel); + } +} diff --git a/src/Seq.App.Mail.AmazonSes/AmazonSesOptions.cs b/src/Seq.App.Mail.AmazonSes/AmazonSesOptions.cs new file mode 100644 index 0000000..1ac7f3d --- /dev/null +++ b/src/Seq.App.Mail.AmazonSes/AmazonSesOptions.cs @@ -0,0 +1,13 @@ +namespace Seq.App.Mail.AmazonSes; + +class AmazonSesOptions +{ + public string AccessKeyId { get; } + public string SecretKey { get; } + + public AmazonSesOptions(string accessKeyId, string secretKey) + { + AccessKeyId = accessKeyId; + SecretKey = secretKey; + } +} diff --git a/src/Seq.App.Mail.AmazonSes/AmazonSimpleEmailServiceClientMailGateway.cs b/src/Seq.App.Mail.AmazonSes/AmazonSimpleEmailServiceClientMailGateway.cs new file mode 100644 index 0000000..f930cf0 --- /dev/null +++ b/src/Seq.App.Mail.AmazonSes/AmazonSimpleEmailServiceClientMailGateway.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; +using Amazon.SimpleEmail; +using Amazon.SimpleEmail.Model; + +namespace Seq.App.Mail.AmazonSes; + +class AmazonSimpleEmailServiceClientMailGateway : IAmazonSesMailGateway +{ + public async Task SendAsync(AmazonSesOptions options, SendEmailRequest request, CancellationToken cancel) + { + using var client = new AmazonSimpleEmailServiceClient(options.AccessKeyId, options.SecretKey); + await client.SendEmailAsync(request, cancel); + } +} diff --git a/src/Seq.App.Mail.AmazonSes/IAmazonSesMailGateway.cs b/src/Seq.App.Mail.AmazonSes/IAmazonSesMailGateway.cs new file mode 100644 index 0000000..343d4d4 --- /dev/null +++ b/src/Seq.App.Mail.AmazonSes/IAmazonSesMailGateway.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; +using Amazon.SimpleEmail.Model; + +namespace Seq.App.Mail.AmazonSes; + +interface IAmazonSesMailGateway +{ + Task SendAsync(AmazonSesOptions options, SendEmailRequest request, CancellationToken cancel); +} diff --git a/src/Seq.App.Mail.AmazonSes/Seq.App.Mail.AmazonSes.csproj b/src/Seq.App.Mail.AmazonSes/Seq.App.Mail.AmazonSes.csproj new file mode 100644 index 0000000..8f14b78 --- /dev/null +++ b/src/Seq.App.Mail.AmazonSes/Seq.App.Mail.AmazonSes.csproj @@ -0,0 +1,36 @@ + + + + net6.0 + enable + Send events and notifications from Seq via the Amazon Simple Email Service (SES) API. + Datalust, Serilog Contributors + seq-app amazon aws ses simple email service + https://github.com/datalust/seq-app-mail + seq-app-mail.png + Apache-2.0 + https://github.com/datalust/seq-app-mail + git + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Seq.App.Mail.Microsoft365/Seq.App.Mail.Microsoft365.csproj b/src/Seq.App.Mail.Microsoft365/Seq.App.Mail.Microsoft365.csproj index 5ec53cb..5c171c9 100644 --- a/src/Seq.App.Mail.Microsoft365/Seq.App.Mail.Microsoft365.csproj +++ b/src/Seq.App.Mail.Microsoft365/Seq.App.Mail.Microsoft365.csproj @@ -4,7 +4,6 @@ net6.0 enable Send events and notifications from Seq via Microsoft 365 mail. - 1.0.0 Datalust, Serilog Contributors seq-app microsoft-365 email https://github.com/datalust/seq-app-mail @@ -25,8 +24,8 @@ - - + + diff --git a/src/Seq.App.Mail.Smtp/Seq.App.Mail.Smtp.csproj b/src/Seq.App.Mail.Smtp/Seq.App.Mail.Smtp.csproj index 1219a07..9f29b17 100644 --- a/src/Seq.App.Mail.Smtp/Seq.App.Mail.Smtp.csproj +++ b/src/Seq.App.Mail.Smtp/Seq.App.Mail.Smtp.csproj @@ -4,7 +4,6 @@ net6.0 enable Send events and notifications from Seq via SMTP mail. - 1.0.0 Datalust, Serilog Contributors seq-app smtp email https://github.com/datalust/seq-app-mail diff --git a/src/Seq.Apps.Testing/Seq.Apps.Testing.csproj b/src/Seq.Apps.Testing/Seq.Apps.Testing.csproj index 67b43be..5b70c97 100644 --- a/src/Seq.Apps.Testing/Seq.Apps.Testing.csproj +++ b/src/Seq.Apps.Testing/Seq.Apps.Testing.csproj @@ -6,7 +6,6 @@ true true Helper types for development and debugging of Seq plug-in apps. - 1.0.0 Datalust Apache-2.0 seq-apps-testing.png @@ -14,8 +13,8 @@ - - + + diff --git a/src/Seq.Mail/MailApp.cs b/src/Seq.Mail/MailApp.cs index 03b6805..76f8d8d 100644 --- a/src/Seq.Mail/MailApp.cs +++ b/src/Seq.Mail/MailApp.cs @@ -83,9 +83,12 @@ protected override void OnAttached() protected abstract Task SendAsync(MimeMessage message, CancellationToken cancel); + protected virtual void PrepareMessage(LogEvent logEvent, MimeMessage message) { } + public async Task OnAsync(Event evt) { using var message = _mailMessageFactory!.FromEvent(evt.Data); + PrepareMessage(evt.Data, message); await SendAsync(message, default); } diff --git a/src/Seq.Mail/Seq.Mail.csproj b/src/Seq.Mail/Seq.Mail.csproj index 8309ace..01c796e 100644 --- a/src/Seq.Mail/Seq.Mail.csproj +++ b/src/Seq.Mail/Seq.Mail.csproj @@ -4,7 +4,6 @@ net6.0 enable Shared infrastructure for Seq apps that integrate with email and similar messaging services. - 1.0.0 Datalust https://github.com/datalust/seq-app-mail seq-mail.png @@ -23,7 +22,7 @@ - + diff --git a/src/Seq.Syntax/BuiltIns/SeqBuiltInPropertyNameResolver.cs b/src/Seq.Syntax/BuiltIns/SeqBuiltInPropertyNameResolver.cs index d7febca..9cb0554 100644 --- a/src/Seq.Syntax/BuiltIns/SeqBuiltInPropertyNameResolver.cs +++ b/src/Seq.Syntax/BuiltIns/SeqBuiltInPropertyNameResolver.cs @@ -24,8 +24,9 @@ public override bool TryResolveBuiltInPropertyName(string alias, [NotNullWhen(tr "TraceId" or "tr" => "@p['@tr']", "SpanId" or "sp" => "@p['@sp']", "Resource" or "ra" => "@p['@ra']", - "Start" or "st" => "@p['@st']", + "Start" or "st" => "_AsDateTimeOffset(@p['@st'])", "ParentId" or "ps" => "@p['@ps']", + "SpanKind" or "sk" => "@p['@sk']", "Scope" or "sa" => "@p['@sa']", "Elapsed" => "_Elapsed(@st, @t)", "Arrived" or "Document" or "Data" => "undefined()", @@ -37,7 +38,7 @@ public override bool TryResolveBuiltInPropertyName(string alias, [NotNullWhen(tr public override bool TryResolveFunctionName(string name, [NotNullWhen(true)] out MethodInfo? implementation) { - if (name == nameof(_Elapsed)) + if (name == nameof(_Elapsed) || name == nameof(_AsDateTimeOffset)) { implementation = typeof(SeqBuiltInPropertyNameResolver).GetMethod(name)!; return true; @@ -55,6 +56,14 @@ public override bool TryResolveFunctionName(string name, [NotNullWhen(true)] out return null; } + public static LogEventPropertyValue? _AsDateTimeOffset(LogEventPropertyValue? value) + { + if (AsDateTimeOffset(value) is { } dto) + return new ScalarValue(dto); + + return null; + } + static DateTimeOffset? AsDateTimeOffset(LogEventPropertyValue? value) { if (value is ScalarValue { Value: DateTime dt }) diff --git a/src/Seq.Syntax/Expressions/Compilation/Linq/LinqExpressionCompiler.cs b/src/Seq.Syntax/Expressions/Compilation/Linq/LinqExpressionCompiler.cs index 442c8e2..35d7dd2 100644 --- a/src/Seq.Syntax/Expressions/Compilation/Linq/LinqExpressionCompiler.cs +++ b/src/Seq.Syntax/Expressions/Compilation/Linq/LinqExpressionCompiler.cs @@ -104,6 +104,10 @@ protected override ExpressionBody Transform(CallExpression call) if (!_nameResolver.TryResolveFunctionName(call.OperatorName, out var m)) throw new ArgumentException($"The function name `{call.OperatorName}` was not recognized."); + if (m == null!) + throw new InvalidOperationException( + $"The name resolver {_nameResolver} failed to return a valid `MethodInfo` for function `{call.OperatorName}`."); + var methodParameters = m.GetParameters() .Select(info => (pi: info, optional: info.GetCustomAttribute() != null)) .ToList(); diff --git a/src/Seq.Syntax/Expressions/Operators.cs b/src/Seq.Syntax/Expressions/Operators.cs index adbd4a5..874af92 100644 --- a/src/Seq.Syntax/Expressions/Operators.cs +++ b/src/Seq.Syntax/Expressions/Operators.cs @@ -16,9 +16,7 @@ using System.Collections.Generic; using Seq.Syntax.Expressions.Ast; -// ReSharper disable UnusedMember.Global - -// ReSharper disable InconsistentNaming, MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global, InconsistentNaming, MemberCanBePrivate.Global namespace Seq.Syntax.Expressions; @@ -37,8 +35,10 @@ static class Operators public const string OpEndsWith = "EndsWith"; public const string OpIndexOf = "IndexOf"; public const string OpIndexOfMatch = "IndexOfMatch"; - public const string OpIsMatch = "IsMatch"; public const string OpIsDefined = "IsDefined"; + public const string OpIsMatch = "IsMatch"; + public const string OpIsRootSpan = "IsRootSpan"; + public const string OpIsSpan = "IsSpan"; public const string OpLastIndexOf = "LastIndexOf"; public const string OpLength = "Length"; public const string OpNow = "Now"; @@ -49,6 +49,7 @@ static class Operators public const string OpToLower = "ToLower"; public const string OpToUpper = "ToUpper"; public const string OpToString = "ToString"; + public const string OpTotalMilliseconds = "TotalMilliseconds"; public const string OpTypeOf = "TypeOf"; public const string OpUndefined = "Undefined"; public const string OpUriEncode = "UriEncode"; diff --git a/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs b/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs index f72878b..1597707 100644 --- a/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs +++ b/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs @@ -29,6 +29,8 @@ static class RuntimeOperators static readonly LogEventPropertyValue ConstantTrue = new ScalarValue(true), ConstantFalse = new ScalarValue(false); + const string SpanStartTimestampPropertyName = "@st", ParentSpanIdPropertyName = "@ps"; + internal static LogEventPropertyValue ScalarBoolean(bool value) { return value ? ConstantTrue : ConstantFalse; @@ -505,6 +507,10 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v var toString = sv.Value switch { + bool boolean => boolean ? "true" : "false", + DateTimeOffset dto when dto.Offset == TimeSpan.Zero && fmt == null => dto.UtcDateTime.ToString("O"), + DateTimeOffset dto when fmt == null => dto.ToString("O"), + DateTime dt when fmt == null => dt.ToString("O"), LogEventLevel level => LevelRenderer.GetLevelMoniker(level, fmt), IFormattable formattable => formattable.ToString(fmt, formatProvider), _ => sv.Value.ToString() @@ -557,4 +563,39 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v return null; } + + public static LogEventPropertyValue? IsSpan(LogEvent logEvent) + { + return new ScalarValue(logEvent is { TraceId: not null, SpanId: not null } && + logEvent.Properties.ContainsKey(SpanStartTimestampPropertyName)); + } + + public static LogEventPropertyValue? IsRootSpan(LogEvent logEvent) + { + return new ScalarValue(logEvent is { TraceId: not null, SpanId: not null } && + logEvent.Properties.ContainsKey(SpanStartTimestampPropertyName) && + !logEvent.Properties.ContainsKey(ParentSpanIdPropertyName)); + } + + public static LogEventPropertyValue? FromUnixEpoch(LogEventPropertyValue? dateTime) + { + if (dateTime is ScalarValue sv) + { + if (sv.Value is DateTimeOffset dto) + return new ScalarValue(dto.UtcDateTime - DateTime.UnixEpoch); + + if (sv.Value is DateTime dt) + return new ScalarValue(dt.ToUniversalTime() - DateTime.UnixEpoch); + } + + return null; + } + + public static LogEventPropertyValue? TotalMilliseconds(LogEventPropertyValue? timeSpan) + { + if (timeSpan is ScalarValue { Value: TimeSpan ts }) + return new ScalarValue(ts.Ticks / (decimal)TimeSpan.TicksPerMillisecond); + + return null; + } } \ No newline at end of file diff --git a/src/Seq.Syntax/Seq.Syntax.csproj b/src/Seq.Syntax/Seq.Syntax.csproj index a1a0cd6..6656735 100644 --- a/src/Seq.Syntax/Seq.Syntax.csproj +++ b/src/Seq.Syntax/Seq.Syntax.csproj @@ -4,7 +4,6 @@ net6.0 enable Template and expression evaluation using a syntax based on the Seq query language. - 1.0.0 Datalust https://github.com/datalust/seq-app-mail seq-syntax.png @@ -12,6 +11,7 @@ https://github.com/datalust/seq-app-mail git true + latest @@ -21,7 +21,7 @@ - + diff --git a/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs b/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs index 8e21a45..e7670da 100644 --- a/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs +++ b/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs @@ -74,16 +74,49 @@ void EvaluateUnaligned(EvaluationContext ctx, TextWriter output) } } } - - void EvaluateProperty(IReadOnlyDictionary properties, PropertyToken pt, - TextWriter output) + + void EvaluateProperty(IReadOnlyDictionary properties, PropertyToken pt, TextWriter output) { - if (!properties.TryGetValue(pt.PropertyName, out var value)) + var rest = pt.PropertyName.AsSpan(); + if (!TryGetNextStep(rest, out var name, out rest)) + { + output.Write(pt); + return; + } + + if (!properties.TryGetValue(name.ToString(), out var value)) { output.Write(pt.ToString()); return; } + + while (TryGetNextStep(rest, out name, out rest)) + { + if (value is not StructureValue obj) + { + output.Write(pt); + return; + } + + var nameString = name.ToString(); + var found = false; + foreach (var property in obj.Properties) + { + if (property.Name == nameString) + { + value = property.Value; + found = true; + break; + } + } + if (!found) + { + output.Write(pt); + return; + } + } + if (pt.Alignment is null) { EvaluatePropertyUnaligned(value, output, pt.Format); @@ -159,4 +192,27 @@ void EvaluatePropertyUnaligned(LogEventPropertyValue propertyValue, TextWriter o output.Write(value); } + + static bool TryGetNextStep(ReadOnlySpan path, out ReadOnlySpan name, out ReadOnlySpan rest) + { + if (path.Length == 0) + { + name = []; + rest = []; + return false; + } + + var i = path.IndexOf('.'); + if (i == -1) + { + name = path; + rest = []; + return true; + } + + name = path[..i]; + rest = i == name.Length - 1 ? [] : path[(i + 1)..]; + + return true; + } } \ No newline at end of file diff --git a/test/Seq.Mail.Tests/AmazonSesMailAppTests.cs b/test/Seq.Mail.Tests/AmazonSesMailAppTests.cs new file mode 100644 index 0000000..a3c7733 --- /dev/null +++ b/test/Seq.Mail.Tests/AmazonSesMailAppTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Seq.App.Mail.AmazonSes; +using Seq.Apps; +using Seq.Apps.Testing.Hosting; +using Seq.Mail.Tests.Support; +using Serilog.Events; +using Xunit; + +namespace Seq.Mail.Tests; + +public class AmazonSesMailAppTests +{ + [Fact] + public async Task EventsAreSentAsMessages() + { + var gateway = new TestAmazonSesMailGateway(); + + var app = new AmazonSesMailApp(gateway) + { + AccessKeyId = "aki", + SecretKey = "sk", + From = "f@localhost", + To = "t@localhost,r@localhost", + Subject = "s", + Body = "b", + BodyIsPlainText = false, + TimeZoneName = "Australia/Brisbane", + DateTimeFormat = "R" + }; + + app.Attach(new TestAppHost()); + + var evt = Some.InformationEvent(); + + await app.OnAsync(new Event("event-1", 123, DateTime.UtcNow, evt)); + + var (options, request) = Assert.Single(gateway.Received); + Assert.Equal("aki", options.AccessKeyId); + Assert.Equal("sk", options.SecretKey); + Assert.Equal("f@localhost", request.Source); + Assert.Equal(new[] { "t@localhost", "r@localhost" }, request.Destination.ToAddresses); + Assert.Equal("s", request.Message.Subject.Data); + Assert.Equal("b", request.Message.Body.Html.Data.Trim()); + Assert.Null(request.Message.Body.Text); + } +} diff --git a/test/Seq.Mail.Tests/Seq.Mail.Tests.csproj b/test/Seq.Mail.Tests/Seq.Mail.Tests.csproj index 93e7fd5..a139b68 100644 --- a/test/Seq.Mail.Tests/Seq.Mail.Tests.csproj +++ b/test/Seq.Mail.Tests/Seq.Mail.Tests.csproj @@ -5,16 +5,17 @@ true - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + diff --git a/test/Seq.Mail.Tests/Support/TestAmazonSesMailGateway.cs b/test/Seq.Mail.Tests/Support/TestAmazonSesMailGateway.cs new file mode 100644 index 0000000..a7ce9cb --- /dev/null +++ b/test/Seq.Mail.Tests/Support/TestAmazonSesMailGateway.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon.SimpleEmail.Model; +using Seq.App.Mail.AmazonSes; + +namespace Seq.Mail.Tests.Support; + +class TestAmazonSesMailGateway : IAmazonSesMailGateway +{ + public List<(AmazonSesOptions, SendEmailRequest)> Received { get; } = []; + + public Task SendAsync(AmazonSesOptions options, SendEmailRequest request, CancellationToken cancel) + { + Received.Add((options, request)); + return Task.CompletedTask; + } +} diff --git a/test/Seq.Mail.Tests/Support/TestMicrosoftGraphMailGateway.cs b/test/Seq.Mail.Tests/Support/TestMicrosoftGraphMailGateway.cs index d8ac9a6..a8fc92d 100644 --- a/test/Seq.Mail.Tests/Support/TestMicrosoftGraphMailGateway.cs +++ b/test/Seq.Mail.Tests/Support/TestMicrosoftGraphMailGateway.cs @@ -8,7 +8,7 @@ namespace Seq.Mail.Tests.Support; class TestMicrosoftGraphMailGateway : IMicrosoftGraphMailGateway { - public List<(Microsoft365Options, Message)> Received { get; } = new(); + public List<(Microsoft365Options, Message)> Received { get; } = []; public Task SendAsync(Microsoft365Options options, Message message, CancellationToken cancel) { diff --git a/test/Seq.Mail.Tests/Support/TestSmtpMailGateway.cs b/test/Seq.Mail.Tests/Support/TestSmtpMailGateway.cs index c82f497..973b92a 100644 --- a/test/Seq.Mail.Tests/Support/TestSmtpMailGateway.cs +++ b/test/Seq.Mail.Tests/Support/TestSmtpMailGateway.cs @@ -9,7 +9,7 @@ namespace Seq.Mail.Tests.Support; class TestSmtpMailGateway : ISmtpMailGateway { - public List<(SmtpOptions, MimeMessage)> Received { get; } = new(); + public List<(SmtpOptions, MimeMessage)> Received { get; } = []; public Task SendAsync(SmtpOptions options, MimeMessage message, CancellationToken cancel) { diff --git a/test/Seq.Syntax.Tests/Cases/app-host-compatibility-case.json b/test/Seq.Syntax.Tests/Cases/app-host-compatibility-case.json new file mode 100644 index 0000000..ecd7e27 --- /dev/null +++ b/test/Seq.Syntax.Tests/Cases/app-host-compatibility-case.json @@ -0,0 +1 @@ +{"@t":"2025-09-30T00:56:42.4868884Z","@mt":"Run demo app","@m":"Run demo app","@i":"6047df22","@tr":"f42777ef7fcfa457aa7126d57da507fe","@sp":"a6c09c6e0fa0d607","@st":"2025-09-30T00:56:41.4493934Z","@sk":"Internal","Application":"Demo"} diff --git a/test/Seq.Syntax.Tests/Expressions/AppHostCompatibilityTests.cs b/test/Seq.Syntax.Tests/Expressions/AppHostCompatibilityTests.cs new file mode 100644 index 0000000..b76bd8f --- /dev/null +++ b/test/Seq.Syntax.Tests/Expressions/AppHostCompatibilityTests.cs @@ -0,0 +1,32 @@ +using System.IO; +using System.Linq; +using Seq.Syntax.Templates; +using Seq.Syntax.Tests.Support; +using Serilog.Formatting.Compact.Reader; +using Xunit; + +namespace Seq.Syntax.Tests.Expressions; + +public class AppHostCompatibilityTests +{ + [Theory] + [InlineData("TotalMilliseconds(@Elapsed)", "1037.495")] + [InlineData("Round(TotalMilliseconds(@Elapsed), 0)", "1037")] + [InlineData("IsSpan()", "true")] + [InlineData("IsRootSpan()", "true")] + [InlineData("@SpanKind", "Internal")] + [InlineData("@Start", "2025-09-30T00:56:41.4493934Z")] + [InlineData("FromUnixEpoch(@Start)", "20361.00:56:41.4493934")] + [InlineData("TotalMilliseconds(FromUnixEpoch(@Start))", "1759193801449.3934")] + [InlineData("FromUnixEpoch(@Timestamp)", "20361.00:56:42.4868884")] + [InlineData("@EventType", "1615322914")] + public void AppHostJsonProcessingIsReasonable(string expression, string expected) + { + var json = TestCases.ReadNDJsonCases("app-host-compatibility-case.json").Single(); + var evt = LogEventReader.ReadFromString(json); + var template = $"{{ {expression} }}"; + var output = new StringWriter(); + new ExpressionTemplate(template).Format(evt, output); + Assert.Equal(expected, output.ToString()); + } +} diff --git a/test/Seq.Syntax.Tests/Expressions/ExpressionEvaluationTests.cs b/test/Seq.Syntax.Tests/Expressions/ExpressionEvaluationTests.cs index 3de6ec5..b70bbd2 100644 --- a/test/Seq.Syntax.Tests/Expressions/ExpressionEvaluationTests.cs +++ b/test/Seq.Syntax.Tests/Expressions/ExpressionEvaluationTests.cs @@ -5,6 +5,7 @@ using Seq.Syntax.Expressions.Runtime; using Seq.Syntax.Tests.Support; using Serilog.Events; +using Serilog.Parsing; using Xunit; namespace Seq.Syntax.Tests.Expressions; @@ -12,7 +13,7 @@ namespace Seq.Syntax.Tests.Expressions; public class ExpressionEvaluationTests { public static IEnumerable ExpressionEvaluationCases => - AsvCases.ReadCases("expression-evaluation-cases.asv"); + TestCases.ReadAsvCases("expression-evaluation-cases.asv"); [Theory] [MemberData(nameof(ExpressionEvaluationCases))] @@ -21,11 +22,10 @@ public void ExpressionsAreCorrectlyEvaluated(string expr, string result) var evt = Some.InformationEvent(); evt.AddPropertyIfAbsent( - new LogEventProperty("User", new StructureValue(new[] - { + new LogEventProperty("User", new StructureValue([ new LogEventProperty("Id", new ScalarValue(42)), - new LogEventProperty("Name", new ScalarValue("nblumhardt")), - }))); + new LogEventProperty("Name", new ScalarValue("nblumhardt")) + ]))); evt.AddPropertyIfAbsent(new LogEventProperty("@st", new ScalarValue((evt.Timestamp - TimeSpan.FromMinutes(10)).ToString("o")))); @@ -39,7 +39,9 @@ public void ExpressionsAreCorrectlyEvaluated(string expr, string result) } else { - Assert.True(Coerce.IsTrue(RuntimeOperators._Internal_Equal(StringComparison.OrdinalIgnoreCase, actual, expected)), $"Expected value: {Display(expected)}{Environment.NewLine}Actual value: {Display(actual)}"); + Assert.True( + Coerce.IsTrue(RuntimeOperators._Internal_Equal(StringComparison.OrdinalIgnoreCase, actual, expected)), + $"Expected value: {Display(expected)}{Environment.NewLine}Actual value: {Display(actual)}"); } } @@ -50,4 +52,35 @@ static string Display(LogEventPropertyValue? value) return value.ToString(); } + + [Fact] + public void MessageRenderingSupportsNestedProperties() + { + // From the point of view of Seq and Seq Syntax, dotted identifiers in property names are paths into + // nested objects. This differs from Serilog's interpretation, which is that they are flat names with + // embedded dots. When Seq and Serilog are used together, Serilog.Sinks.Seq performs the conversion + // from flat names to nested objects, so on the server, apps etc. need message rendering to work with + // the nested data representation. + + var messageTemplate = new MessageTemplateParser().Parse("HTTP {request.method} {request.path}"); + var properties = new[] + { + new LogEventProperty("request", new StructureValue([ + new LogEventProperty("method", new ScalarValue("GET")), + new LogEventProperty("path", new ScalarValue("/example")) + ])) + }; + + var evt = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Debug, + exception: null, + messageTemplate, + properties); + + var message = SerilogExpression.Compile("@m")(evt); + var messageValue = Assert.IsType(message).Value; + + Assert.Equal("HTTP GET /example", messageValue); + } } \ No newline at end of file diff --git a/test/Seq.Syntax.Tests/Expressions/ExpressionTranslationTests.cs b/test/Seq.Syntax.Tests/Expressions/ExpressionTranslationTests.cs index 864426e..f359118 100644 --- a/test/Seq.Syntax.Tests/Expressions/ExpressionTranslationTests.cs +++ b/test/Seq.Syntax.Tests/Expressions/ExpressionTranslationTests.cs @@ -9,7 +9,7 @@ namespace Seq.Syntax.Tests.Expressions; public class ExpressionTranslationTests { public static IEnumerable ExpressionEvaluationCases => - AsvCases.ReadCases("translation-cases.asv"); + TestCases.ReadAsvCases("translation-cases.asv"); [Theory] [MemberData(nameof(ExpressionEvaluationCases))] diff --git a/test/Seq.Syntax.Tests/Seq.Syntax.Tests.csproj b/test/Seq.Syntax.Tests/Seq.Syntax.Tests.csproj index 7067b9c..55e7f30 100644 --- a/test/Seq.Syntax.Tests/Seq.Syntax.Tests.csproj +++ b/test/Seq.Syntax.Tests/Seq.Syntax.Tests.csproj @@ -5,13 +5,14 @@ true - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + @@ -20,5 +21,8 @@ PreserveNewest + + PreserveNewest + \ No newline at end of file diff --git a/test/Seq.Syntax.Tests/Support/AsvCases.cs b/test/Seq.Syntax.Tests/Support/TestCases.cs similarity index 73% rename from test/Seq.Syntax.Tests/Support/AsvCases.cs rename to test/Seq.Syntax.Tests/Support/TestCases.cs index dfc1258..5d3a26f 100644 --- a/test/Seq.Syntax.Tests/Support/AsvCases.cs +++ b/test/Seq.Syntax.Tests/Support/TestCases.cs @@ -11,15 +11,22 @@ namespace Seq.Syntax.Tests.Support; // or other!). // // The ASV format informally supports `//` comment lines, as long as they don't contain the arrow character. -static class AsvCases +static class TestCases { static readonly string CasesPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Cases"); - public static IEnumerable ReadCases(string filename) + public static IEnumerable ReadAsvCases(string filename) { return from line in File.ReadLines(Path.Combine(CasesPath, filename)) select line.Split("⇶", StringSplitOptions.RemoveEmptyEntries) into cols where cols.Length == 2 select cols.Select(c => c.Trim()).ToArray(); } + + public static IEnumerable ReadNDJsonCases(string filename) + { + return File.ReadLines(Path.Combine(CasesPath, filename)) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => line.Trim()); + } } \ No newline at end of file diff --git a/test/Seq.Syntax.Tests/Templates/TemplateEncodingTests.cs b/test/Seq.Syntax.Tests/Templates/TemplateEncodingTests.cs index 79bc957..a5d99b2 100644 --- a/test/Seq.Syntax.Tests/Templates/TemplateEncodingTests.cs +++ b/test/Seq.Syntax.Tests/Templates/TemplateEncodingTests.cs @@ -9,7 +9,7 @@ namespace Seq.Syntax.Tests.Templates; public class TemplateEncodingTests { public static IEnumerable TemplateEvaluationCases => - AsvCases.ReadCases("template-encoding-cases.asv"); + TestCases.ReadAsvCases("template-encoding-cases.asv"); [Theory] [MemberData(nameof(TemplateEvaluationCases))] diff --git a/test/Seq.Syntax.Tests/Templates/TemplateEvaluationTests.cs b/test/Seq.Syntax.Tests/Templates/TemplateEvaluationTests.cs index 2c0a342..5fead50 100644 --- a/test/Seq.Syntax.Tests/Templates/TemplateEvaluationTests.cs +++ b/test/Seq.Syntax.Tests/Templates/TemplateEvaluationTests.cs @@ -10,7 +10,7 @@ namespace Seq.Syntax.Tests.Templates; public class TemplateEvaluationTests { public static IEnumerable TemplateEvaluationCases => - AsvCases.ReadCases("template-evaluation-cases.asv"); + TestCases.ReadAsvCases("template-evaluation-cases.asv"); [Theory] [MemberData(nameof(TemplateEvaluationCases))] diff --git a/test/Seq.Syntax.Tests/Templates/TemplateParserTests.cs b/test/Seq.Syntax.Tests/Templates/TemplateParserTests.cs index 38e9bcb..97678a1 100644 --- a/test/Seq.Syntax.Tests/Templates/TemplateParserTests.cs +++ b/test/Seq.Syntax.Tests/Templates/TemplateParserTests.cs @@ -15,8 +15,10 @@ public class TemplateParserTests [InlineData("Syntax {+Err}or", "Syntax error (line 1, column 9): unexpected operator `+`, expected expression.")] [InlineData("Syntax {1 + 2 and}or", "Syntax error (line 1, column 18): unexpected `}`, expected expression.")] [InlineData("Missing {Align,-} digits", "Syntax error (line 1, column 17): unexpected `}`, expected number.")] - [InlineData("Non-digit {Align,x} specifier", "Syntax error (line 1, column 18): unexpected identifier `x`, expected alignment and width.")] - [InlineData("Empty {Align,} digits", "Syntax error (line 1, column 14): unexpected `}`, expected alignment and width.")] + [InlineData("Non-digit {Align,x} specifier", + "Syntax error (line 1, column 18): unexpected identifier `x`, expected alignment and width.")] + [InlineData("Empty {Align,} digits", + "Syntax error (line 1, column 14): unexpected `}`, expected alignment and width.")] public void ErrorsAreReported(string input, string error) { Assert.False(ExpressionTemplate.TryParse(input, null, null, null, out _, out var actual)); @@ -31,4 +33,4 @@ public void DefaultAlignmentIsNull() var avt = Assert.IsType(template); Assert.Null(avt.Alignment); } -} \ No newline at end of file +}