From be6d7084812621977fad782f51fc413de587d05c Mon Sep 17 00:00:00 2001 From: Jason Bock Date: Thu, 3 Mar 2022 07:45:28 -0600 Subject: [PATCH] Creating a source generator (#19) * First NuGet Release (#16) * Added NuGet publish * Updated to only deploy when release is merged * Separating publish step * Added release to build process on PR * #12 FINALLY got the source generator to work * #12 YES!! The source generator is working! * #12 got the parsing methods working * #12 added more tests * #12 added support for key attribute * #12 added some comment notes on how to use the source generator in the web app * #12 removed some code for key attributes that's no longer used * #12 added a way to do configuration * #12 made progress on the new approach that has configuration. Tests need to be reworked, but it seems promising * #12 hey, it works in the WorkingApi project! * #12 all tests pass now * #12 got all the API verbs in now * #12 changed "db.Set" to "db.{propertyName}" * #12 very minor code ordering change * Publishing release with new JSON APIs (#44) * Added video demo * adding JSON Mock API * adding instructions * Added HTTP Results calls to fix #7 * Added configuration for JSON APIs * Tagging for a preview release * Added Release Notes file * Moved release notes to top level Co-authored-by: softchris * #12 updated to IEndpointRouteBuilder * #12 got the APIs to return the "right" thing * #12 confirmed new changes work in API app * #12 NICE! Just updated config for SG, initial results are good, added a separate WorkingApi.Generators project to run the SG in its own project. * #12 updated configuration to take an Action, makes it more similiar to the Reflection approach * #12 fixed an issue with ignoring Included value * #12 started working on logging, tests WILL break right now * #12 logging is in, all tests pass now. * #12 added an attribute approach to define which DbContext classes should be mapped * #12 got all tests for the SG approach in (for now) Co-authored-by: Jeffrey T. Fritz Co-authored-by: softchris --- ...nstantAPIs.Generators.Helpers.Tests.csproj | 18 + .../InstanceAPIGeneratorConfigBuilderTests.cs | 100 +++ .../TableConfigTests.cs | 50 ++ .../ApisToGenerate.cs | 13 + ...ritz.InstantAPIs.Generators.Helpers.csproj | 9 + .../Included.cs | 7 + .../InstanceAPIGeneratorConfig.cs | 17 + .../InstanceAPIGeneratorConfigBuilder.cs | 38 + .../InstantAPIsForDbContextAttribute.cs | 12 + .../TableConfig.cs | 46 + ...SharpIncrementalSourceGeneratorVerifier.cs | 84 ++ .../DbContextAPIGeneratorTests.cs | 829 ++++++++++++++++++ .../DuplicateDefinitionDiagnosticTests.cs | 21 + .../NotADbContextDiagnosticTests.cs | 35 + .../Fritz.InstantAPIs.Generators.Tests.csproj | 27 + .../TestAssistants.cs | 70 ++ .../Builders/DbContextAPIBuilder.cs | 85 ++ .../IEndpointRouteBuilderExtensionsBuilder.cs | 231 +++++ .../Builders/TablesEnumBuilder.cs | 19 + .../DbContextAPIGenerator.cs | 111 +++ .../Diagnostics/DescriptorConstants.cs | 6 + .../DuplicateDefinitionDiagnostic.cs | 18 + .../Diagnostics/NotADbContextDiagnostic.cs | 20 + .../Fritz.InstantAPIs.Generators.csproj | 11 + .../HelpUrlBuilder.cs | 7 + .../NamespaceGatherer.cs | 41 + Fritz.InstantAPIs.Generators/TableData.cs | 14 + Fritz.InstantAPIs.sln | 30 + WorkingApi.Generators/MyContext.cs | 27 + WorkingApi.Generators/Program.cs | 53 ++ .../Properties/launchSettings.json | 29 + .../WorkingApi.Generators.csproj | 16 + .../appsettings.Development.json | 8 + WorkingApi.Generators/appsettings.json | 9 + WorkingApi/WorkingApi.csproj | 2 +- 35 files changed, 2112 insertions(+), 1 deletion(-) create mode 100644 Fritz.InstantAPIs.Generators.Helpers.Tests/Fritz.InstantAPIs.Generators.Helpers.Tests.csproj create mode 100644 Fritz.InstantAPIs.Generators.Helpers.Tests/InstanceAPIGeneratorConfigBuilderTests.cs create mode 100644 Fritz.InstantAPIs.Generators.Helpers.Tests/TableConfigTests.cs create mode 100644 Fritz.InstantAPIs.Generators.Helpers/ApisToGenerate.cs create mode 100644 Fritz.InstantAPIs.Generators.Helpers/Fritz.InstantAPIs.Generators.Helpers.csproj create mode 100644 Fritz.InstantAPIs.Generators.Helpers/Included.cs create mode 100644 Fritz.InstantAPIs.Generators.Helpers/InstanceAPIGeneratorConfig.cs create mode 100644 Fritz.InstantAPIs.Generators.Helpers/InstanceAPIGeneratorConfigBuilder.cs create mode 100644 Fritz.InstantAPIs.Generators.Helpers/InstantAPIsForDbContextAttribute.cs create mode 100644 Fritz.InstantAPIs.Generators.Helpers/TableConfig.cs create mode 100644 Fritz.InstantAPIs.Generators.Tests/CSharpIncrementalSourceGeneratorVerifier.cs create mode 100644 Fritz.InstantAPIs.Generators.Tests/DbContextAPIGeneratorTests.cs create mode 100644 Fritz.InstantAPIs.Generators.Tests/Diagnostics/DuplicateDefinitionDiagnosticTests.cs create mode 100644 Fritz.InstantAPIs.Generators.Tests/Diagnostics/NotADbContextDiagnosticTests.cs create mode 100644 Fritz.InstantAPIs.Generators.Tests/Fritz.InstantAPIs.Generators.Tests.csproj create mode 100644 Fritz.InstantAPIs.Generators.Tests/TestAssistants.cs create mode 100644 Fritz.InstantAPIs.Generators/Builders/DbContextAPIBuilder.cs create mode 100644 Fritz.InstantAPIs.Generators/Builders/IEndpointRouteBuilderExtensionsBuilder.cs create mode 100644 Fritz.InstantAPIs.Generators/Builders/TablesEnumBuilder.cs create mode 100644 Fritz.InstantAPIs.Generators/DbContextAPIGenerator.cs create mode 100644 Fritz.InstantAPIs.Generators/Diagnostics/DescriptorConstants.cs create mode 100644 Fritz.InstantAPIs.Generators/Diagnostics/DuplicateDefinitionDiagnostic.cs create mode 100644 Fritz.InstantAPIs.Generators/Diagnostics/NotADbContextDiagnostic.cs create mode 100644 Fritz.InstantAPIs.Generators/Fritz.InstantAPIs.Generators.csproj create mode 100644 Fritz.InstantAPIs.Generators/HelpUrlBuilder.cs create mode 100644 Fritz.InstantAPIs.Generators/NamespaceGatherer.cs create mode 100644 Fritz.InstantAPIs.Generators/TableData.cs create mode 100644 WorkingApi.Generators/MyContext.cs create mode 100644 WorkingApi.Generators/Program.cs create mode 100644 WorkingApi.Generators/Properties/launchSettings.json create mode 100644 WorkingApi.Generators/WorkingApi.Generators.csproj create mode 100644 WorkingApi.Generators/appsettings.Development.json create mode 100644 WorkingApi.Generators/appsettings.json diff --git a/Fritz.InstantAPIs.Generators.Helpers.Tests/Fritz.InstantAPIs.Generators.Helpers.Tests.csproj b/Fritz.InstantAPIs.Generators.Helpers.Tests/Fritz.InstantAPIs.Generators.Helpers.Tests.csproj new file mode 100644 index 0000000..b59977f --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers.Tests/Fritz.InstantAPIs.Generators.Helpers.Tests.csproj @@ -0,0 +1,18 @@ + + + net6.0 + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/Fritz.InstantAPIs.Generators.Helpers.Tests/InstanceAPIGeneratorConfigBuilderTests.cs b/Fritz.InstantAPIs.Generators.Helpers.Tests/InstanceAPIGeneratorConfigBuilderTests.cs new file mode 100644 index 0000000..e277143 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers.Tests/InstanceAPIGeneratorConfigBuilderTests.cs @@ -0,0 +1,100 @@ +using System; +using Xunit; + +namespace Fritz.InstantAPIs.Generators.Helpers.Tests; + +public static class InstanceAPIGeneratorConfigBuilderTests +{ + [Fact] + public static void BuildWithNoConfiguration() + { + var builder = new InstanceAPIGeneratorConfigBuilder(); + var config = builder.Build(); + + foreach (var key in Enum.GetValues()) + { + var tableConfig = config[key]; + + Assert.Equal(key, tableConfig.Key); + Assert.Equal(key.ToString(), tableConfig.Name); + Assert.Equal(Included.Yes, tableConfig.Included); + Assert.Equal(ApisToGenerate.All, tableConfig.APIs); + Assert.Equal("/api/a/{id}", tableConfig.RouteDeleteById("a")); + Assert.Equal("/api/a", tableConfig.RouteGet("a")); + Assert.Equal("/api/a/{id}", tableConfig.RouteGetById("a")); + Assert.Equal("/api/a", tableConfig.RoutePost("a")); + Assert.Equal("/api/a/{id}", tableConfig.RoutePut("a")); + } + } + + [Fact] + public static void BuildWithCustomInclude() + { + var builder = new InstanceAPIGeneratorConfigBuilder(); + builder.Include(Values.Two, "a", ApisToGenerate.Get, + routeGet: value => $"get/{value}", + routeGetById: value => $"getById/{value}", + routePost: value => $"post/{value}", + routePut: value => $"put/{value}", + routeDeleteById: value => $"delete/{value}"); + var config = builder.Build(); + + foreach (var key in Enum.GetValues()) + { + var tableConfig = config[key]; + + if (key != Values.Two) + { + Assert.Equal(key, tableConfig.Key); + Assert.Equal(key.ToString(), tableConfig.Name); + Assert.Equal(Included.Yes, tableConfig.Included); + Assert.Equal(ApisToGenerate.All, tableConfig.APIs); + Assert.Equal("/api/a/{id}", tableConfig.RouteDeleteById("a")); + Assert.Equal("/api/a", tableConfig.RouteGet("a")); + Assert.Equal("/api/a/{id}", tableConfig.RouteGetById("a")); + Assert.Equal("/api/a", tableConfig.RoutePost("a")); + Assert.Equal("/api/a/{id}", tableConfig.RoutePut("a")); + } + else + { + Assert.Equal(Values.Two, tableConfig.Key); + Assert.Equal("a", tableConfig.Name); + Assert.Equal(Included.Yes, tableConfig.Included); + Assert.Equal(ApisToGenerate.Get, tableConfig.APIs); + Assert.Equal("delete/a", tableConfig.RouteDeleteById("a")); + Assert.Equal("get/a", tableConfig.RouteGet("a")); + Assert.Equal("getById/a", tableConfig.RouteGetById("a")); + Assert.Equal("post/a", tableConfig.RoutePost("a")); + Assert.Equal("put/a", tableConfig.RoutePut("a")); + } + } + } + + [Fact] + public static void BuildWithCustomExclude() + { + var builder = new InstanceAPIGeneratorConfigBuilder(); + builder.Exclude(Values.Two); + var config = builder.Build(); + + foreach (var key in Enum.GetValues()) + { + var tableConfig = config[key]; + + Assert.Equal(key, tableConfig.Key); + Assert.Equal(key.ToString(), tableConfig.Name); + Assert.Equal(key != Values.Two ? Included.Yes : Included.No, tableConfig.Included); + Assert.Equal(ApisToGenerate.All, tableConfig.APIs); + Assert.Equal("/api/a/{id}", tableConfig.RouteDeleteById("a")); + Assert.Equal("/api/a", tableConfig.RouteGet("a")); + Assert.Equal("/api/a/{id}", tableConfig.RouteGetById("a")); + Assert.Equal("/api/a", tableConfig.RoutePost("a")); + Assert.Equal("/api/a/{id}", tableConfig.RoutePut("a")); + } + } + + private enum Values + { + One, Two, Three + } +} diff --git a/Fritz.InstantAPIs.Generators.Helpers.Tests/TableConfigTests.cs b/Fritz.InstantAPIs.Generators.Helpers.Tests/TableConfigTests.cs new file mode 100644 index 0000000..77a7a0f --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers.Tests/TableConfigTests.cs @@ -0,0 +1,50 @@ +using Xunit; + +namespace Fritz.InstantAPIs.Generators.Helpers.Tests +{ + public static class TableConfigTests + { + [Fact] + public static void Create() + { + var config = new TableConfig(Values.Three); + + Assert.Equal(Values.Three, config.Key); + Assert.Equal("Three", config.Name); + Assert.Equal(Included.Yes, config.Included); + Assert.Equal(ApisToGenerate.All, config.APIs); + Assert.Equal("/api/a/{id}", config.RouteDeleteById("a")); + Assert.Equal("/api/a", config.RouteGet("a")); + Assert.Equal("/api/a/{id}", config.RouteGetById("a")); + Assert.Equal("/api/a", config.RoutePost("a")); + Assert.Equal("/api/a/{id}", config.RoutePut("a")); + } + + [Fact] + public static void CreateWithCustomization() + { + var config = new TableConfig(Values.Three, + Included.No, "a", ApisToGenerate.Get, + routeGet: value => $"get/{value}", + routeGetById: value => $"getById/{value}", + routePost: value => $"post/{value}", + routePut: value => $"put/{value}", + routeDeleteById: value => $"delete/{value}"); + + Assert.Equal(Values.Three, config.Key); + Assert.Equal("a", config.Name); + Assert.Equal(Included.No, config.Included); + Assert.Equal(ApisToGenerate.Get, config.APIs); + Assert.Equal("delete/a", config.RouteDeleteById("a")); + Assert.Equal("get/a", config.RouteGet("a")); + Assert.Equal("getById/a", config.RouteGetById("a")); + Assert.Equal("post/a", config.RoutePost("a")); + Assert.Equal("put/a", config.RoutePut("a")); + } + + private enum Values + { + One, Two, Three + } + } +} diff --git a/Fritz.InstantAPIs.Generators.Helpers/ApisToGenerate.cs b/Fritz.InstantAPIs.Generators.Helpers/ApisToGenerate.cs new file mode 100644 index 0000000..489d293 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers/ApisToGenerate.cs @@ -0,0 +1,13 @@ +namespace Fritz.InstantAPIs.Generators.Helpers +{ + [Flags] + public enum ApisToGenerate + { + Get = 1, + GetById = 2, + Insert = 4, + Update = 8, + Delete = 16, + All = 31 + } +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators.Helpers/Fritz.InstantAPIs.Generators.Helpers.csproj b/Fritz.InstantAPIs.Generators.Helpers/Fritz.InstantAPIs.Generators.Helpers.csproj new file mode 100644 index 0000000..132c02c --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers/Fritz.InstantAPIs.Generators.Helpers.csproj @@ -0,0 +1,9 @@ + + + + net6.0 + enable + enable + + + diff --git a/Fritz.InstantAPIs.Generators.Helpers/Included.cs b/Fritz.InstantAPIs.Generators.Helpers/Included.cs new file mode 100644 index 0000000..a12fe1f --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers/Included.cs @@ -0,0 +1,7 @@ +namespace Fritz.InstantAPIs.Generators.Helpers +{ + public enum Included + { + Yes, No + } +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators.Helpers/InstanceAPIGeneratorConfig.cs b/Fritz.InstantAPIs.Generators.Helpers/InstanceAPIGeneratorConfig.cs new file mode 100644 index 0000000..3c54ec1 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers/InstanceAPIGeneratorConfig.cs @@ -0,0 +1,17 @@ +using System.Collections.Immutable; + +namespace Fritz.InstantAPIs.Generators.Helpers +{ + public class InstanceAPIGeneratorConfig + where T : struct, Enum + { + private readonly ImmutableDictionary> _tablesConfig; + + internal InstanceAPIGeneratorConfig(ImmutableDictionary> tablesConfig) + { + _tablesConfig = tablesConfig ?? throw new ArgumentNullException(nameof(tablesConfig)); + } + + public virtual TableConfig this[T key] => _tablesConfig[key]; + } +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators.Helpers/InstanceAPIGeneratorConfigBuilder.cs b/Fritz.InstantAPIs.Generators.Helpers/InstanceAPIGeneratorConfigBuilder.cs new file mode 100644 index 0000000..1165920 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers/InstanceAPIGeneratorConfigBuilder.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; + +namespace Fritz.InstantAPIs.Generators.Helpers +{ + public sealed class InstanceAPIGeneratorConfigBuilder + where T : struct, Enum + { + private readonly Dictionary> _tablesConfig = new(); + + public InstanceAPIGeneratorConfigBuilder() + { + foreach(var key in Enum.GetValues()) + { + _tablesConfig.Add(key, new TableConfig(key)); + } + } + + public InstanceAPIGeneratorConfigBuilder Include(T key, string? name = null, ApisToGenerate apis = ApisToGenerate.All, + Func? routeGet = null, Func? routeGetById = null, + Func? routePost = null, Func? routePut = null, + Func? routeDeleteById = null) + { + _tablesConfig[key] = new TableConfig(key, Included.Yes, name: name, + apis: apis, routeGet: routeGet, routeGetById: routeGetById, + routePost: routePost, routePut: routePut, routeDeleteById: routeDeleteById); + return this; + } + + public InstanceAPIGeneratorConfigBuilder Exclude(T key) + { + _tablesConfig[key] = new TableConfig(key, Included.No); + return this; + } + + public InstanceAPIGeneratorConfig Build() => + new InstanceAPIGeneratorConfig(_tablesConfig.ToImmutableDictionary()); + } +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators.Helpers/InstantAPIsForDbContextAttribute.cs b/Fritz.InstantAPIs.Generators.Helpers/InstantAPIsForDbContextAttribute.cs new file mode 100644 index 0000000..9c44e81 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers/InstantAPIsForDbContextAttribute.cs @@ -0,0 +1,12 @@ +namespace Fritz.InstantAPIs.Generators.Helpers +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class InstantAPIsForDbContextAttribute + : Attribute + { + public InstantAPIsForDbContextAttribute(Type dbContextType) => + DbContextType = dbContextType ?? throw new ArgumentNullException(nameof(dbContextType)); + + public Type DbContextType { get; } + } +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators.Helpers/TableConfig.cs b/Fritz.InstantAPIs.Generators.Helpers/TableConfig.cs new file mode 100644 index 0000000..54c8e1a --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Helpers/TableConfig.cs @@ -0,0 +1,46 @@ +namespace Fritz.InstantAPIs.Generators.Helpers +{ + public sealed class TableConfig + where T : struct, Enum + { + public TableConfig(T key) + { + Key = key; + Name = Enum.GetName(key); + } + + public TableConfig(T key, Included included, string? name = null, ApisToGenerate apis = ApisToGenerate.All, + Func? routeGet = null, Func? routeGetById = null, + Func? routePost = null, Func? routePut = null, + Func? routeDeleteById = null) + : this(key) + { + Included = included; + APIs = apis; + if (!string.IsNullOrWhiteSpace(name)) { Name = name; } + if (routeGet is not null) { RouteGet = routeGet; } + if (routeGetById is not null) { RouteGetById = routeGetById; } + if (routePost is not null) { RoutePost = routePost; } + if (routePut is not null) { RoutePut = routePut; } + if (routeDeleteById is not null) { RouteDeleteById = routeDeleteById; } + } + + public T Key { get; } + + public string? Name { get; } = null; + + public Included Included { get; } = Included.Yes; + + public ApisToGenerate APIs { get; } = ApisToGenerate.All; + + public Func RouteDeleteById { get; } = value => $"/api/{value}/{{id}}"; + + public Func RouteGet { get; } = value => $"/api/{value}"; + + public Func RouteGetById { get; } = value => $"/api/{value}/{{id}}"; + + public Func RoutePost { get; } = value => $"/api/{value}"; + + public Func RoutePut { get; } = value => $"/api/{value}/{{id}}"; + } +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators.Tests/CSharpIncrementalSourceGeneratorVerifier.cs b/Fritz.InstantAPIs.Generators.Tests/CSharpIncrementalSourceGeneratorVerifier.cs new file mode 100644 index 0000000..de4cc0a --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Tests/CSharpIncrementalSourceGeneratorVerifier.cs @@ -0,0 +1,84 @@ +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Testing.Verifiers; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using System; +using System.Collections.Generic; + +namespace Fritz.InstantAPIs.Generators.Tests; + +// All of this code was grabbed from Refit +// (https://github.com/reactiveui/refit/pull/1216/files) +// based on a suggestion from +// sharwell - https://discord.com/channels/732297728826277939/732297994699014164/910258213532876861 +// If the .NET Roslyn testing packages get updated to have something like this in the future +// I'll remove these helpers. +public static partial class CSharpIncrementalSourceGeneratorVerifier + where TIncrementalGenerator : IIncrementalGenerator, new() +{ +#pragma warning disable CA1034 // Nested types should not be visible + public class Test : CSharpSourceGeneratorTest +#pragma warning restore CA1034 // Nested types should not be visible + { + public Test() => + this.SolutionTransforms.Add((solution, projectId) => + { + if (solution is null) + { + throw new ArgumentNullException(nameof(solution)); + } + + if (projectId is null) + { + throw new ArgumentNullException(nameof(projectId)); + } + + var compilationOptions = solution.GetProject(projectId)!.CompilationOptions!; + + // NOTE: I commented this out, because I kept getting this error: + // error CS8632: The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + // Which makes NO sense because I have "#nullable enable" emitted in my + // generated code. So, best to just remove this for now. + + //compilationOptions = compilationOptions.WithSpecificDiagnosticOptions( + // compilationOptions.SpecificDiagnosticOptions.SetItems(CSharpVerifierHelper.NullableWarnings)); + + solution = solution.WithProjectCompilationOptions(projectId, compilationOptions); + + return solution; + }); + + protected override IEnumerable GetSourceGenerators() + { + yield return new TIncrementalGenerator().AsSourceGenerator(); + } + + protected override ParseOptions CreateParseOptions() + { + var parseOptions = (CSharpParseOptions)base.CreateParseOptions(); + return parseOptions.WithLanguageVersion(LanguageVersion.Preview); + } + } + + static class CSharpVerifierHelper + { + /// + /// By default, the compiler reports diagnostics for nullable reference types at + /// , and the analyzer test framework defaults to only validating + /// diagnostics at . This map contains all compiler diagnostic IDs + /// related to nullability mapped to , which is then used to enable all + /// of these warnings for default validation during analyzer and code fix tests. + /// + internal static ImmutableDictionary NullableWarnings { get; } = GetNullableWarningsFromCompiler(); + + static ImmutableDictionary GetNullableWarningsFromCompiler() + { + string[] args = { "/warnaserror:nullable" }; + var commandLineArguments = CSharpCommandLineParser.Default.Parse( + args, baseDirectory: Environment.CurrentDirectory, sdkDirectory: Environment.CurrentDirectory); + return commandLineArguments.CompilationOptions.SpecificDiagnosticOptions; + } + } +} diff --git a/Fritz.InstantAPIs.Generators.Tests/DbContextAPIGeneratorTests.cs b/Fritz.InstantAPIs.Generators.Tests/DbContextAPIGeneratorTests.cs new file mode 100644 index 0000000..c865ca3 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Tests/DbContextAPIGeneratorTests.cs @@ -0,0 +1,829 @@ +using Fritz.InstantAPIs.Generators.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Fritz.InstantAPIs.Generators.Tests; + +public static class DbContextAPIGeneratorTests +{ + [Theory] + [InlineData("int", "int.Parse(id)")] + [InlineData("long", "long.Parse(id)")] + [InlineData("Guid", "Guid.Parse(id)")] + [InlineData("string", "id")] + public static async Task GenerateWhenDbContextExists(string idType, string idParseMethod) + { + var code = +$@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.EntityFrameworkCore; +using MyApplication; +using System; + +[assembly: InstantAPIsForDbContext(typeof(CustomerContext))] + +namespace MyApplication +{{ + public class CustomerContext : DbContext + {{ + public DbSet Contacts => Set(); + }} + + public class Contact + {{ + public {idType} Id {{ get; set; }} + public string? Name {{ get; set; }} + }} +}}"; + var generatedCode = +$@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; + +#nullable enable + +namespace MyApplication +{{ + public enum CustomerContextTables + {{ + Contacts + }} + + public static partial class IEndpointRouteBuilderExtensions + {{ + public static IEndpointRouteBuilder MapCustomerContextToAPIs(this IEndpointRouteBuilder app, Action>? options = null) + {{ + ILogger logger = NullLogger.Instance; + if (app.ServiceProvider is not null) + {{ + var loggerFactory = app.ServiceProvider.GetRequiredService(); + logger = loggerFactory.CreateLogger(""InstantAPIs""); + }} + + var builder = new InstanceAPIGeneratorConfigBuilder(); + if (options is not null) {{ options(builder); }} + var config = builder.Build(); + + var tableContacts = config[CustomerContextTables.Contacts]; + + if (tableContacts.Included == Included.Yes) + {{ + if (tableContacts.APIs.HasFlag(ApisToGenerate.Get)) + {{ + var url = tableContacts.RouteGet.Invoke(tableContacts.Name); + app.MapGet(url, ([FromServices] CustomerContext db) => + Results.Ok(db.Contacts)); + + logger.LogInformation($""Created API: HTTP GET\t{{url}}""); + }} + + if (tableContacts.APIs.HasFlag(ApisToGenerate.GetById)) + {{ + var url = tableContacts.RouteGetById.Invoke(tableContacts.Name); + app.MapGet(url, async ([FromServices] CustomerContext db, [FromRoute] string id) => + {{ + var outValue = await db.Contacts.FindAsync({idParseMethod}); + if (outValue is null) {{ return Results.NotFound(); }} + return Results.Ok(outValue); + }}); + + logger.LogInformation($""Created API: HTTP GET\t{{url}}""); + }} + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Insert)) + {{ + var url = tableContacts.RoutePost.Invoke(tableContacts.Name); + app.MapPost(url, async ([FromServices] CustomerContext db, [FromBody] Contact newObj) => + {{ + db.Add(newObj); + await db.SaveChangesAsync(); + var id = newObj.Id; + return Results.Created($""{{url}}/{{id}}"", newObj); + }}); + + logger.LogInformation($""Created API: HTTP POST\t{{url}}""); + }} + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Update)) + {{ + var url = tableContacts.RoutePut.Invoke(tableContacts.Name); + app.MapPut(url, async ([FromServices] CustomerContext db, [FromRoute] string id, [FromBody] Contact newObj) => + {{ + db.Contacts.Attach(newObj); + db.Entry(newObj).State = EntityState.Modified; + await db.SaveChangesAsync(); + return Results.NoContent(); + }}); + + logger.LogInformation($""Created API: HTTP PUT\t{{url}}""); + }} + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Delete)) + {{ + var url = tableContacts.RouteDeleteById.Invoke(tableContacts.Name); + app.MapDelete(url, async ([FromServices] CustomerContext db, [FromRoute] string id) => + {{ + Contact? obj = await db.Contacts.FindAsync({idParseMethod}); + + if (obj is null) {{ return Results.NotFound(); }} + + db.Contacts.Remove(obj); + await db.SaveChangesAsync(); + return Results.NoContent(); + }}); + + logger.LogInformation($""Created API: HTTP DELETE\t{{url}}""); + }} + }} + + return app; + }} + }} +}} +"; + + await TestAssistants.RunAsync(code, + new[] { (typeof(DbContextAPIGenerator), "CustomerContext_DbContextAPIGenerator.g.cs", generatedCode) }, + Enumerable.Empty()).ConfigureAwait(false); + } + + [Fact] + public static async Task GenerateWhenMultipleDbContextsExists() + { + var code = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.EntityFrameworkCore; +using MyApplication; +using System; + +[assembly: InstantAPIsForDbContext(typeof(CustomerContext))] +[assembly: InstantAPIsForDbContext(typeof(PersonContext))] + +namespace MyApplication +{ + public class CustomerContext : DbContext + { + public DbSet Contacts => Set(); + } + + public class PersonContext : DbContext + { + public DbSet Contacts => Set(); + } + + public class Contact + { + public string? Name { get; set; } + } +}"; + var customerGeneratedCode = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; + +#nullable enable + +namespace MyApplication +{ + public enum CustomerContextTables + { + Contacts + } + + public static partial class IEndpointRouteBuilderExtensions + { + public static IEndpointRouteBuilder MapCustomerContextToAPIs(this IEndpointRouteBuilder app, Action>? options = null) + { + ILogger logger = NullLogger.Instance; + if (app.ServiceProvider is not null) + { + var loggerFactory = app.ServiceProvider.GetRequiredService(); + logger = loggerFactory.CreateLogger(""InstantAPIs""); + } + + var builder = new InstanceAPIGeneratorConfigBuilder(); + if (options is not null) { options(builder); } + var config = builder.Build(); + + var tableContacts = config[CustomerContextTables.Contacts]; + + if (tableContacts.Included == Included.Yes) + { + if (tableContacts.APIs.HasFlag(ApisToGenerate.Get)) + { + var url = tableContacts.RouteGet.Invoke(tableContacts.Name); + app.MapGet(url, ([FromServices] CustomerContext db) => + Results.Ok(db.Contacts)); + + logger.LogInformation($""Created API: HTTP GET\t{url}""); + } + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Update)) + { + var url = tableContacts.RoutePut.Invoke(tableContacts.Name); + app.MapPut(url, async ([FromServices] CustomerContext db, [FromRoute] string id, [FromBody] Contact newObj) => + { + db.Contacts.Attach(newObj); + db.Entry(newObj).State = EntityState.Modified; + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + logger.LogInformation($""Created API: HTTP PUT\t{url}""); + } + } + + return app; + } + } +} +"; + + var personGeneratedCode = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; + +#nullable enable + +namespace MyApplication +{ + public enum PersonContextTables + { + Contacts + } + + public static partial class IEndpointRouteBuilderExtensions + { + public static IEndpointRouteBuilder MapPersonContextToAPIs(this IEndpointRouteBuilder app, Action>? options = null) + { + ILogger logger = NullLogger.Instance; + if (app.ServiceProvider is not null) + { + var loggerFactory = app.ServiceProvider.GetRequiredService(); + logger = loggerFactory.CreateLogger(""InstantAPIs""); + } + + var builder = new InstanceAPIGeneratorConfigBuilder(); + if (options is not null) { options(builder); } + var config = builder.Build(); + + var tableContacts = config[PersonContextTables.Contacts]; + + if (tableContacts.Included == Included.Yes) + { + if (tableContacts.APIs.HasFlag(ApisToGenerate.Get)) + { + var url = tableContacts.RouteGet.Invoke(tableContacts.Name); + app.MapGet(url, ([FromServices] PersonContext db) => + Results.Ok(db.Contacts)); + + logger.LogInformation($""Created API: HTTP GET\t{url}""); + } + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Update)) + { + var url = tableContacts.RoutePut.Invoke(tableContacts.Name); + app.MapPut(url, async ([FromServices] PersonContext db, [FromRoute] string id, [FromBody] Contact newObj) => + { + db.Contacts.Attach(newObj); + db.Entry(newObj).State = EntityState.Modified; + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + logger.LogInformation($""Created API: HTTP PUT\t{url}""); + } + } + + return app; + } + } +} +"; + + await TestAssistants.RunAsync(code, + new[] + { + (typeof(DbContextAPIGenerator), "CustomerContext_DbContextAPIGenerator.g.cs", customerGeneratedCode), + (typeof(DbContextAPIGenerator), "PersonContext_DbContextAPIGenerator.g.cs", personGeneratedCode) + }, + Enumerable.Empty()).ConfigureAwait(false); + } + + [Fact] + public static async Task GenerateWhenIdentifierUsesKeyAttribute() + { + var code = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.EntityFrameworkCore; +using MyApplication; +using System; +using System.ComponentModel.DataAnnotations; + +[assembly: InstantAPIsForDbContext(typeof(CustomerContext))] + +namespace MyApplication +{ + public class CustomerContext : DbContext + { + public DbSet Contacts => Set(); + } + + public class Contact + { + [Key] + public int Unique { get; set; } + public string? Name { get; set; } + } +}"; + var generatedCode = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; + +#nullable enable + +namespace MyApplication +{ + public enum CustomerContextTables + { + Contacts + } + + public static partial class IEndpointRouteBuilderExtensions + { + public static IEndpointRouteBuilder MapCustomerContextToAPIs(this IEndpointRouteBuilder app, Action>? options = null) + { + ILogger logger = NullLogger.Instance; + if (app.ServiceProvider is not null) + { + var loggerFactory = app.ServiceProvider.GetRequiredService(); + logger = loggerFactory.CreateLogger(""InstantAPIs""); + } + + var builder = new InstanceAPIGeneratorConfigBuilder(); + if (options is not null) { options(builder); } + var config = builder.Build(); + + var tableContacts = config[CustomerContextTables.Contacts]; + + if (tableContacts.Included == Included.Yes) + { + if (tableContacts.APIs.HasFlag(ApisToGenerate.Get)) + { + var url = tableContacts.RouteGet.Invoke(tableContacts.Name); + app.MapGet(url, ([FromServices] CustomerContext db) => + Results.Ok(db.Contacts)); + + logger.LogInformation($""Created API: HTTP GET\t{url}""); + } + + if (tableContacts.APIs.HasFlag(ApisToGenerate.GetById)) + { + var url = tableContacts.RouteGetById.Invoke(tableContacts.Name); + app.MapGet(url, async ([FromServices] CustomerContext db, [FromRoute] string id) => + { + var outValue = await db.Contacts.FindAsync(int.Parse(id)); + if (outValue is null) { return Results.NotFound(); } + return Results.Ok(outValue); + }); + + logger.LogInformation($""Created API: HTTP GET\t{url}""); + } + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Insert)) + { + var url = tableContacts.RoutePost.Invoke(tableContacts.Name); + app.MapPost(url, async ([FromServices] CustomerContext db, [FromBody] Contact newObj) => + { + db.Add(newObj); + await db.SaveChangesAsync(); + var id = newObj.Unique; + return Results.Created($""{url}/{id}"", newObj); + }); + + logger.LogInformation($""Created API: HTTP POST\t{url}""); + } + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Update)) + { + var url = tableContacts.RoutePut.Invoke(tableContacts.Name); + app.MapPut(url, async ([FromServices] CustomerContext db, [FromRoute] string id, [FromBody] Contact newObj) => + { + db.Contacts.Attach(newObj); + db.Entry(newObj).State = EntityState.Modified; + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + logger.LogInformation($""Created API: HTTP PUT\t{url}""); + } + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Delete)) + { + var url = tableContacts.RouteDeleteById.Invoke(tableContacts.Name); + app.MapDelete(url, async ([FromServices] CustomerContext db, [FromRoute] string id) => + { + Contact? obj = await db.Contacts.FindAsync(int.Parse(id)); + + if (obj is null) { return Results.NotFound(); } + + db.Contacts.Remove(obj); + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + logger.LogInformation($""Created API: HTTP DELETE\t{url}""); + } + } + + return app; + } + } +} +"; + + await TestAssistants.RunAsync(code, + new[] { (typeof(DbContextAPIGenerator), "CustomerContext_DbContextAPIGenerator.g.cs", generatedCode) }, + Enumerable.Empty()).ConfigureAwait(false); + } + + [Fact] + public static async Task GenerateWhenTableTypeNamespaceIsDifferentThanDbContextNamespace() + { + var code = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.EntityFrameworkCore; +using MyApplication; +using MyTableTypes; +using System; + +[assembly: InstantAPIsForDbContext(typeof(CustomerContext))] + +namespace MyApplication +{ + public class CustomerContext : DbContext + { + public DbSet Contacts => Set(); + } +} + +namespace MyTableTypes +{ + public class Contact + { + public string? Name { get; set; } + } +}"; + var generatedCode = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MyTableTypes; +using System; +using System.Collections.Generic; + +#nullable enable + +namespace MyApplication +{ + public enum CustomerContextTables + { + Contacts + } + + public static partial class IEndpointRouteBuilderExtensions + { + public static IEndpointRouteBuilder MapCustomerContextToAPIs(this IEndpointRouteBuilder app, Action>? options = null) + { + ILogger logger = NullLogger.Instance; + if (app.ServiceProvider is not null) + { + var loggerFactory = app.ServiceProvider.GetRequiredService(); + logger = loggerFactory.CreateLogger(""InstantAPIs""); + } + + var builder = new InstanceAPIGeneratorConfigBuilder(); + if (options is not null) { options(builder); } + var config = builder.Build(); + + var tableContacts = config[CustomerContextTables.Contacts]; + + if (tableContacts.Included == Included.Yes) + { + if (tableContacts.APIs.HasFlag(ApisToGenerate.Get)) + { + var url = tableContacts.RouteGet.Invoke(tableContacts.Name); + app.MapGet(url, ([FromServices] CustomerContext db) => + Results.Ok(db.Contacts)); + + logger.LogInformation($""Created API: HTTP GET\t{url}""); + } + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Update)) + { + var url = tableContacts.RoutePut.Invoke(tableContacts.Name); + app.MapPut(url, async ([FromServices] CustomerContext db, [FromRoute] string id, [FromBody] Contact newObj) => + { + db.Contacts.Attach(newObj); + db.Entry(newObj).State = EntityState.Modified; + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + logger.LogInformation($""Created API: HTTP PUT\t{url}""); + } + } + + return app; + } + } +} +"; + + await TestAssistants.RunAsync(code, + new[] { (typeof(DbContextAPIGenerator), "CustomerContext_DbContextAPIGenerator.g.cs", generatedCode) }, + Enumerable.Empty()).ConfigureAwait(false); + } + + [Fact] + public static async Task GenerateWhenDbContextIsNotMarkedByAttribute() + { + var code = +@"using Microsoft.EntityFrameworkCore; + +namespace MyApplication +{ + public class CustomerContext : DbContext + { + public DbSet Contacts { get; set; } + } + + public class Contact + { + public string? Name { get; set; } + } +}"; + + await TestAssistants.RunAsync(code, + Enumerable.Empty<(Type, string, string)>(), + Enumerable.Empty()).ConfigureAwait(false); + } + + [Fact] + public static async Task GenerateWhenTypeGivenInAttributeIsNotDbContext() + { + var code = +@"using Fritz.InstantAPIs.Generators.Helpers; + +[assembly: InstantAPIsForDbContext(typeof(string))]"; + + var diagnostic = new DiagnosticResult(NotADbContextDiagnostic.Id, DiagnosticSeverity.Error) + .WithSpan(3, 12, 3, 51); + + await TestAssistants.RunAsync(code, + Enumerable.Empty<(Type, string, string)>(), + new[] { diagnostic }).ConfigureAwait(false); + } + + [Fact] + public static async Task GenerateWhenDbContextExistsAndDoesNotHaveIdProperty() + { + var code = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.EntityFrameworkCore; +using MyApplication; + +[assembly: InstantAPIsForDbContext(typeof(CustomerContext))] + +namespace MyApplication +{ + public class CustomerContext : DbContext + { + public DbSet Contacts => Set(); + } + + public class Contact + { + public string? Name { get; set; } + } +}"; + var generatedCode = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; + +#nullable enable + +namespace MyApplication +{ + public enum CustomerContextTables + { + Contacts + } + + public static partial class IEndpointRouteBuilderExtensions + { + public static IEndpointRouteBuilder MapCustomerContextToAPIs(this IEndpointRouteBuilder app, Action>? options = null) + { + ILogger logger = NullLogger.Instance; + if (app.ServiceProvider is not null) + { + var loggerFactory = app.ServiceProvider.GetRequiredService(); + logger = loggerFactory.CreateLogger(""InstantAPIs""); + } + + var builder = new InstanceAPIGeneratorConfigBuilder(); + if (options is not null) { options(builder); } + var config = builder.Build(); + + var tableContacts = config[CustomerContextTables.Contacts]; + + if (tableContacts.Included == Included.Yes) + { + if (tableContacts.APIs.HasFlag(ApisToGenerate.Get)) + { + var url = tableContacts.RouteGet.Invoke(tableContacts.Name); + app.MapGet(url, ([FromServices] CustomerContext db) => + Results.Ok(db.Contacts)); + + logger.LogInformation($""Created API: HTTP GET\t{url}""); + } + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Update)) + { + var url = tableContacts.RoutePut.Invoke(tableContacts.Name); + app.MapPut(url, async ([FromServices] CustomerContext db, [FromRoute] string id, [FromBody] Contact newObj) => + { + db.Contacts.Attach(newObj); + db.Entry(newObj).State = EntityState.Modified; + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + logger.LogInformation($""Created API: HTTP PUT\t{url}""); + } + } + + return app; + } + } +} +"; + + await TestAssistants.RunAsync(code, + new[] { (typeof(DbContextAPIGenerator), "CustomerContext_DbContextAPIGenerator.g.cs", generatedCode) }, + Enumerable.Empty()).ConfigureAwait(false); + } + + [Fact] + public static async Task GenerateWhenMultipleAttributeDefinitionsExist() + { + var code = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.EntityFrameworkCore; +using MyApplication; + +[assembly: InstantAPIsForDbContext(typeof(CustomerContext))] +[assembly: InstantAPIsForDbContext(typeof(CustomerContext))] + +namespace MyApplication +{ + public class CustomerContext : DbContext + { + public DbSet Contacts => Set(); + } + + public class Contact + { + public string? Name { get; set; } + } +}"; + var generatedCode = +@"using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; + +#nullable enable + +namespace MyApplication +{ + public enum CustomerContextTables + { + Contacts + } + + public static partial class IEndpointRouteBuilderExtensions + { + public static IEndpointRouteBuilder MapCustomerContextToAPIs(this IEndpointRouteBuilder app, Action>? options = null) + { + ILogger logger = NullLogger.Instance; + if (app.ServiceProvider is not null) + { + var loggerFactory = app.ServiceProvider.GetRequiredService(); + logger = loggerFactory.CreateLogger(""InstantAPIs""); + } + + var builder = new InstanceAPIGeneratorConfigBuilder(); + if (options is not null) { options(builder); } + var config = builder.Build(); + + var tableContacts = config[CustomerContextTables.Contacts]; + + if (tableContacts.Included == Included.Yes) + { + if (tableContacts.APIs.HasFlag(ApisToGenerate.Get)) + { + var url = tableContacts.RouteGet.Invoke(tableContacts.Name); + app.MapGet(url, ([FromServices] CustomerContext db) => + Results.Ok(db.Contacts)); + + logger.LogInformation($""Created API: HTTP GET\t{url}""); + } + + if (tableContacts.APIs.HasFlag(ApisToGenerate.Update)) + { + var url = tableContacts.RoutePut.Invoke(tableContacts.Name); + app.MapPut(url, async ([FromServices] CustomerContext db, [FromRoute] string id, [FromBody] Contact newObj) => + { + db.Contacts.Attach(newObj); + db.Entry(newObj).State = EntityState.Modified; + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + logger.LogInformation($""Created API: HTTP PUT\t{url}""); + } + } + + return app; + } + } +} +"; + + var diagnostic = new DiagnosticResult(DuplicateDefinitionDiagnostic.Id, DiagnosticSeverity.Warning) + .WithSpan(6, 12, 6, 60); + await TestAssistants.RunAsync(code, + new[] { (typeof(DbContextAPIGenerator), "CustomerContext_DbContextAPIGenerator.g.cs", generatedCode) }, + new[] { diagnostic }).ConfigureAwait(false); + } +} diff --git a/Fritz.InstantAPIs.Generators.Tests/Diagnostics/DuplicateDefinitionDiagnosticTests.cs b/Fritz.InstantAPIs.Generators.Tests/Diagnostics/DuplicateDefinitionDiagnosticTests.cs new file mode 100644 index 0000000..d81e563 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Tests/Diagnostics/DuplicateDefinitionDiagnosticTests.cs @@ -0,0 +1,21 @@ +using Fritz.InstantAPIs.Generators.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Fritz.InstantAPIs.Generators.Tests.Diagnostics; + +public static class DuplicateDefinitionDiagnosticTests +{ + [Fact] + public static void Create() + { + var diagnostic = DuplicateDefinitionDiagnostic.Create(SyntaxFactory.Attribute(SyntaxFactory.ParseName("A"))); + + Assert.Equal(DuplicateDefinitionDiagnostic.Message, diagnostic.GetMessage()); + Assert.Equal(DuplicateDefinitionDiagnostic.Title, diagnostic.Descriptor.Title); + Assert.Equal(DuplicateDefinitionDiagnostic.Id, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Equal(DescriptorConstants.Usage, diagnostic.Descriptor.Category); + } +} diff --git a/Fritz.InstantAPIs.Generators.Tests/Diagnostics/NotADbContextDiagnosticTests.cs b/Fritz.InstantAPIs.Generators.Tests/Diagnostics/NotADbContextDiagnosticTests.cs new file mode 100644 index 0000000..1b6d934 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Tests/Diagnostics/NotADbContextDiagnosticTests.cs @@ -0,0 +1,35 @@ +using Fritz.InstantAPIs.Generators.Diagnostics; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis; +using System; +using Xunit; +using System.Linq; + +namespace Fritz.InstantAPIs.Generators.Tests.Diagnostics; + +public static class NotADbContextDiagnosticTests +{ + [Fact] + public static void Create() + { + var syntaxTree = CSharpSyntaxTree.ParseText("public class A { }"); + var typeSyntax = syntaxTree.GetRoot().DescendantNodes(_ => true).OfType().Single(); + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(_ => !_.IsDynamic && !string.IsNullOrWhiteSpace(_.Location)) + .Select(_ => MetadataReference.CreateFromFile(_.Location)); + var compilation = CSharpCompilation.Create("generator", new SyntaxTree[] { syntaxTree }, + references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(syntaxTree, true); + + var typeSymbol = model.GetDeclaredSymbol(typeSyntax)!; + + var diagnostic = NotADbContextDiagnostic.Create(typeSymbol, typeSyntax); + + Assert.Equal("The given type, A, does not derive from DbContext.", diagnostic.GetMessage()); + Assert.Equal(NotADbContextDiagnostic.Title, diagnostic.Descriptor.Title); + Assert.Equal(NotADbContextDiagnostic.Id, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Equal(DescriptorConstants.Usage, diagnostic.Descriptor.Category); + } +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators.Tests/Fritz.InstantAPIs.Generators.Tests.csproj b/Fritz.InstantAPIs.Generators.Tests/Fritz.InstantAPIs.Generators.Tests.csproj new file mode 100644 index 0000000..c002e92 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Tests/Fritz.InstantAPIs.Generators.Tests.csproj @@ -0,0 +1,27 @@ + + + net6.0 + enable + + + + + NU1608 + + + NU1608 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Fritz.InstantAPIs.Generators.Tests/TestAssistants.cs b/Fritz.InstantAPIs.Generators.Tests/TestAssistants.cs new file mode 100644 index 0000000..2fd8bc6 --- /dev/null +++ b/Fritz.InstantAPIs.Generators.Tests/TestAssistants.cs @@ -0,0 +1,70 @@ +using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Threading.Tasks; + +namespace Fritz.InstantAPIs.Generators.Tests; + +using GeneratorTest = CSharpIncrementalSourceGeneratorVerifier; + +internal static class TestAssistants +{ + internal static async Task RunAsync(string code, + IEnumerable<(Type, string, string)> generatedSources, + IEnumerable expectedDiagnostics) + { + var test = new GeneratorTest.Test + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net60, + TestState = + { + Sources = { code }, + }, + }; + + foreach (var generatedSource in generatedSources) + { + test.TestState.GeneratedSources.Add(generatedSource); + } + + var referencedAssemblies = new HashSet + { + typeof(DbContextAPIGenerator).Assembly, + typeof(DbContext).Assembly, + typeof(WebApplication).Assembly, + typeof(FromServicesAttribute).Assembly, + typeof(EndpointRouteBuilderExtensions).Assembly, + typeof(IApplicationBuilder).Assembly, + typeof(IHost).Assembly, + typeof(KeyAttribute).Assembly, + typeof(Included).Assembly, + typeof(IEndpointRouteBuilder).Assembly, + typeof(RouteData).Assembly, + typeof(Results).Assembly, + typeof(NullLogger).Assembly, + typeof(ILogger).Assembly, + typeof(ServiceProviderServiceExtensions).Assembly, + //typeof(IServiceProvider).Assembly + }; + + foreach(var referencedAssembly in referencedAssemblies) + { + test.TestState.AdditionalReferences.Add(referencedAssembly); + } + + test.TestState.ExpectedDiagnostics.AddRange(expectedDiagnostics); + await test.RunAsync().ConfigureAwait(false); + } +} diff --git a/Fritz.InstantAPIs.Generators/Builders/DbContextAPIBuilder.cs b/Fritz.InstantAPIs.Generators/Builders/DbContextAPIBuilder.cs new file mode 100644 index 0000000..2d4c8cf --- /dev/null +++ b/Fritz.InstantAPIs.Generators/Builders/DbContextAPIBuilder.cs @@ -0,0 +1,85 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace Fritz.InstantAPIs.Generators.Builders +{ + public static class DbContextAPIBuilder + { + public static SourceText? Build(INamedTypeSymbol type) + { + var tables = new List(); + + foreach(var property in type.GetMembers().OfType() + .Where(_ => !_.IsStatic && _.DeclaredAccessibility == Accessibility.Public && + _.Type.ToDisplayString().StartsWith("Microsoft.EntityFrameworkCore.DbSet"))) + { + var propertyType = (INamedTypeSymbol)property.Type; + var propertySetType = (INamedTypeSymbol)propertyType.TypeArguments.First()!; + + var idProperty = propertySetType.GetMembers().OfType() + .FirstOrDefault(_ => string.Equals(_.Name, "id", StringComparison.OrdinalIgnoreCase) && + !_.IsStatic && _.DeclaredAccessibility == Accessibility.Public); + + if (idProperty is null) + { + idProperty = propertySetType.GetMembers().OfType() + .FirstOrDefault(_ => _.GetAttributes().Any(_ => _.AttributeClass!.Name == "Key" || _.AttributeClass.Name == "KeyAttribute")); + } + + tables.Add(new TableData(property.Name, propertySetType, idProperty?.Type as INamedTypeSymbol, idProperty?.Name)); + } + + if(tables.Count > 0) + { + using var writer = new StringWriter(); + using var indentWriter = new IndentedTextWriter(writer, "\t"); + + var namespaces = new NamespaceGatherer(); + namespaces.Add("System"); + namespaces.Add("System.Collections.Generic"); + namespaces.Add("Fritz.InstantAPIs.Generators.Helpers"); + namespaces.Add("Microsoft.EntityFrameworkCore"); + namespaces.Add("Microsoft.Extensions.Logging"); + namespaces.Add("Microsoft.Extensions.Logging.Abstractions"); + namespaces.Add("Microsoft.AspNetCore.Builder"); + namespaces.Add("Microsoft.AspNetCore.Mvc"); + namespaces.Add("Microsoft.AspNetCore.Routing"); + namespaces.Add("Microsoft.AspNetCore.Http"); + namespaces.Add("Microsoft.Extensions.DependencyInjection"); + + if (!type.ContainingNamespace.IsGlobalNamespace) + { + indentWriter.WriteLine($"namespace {type.ContainingNamespace.ToDisplayString()}"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + } + + TablesEnumBuilder.Build(indentWriter, type.Name, tables); + indentWriter.WriteLine(); + IEndpointRouteBuilderExtensionsBuilder.Build(indentWriter, type, tables, namespaces); + + if (!type.ContainingNamespace.IsGlobalNamespace) + { + indentWriter.Indent--; + indentWriter.WriteLine("}"); + } + + var code = namespaces.Values.Count > 0 ? + string.Join(Environment.NewLine, + string.Join(Environment.NewLine, namespaces.Values.Select(_ => $"using {_};")), + string.Empty, "#nullable enable", string.Empty, writer.ToString()) : + string.Join(Environment.NewLine, "#nullable enable", string.Empty, writer.ToString()); + + return SourceText.From(code, Encoding.UTF8); + } + + return null; + } + } +} diff --git a/Fritz.InstantAPIs.Generators/Builders/IEndpointRouteBuilderExtensionsBuilder.cs b/Fritz.InstantAPIs.Generators/Builders/IEndpointRouteBuilderExtensionsBuilder.cs new file mode 100644 index 0000000..e7d9200 --- /dev/null +++ b/Fritz.InstantAPIs.Generators/Builders/IEndpointRouteBuilderExtensionsBuilder.cs @@ -0,0 +1,231 @@ +using Microsoft.CodeAnalysis; +using System.CodeDom.Compiler; +using System.Collections.Generic; + +namespace Fritz.InstantAPIs.Generators.Builders +{ + internal static class IEndpointRouteBuilderExtensionsBuilder + { + internal static void Build(IndentedTextWriter indentWriter, INamedTypeSymbol type, List tables, + NamespaceGatherer namespaces) + { + indentWriter.WriteLine("public static partial class IEndpointRouteBuilderExtensions"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine($"public static IEndpointRouteBuilder Map{type.Name}ToAPIs(this IEndpointRouteBuilder app, Action>? options = null)"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine("ILogger logger = NullLogger.Instance;"); + indentWriter.WriteLine("if (app.ServiceProvider is not null)"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine("var loggerFactory = app.ServiceProvider.GetRequiredService();"); + indentWriter.WriteLine("logger = loggerFactory.CreateLogger(\"InstantAPIs\");"); + + indentWriter.Indent--; + indentWriter.WriteLine("}"); + indentWriter.WriteLine(); + + indentWriter.WriteLine($"var builder = new InstanceAPIGeneratorConfigBuilder<{type.Name}Tables>();"); + indentWriter.WriteLine("if (options is not null) { options(builder); }"); + indentWriter.WriteLine("var config = builder.Build();"); + indentWriter.WriteLine(); + + foreach (var table in tables) + { + if (!table.PropertyType.ContainingNamespace.Equals(type.ContainingNamespace, SymbolEqualityComparer.Default)) + { + namespaces.Add(table.PropertyType.ContainingNamespace); + } + + var tableVariableName = $"table{table.Name}"; + + indentWriter.WriteLine($"var {tableVariableName} = config[{type.Name}Tables.{table.Name}];"); + indentWriter.WriteLine(); + indentWriter.WriteLine($"if ({tableVariableName}.Included == Included.Yes)"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + BuildGet(indentWriter, type, table, tableVariableName); + indentWriter.WriteLine(); + + if (table.IdType is not null) + { + BuildGetById(indentWriter, type, table, tableVariableName); + indentWriter.WriteLine(); + BuildPost(indentWriter, type, table, tableVariableName); + indentWriter.WriteLine(); + } + + BuildPut(indentWriter, type, table, tableVariableName); + + if (table.IdType is not null) + { + indentWriter.WriteLine(); + BuildDeleteById(indentWriter, type, table, tableVariableName); + } + + indentWriter.Indent--; + indentWriter.WriteLine("}"); + } + + indentWriter.WriteLine(); + indentWriter.WriteLine("return app;"); + indentWriter.Indent--; + indentWriter.WriteLine("}"); + + indentWriter.Indent--; + indentWriter.WriteLine("}"); + } + + private static void BuildGet(IndentedTextWriter indentWriter, INamedTypeSymbol type, TableData table, string tableVariableName) + { + indentWriter.WriteLine($"if ({tableVariableName}.APIs.HasFlag(ApisToGenerate.Get))"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine($"var url = {tableVariableName}.RouteGet.Invoke({tableVariableName}.Name);"); + indentWriter.WriteLine($"app.MapGet(url, ([FromServices] {type.Name} db) =>"); + indentWriter.Indent++; + indentWriter.WriteLine($"Results.Ok(db.{table.Name}));"); + indentWriter.Indent--; + indentWriter.WriteLine(); + indentWriter.WriteLine("logger.LogInformation($\"Created API: HTTP GET\\t{url}\");"); + + indentWriter.Indent--; + indentWriter.WriteLine("}"); + } + + private static void BuildGetById(IndentedTextWriter indentWriter, INamedTypeSymbol type, TableData table, string tableVariableName) + { + indentWriter.WriteLine($"if ({tableVariableName}.APIs.HasFlag(ApisToGenerate.GetById))"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine($"var url = {tableVariableName}.RouteGetById.Invoke({tableVariableName}.Name);"); + indentWriter.WriteLine($"app.MapGet(url, async ([FromServices] {type.Name} db, [FromRoute] string id) =>"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine($"var outValue = await db.{table.Name}.FindAsync({GetIdParseCode(table.IdType!)});"); + indentWriter.WriteLine("if (outValue is null) { return Results.NotFound(); }"); + indentWriter.WriteLine("return Results.Ok(outValue);"); + + indentWriter.Indent--; + indentWriter.WriteLine("});"); + + indentWriter.WriteLine(); + indentWriter.WriteLine("logger.LogInformation($\"Created API: HTTP GET\\t{url}\");"); + + indentWriter.Indent--; + indentWriter.WriteLine("}"); + } + + private static void BuildPost(IndentedTextWriter indentWriter, INamedTypeSymbol type, TableData table, string tableVariableName) + { + indentWriter.WriteLine($"if ({tableVariableName}.APIs.HasFlag(ApisToGenerate.Insert))"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine($"var url = {tableVariableName}.RoutePost.Invoke({tableVariableName}.Name);"); + indentWriter.WriteLine($"app.MapPost(url, async ([FromServices] {type.Name} db, [FromBody] {table.PropertyType.Name} newObj) =>"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine("db.Add(newObj);"); + indentWriter.WriteLine("await db.SaveChangesAsync();"); + indentWriter.WriteLine($"var id = newObj.{table.IdName!};"); + // TODO: We're assuming that the "created" route is the same as POST/id, + // and this may not be true. + indentWriter.WriteLine($"return Results.Created($\"{{url}}/{{id}}\", newObj);"); + + indentWriter.Indent--; + indentWriter.WriteLine("});"); + + indentWriter.WriteLine(); + indentWriter.WriteLine("logger.LogInformation($\"Created API: HTTP POST\\t{url}\");"); + + indentWriter.Indent--; + indentWriter.WriteLine("}"); + } + + private static void BuildPut(IndentedTextWriter indentWriter, INamedTypeSymbol type, TableData table, string tableVariableName) + { + indentWriter.WriteLine($"if ({tableVariableName}.APIs.HasFlag(ApisToGenerate.Update))"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine($"var url = {tableVariableName}.RoutePut.Invoke({tableVariableName}.Name);"); + indentWriter.WriteLine($"app.MapPut(url, async ([FromServices] {type.Name} db, [FromRoute] string id, [FromBody] {table.PropertyType.Name} newObj) =>"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine($"db.{table.Name}.Attach(newObj);"); + indentWriter.WriteLine("db.Entry(newObj).State = EntityState.Modified;"); + indentWriter.WriteLine("await db.SaveChangesAsync();"); + indentWriter.WriteLine("return Results.NoContent();"); + + indentWriter.Indent--; + indentWriter.WriteLine("});"); + + indentWriter.WriteLine(); + indentWriter.WriteLine("logger.LogInformation($\"Created API: HTTP PUT\\t{url}\");"); + + indentWriter.Indent--; + indentWriter.WriteLine("}"); + } + + private static void BuildDeleteById(IndentedTextWriter indentWriter, INamedTypeSymbol type, TableData table, string tableVariableName) + { + indentWriter.WriteLine($"if ({tableVariableName}.APIs.HasFlag(ApisToGenerate.Delete))"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine($"var url = {tableVariableName}.RouteDeleteById.Invoke({tableVariableName}.Name);"); + indentWriter.WriteLine($"app.MapDelete(url, async ([FromServices] {type.Name} db, [FromRoute] string id) =>"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + + indentWriter.WriteLine($"{table.PropertyType.Name}? obj = await db.{table.Name}.FindAsync({GetIdParseCode(table.IdType!)});"); + indentWriter.WriteLine(); + indentWriter.WriteLine("if (obj is null) { return Results.NotFound(); }"); + indentWriter.WriteLine(); + indentWriter.WriteLine($"db.{table.Name}.Remove(obj);"); + indentWriter.WriteLine("await db.SaveChangesAsync();"); + indentWriter.WriteLine("return Results.NoContent();"); + + indentWriter.Indent--; + indentWriter.WriteLine("});"); + + indentWriter.WriteLine(); + indentWriter.WriteLine("logger.LogInformation($\"Created API: HTTP DELETE\\t{url}\");"); + + indentWriter.Indent--; + indentWriter.WriteLine("}"); + } + + private static string GetIdParseCode(INamedTypeSymbol tableType) + { + var idValue = "id"; + + if (tableType.SpecialType == SpecialType.System_Int32) + { + idValue = "int.Parse(id)"; + } + else if (tableType.SpecialType == SpecialType.System_Int64) + { + idValue = "long.Parse(id)"; + } + // TODO: This is not ideal for identifying a Guid...I think... + else if (tableType.ToDisplayString() == "System.Guid") + { + idValue = "Guid.Parse(id)"; + } + + return idValue; + } + } +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators/Builders/TablesEnumBuilder.cs b/Fritz.InstantAPIs.Generators/Builders/TablesEnumBuilder.cs new file mode 100644 index 0000000..0600a2a --- /dev/null +++ b/Fritz.InstantAPIs.Generators/Builders/TablesEnumBuilder.cs @@ -0,0 +1,19 @@ +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Linq; + +namespace Fritz.InstantAPIs.Generators.Builders +{ + internal sealed class TablesEnumBuilder + { + internal static void Build(IndentedTextWriter indentWriter, string name, List tables) + { + indentWriter.WriteLine($"public enum {name}Tables"); + indentWriter.WriteLine("{"); + indentWriter.Indent++; + indentWriter.WriteLine(string.Join(", ", tables.Select(_ => _.Name))); + indentWriter.Indent--; + indentWriter.WriteLine("}"); + } + } +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators/DbContextAPIGenerator.cs b/Fritz.InstantAPIs.Generators/DbContextAPIGenerator.cs new file mode 100644 index 0000000..4ed74e7 --- /dev/null +++ b/Fritz.InstantAPIs.Generators/DbContextAPIGenerator.cs @@ -0,0 +1,111 @@ +using Fritz.InstantAPIs.Generators.Builders; +using Fritz.InstantAPIs.Generators.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; + +namespace Fritz.InstantAPIs.Generators; + +[Generator] +public sealed class DbContextAPIGenerator + : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + static bool IsSyntaxTargetForGeneration(SyntaxNode node, CancellationToken token) => + node is AttributeSyntax attributeNode && + (attributeNode.Name.ToString() == "InstantAPIsForDbContext" || attributeNode.Name.ToString() == "InstantAPIsForDbContextAttribute"); + + static (AttributeSyntax, INamedTypeSymbol)? TransformTargets(GeneratorSyntaxContext context, CancellationToken token) + { + // We only want to return types with our attribute + var node = (AttributeSyntax)context.Node; + var model = context.SemanticModel; + + // AttributeSyntax maps to a IMethodSymbol (you're basically calling a constructor + // when you declare an attribute on a member). + var symbol = model.GetSymbolInfo(node, token).Symbol as IMethodSymbol; + + if (symbol is not null) + { + // Let's do a best guess that it's the attribute we're looking for. + if(symbol.ContainingType.Name == "InstantAPIsForDbContextAttribute" && + symbol.ContainingNamespace.ToDisplayString() == "Fritz.InstantAPIs.Generators.Helpers") + { + // Find the attribute data for the node. + var attributeData = model.Compilation.Assembly.GetAttributes().SingleOrDefault( + _ => _.ApplicationSyntaxReference!.GetSyntax() == node); + + if (attributeData is not null && + attributeData.ConstructorArguments[0].Value is not null && + attributeData.ConstructorArguments[0].Value is INamedTypeSymbol typeSymbol) + { + return (node, typeSymbol); + } + } + } + + return null; + } + + var provider = context.SyntaxProvider + .CreateSyntaxProvider(IsSyntaxTargetForGeneration, TransformTargets) + .Where(static _ => _ is not null); + var output = context.CompilationProvider.Combine(provider.Collect()); + + context.RegisterSourceOutput(output, + (context, source) => CreateOutput(source.Right, context)); + } + + private static void CreateOutput(ImmutableArray<(AttributeSyntax, INamedTypeSymbol)?> symbols, SourceProductionContext context) + { + static bool IsDbContext(INamedTypeSymbol type) + { + var baseType = type.BaseType; + + while (baseType is not null) + { + if (baseType.Name == "DbContext" && baseType.ContainingNamespace.ToDisplayString() == "Microsoft.EntityFrameworkCore") + { + return true; + } + + baseType = baseType.BaseType; + } + + return false; + } + + var dbTypes = new HashSet(SymbolEqualityComparer.Default); + + foreach(var symbol in symbols) + { + var node = symbol!.Value.Item1; + var typeSymbol = symbol!.Value.Item2; + + if (!IsDbContext(typeSymbol)) + { + context.ReportDiagnostic(NotADbContextDiagnostic.Create(typeSymbol, node)); + } + else + { + if(!dbTypes.Add(typeSymbol)) + { + context.ReportDiagnostic(DuplicateDefinitionDiagnostic.Create(node)); + } + else + { + var text = DbContextAPIBuilder.Build(typeSymbol); + + if (text is not null) + { + context.AddSource($"{typeSymbol.Name}_DbContextAPIGenerator.g.cs", text); + } + } + } + } + } +} diff --git a/Fritz.InstantAPIs.Generators/Diagnostics/DescriptorConstants.cs b/Fritz.InstantAPIs.Generators/Diagnostics/DescriptorConstants.cs new file mode 100644 index 0000000..c5594ee --- /dev/null +++ b/Fritz.InstantAPIs.Generators/Diagnostics/DescriptorConstants.cs @@ -0,0 +1,6 @@ +namespace Fritz.InstantAPIs.Generators.Diagnostics; + +public static class DescriptorConstants +{ + public const string Usage = nameof(DescriptorConstants.Usage); +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators/Diagnostics/DuplicateDefinitionDiagnostic.cs b/Fritz.InstantAPIs.Generators/Diagnostics/DuplicateDefinitionDiagnostic.cs new file mode 100644 index 0000000..6acbd53 --- /dev/null +++ b/Fritz.InstantAPIs.Generators/Diagnostics/DuplicateDefinitionDiagnostic.cs @@ -0,0 +1,18 @@ +using Microsoft.CodeAnalysis; + +namespace Fritz.InstantAPIs.Generators.Diagnostics; + +public class DuplicateDefinitionDiagnostic +{ + public static Diagnostic Create(SyntaxNode currentNode) => + Diagnostic.Create(new DiagnosticDescriptor( + DuplicateDefinitionDiagnostic.Id, DuplicateDefinitionDiagnostic.Title, + DuplicateDefinitionDiagnostic.Message, DescriptorConstants.Usage, DiagnosticSeverity.Warning, true, + helpLinkUri: HelpUrlBuilder.Build( + DuplicateDefinitionDiagnostic.Id, DuplicateDefinitionDiagnostic.Title)), + currentNode.GetLocation()); + + public const string Id = "IA2"; + public const string Message = "The given DbContext has already been defined."; + public const string Title = "Duplicate DbContext Definition"; +} diff --git a/Fritz.InstantAPIs.Generators/Diagnostics/NotADbContextDiagnostic.cs b/Fritz.InstantAPIs.Generators/Diagnostics/NotADbContextDiagnostic.cs new file mode 100644 index 0000000..2d3a476 --- /dev/null +++ b/Fritz.InstantAPIs.Generators/Diagnostics/NotADbContextDiagnostic.cs @@ -0,0 +1,20 @@ +using Microsoft.CodeAnalysis; +using System.Globalization; + +namespace Fritz.InstantAPIs.Generators.Diagnostics; + +public static class NotADbContextDiagnostic +{ + public static Diagnostic Create(INamedTypeSymbol type, SyntaxNode attribute) => + Diagnostic.Create(new DiagnosticDescriptor( + NotADbContextDiagnostic.Id, NotADbContextDiagnostic.Title, + string.Format(CultureInfo.CurrentCulture, NotADbContextDiagnostic.Message, type.Name), + DescriptorConstants.Usage, DiagnosticSeverity.Error, true, + helpLinkUri: HelpUrlBuilder.Build( + NotADbContextDiagnostic.Id, NotADbContextDiagnostic.Title)), + attribute.GetLocation()); + + public const string Id = "IA1"; + public const string Message = "The given type, {0}, does not derive from DbContext."; + public const string Title = "Not a DbContext"; +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators/Fritz.InstantAPIs.Generators.csproj b/Fritz.InstantAPIs.Generators/Fritz.InstantAPIs.Generators.csproj new file mode 100644 index 0000000..5d5c4ed --- /dev/null +++ b/Fritz.InstantAPIs.Generators/Fritz.InstantAPIs.Generators.csproj @@ -0,0 +1,11 @@ + + + latest + enable + netstandard2.0 + + + + + + diff --git a/Fritz.InstantAPIs.Generators/HelpUrlBuilder.cs b/Fritz.InstantAPIs.Generators/HelpUrlBuilder.cs new file mode 100644 index 0000000..45ab0a7 --- /dev/null +++ b/Fritz.InstantAPIs.Generators/HelpUrlBuilder.cs @@ -0,0 +1,7 @@ +namespace Fritz.InstantAPIs.Generators; + +internal static class HelpUrlBuilder +{ + internal static string Build(string identifier, string title) => + $"https://github.com/csharpfritz/InstantAPIs/tree/main/docs/{identifier}-{title}.md"; +} \ No newline at end of file diff --git a/Fritz.InstantAPIs.Generators/NamespaceGatherer.cs b/Fritz.InstantAPIs.Generators/NamespaceGatherer.cs new file mode 100644 index 0000000..91d3595 --- /dev/null +++ b/Fritz.InstantAPIs.Generators/NamespaceGatherer.cs @@ -0,0 +1,41 @@ +using Microsoft.CodeAnalysis; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Fritz.InstantAPIs.Generators; + +internal sealed class NamespaceGatherer +{ + private readonly ImmutableHashSet.Builder builder = + ImmutableHashSet.CreateBuilder(); + + public void Add(INamespaceSymbol @namespace) + { + if (!@namespace.IsGlobalNamespace) + { + this.builder.Add(@namespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)); + } + } + + public void Add(string @namespace) => + this.builder.Add(@namespace); + + public void Add(Type type) + { + if (!string.IsNullOrWhiteSpace(type.Namespace)) + { + this.builder.Add(type.Namespace); + } + } + + public void AddRange(IEnumerable namespaces) + { + foreach (var @namespace in namespaces) + { + this.Add(@namespace); + } + } + + public IImmutableSet Values => this.builder.ToImmutableSortedSet(); +} diff --git a/Fritz.InstantAPIs.Generators/TableData.cs b/Fritz.InstantAPIs.Generators/TableData.cs new file mode 100644 index 0000000..506279e --- /dev/null +++ b/Fritz.InstantAPIs.Generators/TableData.cs @@ -0,0 +1,14 @@ +using Microsoft.CodeAnalysis; + +namespace Fritz.InstantAPIs.Generators; + +internal sealed class TableData +{ + internal TableData(string name, INamedTypeSymbol propertyType, INamedTypeSymbol? idType, string? idName) => + (Name, PropertyType, IdType, IdName) = (name, propertyType, idType, idName); + + public INamedTypeSymbol PropertyType { get; } + public string? IdName { get; } + public INamedTypeSymbol? IdType { get; } + internal string Name { get; } +} diff --git a/Fritz.InstantAPIs.sln b/Fritz.InstantAPIs.sln index a069bb2..89177cd 100644 --- a/Fritz.InstantAPIs.sln +++ b/Fritz.InstantAPIs.sln @@ -12,6 +12,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Workflows", "Workflows", "{ .github\workflows\build.yaml = .github\workflows\build.yaml EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Fritz.InstantAPIs.Generators", "Fritz.InstantAPIs.Generators\Fritz.InstantAPIs.Generators.csproj", "{56ABEEC4-77C0-42CE-A68B-592E4705D90E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Fritz.InstantAPIs.Generators.Tests", "Fritz.InstantAPIs.Generators.Tests\Fritz.InstantAPIs.Generators.Tests.csproj", "{EE295501-1A67-4E55-A1AD-B98C290F604D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Fritz.InstantAPIs.Generators.Helpers", "Fritz.InstantAPIs.Generators.Helpers\Fritz.InstantAPIs.Generators.Helpers.csproj", "{2FF08F79-C2C5-4DD4-9A95-EABFA09A054C}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C73968E9-8A12-401C-BA5D-127C6D5A55D6}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig @@ -24,6 +30,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Test", "Test\Test.csproj", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestJson", "TestJson\TestJson.csproj", "{99D818F8-63A2-4004-87AB-CA61E1B125CC}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkingApi.Generators", "WorkingApi.Generators\WorkingApi.Generators.csproj", "{4BDA89CB-733A-49DE-A5C5-D4695EB8A483}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fritz.InstantAPIs.Generators.Helpers.Tests", "Fritz.InstantAPIs.Generators.Helpers.Tests\Fritz.InstantAPIs.Generators.Helpers.Tests.csproj", "{0F5A7147-94E3-4BAA-AC94-A6EBD44B8966}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +48,18 @@ Global {C945FAF9-D8A9-48EE-8A19-4AB0996CABEC}.Debug|Any CPU.Build.0 = Debug|Any CPU {C945FAF9-D8A9-48EE-8A19-4AB0996CABEC}.Release|Any CPU.ActiveCfg = Release|Any CPU {C945FAF9-D8A9-48EE-8A19-4AB0996CABEC}.Release|Any CPU.Build.0 = Release|Any CPU + {56ABEEC4-77C0-42CE-A68B-592E4705D90E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56ABEEC4-77C0-42CE-A68B-592E4705D90E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56ABEEC4-77C0-42CE-A68B-592E4705D90E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56ABEEC4-77C0-42CE-A68B-592E4705D90E}.Release|Any CPU.Build.0 = Release|Any CPU + {EE295501-1A67-4E55-A1AD-B98C290F604D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE295501-1A67-4E55-A1AD-B98C290F604D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE295501-1A67-4E55-A1AD-B98C290F604D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE295501-1A67-4E55-A1AD-B98C290F604D}.Release|Any CPU.Build.0 = Release|Any CPU + {2FF08F79-C2C5-4DD4-9A95-EABFA09A054C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2FF08F79-C2C5-4DD4-9A95-EABFA09A054C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FF08F79-C2C5-4DD4-9A95-EABFA09A054C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2FF08F79-C2C5-4DD4-9A95-EABFA09A054C}.Release|Any CPU.Build.0 = Release|Any CPU {CD123B01-1B52-4E80-84F7-4D10E01EE10F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CD123B01-1B52-4E80-84F7-4D10E01EE10F}.Debug|Any CPU.Build.0 = Debug|Any CPU {CD123B01-1B52-4E80-84F7-4D10E01EE10F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -46,6 +68,14 @@ Global {99D818F8-63A2-4004-87AB-CA61E1B125CC}.Debug|Any CPU.Build.0 = Debug|Any CPU {99D818F8-63A2-4004-87AB-CA61E1B125CC}.Release|Any CPU.ActiveCfg = Release|Any CPU {99D818F8-63A2-4004-87AB-CA61E1B125CC}.Release|Any CPU.Build.0 = Release|Any CPU + {4BDA89CB-733A-49DE-A5C5-D4695EB8A483}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BDA89CB-733A-49DE-A5C5-D4695EB8A483}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BDA89CB-733A-49DE-A5C5-D4695EB8A483}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BDA89CB-733A-49DE-A5C5-D4695EB8A483}.Release|Any CPU.Build.0 = Release|Any CPU + {0F5A7147-94E3-4BAA-AC94-A6EBD44B8966}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F5A7147-94E3-4BAA-AC94-A6EBD44B8966}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F5A7147-94E3-4BAA-AC94-A6EBD44B8966}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F5A7147-94E3-4BAA-AC94-A6EBD44B8966}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/WorkingApi.Generators/MyContext.cs b/WorkingApi.Generators/MyContext.cs new file mode 100644 index 0000000..ab87f74 --- /dev/null +++ b/WorkingApi.Generators/MyContext.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; + +namespace WorkingApi; + +public sealed class MyContext : DbContext +{ + public MyContext() { } + + public MyContext(DbContextOptions options) : base(options) {} + + public DbSet Contacts => Set(); + + public DbSet Orders => Set(); +} + +public sealed class Contact +{ + public int Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } +} + +public sealed class Order +{ + public int Id { get; set; } + public string? Name { get; set; } +} diff --git a/WorkingApi.Generators/Program.cs b/WorkingApi.Generators/Program.cs new file mode 100644 index 0000000..f294796 --- /dev/null +++ b/WorkingApi.Generators/Program.cs @@ -0,0 +1,53 @@ +using Fritz.InstantAPIs.Generators.Helpers; +using Microsoft.EntityFrameworkCore; +using WorkingApi; + +[assembly: InstantAPIsForDbContext(typeof(MyContext))] + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext( + options => options.UseInMemoryDatabase("Test")); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + await SetupMyContextAsync(scope.ServiceProvider.GetService()!); +} + +// This is the configured version. +/* +app.MapMyContextToAPIs(options => + options.Include(MyContextTables.Contacts, "Contacts", ApisToGenerate.Get) + .Exclude(MyContextTables.Orders)); +*/ + +// This is the simple "configure everything" version. +app.MapMyContextToAPIs(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.Run(); + +static async Task SetupMyContextAsync(MyContext context) +{ + await context.Contacts.AddAsync(new Contact + { + Id = 1, + Name = "Jason", + Email = "jason@bock.com" + }); + + await context.Contacts.AddAsync(new Contact + { + Id = 2, + Name = "Jeff", + Email = "jeff@fritz.com" + }); + + await context.SaveChangesAsync(); +} \ No newline at end of file diff --git a/WorkingApi.Generators/Properties/launchSettings.json b/WorkingApi.Generators/Properties/launchSettings.json new file mode 100644 index 0000000..c056778 --- /dev/null +++ b/WorkingApi.Generators/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:47282", + "sslPort": 0 + } + }, + "profiles": { + "WorkingApi.Generators": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger/index.html", + "applicationUrl": "http://localhost:5215", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/WorkingApi.Generators/WorkingApi.Generators.csproj b/WorkingApi.Generators/WorkingApi.Generators.csproj new file mode 100644 index 0000000..9b5695c --- /dev/null +++ b/WorkingApi.Generators/WorkingApi.Generators.csproj @@ -0,0 +1,16 @@ + + + net6.0 + enable + enable + + + + + + + + + + + diff --git a/WorkingApi.Generators/appsettings.Development.json b/WorkingApi.Generators/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/WorkingApi.Generators/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/WorkingApi.Generators/appsettings.json b/WorkingApi.Generators/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/WorkingApi.Generators/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/WorkingApi/WorkingApi.csproj b/WorkingApi/WorkingApi.csproj index b8dc097..3cb5b3d 100644 --- a/WorkingApi/WorkingApi.csproj +++ b/WorkingApi/WorkingApi.csproj @@ -19,7 +19,7 @@ - +