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
12 changes: 12 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@

<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>

<!--
Compare every packable assembly against the last released one and fail on a binary break.
The contract records are positional records, so adding a trailing optional parameter is source
compatible and binary breaking: it replaces the constructor and Deconstruct in the compiled
signature, and a consumer built against the older package fails at run time with a missing
method and no compiler error anywhere. Studio consumes these records, so a break there surfaces
as a runtime fault in the designer rather than an error here. Capability is therefore added as
an init property, and this makes the alternative an error in the pull request that causes it.
-->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>2.0.0</PackageValidationBaselineVersion>

<RunAnalyzersDuringBuild>True</RunAnalyzersDuringBuild>
<RunAnalyzersDuringLiveAnalysis>True</RunAnalyzersDuringLiveAnalysis>
<RunAnalyzers>True</RunAnalyzers>
Expand Down
54 changes: 54 additions & 0 deletions Source/Contracts/Commands/AuthorizationRequirement.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents what a command's <c>authorize</c> declaration requires of the caller — a policy, or policies combined.
/// </summary>
/// <remarks>
/// A tree rather than a flat list of policy names, deliberately. A set of names cannot distinguish
/// <c>A or B and C</c> from <c>(A or B) and C</c>, so a consumer deciding whether a caller is allowed cannot
/// answer from one — which is what <see href="https://github.com/Cratis/Screenplay/issues/68">Screenplay#68</see>
/// was about. Use <see cref="Policies"/> for the flat set when only the names are needed.
/// </remarks>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(PolicyReference), "policy")]
[JsonDerivedType(typeof(LogicalRequirement), "logical")]
public abstract record AuthorizationRequirement
{
/// <summary>
/// Gets every policy the requirement names, in the order they appear.
/// </summary>
/// <returns>The names of the policies held anywhere in the requirement.</returns>
/// <remarks>
/// 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.
/// </remarks>
public IEnumerable<string> Policies() =>
this switch
{
PolicyReference reference => [reference.Policy],
LogicalRequirement logical => logical.Left.Policies().Concat(logical.Right.Policies()),
_ => []
};
}

/// <summary>
/// Represents a reference to a single named policy the caller must satisfy.
/// </summary>
/// <param name="Policy">The name of the referenced policy.</param>
public record PolicyReference(string Policy) : AuthorizationRequirement;

