Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions Source/Rendering.Cratis/Expressions/CommandContextAccess.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Screenplay.Syntax;
using Cratis.Screenplay.Syntax.Projections;
using Cratis.Stage.Rendering.Cratis.CodeGeneration;
using Cratis.Stage.Rendering.Cratis.Naming;

namespace Cratis.Stage.Rendering.Cratis.Expressions;

/// <summary>
/// Renders the context expressions inside a rendered command handler, and records the collaborators the handler
/// has to take a parameter for to reach them.
/// </summary>
/// <remarks>
/// <para>
/// A rendered command handler is an Arc model-bound <c>Handle()</c>. Arc's own <c>CommandContext</c> carries the
/// correlation id, the command instance and the dependencies — and none of what the Screenplay language names:
/// no <c>Occurred</c>, no <c>Identity</c>, no <c>Tenant</c>, no <c>CausedBy</c>, no <c>Causation</c>. Screenplay
/// defines those on its own <c>Cratis.Screenplay.Contexts.CommandContext</c>, which is a different type that a
/// rendered application never receives. Rendering <c>$context.occurred</c> as <c>context.Occurred</c> therefore
/// produced an application that did not compile.
/// </para>
/// <para>
/// Every path is rendered instead against something the Cratis runtime really offers, asked for as a handler
/// parameter — which is how a hand-written slice reaches the same values. A path with no such equivalent is
/// reported and rendered as a missing value rather than as a member that does not exist.
/// </para>
/// </remarks>
/// <param name="subject">What is being rendered, for diagnostics (for example <c>Command 'RegisterInvoice'</c>).</param>
/// <param name="diagnostics">Collects anything that could not be rendered faithfully.</param>
public sealed class CommandContextAccess(string subject, ICollection<string> diagnostics) : IExpressionContext
{
readonly List<HandlerCollaborator> _collaborators = [];
readonly SortedSet<string> _namespaces = new(StringComparer.Ordinal);

/// <summary>
/// Gets the collaborators the rendered handler needs, in the order they were first asked for.
/// </summary>
public IReadOnlyList<HandlerCollaborator> Collaborators => _collaborators;

/// <summary>
/// Gets every namespace the rendered expressions need in scope.
/// </summary>
public IEnumerable<string> Namespaces => _namespaces;

/// <summary>
/// The C# type a context value is rendered as, by path — so a caller can tell whether what the document maps
/// it onto can hold it. A path the language does not name has no type, and neither has one that renders to a
/// missing value.
/// </summary>
/// <param name="path">The <c>$context</c> path, without the <c>$context.</c> prefix.</param>
/// <returns>The C# type name, or <see langword="null"/> when the path resolves to nothing typed.</returns>
public static string? ValueTypeOf(string path) => path.Split('.') switch
{
["occurred"] => "DateTimeOffset",
["tenant"] => "string",
["causedBy", "subject" or "name" or "userName"] => "string",
["causation", "type"] => "string",
["identity", "id" or "name" or "userName"] => "string",
["identity", "isAuthenticated"] => "bool",
["identity", "claims", ..] => "string",
_ => null,
};

/// <inheritdoc/>
public string Render(ContextExpressionSyntax context) => Resolve(context.Path);

/// <inheritdoc/>
public string Render(EventContextExpressionSyntax eventContext) =>
Unrenderable($"$eventContext.{eventContext.Path}", "a command handler runs before anything is appended, so there is no event context");

/// <inheritdoc/>
public string Render(CausedByExpressionSyntax causedBy) =>
causedBy.Property is null ? Identity(null) : Resolve($"causedBy.{causedBy.Property}");

/// <inheritdoc/>
public string RenderEventSourceId() =>
Unrenderable("$eventSourceId", "the event source id is resolved from the command's own identifier rather than read in the handler");

static string Property(string[] segments) => string.Join('.', segments.Skip(1).Select(Identifiers.ToPascalCase));

string Resolve(string path)
{
var segments = path.Split('.');
return segments switch
{
["occurred"] => "DateTimeOffset.UtcNow",
["tenant"] => $"{Use(HandlerCollaborator.Tenants)}.Current.Value",
["command", ..] when segments.Length > 1 => Property(segments),
["causedBy", var value] => CausedBy(value, path),
["causation", "type"] => $"{Use(HandlerCollaborator.Causations)}.GetCurrentChain()[^1].Type.Value",
["identity", ..] => Identity(segments, path),
_ => Unrenderable($"$context.{path}", "the language names no such value"),
};
}

string CausedBy(string value, string path) => value switch
{
"subject" => Identity("Subject"),
"name" => Identity("Name"),
"userName" => Identity("UserName"),
_ => Unrenderable($"$context.{path}", "the language names no such value"),
};

string Identity(string[] segments, string path) => segments switch
{
["identity", "id"] => Identity("Subject"),
["identity", "name"] => Identity("Name"),
["identity", "userName"] => Identity("UserName"),
["identity", "isAuthenticated"] => $"{Use(HandlerCollaborator.Principals)}.Current?.Identity?.IsAuthenticated == true",
["identity", "roles"] => Roles(),
["identity", "claims", .. var claim] when claim.Length > 0 => Claim(string.Join('.', claim)),
_ => Unrenderable($"$context.{path}", "the language names no such value"),
};

string Identity(string? property) =>
property is null ? $"{Use(HandlerCollaborator.Identities)}.GetCurrent()" : $"{Use(HandlerCollaborator.Identities)}.GetCurrent().{property}";

string Claim(string name) =>
$"{Use(HandlerCollaborator.Principals)}.Current?.FindFirst({CSharpCodeBuilder.StringLiteral(name)})?.Value ?? string.Empty";

string Roles()
{
var principals = Use(HandlerCollaborator.Principals);
_namespaces.Add("System.Security.Claims");
return $"({principals}.Current?.FindAll(ClaimTypes.Role) ?? []).Select(claim => claim.Value)";
}

string Unrenderable(string expression, string reason)
{
diagnostics.Add($"{subject} reads '{expression}', which the rendered handler cannot reach — {reason}; rendered as a missing value.");
return "default!";
}

string Use(HandlerCollaborator collaborator)
{
if (!_collaborators.Contains(collaborator))
{
_collaborators.Add(collaborator);

if (collaborator.Namespace.Length > 0)
{
_namespaces.Add(collaborator.Namespace);
}
}

return collaborator.ParameterName;
}
}
39 changes: 39 additions & 0 deletions Source/Rendering.Cratis/Expressions/EventContextAccess.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Screenplay.Syntax;
using Cratis.Screenplay.Syntax.Projections;
using Cratis.Stage.Rendering.Cratis.Naming;

