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
2 changes: 1 addition & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,6 @@ jobs:
uses: lycheeverse/lychee-action@v2
with:
fail: true
args: --remap '${{ github.event.repository.default_branch }} ${{ github.head_ref || github.ref_name }}' --include-fragments .
args: --remap '${{ github.event.repository.default_branch }} ${{ github.head_ref || github.ref_name }}' .
jobSummary: true
format: markdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using FluentAssertions;
using Microsoft.CodeAnalysis;

namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests.Rules;

public class ParameterlessConstructorRuleTests
{
[Fact]
public void TestParameterlessConstructor()
{
var sourceCode = """
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using CSharpFunctionalExtensions.HttpResults;

public class DocumentCreationError
{
public required string DocumentId { get; init; }
}

// Not parameterless --> error
public class DocumentCreationErrorMapper : IResultErrorMapper<DocumentCreationError, Conflict<string>>
{
public DocumentCreationErrorMapper(string foo) { }

public Conflict<string> Map(DocumentCreationError error) => TypedResults.Conflict(error.DocumentId);
}

// Explicit parameterless --> No error
public class DocumentCreationErrorMapper2 : IResultErrorMapper<string, Conflict<string>>
{
public DocumentCreationErrorMapper2() { }

public Conflict<string> Map(string error) => TypedResults.Conflict(error.DocumentId);
}

// Explicit parameterless & with parameters --> No error
public class DocumentCreationErrorMapper3 : IResultErrorMapper<int, Conflict<string>>
{
public DocumentCreationErrorMapper3() { }
public DocumentCreationErrorMapper3(string foo) { }

public Conflict<string> Map(int error) => TypedResults.Conflict(error.DocumentId);
}
""";

var diagnostics = ResultExtensionsGeneratorTestHelper.RunGenerator(sourceCode).ToList();

diagnostics.Count.Should().Be(1);

var diagnostic = diagnostics[0];

diagnostic.Id.Should().Be("CFEHTTPR004");
diagnostic.Severity.Should().Be(DiagnosticSeverity.Error);
diagnostic
.GetMessage()
.Should()
.Be("Class 'DocumentCreationErrorMapper' does not have a parameterless constructor");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@
| Rule ID | Category | Severity | Notes |
|-------------|----------|----------|----------------------------------------|
| CFEHTTPR003 | Mapping | Error | Empty Map getter in IResultErrorMapper |

## Release v1.0.1

### New Rules

| Rule ID | Category | Severity | Notes |
|-------------|----------|----------|---------------------------------------------------------|
| CFEHTTPR004 | Mapping | Error | Missing parameterless constructor in IResultErrorMapper |
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
public static Results<AcceptedAtRoute<T>, {{httpResultType}}> ToAcceptedAtRouteHttpResult<T>(this Result<T,{{resultErrorType}}> result, string? routeName = null, Func<T, object>? routeValues = null)
{
if (result.IsSuccess) return TypedResults.AcceptedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value));
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
public static Results<Accepted<T>, {{httpResultType}}> ToAcceptedHttpResult<T>(this Result<T,{{resultErrorType}}> result, Func<T, Uri> uri)
{
if (result.IsSuccess) return TypedResults.Accepted(uri(result.Value), result.Value);
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
{
if (result.IsSuccess) return TypedResults.Content(result.Value, contentType, contentEncoding, statusCode);

return new {{mapperClassName}}().Map(result.Error);
return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
public static Results<CreatedAtRoute<T>, {{httpResultType}}> ToCreatedAtRouteHttpResult<T>(this Result<T,{{resultErrorType}}> result, string? routeName = null, Func<T, object>? routeValues = null)
{
if (result.IsSuccess) return TypedResults.CreatedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value));
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
? TypedResults.Created(string.Empty, result.Value)
: TypedResults.Created(uri.Invoke(result.Value), result.Value);

return new {{mapperClassName}}().Map(result.Error);
return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
bool enableRangeProcessing = false)
{
if (result.IsSuccess) return TypedResults.File(result.Value, contentType, fileDownloadName, enableRangeProcessing, lastModified, entityTag);
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
bool enableRangeProcessing = false) where T : Stream
{
if (result.IsSuccess) return TypedResults.Stream(result.Value, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing);
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
public static Results<JsonHttpResult<T>, {{httpResultType}}> ToJsonHttpResult<T>(this Result<T,{{resultErrorType}}> result, int successStatusCode = 200)
{
if (result.IsSuccess) return TypedResults.Json(result.Value, statusCode: successStatusCode);
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
public static Results<NoContent, {{httpResultType}}> ToNoContentHttpResult<T>(this Result<T,{{resultErrorType}}> result)
{
if (result.IsSuccess) return TypedResults.NoContent();
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
public static Results<Ok<T>, {{httpResultType}}> ToOkHttpResult<T>(this Result<T,{{resultErrorType}}> result)
{
if (result.IsSuccess) return TypedResults.Ok(result.Value);
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
public static Results<StatusCodeHttpResult, {{httpResultType}}> ToStatusCodeHttpResult<T>(this Result<T,{{resultErrorType}}> result, int successStatusCode = 204)
{
if (result.IsSuccess) return TypedResults.StatusCode(successStatusCode);
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
if (!ResultExtensionsGeneratorValidator.CheckRules(mapperClasses, context))
return;

var (fileName, sourceText) = CreateErrorMapperInstancesClass(mapperClasses, requiredNamespaces);
context.AddSource(fileName, SourceText.From(sourceText, Encoding.UTF8));

var classBuilders = new List<ClassBuilder>
{
new ResultExtensionsClassBuilder(requiredNamespaces, mapperClasses),
Expand All @@ -76,6 +79,40 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
);
}

/// <summary>
/// Creates a class to get singleton instances of the various <see cref="IResultErrorMapper{T}" />
/// </summary>
private static (string FileName, string SourceText) CreateErrorMapperInstancesClass(
List<ClassDeclarationSyntax> mapperClasses,
HashSet<string> requiredNamespaces
)
{
var sourceBuilder = new StringBuilder();

sourceBuilder.AppendLine("// <auto-generated/>");
sourceBuilder.AppendLine();
sourceBuilder.AppendLine("#nullable enable");
sourceBuilder.AppendLine();
requiredNamespaces
.Where(@namespace => !@namespace.StartsWith("global"))
.Distinct()
.Select(@namespace => $"using {@namespace};")
.ToList()
.ForEach(@using => sourceBuilder.AppendLine(@using));
sourceBuilder.AppendLine();

sourceBuilder.AppendLine("public static class ErrorMapperInstances {");

foreach (var mapperName in mapperClasses)
sourceBuilder.AppendLine(
$" public static {mapperName.Identifier.Text} {mapperName.Identifier.Text} {{ get; }} = new();"
);

sourceBuilder.AppendLine("}");

return ("ErrorMapperInstances.g.cs", sourceBuilder.ToString());
}

/// <summary>
/// Checks if a class implements the <see cref="IResultErrorMapper{T}" /> interface.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace CSharpFunctionalExtensions.HttpResults.Generators;
/// </summary>
internal static class ResultExtensionsGeneratorValidator
{
private static readonly List<IRule> Rules = [new DuplicateMapperRule()];
private static readonly List<IRule> Rules = [new DuplicateMapperRule(), new ParameterlessConstructorRule()];

/// <summary>
/// Validates the rules for the generator.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace CSharpFunctionalExtensions.HttpResults.Generators.Rules;

internal class ParameterlessConstructorRule : IRule
{
public DiagnosticDescriptor RuleDescriptor { get; } =
new(
"CFEHTTPR004",
"Missing parameterless constructor in IResultErrorMapper",
"Class '{0}' does not have a parameterless constructor",
"Mapping",
DiagnosticSeverity.Error,
true,
customTags: ["CompilationEnd"]
);

public IEnumerable<Diagnostic> Check(List<ClassDeclarationSyntax> mapperClasses)
{
foreach (var mapperClass in mapperClasses)
{
if (!HasParameterlessConstructor(mapperClass))
yield return Diagnostic.Create(
RuleDescriptor,
mapperClass.Identifier.GetLocation(),
mapperClass.Identifier.Text
);
}
}

private static bool HasParameterlessConstructor(ClassDeclarationSyntax classDeclaration)
{
var hasExplicitParameterless = classDeclaration
.Members.OfType<ConstructorDeclarationSyntax>()
.Any(c => c.ParameterList.Parameters.Count == 0);

if (hasExplicitParameterless)
return true;

var hasAnyConstructors = classDeclaration.Members.OfType<ConstructorDeclarationSyntax>().Any();

return !hasAnyConstructors;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
public static Results<NoContent, {{httpResultType}}> ToNoContentHttpResult(this UnitResult<{{resultErrorType}}> result)
{
if (result.IsSuccess) return TypedResults.NoContent();
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public string Generate(string mapperClassName, string resultErrorType, string ht
public static Results<StatusCodeHttpResult, {{httpResultType}}> ToStatusCodeHttpResult(this UnitResult<{{resultErrorType}}> result, int successStatusCode = 204)
{
if (result.IsSuccess) return TypedResults.StatusCode(successStatusCode);
return new {{mapperClassName}}().Map(result.Error);

return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
}

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ When using `Result<T,E>` or `UnitResult<E>`, this library uses a Source Generato
};
}
```
3. Use the auto generated extension method:
3. Use the auto-generated extension method:
```csharp
app.MapGet("/users/{id}", (string id, UserRepository repo) =>
repo.Find(id) //Result<User,UserNotFoundError>
Expand All @@ -222,7 +222,7 @@ When using `Result<T,E>` or `UnitResult<E>`, this library uses a Source Generato

This library includes analyzers to help you use it correctly.

For example, they will notify you if you have multiple mappers for the same custom error type.
For example, they will notify you if you have multiple mappers for the same custom error type or if your mapper class doesn't have a parameterless constructor.

You can find a complete list of all analyzers [here](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md).

Expand Down