diff --git a/Source/Rendering.Cratis/Expressions/CommandContextAccess.cs b/Source/Rendering.Cratis/Expressions/CommandContextAccess.cs
new file mode 100644
index 0000000..72abca1
--- /dev/null
+++ b/Source/Rendering.Cratis/Expressions/CommandContextAccess.cs
@@ -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;
+
+///
+/// Renders the context expressions inside a rendered command handler, and records the collaborators the handler
+/// has to take a parameter for to reach them.
+///
+///
+///
+/// A rendered command handler is an Arc model-bound Handle(). Arc's own CommandContext carries the
+/// correlation id, the command instance and the dependencies — and none of what the Screenplay language names:
+/// no Occurred, no Identity, no Tenant, no CausedBy, no Causation. Screenplay
+/// defines those on its own Cratis.Screenplay.Contexts.CommandContext, which is a different type that a
+/// rendered application never receives. Rendering $context.occurred as context.Occurred therefore
+/// produced an application that did not compile.
+///
+///
+/// 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.
+///
+///
+/// What is being rendered, for diagnostics (for example Command 'RegisterInvoice').
+/// Collects anything that could not be rendered faithfully.
+public sealed class CommandContextAccess(string subject, ICollection diagnostics) : IExpressionContext
+{
+ readonly List _collaborators = [];
+ readonly SortedSet _namespaces = new(StringComparer.Ordinal);
+
+ ///
+ /// Gets the collaborators the rendered handler needs, in the order they were first asked for.
+ ///
+ public IReadOnlyList Collaborators => _collaborators;
+
+ ///
+ /// Gets every namespace the rendered expressions need in scope.
+ ///
+ public IEnumerable Namespaces => _namespaces;
+
+ ///
+ /// 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.
+ ///
+ /// The $context path, without the $context. prefix.
+ /// The C# type name, or when the path resolves to nothing typed.
+ 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,
+ };
+
+ ///
+ public string Render(ContextExpressionSyntax context) => Resolve(context.Path);
+
+ ///
+ public string Render(EventContextExpressionSyntax eventContext) =>
+ Unrenderable($"$eventContext.{eventContext.Path}", "a command handler runs before anything is appended, so there is no event context");
+
+ ///
+ public string Render(CausedByExpressionSyntax causedBy) =>
+ causedBy.Property is null ? Identity(null) : Resolve($"causedBy.{causedBy.Property}");
+
+ ///
+ 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;
+ }
+}
diff --git a/Source/Rendering.Cratis/Expressions/EventContextAccess.cs b/Source/Rendering.Cratis/Expressions/EventContextAccess.cs
new file mode 100644
index 0000000..0c821eb
--- /dev/null
+++ b/Source/Rendering.Cratis/Expressions/EventContextAccess.cs
@@ -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;
+
+///
+/// Renders the context expressions against Chronicle's EventContext, for the artifacts that receive one —
+/// a reactor method and a projection.
+///
+///
+/// EventContext carries Occurred, CausedBy and EventSourceId under those names, so
+/// PascalCasing the declared path resolves for the paths a document actually uses here.
+///
+public sealed class EventContextAccess : IExpressionContext
+{
+ ///
+ /// Gets the shared instance — the rendering carries no state.
+ ///
+ public static readonly EventContextAccess Instance = new();
+
+ ///
+ public string Render(ContextExpressionSyntax context) => Path(context.Path);
+
+ ///
+ public string Render(EventContextExpressionSyntax eventContext) => Path(eventContext.Path);
+
+ ///
+ public string Render(CausedByExpressionSyntax causedBy) =>
+ causedBy.Property is null ? "context.CausedBy" : $"context.CausedBy.{Identifiers.ToPascalCase(causedBy.Property)}";
+
+ ///
+ public string RenderEventSourceId() => "context.EventSourceId";
+
+ static string Path(string path) => $"context.{string.Join('.', path.Split('.').Select(Identifiers.ToPascalCase))}";
+}
diff --git a/Source/Rendering.Cratis/Expressions/ExpressionRenderer.cs b/Source/Rendering.Cratis/Expressions/ExpressionRenderer.cs
index 84d48e4..d56d188 100644
--- a/Source/Rendering.Cratis/Expressions/ExpressionRenderer.cs
+++ b/Source/Rendering.Cratis/Expressions/ExpressionRenderer.cs
@@ -16,41 +16,47 @@ namespace Cratis.Stage.Rendering.Cratis.Expressions;
/// and their guarding conditions all resolve through the same set of rules.
///
///
-/// Every rendered expression that reaches beyond the command/event's own properties ($context.*,
-/// $eventContext.*, $causedBy, $eventSourceId) assumes the enclosing method declares its
-/// context parameter as context — the same fixed name Screenplay's own authored csharp code blocks
-/// assume (see HandlerSyntax/ReactorTriggerSyntax code blocks). Root-specific semantics of
-/// $context.<root>.* 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 ($context.*, $eventContext.*,
+/// $causedBy, $eventSourceId) has no single C# rendering — what it becomes depends on what the
+/// enclosing artifact receives. The overloads taking an let the caller say;
+/// the ones without assume Chronicle's EventContext is in scope as context, which holds for a
+/// reactor method and a projection and for nothing else.
///
public static class ExpressionRenderer
{
///
- /// Renders an expression as C# expression text.
+ /// Renders an expression as C# expression text, against Chronicle's EventContext.
///
/// The expression to render.
/// The rendered C# expression text.
/// Thrown when the expression has no C# rendering.
- public static string Render(ExpressionSyntax expression) => expression switch
+ public static string Render(ExpressionSyntax expression) => Render(expression, EventContextAccess.Instance);
+
+ ///
+ /// Renders an expression as C# expression text, against the surroundings the enclosing artifact provides.
+ ///
+ /// The expression to render.
+ /// The rendering what reaches outside the artifact.
+ /// The rendered C# expression text.
+ /// Thrown when the expression has no C# rendering.
+ 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),
};
///
- /// Renders a condition as a C# boolean expression.
+ /// Renders a condition as a C# boolean expression, against Chronicle's EventContext.
///
/// The condition to render.
///
@@ -60,19 +66,30 @@ public static class ExpressionRenderer
///
/// The rendered C# boolean expression text.
/// Thrown when the condition has no C# rendering.
- public static string Render(ConditionSyntax condition, Func? enumTypeOf = null) => condition switch
+ public static string Render(ConditionSyntax condition, Func? enumTypeOf = null) =>
+ Render(condition, EventContextAccess.Instance, enumTypeOf);
+
+ ///
+ /// Renders a condition as a C# boolean expression, against the surroundings the enclosing artifact provides.
+ ///
+ /// The condition to render.
+ /// The rendering what reaches outside the artifact.
+ /// Resolves the enum type name of a path being compared, when it has one.
+ /// The rendered C# boolean expression text.
+ /// Thrown when the condition has no C# rendering.
+ public static string Render(ConditionSyntax condition, IExpressionContext context, Func? 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? enumTypeOf) =>
+ static string RenderComparand(ComparisonConditionSyntax comparison, IExpressionContext context, Func? 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
{
@@ -88,9 +105,7 @@ static string RenderComparand(ComparisonConditionSyntax comparison, Func 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)
@@ -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('}');
}
}
diff --git a/Source/Rendering.Cratis/Expressions/HandlerCollaborator.cs b/Source/Rendering.Cratis/Expressions/HandlerCollaborator.cs
new file mode 100644
index 0000000..a557b66
--- /dev/null
+++ b/Source/Rendering.Cratis/Expressions/HandlerCollaborator.cs
@@ -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;
+
+///
+/// Represents a collaborator a rendered handler takes as a parameter to reach a value the Screenplay document
+/// asks for from the surrounding context.
+///
+/// The collaborator's C# type name.
+/// The parameter name the rendered expressions refer to it by.
+/// The namespace to import for the type, or empty when the type name is already qualified.
+///
+/// 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.
+///
+public sealed record HandlerCollaborator(string TypeName, string ParameterName, string Namespace)
+{
+ ///
+ /// Gets the collaborator giving the tenant the command executes for.
+ ///
+ public static readonly HandlerCollaborator Tenants = new("ITenantIdAccessor", "tenants", "Cratis.Arc.Tenancy");
+
+ ///
+ /// Gets the collaborator giving the identity recorded as having caused what the command appends.
+ ///
+ ///
+ /// Named in full: the Cratis package's global usings bring in both Cratis.Chronicle.Identities and
+ /// Cratis.Arc.Identity, and each declares an IIdentityProvider, so the short name is ambiguous
+ /// in every rendered file whether or not this one adds a using of its own.
+ ///
+ public static readonly HandlerCollaborator Identities = new("Cratis.Chronicle.Identities.IIdentityProvider", "identities", string.Empty);
+
+ ///
+ /// Gets the collaborator giving the calling principal — what the caller can prove, rather than who they are.
+ ///
+ public static readonly HandlerCollaborator Principals = new("ICurrentPrincipalAccessor", "principals", "Cratis.Arc.Authorization");
+
+ ///
+ /// Gets the collaborator giving what caused the command to run.
+ ///
+ public static readonly HandlerCollaborator Causations = new("ICausationManager", "causations", "Cratis.Chronicle.Auditing");
+
+ ///
+ /// Renders the collaborator as a method parameter.
+ ///
+ /// The parameter declaration.
+ public string ToParameter() => $"{TypeName} {ParameterName}";
+}
diff --git a/Source/Rendering.Cratis/Expressions/IExpressionContext.cs b/Source/Rendering.Cratis/Expressions/IExpressionContext.cs
new file mode 100644
index 0000000..ce90382
--- /dev/null
+++ b/Source/Rendering.Cratis/Expressions/IExpressionContext.cs
@@ -0,0 +1,48 @@
+// 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;
+
+namespace Cratis.Stage.Rendering.Cratis.Expressions;
+
+///
+/// Defines how the expressions that reach outside the artifact being rendered — $context,
+/// $eventContext, $causedBy and $eventSourceId — are rendered where they land.
+///
+///
+/// The same Screenplay expression has different C# in different places, because the surroundings differ: a
+/// reactor method receives Chronicle's EventContext and can read Occurred straight off it, while a
+/// command handler receives no such thing and has to ask a collaborator. Rendering both the same way is what made
+/// a command handler emit members Arc's CommandContext does not have, so the enclosing artifact supplies
+/// the rendering rather than the expression assuming one.
+///
+public interface IExpressionContext
+{
+ ///
+ /// Renders a $context.<path> expression.
+ ///
+ /// The expression to render.
+ /// The rendered C# expression text.
+ string Render(ContextExpressionSyntax context);
+
+ ///
+ /// Renders a $eventContext.<path> expression.
+ ///
+ /// The expression to render.
+ /// The rendered C# expression text.
+ string Render(EventContextExpressionSyntax eventContext);
+
+ ///
+ /// Renders a $causedBy expression.
+ ///
+ /// The expression to render.
+ /// The rendered C# expression text.
+ string Render(CausedByExpressionSyntax causedBy);
+
+ ///
+ /// Renders a $eventSourceId expression.
+ ///
+ /// The rendered C# expression text.
+ string RenderEventSourceId();
+}
diff --git a/Source/Rendering.Cratis/Renderers/StateChangeSliceRenderer.cs b/Source/Rendering.Cratis/Renderers/StateChangeSliceRenderer.cs
index dc74408..dd21764 100644
--- a/Source/Rendering.Cratis/Renderers/StateChangeSliceRenderer.cs
+++ b/Source/Rendering.Cratis/Renderers/StateChangeSliceRenderer.cs
@@ -58,14 +58,9 @@ static void RenderCommand(CSharpCodeBuilder builder, CommandSyntax command, Appl
{
var typeName = Identifiers.ToPascalCase(command.Name);
var parameters = string.Join(", ", command.Properties.Select(property => RenderParameter(property, command.Name, applicationSet, diagnostics)));
- var requiresContext = RequiresContext(command);
var authorization = AuthorizationRenderer.Render(command.Authorize, applicationSet, $"Command '{command.Name}'", diagnostics);
builder.Using("Cratis.Arc.Commands.ModelBound").Using(AuthorizationRenderer.Namespace);
- if (requiresContext || command.Handler?.Code is not null)
- {
- builder.Using("Cratis.Arc.Commands");
- }
if (command.Properties.Any(property => property.IsIdentifier && TypeResolver.Resolve(property.Type, applicationSet).Kind != ResolvedTypeKind.Concept))
{
@@ -75,18 +70,23 @@ static void RenderCommand(CSharpCodeBuilder builder, CommandSyntax command, Appl
builder.BlankLine().Attribute("Command").Attribute(authorization).OpenBlock($"public record {typeName}({parameters})");
CommandValidatorRenderer.Render(builder, command, typeName, applicationSet, diagnostics);
- RenderHandle(builder, command, requiresContext, applicationSet, diagnostics);
+ RenderHandle(builder, command, applicationSet, diagnostics);
builder.EndBlock();
}
- static void RenderHandle(CSharpCodeBuilder builder, CommandSyntax command, bool requiresContext, ApplicationSet applicationSet, ICollection diagnostics)
+ static void RenderHandle(CSharpCodeBuilder builder, CommandSyntax command, ApplicationSet applicationSet, ICollection diagnostics)
{
- var contextParameter = requiresContext ? "CommandContext context" : string.Empty;
-
if (command.Handler?.Code is not null)
{
- builder.BlankLine().OpenBlock("public IEnumerable