namespace Cratis.Stage.Rendering.Cratis.Expressions;

/// <summary>
/// Renders the context expressions against Chronicle's <c>EventContext</c>, for the artifacts that receive one —
/// a reactor method and a projection.
/// </summary>
/// <remarks>
/// <c>EventContext</c> carries <c>Occurred</c>, <c>CausedBy</c> and <c>EventSourceId</c> under those names, so
/// PascalCasing the declared path resolves for the paths a document actually uses here.
/// </remarks>
public sealed class EventContextAccess : IExpressionContext
{
/// <summary>
/// Gets the shared instance — the rendering carries no state.
/// </summary>
public static readonly EventContextAccess Instance = new();

/// <inheritdoc/>
public string Render(ContextExpressionSyntax context) => Path(context.Path);

/// <inheritdoc/>
public string Render(EventContextExpressionSyntax eventContext) => Path(eventContext.Path);

/// <inheritdoc/>
public string Render(CausedByExpressionSyntax causedBy) =>
causedBy.Property is null ? "context.CausedBy" : $"context.CausedBy.{Identifiers.ToPascalCase(causedBy.Property)}";

/// <inheritdoc/>
public string RenderEventSourceId() => "context.EventSourceId";

static string Path(string path) => $"context.{string.Join('.', path.Split('.').Select(Identifiers.ToPascalCase))}";
}
65 changes: 40 additions & 25 deletions Source/Rendering.Cratis/Expressions/ExpressionRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,41 +16,47 @@ namespace Cratis.Stage.Rendering.Cratis.Expressions;
/// and their guarding conditions all resolve through the same set of rules.
/// </summary>
/// <remarks>
/// Every rendered expression that reaches beyond the command/event's own properties (<c>$context.*</c>,
/// <c>$eventContext.*</c>, <c>$causedBy</c>, <c>$eventSourceId</c>) assumes the enclosing method declares its
/// context parameter as <c>context</c> — the same fixed name Screenplay's own authored <c>csharp</c> code blocks
/// assume (see <c>HandlerSyntax</c>/<c>ReactorTriggerSyntax</c> code blocks). Root-specific semantics of
/// <c>$context.&lt;root&gt;.*</c> beyond that are best-effort (PascalCase every path segment) since no confirmed
/// Arc API mapping exists for every root.
/// An expression that reaches beyond the artifact's own properties (<c>$context.*</c>, <c>$eventContext.*</c>,
/// <c>$causedBy</c>, <c>$eventSourceId</c>) has no single C# rendering — what it becomes depends on what the
/// enclosing artifact receives. The overloads taking an <see cref="IExpressionContext"/> let the caller say;
/// the ones without assume Chronicle's <c>EventContext</c> is in scope as <c>context</c>, which holds for a
/// reactor method and a projection and for nothing else.
/// </remarks>
public static class ExpressionRenderer
{
/// <summary>
/// Renders an expression as C# expression text.
/// Renders an expression as C# expression text, against Chronicle's <c>EventContext</c>.
/// </summary>
/// <param name="expression">The expression to render.</param>
/// <returns>The rendered C# expression text.</returns>
/// <exception cref="UnsupportedExpression">Thrown when the expression has no C# rendering.</exception>
public static string Render(ExpressionSyntax expression) => expression switch
public static string Render(ExpressionSyntax expression) => Render(expression, EventContextAccess.Instance);

/// <summary>
/// Renders an expression as C# expression text, against the surroundings the enclosing artifact provides.
/// </summary>
/// <param name="expression">The expression to render.</param>
/// <param name="context">The <see cref="IExpressionContext"/> rendering what reaches outside the artifact.</param>
/// <returns>The rendered C# expression text.</returns>
/// <exception cref="UnsupportedExpression">Thrown when the expression has no C# rendering.</exception>
public static string Render(ExpressionSyntax expression, IExpressionContext context) => expression switch
{
LiteralExpressionSyntax literal => RenderLiteral(literal.Value),
PathExpressionSyntax path => RenderPath(path.Path),
SourceItemExpressionSyntax sourceItem => RenderPath(sourceItem.Path),
ContextExpressionSyntax context => RenderContextPath(context.Path),
ContextExpressionSyntax contextExpression => context.Render(contextExpression),
EnvironmentExpressionSyntax environment => $"Environment.GetEnvironmentVariable({CSharpCodeBuilder.StringLiteral(environment.Name)})",
StringsExpressionSyntax strings =>$"{CSharpCodeBuilder.StringLiteral(strings.Key)} /* TODO: resolve localized string */",
RawExpressionSyntax raw => raw.Text,
EventSourceIdExpressionSyntax => "context.EventSourceId",
EventContextExpressionSyntax eventContext => RenderContextPath(eventContext.Path),
CausedByExpressionSyntax causedBy => causedBy.Property is null
? "context.CausedBy"
: $"context.CausedBy.{Identifiers.ToPascalCase(causedBy.Property)}",
TemplateExpressionSyntax template => RenderTemplate(template),
EventSourceIdExpressionSyntax => context.RenderEventSourceId(),
EventContextExpressionSyntax eventContext => context.Render(eventContext),
CausedByExpressionSyntax causedBy => context.Render(causedBy),
TemplateExpressionSyntax template => RenderTemplate(template, context),
_ => throw new UnsupportedExpression(expression),
};

/// <summary>
/// Renders a condition as a C# boolean expression.
/// Renders a condition as a C# boolean expression, against Chronicle's <c>EventContext</c>.
/// </summary>
/// <param name="condition">The condition to render.</param>
/// <param name="enumTypeOf">
Expand All @@ -60,19 +66,30 @@ public static class ExpressionRenderer
/// </param>
/// <returns>The rendered C# boolean expression text.</returns>
/// <exception cref="UnsupportedCondition">Thrown when the condition has no C# rendering.</exception>
public static string Render(ConditionSyntax condition, Func<string, string?>? enumTypeOf = null) => condition switch
public static string Render(ConditionSyntax condition, Func<string, string?>? enumTypeOf = null) =>
Render(condition, EventContextAccess.Instance, enumTypeOf);

/// <summary>
/// Renders a condition as a C# boolean expression, against the surroundings the enclosing artifact provides.
/// </summary>
/// <param name="condition">The condition to render.</param>
/// <param name="context">The <see cref="IExpressionContext"/> rendering what reaches outside the artifact.</param>
/// <param name="enumTypeOf">Resolves the enum type name of a path being compared, when it has one.</param>
/// <returns>The rendered C# boolean expression text.</returns>
/// <exception cref="UnsupportedCondition">Thrown when the condition has no C# rendering.</exception>
public static string Render(ConditionSyntax condition, IExpressionContext context, Func<string, string?>? enumTypeOf = null) => condition switch
{
ComparisonConditionSyntax comparison =>
$"{RenderPath(comparison.Left)} {Operator(comparison.Operator)} {RenderComparand(comparison, enumTypeOf)}",
$"{RenderPath(comparison.Left)} {Operator(comparison.Operator)} {RenderComparand(comparison, context, enumTypeOf)}",
LogicalConditionSyntax logical =>
$"({Render(logical.Left, enumTypeOf)}) {Operator(logical.Operator)} ({Render(logical.Right, enumTypeOf)})",
$"({Render(logical.Left, context, enumTypeOf)}) {Operator(logical.Operator)} ({Render(logical.Right, context, enumTypeOf)})",
_ => throw new UnsupportedCondition(condition),
};

static string RenderComparand(ComparisonConditionSyntax comparison, Func<string, string?>? enumTypeOf) =>
static string RenderComparand(ComparisonConditionSyntax comparison, IExpressionContext context, Func<string, string?>? enumTypeOf) =>
comparison.Right is LiteralExpressionSyntax { Value: string text } && enumTypeOf?.Invoke(comparison.Left) is { } enumName
? $"{enumName}.{Identifiers.ToPascalCase(text)}"
: Render(comparison.Right);
: Render(comparison.Right, context);

static string RenderLiteral(object? value) => value switch
{
Expand All @@ -88,9 +105,7 @@ static string RenderComparand(ComparisonConditionSyntax comparison, Func<string,

static string RenderPath(string path) => string.Join('.', path.Split('.').Select(Identifiers.ToPascalCase));

static string RenderContextPath(string path) => $"context.{RenderPath(path)}";

static string RenderTemplate(TemplateExpressionSyntax template)
static string RenderTemplate(TemplateExpressionSyntax template, IExpressionContext context)
{
var builder = new StringBuilder("$\"");
foreach (var part in template.Parts)
Expand All @@ -101,7 +116,7 @@ static string RenderTemplate(TemplateExpressionSyntax template)
}
else if (part is TemplateInterpolationSyntax interpolation)
{
builder.Append('{').Append(Render(interpolation.Expression)).Append('}');
builder.Append('{').Append(Render(interpolation.Expression, context)).Append('}');
}
}

Expand Down
50 changes: 50 additions & 0 deletions Source/Rendering.Cratis/Expressions/HandlerCollaborator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Stage.Rendering.Cratis.Expressions;

/// <summary>
/// Represents a collaborator a rendered handler takes as a parameter to reach a value the Screenplay document
/// asks for from the surrounding context.
/// </summary>
/// <param name="TypeName">The collaborator's C# type name.</param>
/// <param name="ParameterName">The parameter name the rendered expressions refer to it by.</param>
/// <param name="Namespace">The namespace to import for the type, or empty when the type name is already qualified.</param>
/// <remarks>
/// Arc resolves a command handler's parameters from dependency injection by type, so asking for a collaborator is
/// how a rendered handler reaches anything beyond the command's own properties. Each one here is a type the Cratis
/// runtime registers by convention.
/// </remarks>
public sealed record HandlerCollaborator(string TypeName, string ParameterName, string Namespace)
{
/// <summary>
/// Gets the collaborator giving the tenant the command executes for.
/// </summary>
public static readonly HandlerCollaborator Tenants = new("ITenantIdAccessor", "tenants", "Cratis.Arc.Tenancy");

/// <summary>
/// Gets the collaborator giving the identity recorded as having caused what the command appends.
/// </summary>
/// <remarks>
/// Named in full: the Cratis package's global usings bring in both <c>Cratis.Chronicle.Identities</c> and
/// <c>Cratis.Arc.Identity</c>, and each declares an <c>IIdentityProvider</c>, so the short name is ambiguous
/// in every rendered file whether or not this one adds a using of its own.
/// </remarks>
public static readonly HandlerCollaborator Identities = new("Cratis.Chronicle.Identities.IIdentityProvider", "identities", string.Empty);

/// <summary>
/// Gets the collaborator giving the calling principal — what the caller can prove, rather than who they are.
/// </summary>
public static readonly HandlerCollaborator Principals = new("ICurrentPrincipalAccessor", "principals", "Cratis.Arc.Authorization");

/// <summary>
/// Gets the collaborator giving what caused the command to run.
/// </summary>
public static readonly HandlerCollaborator Causations = new("ICausationManager", "causations", "Cratis.Chronicle.Auditing");

/// <summary>
/// Renders the collaborator as a method parameter.
/// </summary>
/// <returns>The parameter declaration.</returns>
public string ToParameter() => $"{TypeName} {ParameterName}";
}
Loading
Loading