diff --git a/Directory.Build.props b/Directory.Build.props index 302df1c..b0f4508 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,6 +7,18 @@ true + + true + 2.0.0 + True True True diff --git a/Source/Contracts/Commands/AuthorizationRequirement.cs b/Source/Contracts/Commands/AuthorizationRequirement.cs new file mode 100644 index 0000000..ad474df --- /dev/null +++ b/Source/Contracts/Commands/AuthorizationRequirement.cs @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; + +namespace Cratis.Stage.Contracts.Commands; + +/// +/// Represents what a command's authorize declaration requires of the caller — a policy, or policies combined. +/// +/// +/// A tree rather than a flat list of policy names, deliberately. A set of names cannot distinguish +/// A or B and C from (A or B) and C, so a consumer deciding whether a caller is allowed cannot +/// answer from one — which is what Screenplay#68 +/// was about. Use for the flat set when only the names are needed. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] +[JsonDerivedType(typeof(PolicyReference), "policy")] +[JsonDerivedType(typeof(LogicalRequirement), "logical")] +public abstract record AuthorizationRequirement +{ + /// + /// Gets every policy the requirement names, in the order they appear. + /// + /// The names of the policies held anywhere in the requirement. + /// + /// Derived from the tree rather than stored beside it, for a consumer that only needs to know which policies + /// are involved — resolving them, listing them — and not how they combine. + /// + public IEnumerable Policies() => + this switch + { + PolicyReference reference => [reference.Policy], + LogicalRequirement logical => logical.Left.Policies().Concat(logical.Right.Policies()), + _ => [] + }; +} + +/// +/// Represents a reference to a single named policy the caller must satisfy. +/// +/// The name of the referenced policy. +public record PolicyReference(string Policy) : AuthorizationRequirement; + +/// +/// Represents two authorization requirements combined with and or or. +/// +/// The left hand requirement. +/// The operator combining the requirements. +/// The right hand requirement. +public record LogicalRequirement( + AuthorizationRequirement Left, + ProducedEventLogicalOperator Operator, + AuthorizationRequirement Right) : AuthorizationRequirement; diff --git a/Source/Contracts/Commands/CommandDefinition.cs b/Source/Contracts/Commands/CommandDefinition.cs index 26f40de..ced617e 100644 --- a/Source/Contracts/Commands/CommandDefinition.cs +++ b/Source/Contracts/Commands/CommandDefinition.cs @@ -18,6 +18,13 @@ namespace Cratis.Stage.Contracts.Commands; /// The name of the command property whose value identifies the event source the command /// appends to, or when the command declares none - in which case every execution opens a /// stream of its own. At most one property can be the identifier. +/// +/// Capability added after the record shipped is an init property rather than a trailing parameter of the +/// primary constructor, deliberately. A trailing parameter on a positional record is source compatible and +/// binary breaking: it replaces the constructor and Deconstruct in the compiled signature, so a +/// package built against the previous version fails at run time with a missing method and no compiler error +/// anywhere. Package validation now fails the build on that, and is how this record should grow from here. +/// public record CommandDefinition( Guid Id, string Name, @@ -26,4 +33,21 @@ public record CommandDefinition( IReadOnlyList Rules, string LogicDescription, IReadOnlyList Produces, - string? Identifier = null); + string? Identifier = null) +{ + /// + /// Gets what the caller must satisfy to execute the command, or when the command + /// declares no authorization and anyone may execute it. + /// + public AuthorizationRequirement? Authorization { get; init; } + + /// + /// Gets the conditions the command as a whole must satisfy — the modeled require rules. + /// + public IReadOnlyList Requirements { get; init; } = []; + + /// + /// Gets the read models the command consults before it decides — the modeled reads declarations. + /// + public IReadOnlyList Reads { get; init; } = []; +} diff --git a/Source/Contracts/Commands/ProducedEvent.cs b/Source/Contracts/Commands/ProducedEvent.cs index 1d2b9ea..210d0a7 100644 --- a/Source/Contracts/Commands/ProducedEvent.cs +++ b/Source/Contracts/Commands/ProducedEvent.cs @@ -14,4 +14,11 @@ public record ProducedEvent( string Event, ProducedEventCondition? When, IReadOnlyList Properties, - IReadOnlyList Tags); + IReadOnlyList Tags) +{ + /// + /// Gets where the event source this event is appended to comes from — the modeled for clause — or + /// when the event lands on the command's own event source. + /// + public ProducedEventSource? For { get; init; } +} diff --git a/Source/Contracts/Commands/ProducedEventSource.cs b/Source/Contracts/Commands/ProducedEventSource.cs new file mode 100644 index 0000000..726756e --- /dev/null +++ b/Source/Contracts/Commands/ProducedEventSource.cs @@ -0,0 +1,18 @@ +// 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.Contracts.Commands; + +/// +/// Represents where the event source a produced event is appended to comes from — the modeled +/// produces … for clause. +/// +/// Where the value comes from. +/// The source, interpreted according to — a command property name, +/// JSON literal text, an identity path, an environment variable name or a template. +/// +/// Carries no property name, unlike : the value identifies the stream the event +/// is appended to rather than filling a property of its payload. A produces with none of these lands on the +/// command's own event source, which is the common case and stays unstated. +/// +public record ProducedEventSource(ProducedValueKind Kind, string Expression); diff --git a/Source/Contracts/Commands/ReadsDefinition.cs b/Source/Contracts/Commands/ReadsDefinition.cs new file mode 100644 index 0000000..807ef89 --- /dev/null +++ b/Source/Contracts/Commands/ReadsDefinition.cs @@ -0,0 +1,12 @@ +// 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.Contracts.Commands; + +/// +/// Represents a reads declaration on a command — the read model the command consults before it decides. +/// +/// The name of the read model the command reads. +/// The command property the read model is looked up by, or when the read +/// model is not keyed — a single view the whole application shares rather than one instance per identifier. +public record ReadsDefinition(string ReadModel, string? By); diff --git a/Source/Contracts/Commands/Requirement.cs b/Source/Contracts/Commands/Requirement.cs new file mode 100644 index 0000000..7f3f172 --- /dev/null +++ b/Source/Contracts/Commands/Requirement.cs @@ -0,0 +1,18 @@ +// 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.Contracts.Commands; + +/// +/// Represents a require rule on a command — a condition the command as a whole must satisfy, rather than a +/// rule about one of its properties. +/// +/// The condition that must hold. +/// The message reported when it does not, or when none is declared. +/// +/// Carries the same tree a produces when clause carries, so and +/// and or mean here exactly what they mean there. A property rule says something about one value; a +/// requirement says something about the command as a whole — most often against state it +/// reads. +/// +public record Requirement(ProducedEventCondition Condition, string? Message); diff --git a/Source/Contracts/EventModel.schema.json b/Source/Contracts/EventModel.schema.json index bd2ed22..b52f78b 100644 --- a/Source/Contracts/EventModel.schema.json +++ b/Source/Contracts/EventModel.schema.json @@ -78,7 +78,8 @@ "SourceEventId": { "type": "string", "description": "Empty when the event is owned by this slice; otherwise the id of the event this one references (a State Change slice's event referenced by a consumer slice)." }, "Schema": { "type": "string", "description": "The event payload as a JSON Schema string." }, "UniqueEventTypeConstraint": { "oneOf": [{ "$ref": "#/$defs/UniqueEventTypeConstraint" }, { "type": "null" }] }, - "UniqueConstraint": { "oneOf": [{ "$ref": "#/$defs/UniqueConstraint" }, { "type": "null" }] } + "UniqueConstraint": { "oneOf": [{ "$ref": "#/$defs/UniqueConstraint" }, { "type": "null" }] }, + "Tags": { "type": "array", "description": "The tags declared on the event, applied to every append of it.", "items": { "type": "string" } } }, "additionalProperties": false }, @@ -93,7 +94,43 @@ "Rules": { "type": "array", "items": { "$ref": "#/$defs/CommandPropertyRules" } }, "LogicDescription": { "type": "string" }, "Produces": { "type": "array", "description": "The events the command appends, in declaration order.", "items": { "$ref": "#/$defs/ProducedEvent" } }, - "Identifier": { "type": ["string", "null"], "description": "The name of the command property whose value identifies the event source the command appends to. Null when the command declares none, in which case every execution opens a stream of its own." } + "Identifier": { "type": ["string", "null"], "description": "The name of the command property whose value identifies the event source the command appends to. Null when the command declares none, in which case every execution opens a stream of its own." }, + "Authorization": { "oneOf": [{ "$ref": "#/$defs/AuthorizationRequirement" }, { "type": "null" }], "description": "What the caller must satisfy to execute the command; null when the command declares none and anyone may execute it." }, + "Requirements": { "type": "array", "description": "The conditions the command as a whole must satisfy - the modeled 'require' rules.", "items": { "$ref": "#/$defs/Requirement" } }, + "Reads": { "type": "array", "description": "The read models the command consults before it decides - the modeled 'reads' declarations.", "items": { "$ref": "#/$defs/ReadsDefinition" } } + }, + "additionalProperties": false + }, + "AuthorizationRequirement": { + "type": "object", + "description": "What an 'authorize' declaration requires of the caller. Discriminated by 'kind': 'policy' names one policy, 'logical' combines two requirements. A tree rather than a flat list of names, because a flat list cannot distinguish 'A or B and C' from '(A or B) and C'.", + "required": ["kind"], + "properties": { + "kind": { "type": "string", "enum": ["policy", "logical"] }, + "Policy": { "type": "string", "description": "Policy only - the name of the referenced policy." }, + "Left": { "$ref": "#/$defs/AuthorizationRequirement" }, + "Operator": { "type": "string", "enum": ["And", "Or"], "description": "Logical only - the operator combining the requirements." }, + "Right": { "$ref": "#/$defs/AuthorizationRequirement" } + }, + "additionalProperties": false + }, + "Requirement": { + "type": "object", + "description": "A rule the command as a whole must satisfy - the modeled 'require' rule. Carries the same condition tree a 'produces when' guard carries.", + "required": ["Condition"], + "properties": { + "Condition": { "$ref": "#/$defs/ProducedEventCondition" }, + "Message": { "type": ["string", "null"], "description": "The message reported when the condition does not hold; null when none is declared." } + }, + "additionalProperties": false + }, + "ReadsDefinition": { + "type": "object", + "description": "A read model the command consults before it decides - the modeled 'reads' declaration.", + "required": ["ReadModel"], + "properties": { + "ReadModel": { "type": "string", "description": "The name of the read model the command reads." }, + "By": { "type": ["string", "null"], "description": "The command property the read model is looked up by; null when the read model is not keyed." } }, "additionalProperties": false }, @@ -105,7 +142,17 @@ "Event": { "type": "string", "description": "The name of the event type to append." }, "When": { "oneOf": [{ "$ref": "#/$defs/ProducedEventCondition" }, { "type": "null" }], "description": "The condition guarding the production; null when the event is always produced." }, "Properties": { "type": "array", "items": { "$ref": "#/$defs/ProducedEventProperty" } }, - "Tags": { "type": "array", "items": { "type": "string" } } + "Tags": { "type": "array", "items": { "type": "string" } }, + "For": { "oneOf": [{ "$ref": "#/$defs/ProducedEventSource" }, { "type": "null" }], "description": "Where the event source this event is appended to comes from - the modeled 'for' clause. Null when the event lands on the command's own event source." } + }, + "additionalProperties": false + }, + "ProducedEventSource": { + "type": "object", + "description": "Where the event source a produced event is appended to comes from. Carries no property name, unlike ProducedEventProperty: the value identifies the stream rather than filling a property of the payload.", + "properties": { + "Kind": { "type": "string", "enum": ["CommandProperty", "Literal", "Occurred", "Identity", "Environment", "Template", "Unsupported"] }, + "Expression": { "type": "string", "description": "The source, interpreted according to Kind." } }, "additionalProperties": false }, @@ -214,7 +261,9 @@ "Given": { "type": "array", "items": { "$ref": "#/$defs/SpecificationGivenEvent" } }, "When": { "oneOf": [{ "$ref": "#/$defs/SpecificationCommand" }, { "type": "null" }] }, "ThenEvents": { "type": "array", "items": { "$ref": "#/$defs/SpecificationThenEvent" } }, - "ThenErrors": { "type": "array", "items": { "$ref": "#/$defs/SpecificationError" } } + "ThenErrors": { "type": "array", "items": { "$ref": "#/$defs/SpecificationError" } }, + "GivenReadModels": { "type": "array", "description": "The read model states that establish the Given precondition.", "items": { "$ref": "#/$defs/SpecificationReadModel" } }, + "ThenReadModels": { "type": "array", "description": "The read model states expected in the Then step - what a ThenReadModel run step verifies.", "items": { "$ref": "#/$defs/SpecificationReadModel" } } }, "additionalProperties": false }, @@ -227,6 +276,16 @@ "Values": { "type": "string", "description": "The event values as a JSON string." } } }, + "SpecificationReadModel": { + "type": "object", + "description": "A read model state in a specification - used for both the Given precondition and the Then expectation.", + "properties": { + "Id": { "type": "string", "format": "uuid" }, + "Name": { "type": "string" }, + "ReadModelId": { "type": "string", "format": "uuid" }, + "Values": { "type": "string", "description": "The read model values as a JSON string." } + } + }, "SpecificationCommand": { "type": "object", "properties": { diff --git a/Source/Contracts/Events/EventDefinition.cs b/Source/Contracts/Events/EventDefinition.cs index c28d6ec..86e114a 100644 --- a/Source/Contracts/Events/EventDefinition.cs +++ b/Source/Contracts/Events/EventDefinition.cs @@ -20,4 +20,10 @@ public record EventDefinition( string SourceEventId, string Schema, UniqueEventTypeConstraint? UniqueEventTypeConstraint, - UniqueConstraint? UniqueConstraint); + UniqueConstraint? UniqueConstraint) +{ + /// + /// Gets the tags declared on the event, applied to every occurrence of it. + /// + public IReadOnlyList Tags { get; init; } = []; +} diff --git a/Source/Contracts/Screenplay/AuthorizationConverter.cs b/Source/Contracts/Screenplay/AuthorizationConverter.cs new file mode 100644 index 0000000..d7edfda --- /dev/null +++ b/Source/Contracts/Screenplay/AuthorizationConverter.cs @@ -0,0 +1,36 @@ +// 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.Stage.Contracts.Commands; + +namespace Cratis.Stage.Contracts.Screenplay; + +/// +/// Converts a Screenplay into the Stage tree. +/// +/// +/// The tree is carried whole rather than flattened to the policy names it mentions. Flattening cannot distinguish +/// A or B and C from (A or B) and C, so a consumer deciding whether a caller is allowed could not +/// answer from the result — see Screenplay#68. +/// A consumer that only wants the names calls . +/// +public static class AuthorizationConverter +{ + /// + /// Converts an authorize declaration into its Stage requirement. + /// + /// The declaration, or when the construct declares none. + /// The Stage requirement, or when nothing is required. + public static AuthorizationRequirement? Convert(AuthorizeSyntax? authorize) => + authorize is null ? null : Convert(authorize.Requirement); + + static AuthorizationRequirement? Convert(PolicyRequirementSyntax requirement) => + requirement switch + { + PolicyReferenceSyntax reference => new PolicyReference(reference.Name), + LogicalPolicyRequirementSyntax logical when Convert(logical.Left) is { } left && Convert(logical.Right) is { } right => + new LogicalRequirement(left, ConditionConverter.Operator(logical.Operator), right), + _ => null + }; +} diff --git a/Source/Contracts/Screenplay/CommandConverter.cs b/Source/Contracts/Screenplay/CommandConverter.cs index 4438e70..f233b6c 100644 --- a/Source/Contracts/Screenplay/CommandConverter.cs +++ b/Source/Contracts/Screenplay/CommandConverter.cs @@ -30,7 +30,25 @@ public static CommandDefinition Convert(CommandSyntax command, SchemaSynthesizer ValidationRuleConverter.Convert(command.Validations), string.Empty, ProducesConverter.Convert(command.Produces), - Identifier(command)); + Identifier(command)) + { + Authorization = AuthorizationConverter.Convert(command.Authorize), + Requirements = Requirements(command.Validations), + Reads = [.. (command.Reads ?? []).Select(reads => new ReadsDefinition(reads.ReadModel, reads.By))] + }; + + // 'require' rules live inside the declarative validate blocks rather than on the command itself, alongside the + // per-property rules ValidationRuleConverter takes from the same blocks. A requirement whose condition has no + // Stage equivalent is dropped rather than carried as an empty rule that would always hold. + static IReadOnlyList Requirements(IEnumerable validations) => + [ + .. validations + .OfType() + .SelectMany(block => block.Requirements ?? []) + .Select(requirement => (Condition: ConditionConverter.Convert(requirement.Condition), requirement.Message)) + .Where(requirement => requirement.Condition is not null) + .Select(requirement => new Requirement(requirement.Condition!, requirement.Message)) + ]; // The Screenplay compiler already reports a second 'identifier' as an error and drops it, so a compiled document // never carries more than one. A syntax tree built any other way still can - and silently taking the first would diff --git a/Source/Contracts/Screenplay/ConditionConverter.cs b/Source/Contracts/Screenplay/ConditionConverter.cs new file mode 100644 index 0000000..79043d4 --- /dev/null +++ b/Source/Contracts/Screenplay/ConditionConverter.cs @@ -0,0 +1,56 @@ +// 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.Stage.Contracts.Commands; + +namespace Cratis.Stage.Contracts.Screenplay; + +/// +/// Converts a Screenplay into the Stage tree. +/// +/// +/// One condition converter for every construct that carries a condition — a produces when guard and a +/// require rule are the same condition grammar in the language, so they are the same tree here. A second +/// tree for requirements would let and and or drift apart between the two. +/// +public static class ConditionConverter +{ + /// + /// Converts a condition into its Stage tree. + /// + /// The condition to convert, or when none is declared. + /// The Stage condition, or when there is none to express. + public static ProducedEventCondition? Convert(ConditionSyntax? condition) => + condition switch + { + null => null, + ComparisonConditionSyntax comparison => new ProducedEventComparison( + comparison.Left, + Operator(comparison.Operator), + ProducedValueConverter.Convert(comparison.Right) is { Kind: ProducedValueKind.Literal } literal ? literal.Expression : "null"), + LogicalConditionSyntax logical when Convert(logical.Left) is { } left && Convert(logical.Right) is { } right => + new ProducedEventLogicalCondition(left, Operator(logical.Operator), right), + _ => null + }; + + /// + /// Converts a Screenplay logical operator into its Stage equivalent. + /// + /// The operator to convert. + /// The Stage operator. + public static ProducedEventLogicalOperator Operator(LogicalOperator @operator) => + @operator == LogicalOperator.Or ? ProducedEventLogicalOperator.Or : ProducedEventLogicalOperator.And; + + static ProducedEventComparisonOperator Operator(ComparisonOperator @operator) => + @operator switch + { + ComparisonOperator.Equal => ProducedEventComparisonOperator.Equal, + ComparisonOperator.NotEqual => ProducedEventComparisonOperator.NotEqual, + ComparisonOperator.GreaterThan => ProducedEventComparisonOperator.GreaterThan, + ComparisonOperator.GreaterThanOrEqual => ProducedEventComparisonOperator.GreaterThanOrEqual, + ComparisonOperator.LessThan => ProducedEventComparisonOperator.LessThan, + ComparisonOperator.LessThanOrEqual => ProducedEventComparisonOperator.LessThanOrEqual, + _ => ProducedEventComparisonOperator.Equal + }; +} diff --git a/Source/Contracts/Screenplay/EventConverter.cs b/Source/Contracts/Screenplay/EventConverter.cs index be68314..1dd925e 100644 --- a/Source/Contracts/Screenplay/EventConverter.cs +++ b/Source/Contracts/Screenplay/EventConverter.cs @@ -33,7 +33,10 @@ public static IReadOnlyList Convert( string.Empty, schema.ForProperties(@event.Properties), UniqueEventTypeConstraint: null, - UniqueConstraint: null)).ToList(); + UniqueConstraint: null) + { + Tags = ProducedValueConverter.Tags(@event.Tags) + }).ToList(); foreach (var constraint in constraints) { diff --git a/Source/Contracts/Screenplay/ProducedValueConverter.cs b/Source/Contracts/Screenplay/ProducedValueConverter.cs new file mode 100644 index 0000000..c078332 --- /dev/null +++ b/Source/Contracts/Screenplay/ProducedValueConverter.cs @@ -0,0 +1,111 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Text; +using System.Text.Json; +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Projections; +using Cratis.Stage.Contracts.Commands; + +namespace Cratis.Stage.Contracts.Screenplay; + +/// +/// Converts a Screenplay into the and expression text +/// the engine evaluates at runtime — the vocabulary shared by a produced event's property mappings, the event source +/// a produces … for names, and the constant side of a condition. +/// +public static class ProducedValueConverter +{ + /// + /// Converts an expression into the kind and expression text describing where its value comes from. + /// + /// The expression to convert. + /// The kind and its expression text. + public static (ProducedValueKind Kind, string Expression) Convert(ExpressionSyntax expression) => + expression switch + { + PathExpressionSyntax path => (ProducedValueKind.CommandProperty, path.Path), + LiteralExpressionSyntax literal => (ProducedValueKind.Literal, Literal(literal.Value)), + EventContextExpressionSyntax eventContext => EventContext(eventContext.Path), + + // $context.occurred is the time the event happened; anything else under $context that names the + // identity resolves against the identity that caused the command. + ContextExpressionSyntax context => Context(context.Path), + CausedByExpressionSyntax causedBy => (ProducedValueKind.Identity, causedBy.Property ?? "id"), + EnvironmentExpressionSyntax environment => (ProducedValueKind.Environment, environment.Name), + TemplateExpressionSyntax template => (ProducedValueKind.Template, Template(template)), + _ => (ProducedValueKind.Unsupported, string.Empty) + }; + + /// + /// Converts an expression used as a tag into its constant text. + /// + /// The expression to convert. + /// The tag text, or an empty string when the tag has no constant form. + /// + /// A tag has to be a constant string by the time the event is appended. Literal and bare-identifier tags render + /// directly; a tag sourced from the runtime context has no constant form and is left off. + /// + public static string Tag(ExpressionSyntax expression) => + expression switch + { + LiteralExpressionSyntax { Value: string text } => text, + PathExpressionSyntax path => path.Path, + _ => string.Empty + }; + + /// + /// Converts a set of tag declarations into the constant tags they carry, dropping those with no constant form. + /// + /// The tag declarations, or when none are declared. + /// The constant tag texts. + public static IReadOnlyList Tags(IEnumerable? tags) => + [.. (tags ?? []).Select(tag => Tag(tag.Value)).Where(tag => tag.Length > 0)]; + + static (ProducedValueKind Kind, string Expression) Context(string path) + { + if (path.Equals("occurred", StringComparison.OrdinalIgnoreCase)) + { + return (ProducedValueKind.Occurred, string.Empty); + } + + return path.StartsWith("identity.", StringComparison.OrdinalIgnoreCase) + ? (ProducedValueKind.Identity, path["identity.".Length..]) + : (ProducedValueKind.Unsupported, path); + } + + static (ProducedValueKind Kind, string Expression) EventContext(string path) => + path.Equals("occurred", StringComparison.OrdinalIgnoreCase) + ? (ProducedValueKind.Occurred, string.Empty) + : (ProducedValueKind.Unsupported, path); + + static string Template(TemplateExpressionSyntax template) + { + var builder = new StringBuilder(); + foreach (var part in template.Parts) + { + switch (part) + { + case TemplateTextSyntax text: + builder.Append(text.Text); + break; + case TemplateInterpolationSyntax interpolation when interpolation.Expression is PathExpressionSyntax path: + builder.Append("${").Append(path.Path).Append('}'); + break; + } + } + + return builder.ToString(); + } + + static string Literal(object? value) => + value switch + { + null => "null", + string text => JsonSerializer.Serialize(text), + bool boolean => boolean ? "true" : "false", + double number => number.ToString(CultureInfo.InvariantCulture), + _ => JsonSerializer.Serialize(System.Convert.ToString(value, CultureInfo.InvariantCulture)) + }; +} diff --git a/Source/Contracts/Screenplay/ProducesConverter.cs b/Source/Contracts/Screenplay/ProducesConverter.cs index b395f14..83da55a 100644 --- a/Source/Contracts/Screenplay/ProducesConverter.cs +++ b/Source/Contracts/Screenplay/ProducesConverter.cs @@ -1,11 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System.Globalization; -using System.Text; -using System.Text.Json; using Cratis.Screenplay.Syntax; -using Cratis.Screenplay.Syntax.Projections; using Cratis.Stage.Contracts.Commands; namespace Cratis.Stage.Contracts.Screenplay; @@ -25,115 +21,32 @@ public static IReadOnlyList Convert(IEnumerable p [ .. produces.Select(declaration => new ProducedEvent( declaration.Event, - Condition(declaration.When), + ConditionConverter.Convert(declaration.When), [.. declaration.Mappings.Select(Property)], - [.. (declaration.Tags ?? []).Select(tag => Tag(tag.Value)).Where(tag => tag.Length > 0)])) - ]; - - // A tag has to be a constant string by the time the event is appended. Literal and bare-identifier tags render - // directly; a tag sourced from the runtime context has no constant form and is left off. - static string Tag(ExpressionSyntax expression) => - expression switch + ProducedValueConverter.Tags(declaration.Tags)) { - LiteralExpressionSyntax { Value: string text } => text, - PathExpressionSyntax path => path.Path, - _ => string.Empty - }; - - static ProducedEventProperty Property(PropertyMappingSyntax mapping) - { - var (kind, expression) = Source(mapping.Source); - - return new(mapping.Property, kind, expression); - } - - static (ProducedValueKind Kind, string Expression) Source(ExpressionSyntax expression) => - expression switch - { - PathExpressionSyntax path => (ProducedValueKind.CommandProperty, path.Path), - LiteralExpressionSyntax literal => (ProducedValueKind.Literal, Literal(literal.Value)), - EventContextExpressionSyntax eventContext => EventContext(eventContext.Path), - - // $context.occurred is the time the event happened; anything else under $context that names the - // identity resolves against the identity that caused the command. - ContextExpressionSyntax context => Context(context.Path), - CausedByExpressionSyntax causedBy => (ProducedValueKind.Identity, causedBy.Property ?? "id"), - EnvironmentExpressionSyntax environment => (ProducedValueKind.Environment, environment.Name), - TemplateExpressionSyntax template => (ProducedValueKind.Template, Template(template)), - _ => (ProducedValueKind.Unsupported, string.Empty) - }; + For = EventSource(declaration.For) + }) + ]; - static (ProducedValueKind Kind, string Expression) Context(string path) + // A 'for' expression the engine has no way to evaluate would silently redirect the append to the command's own + // event source, which is a different stream than the document asked for — so it is left off instead. + static ProducedEventSource? EventSource(ExpressionSyntax? expression) { - if (path.Equals("occurred", StringComparison.OrdinalIgnoreCase)) + if (expression is null) { - return (ProducedValueKind.Occurred, string.Empty); + return null; } - return path.StartsWith("identity.", StringComparison.OrdinalIgnoreCase) - ? (ProducedValueKind.Identity, path["identity.".Length..]) - : (ProducedValueKind.Unsupported, path); - } + var (kind, text) = ProducedValueConverter.Convert(expression); - static (ProducedValueKind Kind, string Expression) EventContext(string path) => - path.Equals("occurred", StringComparison.OrdinalIgnoreCase) - ? (ProducedValueKind.Occurred, string.Empty) - : (ProducedValueKind.Unsupported, path); + return kind is ProducedValueKind.Unsupported ? null : new ProducedEventSource(kind, text); + } - static string Template(TemplateExpressionSyntax template) + static ProducedEventProperty Property(PropertyMappingSyntax mapping) { - var builder = new StringBuilder(); - foreach (var part in template.Parts) - { - switch (part) - { - case TemplateTextSyntax text: - builder.Append(text.Text); - break; - case TemplateInterpolationSyntax interpolation when interpolation.Expression is PathExpressionSyntax path: - builder.Append("${").Append(path.Path).Append('}'); - break; - } - } + var (kind, expression) = ProducedValueConverter.Convert(mapping.Source); - return builder.ToString(); + return new(mapping.Property, kind, expression); } - - static string Literal(object? value) => - value switch - { - null => "null", - string text => JsonSerializer.Serialize(text), - bool boolean => boolean ? "true" : "false", - double number => number.ToString(CultureInfo.InvariantCulture), - _ => JsonSerializer.Serialize(System.Convert.ToString(value, CultureInfo.InvariantCulture)) - }; - - static ProducedEventCondition? Condition(ConditionSyntax? condition) => - condition switch - { - null => null, - ComparisonConditionSyntax comparison => new ProducedEventComparison( - comparison.Left, - Operator(comparison.Operator), - Source(comparison.Right) is { Kind: ProducedValueKind.Literal } literal ? literal.Expression : "null"), - LogicalConditionSyntax logical when Condition(logical.Left) is { } left && Condition(logical.Right) is { } right => - new ProducedEventLogicalCondition(left, Operator(logical.Operator), right), - _ => null - }; - - static ProducedEventComparisonOperator Operator(ComparisonOperator @operator) => - @operator switch - { - ComparisonOperator.Equal => ProducedEventComparisonOperator.Equal, - ComparisonOperator.NotEqual => ProducedEventComparisonOperator.NotEqual, - ComparisonOperator.GreaterThan => ProducedEventComparisonOperator.GreaterThan, - ComparisonOperator.GreaterThanOrEqual => ProducedEventComparisonOperator.GreaterThanOrEqual, - ComparisonOperator.LessThan => ProducedEventComparisonOperator.LessThan, - ComparisonOperator.LessThanOrEqual => ProducedEventComparisonOperator.LessThanOrEqual, - _ => ProducedEventComparisonOperator.Equal - }; - - static ProducedEventLogicalOperator Operator(LogicalOperator @operator) => - @operator == LogicalOperator.Or ? ProducedEventLogicalOperator.Or : ProducedEventLogicalOperator.And; } diff --git a/Source/Contracts/Screenplay/SchemaSynthesizer.cs b/Source/Contracts/Screenplay/SchemaSynthesizer.cs index 538a25a..016fe82 100644 --- a/Source/Contracts/Screenplay/SchemaSynthesizer.cs +++ b/Source/Contracts/Screenplay/SchemaSynthesizer.cs @@ -20,6 +20,30 @@ public sealed class SchemaSynthesizer(IReadOnlyDictionary /// public const string EmptyObjectSchema = """{"type":"object","properties":{}}"""; + /// + /// The schema keyword naming the concept a property is typed as. + /// + /// + /// A concept resolves to its underlying primitive in the schema, which erases which concept it was. Naming it + /// keeps the property joinable back to the concept it came from. + /// + public const string ConceptKeyword = "x-concept"; + + /// + /// The schema keyword carrying the attributes declared on the concept a property is typed as, as a map of + /// attribute name to its declared reason (an empty string when none was declared). + /// + /// + /// Carries @pii and @sensitive — and anything the language adds later, since the map is keyed by + /// whatever the concept declares rather than by a fixed set. Without it a compliance marker does not survive + /// the import at all: the property is indistinguishable from an ordinary string once the concept is resolved. + /// + /// This states the marker; it does not enforce it. Chronicle carries its own compliance schema keyword + /// that drives encryption at rest, which is a separate and deliberate step. + /// + /// + public const string ConceptAttributesKeyword = "x-conceptAttributes"; + /// /// Synthesizes a JSON Schema object for a set of typed properties (a command or event payload). /// @@ -79,6 +103,39 @@ public string ForReadModel(IEnumerable> properties _ => null }; + // Applied after the underlying type is resolved so the marker lands on the node that actually holds the value — + // for a collection that is the item schema, which is where a reader of the property looks. + static JsonNode Annotate(JsonNode node, ConceptSyntax concept) + { + if (node is not JsonObject schema) + { + return node; + } + + schema[ConceptKeyword] = concept.Name; + + var attributes = concept.Attributes.ToArray(); + if (attributes.Length == 0) + { + return schema; + } + + // A concept based on another concept resolves through this method twice; the inner attributes are already + // on the node, so they are merged into rather than replaced. + if (schema[ConceptAttributesKeyword] is not JsonObject declared) + { + declared = []; + schema[ConceptAttributesKeyword] = declared; + } + + foreach (var attribute in attributes) + { + declared[attribute.Name] = attribute.Reason ?? string.Empty; + } + + return schema; + } + JsonNode ForType(TypeRefSyntax type) { var inner = ForTypeName(type.Name); @@ -96,9 +153,11 @@ JsonNode ForTypeName(string name) if (concepts.TryGetValue(name, out var concept)) { - return concept.IsEnum + var node = concept.IsEnum ? new JsonObject { ["type"] = "string", ["enum"] = new JsonArray([.. concept.Values.Select(value => (JsonNode)JsonValue.Create(value))]) } : ForTypeName(concept.Type); + + return Annotate(node, concept); } return new JsonObject { ["type"] = "object" }; diff --git a/Source/Contracts/Screenplay/SpecificationConverter.cs b/Source/Contracts/Screenplay/SpecificationConverter.cs index 48b744a..e70522b 100644 --- a/Source/Contracts/Screenplay/SpecificationConverter.cs +++ b/Source/Contracts/Screenplay/SpecificationConverter.cs @@ -61,9 +61,27 @@ public static Specification Convert(SpecificationSyntax specification, string sl given, when, thenEvents, - thenErrors); + thenErrors) + { + GivenReadModels = ReadModels(specification.GivenReadModels, $"{specificationPath}.given", slicePath), + ThenReadModels = ReadModels(specification.ThenReadModels, $"{specificationPath}.then", slicePath) + }; } + // The read model is referred to by name, resolved to the same identifier ReadModelConverter derives for the + // slice's read model — the way the event and command steps already resolve what they refer to. + static IReadOnlyList ReadModels( + IEnumerable? readModels, + string stepPath, + string slicePath) => + [ + .. (readModels ?? []).Select((readModel, index) => new SpecificationReadModel( + DeterministicId.From($"{stepPath}.readmodel.{index}.{readModel.Name}"), + readModel.Name, + DeterministicId.From($"{slicePath}.readmodel.{readModel.Name}"), + Values(readModel.Properties))) + ]; + static string Values(IEnumerable mappings) { var values = new JsonObject(); diff --git a/Source/Contracts/Specifications/Specification.cs b/Source/Contracts/Specifications/Specification.cs index e5a0f21..c8f65e5 100644 --- a/Source/Contracts/Specifications/Specification.cs +++ b/Source/Contracts/Specifications/Specification.cs @@ -18,4 +18,16 @@ public record Specification( IReadOnlyList Given, SpecificationCommand? When, IReadOnlyList ThenEvents, - IReadOnlyList ThenErrors); + IReadOnlyList ThenErrors) +{ + /// + /// Gets the read model states that establish the Given precondition. + /// + public IReadOnlyList GivenReadModels { get; init; } = []; + + /// + /// Gets the read model states expected in the Then step — what + /// verifies. + /// + public IReadOnlyList ThenReadModels { get; init; } = []; +} diff --git a/Source/Contracts/Specifications/SpecificationReadModel.cs b/Source/Contracts/Specifications/SpecificationReadModel.cs new file mode 100644 index 0000000..4f18d93 --- /dev/null +++ b/Source/Contracts/Specifications/SpecificationReadModel.cs @@ -0,0 +1,17 @@ +// 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.Contracts.Specifications; + +/// +/// Represents a read model state in a specification — used for both the Given precondition and the Then expectation. +/// +/// The unique identifier of the specification item. +/// The name of the read model. +/// The identifier of the slice read model this item refers to. +/// The JSON object of property values for the read model. +public record SpecificationReadModel( + Guid Id, + string Name, + Guid ReadModelId, + string Values); diff --git a/Source/Contracts/for_EventModelLoader/given/a_compiled_model_using_2x_constructs.cs b/Source/Contracts/for_EventModelLoader/given/a_compiled_model_using_2x_constructs.cs new file mode 100644 index 0000000..9a76559 --- /dev/null +++ b/Source/Contracts/for_EventModelLoader/given/a_compiled_model_using_2x_constructs.cs @@ -0,0 +1,97 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; + +namespace Cratis.Stage.Contracts.for_EventModelLoader.given; + +/// +/// A document exercising the constructs the contract gained for Screenplay 2.x — concept compliance attributes, +/// command authorization, require, reads, produces … for, event tags and specification read +/// model steps — compiled the same way the engine compiles one at startup. +/// +public class a_compiled_model_using_2x_constructs : Specification +{ + protected const string Source = + """ + concept InvoiceId : Uuid + concept ContractId : Uuid + concept EmailAddress : String @pii + pii reason "Billing contact - lawful basis: contract performance" + + concept BankAccount : String @pii @sensitive + sensitive reason "A leaked account number enables direct financial harm" + + policy IsAccountant + require role "Accountant" + + policy IsFinance + require role "Finance" + + policy OwnsInvoice + require authenticated + + module Invoicing + + feature Billing + + slice StateChange ActivateInvoice + + readmodel InvoiceScope + isStarted Bool + phase String + + command ActivateInvoice + invoiceId InvoiceId identifier + contractId ContractId + email EmailAddress + account BankAccount + + reads InvoiceScope by invoiceId + + authorize IsAccountant or IsFinance and OwnsInvoice + + validate + require InvoiceScope.isStarted == false + message "Already started" + require InvoiceScope.phase == "Contract" + + produces InvoiceActivated + invoiceId = invoiceId + email = email + + produces ContractPolicyActivated + for contractId + contractId = contractId + + event InvoiceActivated + invoiceId InvoiceId + email EmailAddress + tag invoicing + tag "billing" + + event ContractPolicyActivated + contractId ContractId + + specification ActivatesAnInvoice + given readmodel InvoiceScope + isStarted = false + phase = "Contract" + when ActivateInvoice + invoiceId = "9c858901-8a57-4791-81fe-4c455b099bc9" + then InvoiceActivated + invoiceId = "9c858901-8a57-4791-81fe-4c455b099bc9" + then readmodel InvoiceScope + isStarted = true + """; + + protected EventModel _model = null!; + + protected Slice _slice = null!; + + void Establish() + { + _model = EventModelLoader.LoadFromSource(Source); + _slice = _model.Collections[0].Modules[0].Features[0].Slices.Single(slice => slice.Name == "ActivateInvoice"); + } +} diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_authorization.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_authorization.cs new file mode 100644 index 0000000..3adfdc3 --- /dev/null +++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_authorization.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Contracts.Commands; +using Xunit; + +namespace Cratis.Stage.Contracts.for_EventModelLoader; + +public class when_inspecting_the_command_authorization : given.a_compiled_model_using_2x_constructs +{ + AuthorizationRequirement _authorization = null!; + + void Because() => _authorization = _slice.Command!.Authorization!; + + [Fact] void should_carry_the_requirement() => _authorization.ShouldNotBeNull(); + + // 'IsAccountant or IsFinance and OwnsInvoice' — 'and' binds tighter, so the root is the 'or'. A flat list of + // the three names could not tell this apart from '(IsAccountant or IsFinance) and OwnsInvoice'. + [Fact] void should_root_the_tree_at_the_or() => + ((LogicalRequirement)_authorization).Operator.ShouldEqual(ProducedEventLogicalOperator.Or); + [Fact] void should_put_the_first_policy_on_the_left() => + ((PolicyReference)((LogicalRequirement)_authorization).Left).Policy.ShouldEqual("IsAccountant"); + [Fact] void should_group_the_remaining_two_under_an_and() => + ((LogicalRequirement)((LogicalRequirement)_authorization).Right).Operator.ShouldEqual(ProducedEventLogicalOperator.And); + [Fact] void should_name_every_policy_in_declaration_order() => + _authorization.Policies().ShouldContainOnly(["IsAccountant", "IsFinance", "OwnsInvoice"]); +} diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_requirements.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_requirements.cs new file mode 100644 index 0000000..5c89aa3 --- /dev/null +++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_requirements.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Contracts.Commands; +using Xunit; + +namespace Cratis.Stage.Contracts.for_EventModelLoader; + +public class when_inspecting_the_command_requirements : given.a_compiled_model_using_2x_constructs +{ + IReadOnlyList _requirements = []; + + void Because() => _requirements = _slice.Command!.Requirements; + + [Fact] void should_carry_every_declared_requirement() => _requirements.Count.ShouldEqual(2); + [Fact] void should_carry_the_declared_message() => _requirements[0].Message.ShouldEqual("Already started"); + [Fact] void should_leave_an_undeclared_message_unset() => _requirements[1].Message.ShouldBeNull(); + + // The same condition tree a 'produces when' guard carries — one condition grammar in the language, one here. + [Fact] void should_carry_the_condition_as_a_comparison() => + _requirements[0].Condition.ShouldBeOfExactType(); + [Fact] void should_name_the_state_the_requirement_reads() => + ((ProducedEventComparison)_requirements[0].Condition).Property.ShouldEqual("InvoiceScope.isStarted"); + [Fact] void should_carry_the_comparison_operator() => + ((ProducedEventComparison)_requirements[0].Condition).Operator.ShouldEqual(ProducedEventComparisonOperator.Equal); + [Fact] void should_carry_the_compared_value() => + ((ProducedEventComparison)_requirements[0].Condition).Value.ShouldEqual("false"); +} diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_concept_attributes.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_concept_attributes.cs new file mode 100644 index 0000000..42cd31f --- /dev/null +++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_concept_attributes.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Specifications; +using Cratis.Stage.Contracts.Screenplay; +using Xunit; + +namespace Cratis.Stage.Contracts.for_EventModelLoader; + +/// +/// A concept resolves to its underlying primitive in a synthesized schema, which used to erase the compliance +/// markers declared on it entirely — a @pii property arrived indistinguishable from any other string. +/// +public class when_inspecting_the_concept_attributes : given.a_compiled_model_using_2x_constructs +{ + JsonElement _email; + JsonElement _account; + JsonElement _plain; + + void Because() + { + using var schema = JsonDocument.Parse(_slice.Command!.Schema); + var properties = schema.RootElement.GetProperty("properties").Clone(); + _email = properties.GetProperty("email"); + _account = properties.GetProperty("account"); + _plain = properties.GetProperty("invoiceId"); + } + + [Fact] void should_still_resolve_the_concept_to_its_primitive() => + _email.GetProperty("type").GetString().ShouldEqual("string"); + [Fact] void should_name_the_concept_the_property_is_typed_as() => + _email.GetProperty(SchemaSynthesizer.ConceptKeyword).GetString().ShouldEqual("EmailAddress"); + [Fact] void should_carry_the_pii_marker() => + Attributes(_email).EnumerateObject().Select(attribute => attribute.Name).ShouldContain("pii"); + [Fact] void should_carry_the_declared_reason() => + Attributes(_email).GetProperty("pii").GetString().ShouldEqual("Billing contact - lawful basis: contract performance"); + [Fact] void should_carry_every_marker_the_concept_declares() => + Attributes(_account).EnumerateObject().Select(attribute => attribute.Name).ShouldContainOnly(["pii", "sensitive"]); + [Fact] void should_leave_a_marker_with_no_reason_empty() => + Attributes(_account).GetProperty("pii").GetString().ShouldEqual(string.Empty); + [Fact] void should_not_annotate_a_concept_that_declares_none() => + _plain.TryGetProperty(SchemaSynthesizer.ConceptAttributesKeyword, out _).ShouldBeFalse(); + + static JsonElement Attributes(JsonElement property) => property.GetProperty(SchemaSynthesizer.ConceptAttributesKeyword); +} diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_event_tags.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_event_tags.cs new file mode 100644 index 0000000..b2811d5 --- /dev/null +++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_event_tags.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Xunit; + +namespace Cratis.Stage.Contracts.for_EventModelLoader; + +public class when_inspecting_the_event_tags : given.a_compiled_model_using_2x_constructs +{ + IReadOnlyList _tagged = []; + IReadOnlyList _untagged = []; + + void Because() + { + _tagged = _slice.Events.Single(@event => @event.Name == "InvoiceActivated").Tags; + _untagged = _slice.Events.Single(@event => @event.Name == "ContractPolicyActivated").Tags; + } + + // A bare identifier and a quoted literal are both constant tags and both survive. + [Fact] void should_carry_every_declared_tag() => _tagged.ShouldContainOnly(["invoicing", "billing"]); + [Fact] void should_leave_an_untagged_event_with_no_tags() => _untagged.ShouldBeEmpty(); +} diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_specification_read_models.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_specification_read_models.cs new file mode 100644 index 0000000..c69721e --- /dev/null +++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_specification_read_models.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Specifications; +using Cratis.Stage.Contracts.Specifications; +using Xunit; + +using GivenWhenThen = Cratis.Stage.Contracts.Specifications.Specification; + +namespace Cratis.Stage.Contracts.for_EventModelLoader; + +public class when_inspecting_the_specification_read_models : given.a_compiled_model_using_2x_constructs +{ + GivenWhenThen _specification = null!; + + void Because() => _specification = _slice.Specifications.Single(specification => specification.Name == "ActivatesAnInvoice"); + + [Fact] void should_carry_the_given_read_model() => _specification.GivenReadModels.Count.ShouldEqual(1); + [Fact] void should_carry_the_then_read_model() => _specification.ThenReadModels.Count.ShouldEqual(1); + [Fact] void should_name_the_given_read_model() => _specification.GivenReadModels[0].Name.ShouldEqual("InvoiceScope"); + [Fact] void should_carry_the_given_values() => Value(_specification.GivenReadModels[0], "phase").ShouldEqual("Contract"); + [Fact] void should_carry_the_expected_values() => Value(_specification.ThenReadModels[0], "isStarted").ShouldEqual("True"); + + // Both steps name the same read model, so both resolve to the same identifier — the way the event and command + // steps already resolve what they refer to. + [Fact] void should_resolve_both_steps_to_the_same_read_model() => + _specification.ThenReadModels[0].ReadModelId.ShouldEqual(_specification.GivenReadModels[0].ReadModelId); + [Fact] void should_give_each_step_its_own_identity() => + _specification.ThenReadModels[0].Id.ShouldNotEqual(_specification.GivenReadModels[0].Id); + + static string Value(SpecificationReadModel readModel, string property) + { + using var values = JsonDocument.Parse(readModel.Values); + + return values.RootElement.GetProperty(property).ToString(); + } +} diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_what_the_command_reads.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_what_the_command_reads.cs new file mode 100644 index 0000000..18c37f2 --- /dev/null +++ b/Source/Contracts/for_EventModelLoader/when_inspecting_what_the_command_reads.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Contracts.Commands; +using Xunit; + +namespace Cratis.Stage.Contracts.for_EventModelLoader; + +public class when_inspecting_what_the_command_reads : given.a_compiled_model_using_2x_constructs +{ + IReadOnlyList _reads = []; + + void Because() => _reads = _slice.Command!.Reads; + + [Fact] void should_carry_the_declared_read() => _reads.Count.ShouldEqual(1); + [Fact] void should_name_the_read_model() => _reads[0].ReadModel.ShouldEqual("InvoiceScope"); + [Fact] void should_carry_the_property_it_is_looked_up_by() => _reads[0].By.ShouldEqual("invoiceId"); +} diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_where_a_produced_event_lands.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_where_a_produced_event_lands.cs new file mode 100644 index 0000000..f45be08 --- /dev/null +++ b/Source/Contracts/for_EventModelLoader/when_inspecting_where_a_produced_event_lands.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Contracts.Commands; +using Xunit; + +namespace Cratis.Stage.Contracts.for_EventModelLoader; + +public class when_inspecting_where_a_produced_event_lands : given.a_compiled_model_using_2x_constructs +{ + ProducedEvent _own = null!; + ProducedEvent _elsewhere = null!; + + void Because() + { + var produces = _slice.Command!.Produces; + _own = produces.Single(produced => produced.Event == "InvoiceActivated"); + _elsewhere = produces.Single(produced => produced.Event == "ContractPolicyActivated"); + } + + // An event with no 'for' lands on the command's own event source — the common case, left unstated. + [Fact] void should_leave_the_event_source_unset_when_none_is_declared() => _own.For.ShouldBeNull(); + [Fact] void should_carry_the_declared_event_source() => _elsewhere.For.ShouldNotBeNull(); + [Fact] void should_resolve_the_event_source_from_a_command_property() => + _elsewhere.For!.Kind.ShouldEqual(ProducedValueKind.CommandProperty); + [Fact] void should_name_the_property_the_event_source_comes_from() => + _elsewhere.For!.Expression.ShouldEqual("contractId"); +}