/// <summary>
/// Represents two authorization requirements combined with <c>and</c> or <c>or</c>.
/// </summary>
/// <param name="Left">The left hand requirement.</param>
/// <param name="Operator">The operator combining the requirements.</param>
/// <param name="Right">The right hand requirement.</param>
public record LogicalRequirement(
AuthorizationRequirement Left,
ProducedEventLogicalOperator Operator,
AuthorizationRequirement Right) : AuthorizationRequirement;
26 changes: 25 additions & 1 deletion Source/Contracts/Commands/CommandDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ namespace Cratis.Stage.Contracts.Commands;
/// <param name="Identifier">The name of the command property whose value identifies the event source the command
/// appends to, or <see langword="null"/> when the command declares none - in which case every execution opens a
/// stream of its own. At most one property can be the identifier.</param>
/// <remarks>
/// Capability added after the record shipped is an <c>init</c> property rather than a trailing parameter of the
/// primary constructor, deliberately. A trailing parameter on a positional record is source compatible and
/// <em>binary</em> breaking: it replaces the constructor and <c>Deconstruct</c> 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.
/// </remarks>
public record CommandDefinition(
Guid Id,
string Name,
Expand All @@ -26,4 +33,21 @@ public record CommandDefinition(
IReadOnlyList<CommandPropertyRules> Rules,
string LogicDescription,
IReadOnlyList<ProducedEvent> Produces,
string? Identifier = null);
string? Identifier = null)
{
/// <summary>
/// Gets what the caller must satisfy to execute the command, or <see langword="null"/> when the command
/// declares no authorization and anyone may execute it.
/// </summary>
public AuthorizationRequirement? Authorization { get; init; }

/// <summary>
/// Gets the conditions the command as a whole must satisfy — the modeled <c>require</c> rules.
/// </summary>
public IReadOnlyList<Requirement> Requirements { get; init; } = [];

/// <summary>
/// Gets the read models the command consults before it decides — the modeled <c>reads</c> declarations.
/// </summary>
public IReadOnlyList<ReadsDefinition> Reads { get; init; } = [];
}
9 changes: 8 additions & 1 deletion Source/Contracts/Commands/ProducedEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,11 @@ public record ProducedEvent(
string Event,
ProducedEventCondition? When,
IReadOnlyList<ProducedEventProperty> Properties,
IReadOnlyList<string> Tags);
IReadOnlyList<string> Tags)
{
/// <summary>
/// Gets where the event source this event is appended to comes from — the modeled <c>for</c> clause — or
/// <see langword="null"/> when the event lands on the command's own event source.
/// </summary>
public ProducedEventSource? For { get; init; }
}
18 changes: 18 additions & 0 deletions Source/Contracts/Commands/ProducedEventSource.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents where the event source a produced event is appended to comes from — the modeled
/// <c>produces … for</c> clause.
/// </summary>
/// <param name="Kind">Where the value comes from.</param>
/// <param name="Expression">The source, interpreted according to <paramref name="Kind"/> — a command property name,
/// JSON literal text, an identity path, an environment variable name or a template.</param>
/// <remarks>
/// Carries no property name, unlike <see cref="ProducedEventProperty"/>: the value identifies the stream the event
/// is appended to rather than filling a property of its payload. A <c>produces</c> with none of these lands on the
/// command's own event source, which is the common case and stays unstated.
/// </remarks>
public record ProducedEventSource(ProducedValueKind Kind, string Expression);
12 changes: 12 additions & 0 deletions Source/Contracts/Commands/ReadsDefinition.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents a <c>reads</c> declaration on a command — the read model the command consults before it decides.
/// </summary>
/// <param name="ReadModel">The name of the read model the command reads.</param>
/// <param name="By">The command property the read model is looked up by, or <see langword="null"/> when the read
/// model is not keyed — a single view the whole application shares rather than one instance per identifier.</param>
public record ReadsDefinition(string ReadModel, string? By);
18 changes: 18 additions & 0 deletions Source/Contracts/Commands/Requirement.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents a <c>require</c> rule on a command — a condition the command as a whole must satisfy, rather than a
/// rule about one of its properties.
/// </summary>
/// <param name="Condition">The condition that must hold.</param>
/// <param name="Message">The message reported when it does not, or <see langword="null"/> when none is declared.</param>
/// <remarks>
/// Carries the same <see cref="ProducedEventCondition"/> tree a <c>produces when</c> clause carries, so <c>and</c>
/// and <c>or</c> 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
/// <see cref="ReadsDefinition">reads</see>.
/// </remarks>
public record Requirement(ProducedEventCondition Condition, string? Message);
67 changes: 63 additions & 4 deletions Source/Contracts/EventModel.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand All @@ -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
},
Expand All @@ -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
},
Expand Down Expand Up @@ -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
},
Expand All @@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion Source/Contracts/Events/EventDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ public record EventDefinition(
string SourceEventId,
string Schema,
UniqueEventTypeConstraint? UniqueEventTypeConstraint,
UniqueConstraint? UniqueConstraint);
UniqueConstraint? UniqueConstraint)
{
/// <summary>
/// Gets the tags declared on the event, applied to every occurrence of it.
/// </summary>
public IReadOnlyList<string> Tags { get; init; } = [];
}
36 changes: 36 additions & 0 deletions Source/Contracts/Screenplay/AuthorizationConverter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Converts a Screenplay <see cref="AuthorizeSyntax"/> into the Stage <see cref="AuthorizationRequirement"/> tree.
/// </summary>
/// <remarks>
/// The tree is carried whole rather than flattened to the policy names it mentions. Flattening cannot distinguish
/// <c>A or B and C</c> from <c>(A or B) and C</c>, so a consumer deciding whether a caller is allowed could not
/// answer from the result — see <see href="https://github.com/Cratis/Screenplay/issues/68">Screenplay#68</see>.
/// A consumer that only wants the names calls <see cref="AuthorizationRequirement.Policies"/>.
/// </remarks>
public static class AuthorizationConverter
{
/// <summary>
/// Converts an authorize declaration into its Stage requirement.
/// </summary>
/// <param name="authorize">The declaration, or <see langword="null"/> when the construct declares none.</param>
/// <returns>The Stage requirement, or <see langword="null"/> when nothing is required.</returns>
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
};
}
Loading
Loading