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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -660,3 +660,4 @@ sketch
BenchmarkDotNet.Artifacts/

.tools/purview-build/**
src/tests/cross-platform/
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<RoslynCompilerVersion>5.9.0</RoslynCompilerVersion>
<RoslynAnalyzersVersion>5.9.0</RoslynAnalyzersVersion>
<TUnitVersion>1.68.4</TUnitVersion>
<PurviewSourceGenFramework>1.0.0-prerelease.42</PurviewSourceGenFramework>
<PurviewSourceGenFramework>1.0.0-prerelease.44</PurviewSourceGenFramework>
<DotnetRuntimeVersion>10.0.12</DotnetRuntimeVersion>
</PropertyGroup>
<ItemGroup>
Expand Down
32 changes: 25 additions & 7 deletions docs/wiki/AspNetCore-Integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,32 @@ public static partial class ConcurrentErrorType
Code: "aggregate_save_failed",
Description: "The aggregate could not be saved.",
HttpStatus: StatusCodes.Status409Conflict,
MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save")
{
Parameters = ["AggregateId", "AggregateType"]
};
MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save",
Parameters:
[
new("AggregateId", typeof(string)),
new("AggregateType", typeof(string))
]);
}

// Register once at startup:
ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed);
```

Parameters can also be declared with the `ErrorType.Param<T>("Name")` helper instead of an explicit
`typeof(...)`:

```csharp
Parameters:
[
new("AggregateId", typeof(string)),
ErrorType.Param<string>("AggregateType")
]
```

Both the bundled `ZODSASP001` analyzer and the source generator treat `ErrorType.Param<T>` entries
exactly like any other declared parameter, so placeholder checking and helper generation are unchanged.

When an error carries that code, the response status, title, and message are derived automatically:

```csharp
Expand All @@ -132,10 +148,11 @@ throw new ZodException([
Marking the field with `[ErrorType]` and the containing class `partial` lets the bundled source
generator turn each field into strongly typed static helpers. For the field above it generates
`ConcurrentErrorType.CreateSaveFailed(...)` and `ConcurrentErrorType.ThrowSaveFailed(...)` with one
named parameter per entry in `Parameters`:
strongly typed parameter per entry in `Parameters` (the declared `typeof(...)` type, or the
`ErrorType.Param<T>` generic type argument):

```csharp
// Returns a ValidationError with the code, the formatted message, and the named parameters.
// Returns a ValidationError with the code, the formatted message, and the typed parameters.
var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice");

// Throws a ZodException carrying the same ValidationError.
Expand All @@ -152,7 +169,8 @@ var error = ConcurrentErrorType.CreateSaveFailed(
inclusive: true);
```

The generated `Create` builds the `parameters` dictionary and sets the message from
The generated `Create` builds a typed `ErrorTypeParameters` instance (validated against the declared
parameter types and exposed through `ValidationError.Parameters`) and sets the message from
`ErrorType.FormatMessage`, so `error.Message` already reads
`Aggregate 'agg-123' (of type Invoice) failed to save` and mapping through the registry produces the
`409 Conflict` response described below. The analyzers `ZODSASP001`/`ZODSASP002`/`ZODSASP003`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zodsharp",
"version": "2.0.0-prerelease.11",
"version": "2.0.0-prerelease.12",
"private": true,
"license": "MIT",
"author": {
Expand Down
56 changes: 45 additions & 11 deletions src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ static void AnalyzeObjectCreation(OperationAnalysisContext context, INamedTypeSy
/// </summary>
static HashSet<string>? GetDeclaredParameters(IObjectCreationOperation creation)
{
// `Parameters` may be supplied as a constructor argument (the canonical form) or assigned
// in an object initializer.
foreach (var argument in creation.Arguments)
{
if (argument.ArgumentKind != ArgumentKind.Explicit)
continue;

if (argument.Parameter?.Name == "Parameters")
return ExtractStrings(argument.Value);
}

if (creation.Initializer is null)
return [];

Expand Down Expand Up @@ -143,10 +154,7 @@ initializer is ISimpleAssignmentOperation
{
HashSet<string> names = new(StringComparer.Ordinal);
foreach (var element in array.Initializer.ElementValues)
{
if (UnwrapConversion(element).ConstantValue.Value is string value)
names.Add(value);
}
AddParameterName(element, names);

return names;
}
Expand All @@ -155,13 +163,7 @@ initializer is ISimpleAssignmentOperation
{
HashSet<string> names = new(StringComparer.Ordinal);
foreach (var element in collection.Elements)
{
if (element is ISpreadOperation)
continue;

if (UnwrapConversion(element).ConstantValue.Value is string value)
names.Add(value);
}
AddParameterName(element, names);

return names;
}
Expand All @@ -171,6 +173,38 @@ initializer is ISimpleAssignmentOperation
}
}

static void AddParameterName(IOperation element, HashSet<string> names)
{
if (element is ISpreadOperation)
return;

// Target-typed `new(...)` collection elements arrive wrapped in an implicit conversion.
element = UnwrapConversion(element);

// A declared parameter is `new ErrorTypeParameter("OrderId", typeof(string))`; the name is
// the first constructor argument.
if (
element is IObjectCreationOperation creation
&& creation.Arguments.Length > 0
&& creation.Arguments[0] is { Parameter.Name: "Name" } nameArgument
&& UnwrapConversion(nameArgument.Value).ConstantValue.Value is string name
)
names.Add(name);
else if (
element is IInvocationOperation { TargetMethod.Name: "Param" } invocation
&& IsErrorTypeParam(invocation.TargetMethod)
&& invocation.Arguments.Length > 0
&& UnwrapConversion(invocation.Arguments[0].Value).ConstantValue.Value is string paramName
)
names.Add(paramName);
else if (element.ConstantValue.Value is string legacy)
names.Add(legacy);
}

static bool IsErrorTypeParam(IMethodSymbol method) =>
method.ContainingType?.Name == "ErrorType"
&& method.ContainingType?.ContainingNamespace?.ToDisplayString() == "ZodSharp.AspNetCore";

static IOperation UnwrapConversion(IOperation operation) =>
operation is IConversionOperation { IsImplicit: true } conversion
? UnwrapConversion(conversion.Operand)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Collections.Immutable;
using ZodSharp.AspNetCore.SourceGenerators.Helpers;
using ZodSharp.AspNetCore.SourceGenerators.Models;

namespace ZodSharp.AspNetCore.SourceGenerators;
Expand All @@ -9,7 +8,7 @@ partial class ErrorTypeGenerator
static void Emit(CodeWriter writer, ErrorTypeFieldModel model)
{
var parameterNames = BuildParameterNames(model.Parameters);
var parameters = BuildMethodParameters(parameterNames, writer);
var parameters = BuildMethodParameters(model, parameterNames, writer);

writer
.AutoGeneratedHeader()
Expand All @@ -22,7 +21,7 @@ static void Emit(CodeWriter writer, ErrorTypeFieldModel model)
{
using (
writer.ClassScope(
new TypeDeclarationOptions(new TypeIdentity(model.ClassName, model.Namespace), model.Accessibility)
new(new(model.ClassName, model.Namespace), model.Accessibility)
{
IsPartial = true,
IsStatic = model.IsStatic,
Expand All @@ -46,14 +45,14 @@ static void EmitCreateMethod(
ImmutableArray<ParameterDeclarationOptions> parameters
)
{
var dictionaryName = BuildDictionaryName(parameterNames);
var parametersName = BuildDictionaryName(parameterNames);

writer
.XmlSummary("Creates a ValidationError for this error type.")
.Method(
new MethodDeclarationOptions(
new(
"Create" + PascalCase(model.FieldName),
TypeLibrary.Core.ValidationError.AsTypeReference(),
TypeLibrary.ZodSharp.Core.ValidationError.AsTypeReference(),
TypeDeclarationAccessibility.Public
)
{
Expand All @@ -62,21 +61,23 @@ ImmutableArray<ParameterDeclarationOptions> parameters
},
method =>
{
method.Assignment("ErrorType", "errorType", model.FieldName);
method.Line();

method.OpenDelimitedBlock(
$"global::System.Collections.Generic.Dictionary<string, object?> {dictionaryName} = new({model.Parameters.Count})",
$"var {parametersName} = global::ZodSharp.Core.ErrorTypeParameters.Create(errorType.Parameters, new global::System.Collections.Generic.Dictionary<string, object?>",
"{",
"};",
"});",
body =>
{
for (var i = 0; i < model.Parameters.Count; i++)
body.Line($"[{model.Parameters[i].Surround()}] = {parameterNames[i]},");
body.Line($"[{model.Parameters[i].Name.Surround()}] = {parameterNames[i]},");
}
);

method.Line();
method.Assignment("ErrorType", "errorType", model.FieldName);
method.Return(
$"ValidationError.Create(errorType.Code, errorType.FormatMessage({dictionaryName}), path is null ? [] : [.. path], {dictionaryName}, origin, minimum, maximum, inclusive)"
$"ValidationError.Create(errorType.Code, errorType.FormatMessage({parametersName}), path is null ? [] : [.. path], {parametersName}, origin, minimum, maximum, inclusive)"
);
}
);
Expand All @@ -92,10 +93,10 @@ ImmutableArray<ParameterDeclarationOptions> parameters
writer
.XmlSummary("Throws a ZodException for this error type.")
.Method(
new MethodDeclarationOptions("Throw" + PascalCase(model.FieldName), TypeDeclarationAccessibility.Public)
new("Throw" + PascalCase(model.FieldName), TypeDeclarationAccessibility.Public)
{
IsStatic = true,
Attributes = [new AttributeDeclarationOptions(TypeLibrary.DoesNotReturnAttribute)],
Attributes = [new(TypeLibrary.System.Diagnostics.CodeAnalysis.DoesNotReturnAttribute)],
Parameters = parameters,
},
method =>
Expand All @@ -115,25 +116,35 @@ ImmutableArray<ParameterDeclarationOptions> parameters
}

static ImmutableArray<ParameterDeclarationOptions> BuildMethodParameters(
ErrorTypeFieldModel model,
ImmutableArray<string> parameterNames,
CodeWriter writer
)
{
var objectType = PurviewTypeLibrary.System.Object.AsTypeReference().Nullable(writer);
var stringArrayType = PurviewTypeLibrary.System.String.AsTypeReference().MakeArray().Nullable(writer);
var stringType = PurviewTypeLibrary.System.String.AsTypeReference().Nullable(writer);
var intType = PurviewTypeLibrary.System.Int32.AsTypeReference().Nullable(writer);
var boolType = PurviewTypeLibrary.System.Boolean.AsTypeReference().Nullable(writer);

var builder = ImmutableArray.CreateBuilder<ParameterDeclarationOptions>(parameterNames.Length + 5);
foreach (var name in parameterNames)
builder.Add(new ParameterDeclarationOptions(name, objectType));
for (var i = 0; i < model.Parameters.Count; i++)
{
var parameter = model.Parameters[i];
var reference = parameter.Type.AsTypeReference();
if (parameter.IsNullable)
reference = reference.Nullable(writer);

if (parameter.ArrayRank > 0)
reference = reference.MakeArray(parameter.ArrayRank);

builder.Add(new ParameterDeclarationOptions(parameterNames[i], reference));
}

builder.Add(new ParameterDeclarationOptions("path", stringArrayType) { DefaultValue = "null" });
builder.Add(new ParameterDeclarationOptions("origin", stringType) { DefaultValue = "null" });
builder.Add(new ParameterDeclarationOptions("minimum", intType) { DefaultValue = "null" });
builder.Add(new ParameterDeclarationOptions("maximum", intType) { DefaultValue = "null" });
builder.Add(new ParameterDeclarationOptions("inclusive", boolType) { DefaultValue = "null" });
builder.Add(new("path", stringArrayType) { DefaultValue = "null" });
builder.Add(new("origin", stringType) { DefaultValue = "null" });
builder.Add(new("minimum", intType) { DefaultValue = "null" });
builder.Add(new("maximum", intType) { DefaultValue = "null" });
builder.Add(new("inclusive", boolType) { DefaultValue = "null" });

return builder.ToImmutable();
}
Expand All @@ -142,14 +153,14 @@ CodeWriter writer
/// Camel-cases the declared parameter names while avoiding collisions with the reserved metadata
/// parameter names (<c>path</c>, <c>origin</c>, <c>minimum</c>, <c>maximum</c>, <c>inclusive</c>).
/// </summary>
static ImmutableArray<string> BuildParameterNames(EquatableArray<string> declaredNames)
static ImmutableArray<string> BuildParameterNames(EquatableArray<ErrorTypeParameterModel> declaredParameters)
{
HashSet<string> used = ["path", "origin", "minimum", "maximum", "inclusive", "parameters"];

var builder = ImmutableArray.CreateBuilder<string>(declaredNames.Count);
foreach (var declaredName in declaredNames)
var builder = ImmutableArray.CreateBuilder<string>(declaredParameters.Count);
foreach (var parameter in declaredParameters)
{
var candidate = CamelCase(declaredName);
var candidate = CamelCase(parameter.Name);
while (!used.Add(candidate))
candidate += "Value";

Expand Down
12 changes: 8 additions & 4 deletions src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)

var errorTypeFields = IncrementalPipeline.ForAttributeWithMetadataName(
context,
TypeLibrary.AspNetCore.ErrorTypeAttribute,
TypeLibrary.ZodSharp.AspNetCore.ErrorTypeAttribute,
predicate: static (node, _) => node is VariableDeclaratorSyntax,
transform: static (attributeContext, cancellationToken) =>
ErrorTypeGeneratorLibrary.CreateModel(attributeContext, cancellationToken),
Expand All @@ -56,7 +56,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
if (model.Context.Settings.IsSourceGeneratorDisabled)
return;

if (!model.Context.Capabilities.HasErrorType)
if (
!model.Context.Capabilities.HasErrorType
|| !model.Context.Capabilities.HasErrorTypeParameter
|| !model.Context.Capabilities.HasErrorTypeParameters
)
return;

foreach (var result in model.Fields)
Expand Down Expand Up @@ -94,15 +98,15 @@ static SourceText ErrorTypeAttributeSource()
{
CodeWriter writer = new(GenerationSettings.Create<ErrorTypeGenerator>());

writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.AspNetCore.ErrorTypeAttribute);
writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.ZodSharp.AspNetCore.ErrorTypeAttribute);

writer
.XmlSummary(
"Attribute to mark a static ErrorType field for strongly-typed helper generation.",
"When applied to a static ErrorType field in a partial class, Create/Throw helpers are generated at compile time."
)
.AttributeClass(
new TypeDeclarationOptions(TypeLibrary.AspNetCore.ErrorTypeAttribute),
new TypeDeclarationOptions(TypeLibrary.ZodSharp.AspNetCore.ErrorTypeAttribute),
AttributeTargets.Field,
static _ => { },
inherited: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ static class DiagnosticLibrary
public static readonly DiagnosticDescriptor InvalidParameters = new(
id: "ZODSASP101",
title: "Unable to extract ErrorType parameters",
messageFormat: "The Parameters of ErrorType field '{0}' in '{1}' could not be extracted; only string collection literals are supported",
messageFormat: "The Parameters of ErrorType field '{0}' in '{1}' could not be extracted; only ErrorTypeParameter collection literals with a constant name and typeof, or ErrorType.Param<T> invocations, are supported",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true
Expand Down
Loading
Loading