diff --git a/docs/decisions/0050-api-framework.md b/docs/decisions/0050-api-framework.md new file mode 100644 index 00000000..c8c34333 --- /dev/null +++ b/docs/decisions/0050-api-framework.md @@ -0,0 +1,46 @@ +# API Framework + +* status: proposed + +* date: 2023-09-13 +* deciders: jezzsantos + +# Context and Problem Statement + +When it comes to writing REST web APIs in the template, we want to establish scalable patterns that focus on the API's requests and responses and the cross-cutting concerns involved: + +* Routes and verbs +* Authenticated or not +* Authorized or not, by roles with (RBAC) +* Authorized or not, by feature set +* Request Validation +* Rate limiting +* Response Caching +* Response types (e.g. JSON or Stream) +* Mapping etc +* Exception Handling +* etc... + +We know that in this architecture, the web API will mostly be delegating to an in-proc Application layer, and so we know that there will not be much code in this layer except to identify the Application Layer call. + +We also know that the requests and responses need to be easily referenced by clients and tests. + +Thus, we need a simple, structured, and consistent way to define request and response types and decorate them with various attributes. + +## Considered Options + +The traditional .NET options include: + +1. ASP.NET Minimal APIs ([MediatR](https://github.com/jbogard/MediatR)'ed) +2. ASP.NET Minimal APIs (out of the box) +3. ASP.NET Controllers +4. Web frameworks like: ServiceStack + +## Decision Outcome + +`MediatR'ed Minimal APIs` + +- ASP.NET controllers were never a great abstraction to represent REST APIs. They were a poor adaptation of the ASP.NET MVC implementation re-purposed for sending JSON, and long overdue for a redesign. Until recently (< .net 6.0) MVC Controllers were the only choice from the ASP.NET team. +- ASP.NET Minimal APIs (out of the box > .net 6.0) are simple and easy to define for demonstrating example code, but for use in larger systems are very awkward to organize, maintain, test, and reuse, as they are today. +- ServiceStack is an ideal web framework for structuring and handling complex web APIs. It has resolved many of the design challenges that ASP.NET controllers has suffered from. It implements the [REPR design pattern](https://deviq.com/design-patterns/repr-design-pattern) very well indeed, and is a delight to use, in many aspects. However, the framework today has a huge surface area, which we are not interested in leveraging most of. It is not so well known to the wider developer community, in part because it is also licensed per developer for a significant annual fee. This last point disqualifies it for use in this template. +- ASP.NET Minimal API's that are MediatR'ed and that can remove some of the redundancy and tedium of the current MediatR patterns (i.e. one ctor per handler, and duplicate ctor for api collections) can bring a more usable, structured, and testable way to define minimal APIs that is close to the same feel of defining and using ServiceStack API's, that does not change them functionally, but does offer more maintainable ways to define and reuse them across a whole system diff --git a/docs/design-principles/0010-rest-api.md b/docs/design-principles/0010-rest-api.md index c18fa293..b76e0d38 100644 --- a/docs/design-principles/0010-rest-api.md +++ b/docs/design-principles/0010-rest-api.md @@ -1,4 +1,4 @@ -# REST API Design +# REST API Design Q. How is our web API to be designed? @@ -24,9 +24,9 @@ Level 3 of the [Richardson Maturity Model](http://restcookbook.com/Miscellaneous Even though most web APIs are defined by the HTTP verbs: `POST`, `GET`, `PUT`, `PATCH`, `DELETE` (and others), -​ - AND these verbs *could be* conveniently translated nicely into `Create` `Retrieve`, `Update` and `Delete` (CRUD) functions of a database. + - AND these verbs *could be* conveniently translated nicely into `Create` `Retrieve`, `Update` and `Delete` (CRUD) functions of a database. -​ - AND given that REST is designed around a "Resource", each with an identifier. + - AND given that REST is designed around a "Resource", each with an identifier. Designing a REST API for web interop is not to be confused with designing a database API with CRUD. diff --git a/docs/design-principles/0020-api-framework.md b/docs/design-principles/0020-api-framework.md new file mode 100644 index 00000000..8297c506 --- /dev/null +++ b/docs/design-principles/0020-api-framework.md @@ -0,0 +1,53 @@ +# Web Framework + +## Design Drivers + +1. We want to leverage core supported Microsoft ASP.NET facilities, rather than some other bespoke framework (like ServiceStack.net). +2. We are choosing Minimal API's over Controllers. +3. We want to model Requests and Responses that are related, easy to validate and in one place the code, We desire the [REPR design pattern](https://deviq.com/design-patterns/repr-design-pattern). +4. However, the standard Minimal API patterns are difficult to organize and maintain in larger codebases. +5. We want a design that is easier to define and organize the API into modules, but yet have them be realised as Minimal APIs. +6. We want to have automatic validation, as whole requests +7. We want to [MediatR](https://github.com/jbogard/MediatR) handlers, to make Minimal API registration and dependency injection easier + +### Modularity + +One of the distinguishing design principles of a Modular Monolith over a Monolith is the ability to deploy any, all or some the API's in any number of deployment units. Taken to the extreme, you would end up with granular microservices. But smaller steps are very acceptable depending on the stage of the SaaS product. + +The ability to deploy any (Subdomain) of the code to a separate web host, should be quick and easy to accomplish. + +One of the things that has to be easy to do, is to register who the endpoints of a subdomain in whatever host you like, as well as all its dependencies. + +With minimal API's there should be a modular way of registering both its endpoints and handlers, and then moving them to other hosts later. + +### Organisation + +The design of Minimal API's makes developing 10s or 100s of them in a single project quite unwieldy. They certainly would not live in one file. + +Since they are registered as handlers, there is no concept of groups of API's. Whereas many API endpoints are naturally grouped or categorized. This is certainly the case when exposing subdomains. + +When using MediatR to register handlers for minimal API's, and with dependency injection, it becomes quite tedious and repetitive to write a handler class for every route, when many routes are grouped and will be sharing the same dependencies. + +There are better ways to organize these groups of endpoints into classes, and test them more easily. + +### Validation + +When you design endpoints you want the requests and responses to be coupled, and you want the requests to be validated automatically when requests come in. Writing wiring code for validation is lso very tedious and error prone, and so is writing code to response with errors in a consistent manner. + +We want the codebase to make validation easier to do, and apply it automatically and have standard ways to report errors detected by it. + +## Configuring API's + +All APIs will be defined in a separate project that is initially part of a subdomain group of code. That project can then be registered as a module into a specific web host, and with it all the endpoints, handlers, dependencies needed for all layers of the subdomain. + +The web host, will then code generate the endpoint declarations and handlers and register them with Minimal API, and other components can be registered with the IoC. + +### Reference the Source Generator + +Every API project must reference the Source Generators in `Infrastructure.WebApi.Generators`. + +EveryAPI must provide a plugin. + +The plugin will then automatically call the source-generated registration code, update the runtime configuration of the web host and populate the IoC automatically. + +The configuration of the web host and its features will be encapsulated and provided by various extension methods, so that all API hosts are consistent. \ No newline at end of file diff --git a/src/.run/AllHosts.run.xml b/src/.run/AllHosts.run.xml new file mode 100644 index 00000000..96356352 --- /dev/null +++ b/src/.run/AllHosts.run.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/ApiHost1/ApiHost1.csproj b/src/ApiHost1/ApiHost1.csproj new file mode 100644 index 00000000..6dbd249d --- /dev/null +++ b/src/ApiHost1/ApiHost1.csproj @@ -0,0 +1,19 @@ + + + + net7.0 + + + + + + + + + + + + + diff --git a/src/ApiHost1/Program.cs b/src/ApiHost1/Program.cs new file mode 100644 index 00000000..c29ac919 --- /dev/null +++ b/src/ApiHost1/Program.cs @@ -0,0 +1,38 @@ +using CarsApplication; +using Infrastructure.WebApi.Common; +#if TESTINGONLY +using Infrastructure.WebApi.Interfaces.Operations.TestingOnly; +#endif + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMediatR(configuration => +{ + //TODO: we will have make sure that we have the assemblies of all APIs here somehow + configuration.RegisterServicesFromAssembly(typeof(Program).Assembly); +}); +builder.Services.AddScoped(); + +var app = builder.Build(); + +// app.MapGet("/cars/{id}", +// ([AsParameters] GetCarRequest request) => +// Results.Ok(new GetCarResponse { Message = $"Hello car {request.Id}!" })); + +//TODO: Need to build these registrations at startup by examining classes +#if TESTINGONLY +app.MediateGet("/testingonly/{id}"); +#endif + +//app.RegisterRoutes(); + +//TODO: need to combine with validation +//TODO: need to add swaggerUI +//TODO: need ot register modules + +app.Run(); + + +public partial class Program +{ +} \ No newline at end of file diff --git a/src/ApiHost1/Properties/launchSettings.json b/src/ApiHost1/Properties/launchSettings.json new file mode 100644 index 00000000..332775ca --- /dev/null +++ b/src/ApiHost1/Properties/launchSettings.json @@ -0,0 +1,36 @@ +{ + "profiles": { + "ApiHost1-Development": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "ApiHost1-CI": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "CI" + } + }, + "ApiHost1-Production": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://api.saastack.io", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production" + } + }, + "SourceGenerator-Development": { + "commandName": "DebugRoslynComponent", + "targetProject": "../ApiHost1/ApiHost1.csproj" + } + } +} + diff --git a/src/ApiHost1/Services/TestingOnly/TestingOnlyApi.cs b/src/ApiHost1/Services/TestingOnly/TestingOnlyApi.cs new file mode 100644 index 00000000..957d7cda --- /dev/null +++ b/src/ApiHost1/Services/TestingOnly/TestingOnlyApi.cs @@ -0,0 +1,16 @@ +#if TESTINGONLY +using Infrastructure.WebApi.Interfaces; +using Infrastructure.WebApi.Interfaces.Operations.TestingOnly; + +namespace ApiHost1.Services.TestingOnly; + +public class TestingOnlyApi : IWebApiService +{ + [WebApiRoute("/testingonly/{id}", WebApiOperation.Get)] + public async Task Get(GetTestingOnlyRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + return Results.Ok(new GetTestingOnlyResponse { Message = "amessage" }); + } +} +#endif \ No newline at end of file diff --git a/src/ApiHost1/appsettings.Development.json b/src/ApiHost1/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/ApiHost1/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/ApiHost1/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Application.Interfaces/Application.Interfaces.csproj b/src/Application.Interfaces/Application.Interfaces.csproj new file mode 100644 index 00000000..4f444d8c --- /dev/null +++ b/src/Application.Interfaces/Application.Interfaces.csproj @@ -0,0 +1,7 @@ + + + + net6.0 + + + diff --git a/src/Application.Interfaces/ICallerContext.cs b/src/Application.Interfaces/ICallerContext.cs new file mode 100644 index 00000000..ec32e80e --- /dev/null +++ b/src/Application.Interfaces/ICallerContext.cs @@ -0,0 +1,5 @@ +namespace Application.Interfaces; + +public interface ICallerContext +{ +} \ No newline at end of file diff --git a/src/CarsApi/CarsApi.cs b/src/CarsApi/CarsApi.cs new file mode 100644 index 00000000..4aa15fa0 --- /dev/null +++ b/src/CarsApi/CarsApi.cs @@ -0,0 +1,24 @@ +using CarsApplication; +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using Infrastructure.WebApi.Interfaces.Operations.Cars; +using Microsoft.AspNetCore.Http; + +namespace CarsApi; + +public class CarsApi : IWebApiService +{ + private readonly ICarsApplication _carsApplication; + + public CarsApi(ICarsApplication carsApplication) + { + _carsApplication = carsApplication; + } + + [WebApiRoute("/cars/{id}", WebApiOperation.Get)] + public async Task Get(GetCarRequest request, CancellationToken cancellationToken) + { + var car = await _carsApplication.GetCarAsync(new CallerContext(), request.Id, cancellationToken); + return Results.Ok(new GetCarResponse { Car = car }); + } +} \ No newline at end of file diff --git a/src/CarsApi/CarsApi.csproj b/src/CarsApi/CarsApi.csproj new file mode 100644 index 00000000..75e45b11 --- /dev/null +++ b/src/CarsApi/CarsApi.csproj @@ -0,0 +1,19 @@ + + + + net7.0 + + + + + + + + + + + + + diff --git a/src/CarsApplication/CarsApplication.cs b/src/CarsApplication/CarsApplication.cs new file mode 100644 index 00000000..86a91084 --- /dev/null +++ b/src/CarsApplication/CarsApplication.cs @@ -0,0 +1,12 @@ +using Application.Interfaces; + +namespace CarsApplication; + +public class CarsApplication : ICarsApplication +{ + public async Task GetCarAsync(ICallerContext caller, string? id, CancellationToken cancellationToken) + { + await Task.CompletedTask; + return $"Hello car {id}!"; + } +} \ No newline at end of file diff --git a/src/CarsApplication/CarsApplication.csproj b/src/CarsApplication/CarsApplication.csproj new file mode 100644 index 00000000..f83deb76 --- /dev/null +++ b/src/CarsApplication/CarsApplication.csproj @@ -0,0 +1,11 @@ + + + + net6.0 + + + + + + + diff --git a/src/CarsApplication/ICarsApplication.cs b/src/CarsApplication/ICarsApplication.cs new file mode 100644 index 00000000..b3a0afd2 --- /dev/null +++ b/src/CarsApplication/ICarsApplication.cs @@ -0,0 +1,8 @@ +using Application.Interfaces; + +namespace CarsApplication; + +public interface ICarsApplication +{ + Task GetCarAsync(ICallerContext caller, string? id, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Common.UnitTests/Common.UnitTests.csproj b/src/Common.UnitTests/Common.UnitTests.csproj index a7286eac..5c322dd4 100644 --- a/src/Common.UnitTests/Common.UnitTests.csproj +++ b/src/Common.UnitTests/Common.UnitTests.csproj @@ -1,7 +1,7 @@ - net6.0 + net7.0 diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index 97eb21f9..7265ebd7 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -1,5 +1,5 @@ - net6.0 + net7.0 diff --git a/src/Common/Infrastructure.Api.Interfaces/Class1.cs b/src/Common/Infrastructure.Api.Interfaces/Class1.cs new file mode 100644 index 00000000..d627cac9 --- /dev/null +++ b/src/Common/Infrastructure.Api.Interfaces/Class1.cs @@ -0,0 +1,5 @@ +namespace Common.Infrastructure.Api.Interfaces; + +public class Class1 +{ +} \ No newline at end of file diff --git a/src/Common/Infrastructure.Api.Interfaces/Infrastructure.Api.Interfaces.csproj b/src/Common/Infrastructure.Api.Interfaces/Infrastructure.Api.Interfaces.csproj new file mode 100644 index 00000000..eb2460e9 --- /dev/null +++ b/src/Common/Infrastructure.Api.Interfaces/Infrastructure.Api.Interfaces.csproj @@ -0,0 +1,9 @@ + + + + net6.0 + enable + enable + + + diff --git a/src/Infrastructure.Api.Common.IntegrationTests/Infrastructure.Api.Common.IntegrationTests.csproj b/src/Infrastructure.Api.Common.IntegrationTests/Infrastructure.Api.Common.IntegrationTests.csproj new file mode 100644 index 00000000..503aa153 --- /dev/null +++ b/src/Infrastructure.Api.Common.IntegrationTests/Infrastructure.Api.Common.IntegrationTests.csproj @@ -0,0 +1,16 @@ + + + + net7.0 + + + + + + + + + + + + diff --git a/src/Infrastructure.Api.Common.IntegrationTests/WebApiSpec.cs b/src/Infrastructure.Api.Common.IntegrationTests/WebApiSpec.cs new file mode 100644 index 00000000..0f2f49a9 --- /dev/null +++ b/src/Infrastructure.Api.Common.IntegrationTests/WebApiSpec.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Infrastructure.WebApi.Interfaces.Operations.TestingOnly; +using IntegrationTesting.WebApi.Common; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; +#if TESTINGONLY + +namespace Infrastructure.Api.Common.IntegrationTests; + +[Trait("Category", "Integration.Web")] +public class WebApiSpec : WebApiSpecSetup +{ + public WebApiSpec(WebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task WhenGetApi_ThenReturns200() + { + var result = await Api.GetAsync("/testingonly/1"); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenGetApi_ThenReturnsJsonByDefault() + { + var result = await Api.GetFromJsonAsync("/testingonly/1"); + + result?.Message.Should().Be("Hello car 1!"); + } +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Common/CallerContext.cs b/src/Infrastructure.WebApi.Common/CallerContext.cs new file mode 100644 index 00000000..3755b56d --- /dev/null +++ b/src/Infrastructure.WebApi.Common/CallerContext.cs @@ -0,0 +1,7 @@ +using Application.Interfaces; + +namespace Infrastructure.WebApi.Common; + +public class CallerContext : ICallerContext +{ +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Common/Infrastructure.WebApi.Common.csproj b/src/Infrastructure.WebApi.Common/Infrastructure.WebApi.Common.csproj new file mode 100644 index 00000000..cb7a8451 --- /dev/null +++ b/src/Infrastructure.WebApi.Common/Infrastructure.WebApi.Common.csproj @@ -0,0 +1,16 @@ + + + + net7.0 + + + + + + + + + + + + diff --git a/src/Infrastructure.WebApi.Common/WebExtensions.cs b/src/Infrastructure.WebApi.Common/WebExtensions.cs new file mode 100644 index 00000000..0f5cca3b --- /dev/null +++ b/src/Infrastructure.WebApi.Common/WebExtensions.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using Infrastructure.WebApi.Interfaces; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Infrastructure.WebApi.Common; + +public static class WebExtensions +{ + public static WebApplication MediateGet(this WebApplication app, + [StringSyntax("Route")] string routeTemplate) + where TRequest : IWebRequest where TResponse : IWebResponse + { + app.MapGet(routeTemplate, + async (IMediator mediator, [AsParameters] TRequest request) => await mediator.Send(request)); + + return app; + } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Generators/Infrastructure.WebApi.Generators.csproj b/src/Infrastructure.WebApi.Generators/Infrastructure.WebApi.Generators.csproj new file mode 100644 index 00000000..6c142eea --- /dev/null +++ b/src/Infrastructure.WebApi.Generators/Infrastructure.WebApi.Generators.csproj @@ -0,0 +1,28 @@ + + + + net7.0 + true + true + + + + + + + + + + Reference\IWebApiService.cs + + + Reference\IWebResponse.cs + + + Reference\WebApiOperation.cs + + + Reference\WebApiRouteAttribute.cs + + + diff --git a/src/Infrastructure.WebApi.Generators/MinimalApiMediatRGenerator.cs b/src/Infrastructure.WebApi.Generators/MinimalApiMediatRGenerator.cs new file mode 100644 index 00000000..2dbdbc1d --- /dev/null +++ b/src/Infrastructure.WebApi.Generators/MinimalApiMediatRGenerator.cs @@ -0,0 +1,161 @@ +using System.Text; +using Infrastructure.WebApi.Interfaces; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Infrastructure.WebApi.Generators; + +[Generator] +public class MinimalApiMediatRGenerator : ISourceGenerator +{ + private const string Filename = "MinimalApiMediatRGeneratedHandlers.g.cs"; + + private static readonly string[] RequiredUsingNamespaces = + { "System", "Microsoft.AspNetCore.Builder", "Microsoft.AspNetCore.Http" }; + + public void Initialize(GeneratorInitializationContext context) + { + // No initialization + } + + public void Execute(GeneratorExecutionContext context) + { + var assemblyNamespace = context.Compilation.AssemblyName; + var handlers = GetWebApiServiceOperationsFromAssembly(context); + + var handlerClasses = new StringBuilder(); + var handlerRegistrations = new StringBuilder(); + foreach (var handler in handlers) + { + var constructor = BuildConstructor(handler.RequestDtoType.Name, handler.Class.CtorParameters.ToList()); + BuildHandlerSignatures(handler.OperationType, handler.RoutePath, handler.MethodBody, + handler.RequestDtoType.Name, + handler.RequestDtoType.FullName, constructor, handlerClasses, handlerRegistrations); + } + + var classUsingNamespaces = BuildUsingList(handlers); + var fileSource = BuildFile(assemblyNamespace, classUsingNamespaces, handlerRegistrations.ToString(), + handlerClasses.ToString()); + + context.AddSource(Filename, SourceText.From(fileSource, Encoding.UTF8)); + + return; + + static string BuildFile(string? assemblyNamespace, string usingNamespaces, string handlerRegistrations, + string handlerClasses) + { + return $@"// +{usingNamespaces} +namespace {assemblyNamespace}; + +public static class MediatRRegistration +{{ + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + {{ +{handlerRegistrations} + }} +}} + +{handlerClasses}"; + } + + static string BuildUsingList(IEnumerable handlers) + { + var usingList = new StringBuilder(); + + var allNamespaces = handlers.SelectMany(handler => handler.Class.UsingNamespaces) + .Concat(RequiredUsingNamespaces) + .Distinct() + .OrderByDescending(s => s) + .ToList(); + + allNamespaces.ForEach(@using => + usingList.AppendLine($@"using {@using};")); + + return usingList.ToString(); + } + + static void BuildHandlerSignatures(WebApiOperation operation, string? routePath, string? requestMethodBody, + string? requestTypeName, string? requestTypeFullName, + string? constructor, StringBuilder handlerClasses, StringBuilder handlerRegistrations) + { + var requestBody = new StringBuilder(); + requestBody.Append(!string.IsNullOrEmpty(requestMethodBody) + ? $@"{requestMethodBody}" + : @" {}"); + handlerClasses.Append($@" +public class {requestTypeName}Handler : global::MediatR.IRequestHandler +{{ +{constructor} + public async Task Handle(global::{requestTypeFullName} request, global::System.Threading.CancellationToken cancellationToken) +{requestBody} +}}"); + + var minimalApiMapMethodName = ToMinimalApiMapMethodName(operation); + handlerRegistrations.AppendLine($@" app.Map{minimalApiMapMethodName}(""{routePath}"","); + handlerRegistrations.AppendLine( + $@" async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::{requestTypeFullName} request) =>"); + handlerRegistrations.AppendLine( + @" await mediator.Send(request, global::System.Threading.CancellationToken.None));"); + } + + static string BuildConstructor(string? requestTypeName, + List constructorParameters) + { + var handlerClassConstructor = new StringBuilder(); + if (constructorParameters.Any()) + { + foreach (var param in constructorParameters) + { + handlerClassConstructor.AppendLine( + $@" private readonly global::{param.TypeName.FullName} _{param.VariableName};"); + } + + handlerClassConstructor.AppendLine(); + handlerClassConstructor.Append($@" public {requestTypeName}Handler("); + var paramsRemaining = constructorParameters.Count(); + foreach (var param in constructorParameters) + { + handlerClassConstructor.Append($@"global::{param.TypeName.FullName} {param.VariableName}"); + if (--paramsRemaining > 0) + { + handlerClassConstructor.Append(", "); + } + } + + handlerClassConstructor.AppendLine(@")"); + handlerClassConstructor.AppendLine(" {"); + foreach (var param in constructorParameters) + { + handlerClassConstructor.AppendLine( + $@" this._{param.VariableName} = {param.VariableName};"); + } + + handlerClassConstructor.AppendLine(" }"); + } + + return handlerClassConstructor.ToString(); + } + + static List GetWebApiServiceOperationsFromAssembly( + GeneratorExecutionContext context) + { + var visitor = new WebApiProjectVisitor(context.CancellationToken, context.Compilation); + visitor.Visit(context.Compilation.Assembly); + return visitor.OperationRegistrations; + } + + static string ToMinimalApiMapMethodName(WebApiOperation operation) + { + return operation switch + { + WebApiOperation.Get => "Get", + WebApiOperation.Search => "Get", + WebApiOperation.Post => "Post", + WebApiOperation.PutPatch => "Put", + WebApiOperation.Delete => "Delete", + _ => "Get" + }; + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Generators/README.md b/src/Infrastructure.WebApi.Generators/README.md new file mode 100644 index 00000000..e6926888 --- /dev/null +++ b/src/Infrastructure.WebApi.Generators/README.md @@ -0,0 +1,22 @@ +# Source Generator + +This source generator project is meant to be included by every Api Project for every subdomain. + +It's job is to convert `IWebApiService` class definitions into Minimal API definitions using MediatR handlers. + +# Development Workarounds + +C# Source Generators have difficulties running in the IDE if the code used in them has dependencies on other projects in the solution (and other nugets). +Th is is especially problematic when those referenced projects have transient dependencies to other frameworks. + +Special workarounds (in the project file of this project) are required in order for this source generators to work. + +We are avoiding including certain types from the `Infrastructure.WebApi.Interfaces` project (even though we need them), since that project is dependent on types in: + +* MediatR.Contracts +* The AspNetCore framework + +We have file linked certain source files from that project, so that we can use those symbols in the Source Generator code, and track those names should they change in the future +We have had to hardcode certain other types to avoid referencing AspNetCore and MediatR assemblies, and these cannot be tracked if changed elsewhere. + +None of this is ideal. But until we can figure the magic needed to build and run this Source Generator if it uses these types, this may be the best workaround we have for now. \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Generators/WebApiProjectVisitor.cs b/src/Infrastructure.WebApi.Generators/WebApiProjectVisitor.cs new file mode 100644 index 00000000..f5defbf4 --- /dev/null +++ b/src/Infrastructure.WebApi.Generators/WebApiProjectVisitor.cs @@ -0,0 +1,367 @@ +using Infrastructure.WebApi.Interfaces; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Infrastructure.WebApi.Generators; + +/// +/// Visits all namespaces, and types in the current assembly (only), +/// collecting, service operations of classes that are: +/// 1. Derive from +/// 3. That are not abstract or static +/// 2. That are public or internal +/// Where the methods represent service operations, that are: +/// 1. Named either: Get, Post, Put, PutPatch or Delete +/// 2. They return the type +/// 3. They have a request dto type as their first parameter +/// 4. They may have a as their second parameter, and no other parameters +/// 5. Are decorated with the attribute, and have both a route and operation +/// +public class WebApiProjectVisitor : SymbolVisitor +{ + private static readonly string[] IgnoredNamespaces = + { "System", "Microsoft", "MediatR", "MessagePack", "NerdBank*" }; + + private static readonly string[] SupportedServiceOperationNames = + { "Get", "Post", "Put", "Patch", "PutPatch", "Delete" }; + + private readonly CancellationToken _cancellationToken; + private readonly INamedTypeSymbol _cancellationTokenSymbol; + private readonly INamedTypeSymbol _serviceInterfaceSymbol; + private readonly INamedTypeSymbol _webHandlerResponseSymbol; + private readonly INamedTypeSymbol _webRequestInterfaceSymbol; + private readonly INamedTypeSymbol _webRouteAttributeSymbol; + + public WebApiProjectVisitor(CancellationToken cancellationToken, Compilation compilation) + { + _cancellationToken = cancellationToken; + _serviceInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebApiService).FullName!)!; + _webRequestInterfaceSymbol = + compilation.GetTypeByMetadataName( + "Infrastructure.WebApi.Interfaces.IWebRequest`1") + !; //HACK: we cannot reference the real type here, as it causes runtime issues. See the README.md for more details + _webRouteAttributeSymbol = compilation.GetTypeByMetadataName(typeof(WebApiRouteAttribute).FullName!)!; + _cancellationTokenSymbol = compilation.GetTypeByMetadataName(typeof(CancellationToken).FullName!)!; + _webHandlerResponseSymbol = compilation.GetTypeByMetadataName(typeof(Task<>).FullName!)! + .Construct(compilation.GetTypeByMetadataName( + "Microsoft.AspNetCore.Http.IResult") + !); //HACK: we cannot reference the real type here, as it causes runtime issues. See the README.md for more details + } + + public List OperationRegistrations { get; } = new(); + + + public override void VisitAssembly(IAssemblySymbol symbol) + { + _cancellationToken.ThrowIfCancellationRequested(); + symbol.GlobalNamespace.Accept(this); + } + + public override void VisitNamespace(INamespaceSymbol symbol) + { + if (IsIgnoredNamespace()) + { + return; + } + + foreach (var namespaceOrType in symbol.GetMembers()) + { + _cancellationToken.ThrowIfCancellationRequested(); + namespaceOrType.Accept(this); + } + + return; + + bool IsIgnoredNamespace() + { + var @namespace = symbol.Name; + if (string.IsNullOrEmpty(@namespace)) + { + return false; + } + + foreach (var ignoredNamespace in IgnoredNamespaces) + { + if (ignoredNamespace.EndsWith("*")) + { + var prefix = ignoredNamespace.Substring(0, ignoredNamespace.Length - 1); + if (@namespace.StartsWith(prefix)) + { + return true; + } + } + else + { + if (@namespace == ignoredNamespace) + { + return true; + } + } + } + + return false; + } + } + + public override void VisitNamedType(INamedTypeSymbol symbol) + { + _cancellationToken.ThrowIfCancellationRequested(); + + if (IsServiceClass()) + { + AddRegistration(symbol); + } + + foreach (var nestedType in symbol.GetTypeMembers()) + { + if (IsNotClass(nestedType)) + { + continue; + } + + _cancellationToken.ThrowIfCancellationRequested(); + nestedType.Accept(this); + } + + bool IsServiceClass() + { + if (IsNotClass(symbol)) + { + return false; + } + + var accessibility = symbol.DeclaredAccessibility; + if (accessibility != Accessibility.Public && + accessibility != Accessibility.Internal) + { + return false; + } + + if (symbol is not { IsAbstract: false, IsStatic: false }) + { + return false; + } + + if (IsIncorrectDerivedType(symbol)) + { + return false; + } + + return true; + } + + bool IsIncorrectDerivedType(INamedTypeSymbol @class) + { + return !@class.AllInterfaces.Any(@interface => + SymbolEqualityComparer.Default.Equals(@interface, _serviceInterfaceSymbol)); + } + + bool IsNotClass(ITypeSymbol type) + { + return type.TypeKind != TypeKind.Class; + } + } + + private void AddRegistration(INamedTypeSymbol symbol) + { + var constructorParameters = GetLongestConstructorByParameters(); + var usingNamespaces = GetUsingNamespaces(); + var classRegistration = new ApiServiceClassRegistration + { + CtorParameters = constructorParameters, + UsingNamespaces = usingNamespaces + }; + + var methods = GetServiceOperationMethods(); + foreach (var method in methods) + { + var routeAttribute = GetRouteAttribute(method); + if (routeAttribute is null) + { + continue; + } + + var routePath = routeAttribute.ConstructorArguments[0].Value!.ToString()!; + var operationVerb = routeAttribute.ConstructorArguments[1].Value!.ToString()!; + var operationType = FromOperationVerb(operationVerb); + var requestTypeName = method.Parameters[0].Type.Name; + var requestTypeFullName = method.Parameters[0].Type.ToDisplayString(); + var requestMethodBody = GetMethodBody(method, requestTypeName); + + OperationRegistrations.Add(new ApiServiceOperationRegistration + { + Class = classRegistration, + RequestDtoType = new TypeName + { + Name = requestTypeName, + FullName = requestTypeFullName + }, + OperationType = operationType, + MethodBody = requestMethodBody, + RoutePath = routePath + }); + + continue; + + static string GetMethodBody(ISymbol method, string requestTypeName) + { + var syntaxReference = method.DeclaringSyntaxReferences.FirstOrDefault(); + if (syntaxReference is null) + { + return string.Empty; + } + + var requestMethodSyntax = syntaxReference.SyntaxTree.GetRoot() + .DescendantNodes() + .OfType() + .FirstOrDefault(x => + (x.ParameterList.Parameters.First().Type as IdentifierNameSyntax)!.Identifier.Text == + requestTypeName); + return + requestMethodSyntax is not null + ? requestMethodSyntax.Body!.GetText().ToString() + : string.Empty; + } + + static WebApiOperation FromOperationVerb(string operation) + { + return Enum.Parse(operation, true); + } + } + + return; + + List GetLongestConstructorByParameters() + { + var longest = symbol.InstanceConstructors + .MaxBy(info => info.Parameters.Length); + + return longest is not null + ? longest.Parameters.Select(param => new ConstructorParameter + { + TypeName = new TypeName { Name = param.Type.Name, FullName = param.Type.ToDisplayString() }, + VariableName = param.Name + }).ToList() + : new List(); + } + + List GetServiceOperationMethods() + { + return symbol.GetMembers() + .OfType() + .Where(method => + { + var methodName = method.Name; + if (IsUnsupportedMethodName(methodName)) + { + return false; + } + + if (IsIncorrectReturnType(method)) + { + return false; + } + + if (HasWrongSetOfParameters(method)) + { + return false; + } + + return true; + }) + .ToList(); + } + + List GetUsingNamespaces() + { + var syntaxReference = symbol.DeclaringSyntaxReferences.FirstOrDefault(); + if (syntaxReference is null) + { + return new List(); + } + + var usingSyntaxes = syntaxReference.SyntaxTree.GetRoot() + .DescendantNodes() + .OfType(); + + return usingSyntaxes + .Select(us => us.Name!.ToString()) + .Distinct() + .OrderDescending() + .ToList(); + } + + AttributeData? GetRouteAttribute(ISymbol method) + { + return method.GetAttributes() + .FirstOrDefault(attribute => + SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, _webRouteAttributeSymbol)); + } + + bool IsUnsupportedMethodName(string methodName) + { + return !SupportedServiceOperationNames.Contains(methodName); + } + + bool IsIncorrectReturnType(IMethodSymbol method) + { + return !SymbolEqualityComparer.Default.Equals(method.ReturnType, _webHandlerResponseSymbol); + } + + bool HasWrongSetOfParameters(IMethodSymbol method) + { + var parameters = method.Parameters; + if (parameters.Length is < 1 or > 2) + { + return true; + } + + var firstParameter = parameters[0]; + if (!firstParameter.Type.AllInterfaces.Any(@interface => + SymbolEqualityComparer.Default.Equals(@interface.OriginalDefinition, + _webRequestInterfaceSymbol))) + { + return true; + } + + if (parameters.Length == 2) + { + var secondParameter = parameters[1]; + if (!SymbolEqualityComparer.Default.Equals(secondParameter.Type, _cancellationTokenSymbol)) + { + return true; + } + } + + return false; + } + } + + + public record ApiServiceOperationRegistration + { + public required ApiServiceClassRegistration Class { get; set; } + public required string RoutePath { get; set; } + public required WebApiOperation OperationType { get; set; } + public required TypeName RequestDtoType { get; set; } + public string? MethodBody { get; set; } + } + + public record TypeName + { + public required string FullName { get; set; } + public required string Name { get; set; } + } + + public record ApiServiceClassRegistration + { + public IEnumerable CtorParameters { get; set; } = new List(); + public IEnumerable UsingNamespaces { get; set; } = new List(); + } + + public record ConstructorParameter + { + public required TypeName TypeName { get; set; } + public required string VariableName { get; set; } + } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Interfaces/IWebApiService.cs b/src/Infrastructure.WebApi.Interfaces/IWebApiService.cs new file mode 100644 index 00000000..c42175e5 --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/IWebApiService.cs @@ -0,0 +1,8 @@ +namespace Infrastructure.WebApi.Interfaces; + +/// +/// A marker interface for registering web services +/// +public interface IWebApiService +{ +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Interfaces/IWebRequest.cs b/src/Infrastructure.WebApi.Interfaces/IWebRequest.cs new file mode 100644 index 00000000..8d4c9c81 --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/IWebRequest.cs @@ -0,0 +1,8 @@ +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace Infrastructure.WebApi.Interfaces; + +public interface IWebRequest : IRequest where TResponse : IWebResponse +{ +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Interfaces/IWebResponse.cs b/src/Infrastructure.WebApi.Interfaces/IWebResponse.cs new file mode 100644 index 00000000..0e81cf3b --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/IWebResponse.cs @@ -0,0 +1,5 @@ +namespace Infrastructure.WebApi.Interfaces; + +public interface IWebResponse +{ +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Interfaces/Infrastructure.WebApi.Interfaces.csproj b/src/Infrastructure.WebApi.Interfaces/Infrastructure.WebApi.Interfaces.csproj new file mode 100644 index 00000000..43d5498f --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/Infrastructure.WebApi.Interfaces.csproj @@ -0,0 +1,19 @@ + + + + net7.0 + + + + + + + + + + + + + + + diff --git a/src/Infrastructure.WebApi.Interfaces/Operations/Cars/GetCarRequest.cs b/src/Infrastructure.WebApi.Interfaces/Operations/Cars/GetCarRequest.cs new file mode 100644 index 00000000..9ba2666d --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/Operations/Cars/GetCarRequest.cs @@ -0,0 +1,6 @@ +namespace Infrastructure.WebApi.Interfaces.Operations.Cars; + +public class GetCarRequest : IWebRequest +{ + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Interfaces/Operations/Cars/GetCarResponse.cs b/src/Infrastructure.WebApi.Interfaces/Operations/Cars/GetCarResponse.cs new file mode 100644 index 00000000..d2b95537 --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/Operations/Cars/GetCarResponse.cs @@ -0,0 +1,6 @@ +namespace Infrastructure.WebApi.Interfaces.Operations.Cars; + +public class GetCarResponse : IWebResponse +{ + public string? Car { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Interfaces/Operations/TestingOnly/GetTestingOnlyRequest.cs b/src/Infrastructure.WebApi.Interfaces/Operations/TestingOnly/GetTestingOnlyRequest.cs new file mode 100644 index 00000000..38a5dfd3 --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/Operations/TestingOnly/GetTestingOnlyRequest.cs @@ -0,0 +1,8 @@ +#if TESTINGONLY +namespace Infrastructure.WebApi.Interfaces.Operations.TestingOnly; + +public class GetTestingOnlyRequest : IWebRequest +{ + public string? Id { get; set; } +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Interfaces/Operations/TestingOnly/GetTestingOnlyResponse.cs b/src/Infrastructure.WebApi.Interfaces/Operations/TestingOnly/GetTestingOnlyResponse.cs new file mode 100644 index 00000000..eee2ff9a --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/Operations/TestingOnly/GetTestingOnlyResponse.cs @@ -0,0 +1,8 @@ +#if TESTINGONLY +namespace Infrastructure.WebApi.Interfaces.Operations.TestingOnly; + +public class GetTestingOnlyResponse : IWebResponse +{ + public string? Message { get; set; } +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Interfaces/WebApiOperation.cs b/src/Infrastructure.WebApi.Interfaces/WebApiOperation.cs new file mode 100644 index 00000000..337fd4f4 --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/WebApiOperation.cs @@ -0,0 +1,10 @@ +namespace Infrastructure.WebApi.Interfaces; + +public enum WebApiOperation +{ + Get, + Search, + Post, + PutPatch, + Delete +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Interfaces/WebApiRouteAttribute.cs b/src/Infrastructure.WebApi.Interfaces/WebApiRouteAttribute.cs new file mode 100644 index 00000000..0926181f --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/WebApiRouteAttribute.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +namespace Infrastructure.WebApi.Interfaces; + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public class WebApiRouteAttribute : Attribute +{ + public WebApiRouteAttribute([StringSyntax("Route")] string routeTemplate, WebApiOperation operation) + { + if (!Enum.IsDefined(typeof(WebApiOperation), operation)) + { + throw new InvalidEnumArgumentException(nameof(operation), (int)operation, typeof(WebApiOperation)); + } + + RouteTemplate = routeTemplate; + Operation = operation; + } + + public string RouteTemplate { get; init; } + public WebApiOperation Operation { get; init; } +} \ No newline at end of file diff --git a/src/IntegrationTesting.WebApi.Common/IntegrationTesting.WebApi.Common.csproj b/src/IntegrationTesting.WebApi.Common/IntegrationTesting.WebApi.Common.csproj new file mode 100644 index 00000000..cc6fe08a --- /dev/null +++ b/src/IntegrationTesting.WebApi.Common/IntegrationTesting.WebApi.Common.csproj @@ -0,0 +1,19 @@ + + + + net7.0 + + + + + + + + + + + + + + + diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpecSetup.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpecSetup.cs new file mode 100644 index 00000000..f1e611d3 --- /dev/null +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpecSetup.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace IntegrationTesting.WebApi.Common; + +public abstract class WebApiSpecSetup : IClassFixture> where THost : class +{ + protected readonly HttpClient Api; + + protected WebApiSpecSetup(WebApplicationFactory factory) + { + Api = factory + .WithWebHostBuilder(builder => builder.ConfigureServices(services => + { + //TODO: swap out dependencies + //services.AddScoped(); + })) + .CreateClient(); + } +} \ No newline at end of file diff --git a/src/SaaStack.sln b/src/SaaStack.sln index 6c1fe6d8..3c425e02 100644 --- a/src/SaaStack.sln +++ b/src/SaaStack.sln @@ -53,6 +53,24 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Storage", "Storage", "{E78C EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Api", "Api", "{B68592DF-E8E8-452A-A46F-5C8ECB178FDF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.WebApi.Common", "Infrastructure.WebApi.Common\Infrastructure.WebApi.Common.csproj", "{E343B553-2D44-4BA2-AEF0-3B1F7D2DBCF3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.WebApi.Interfaces", "Infrastructure.WebApi.Interfaces\Infrastructure.WebApi.Interfaces.csproj", "{F9B4357E-7FD0-45FA-87B9-44D7EEB974C5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiHost1", "ApiHost1\ApiHost1.csproj", "{AC380EA5-16A1-4713-99B4-F259F5397F30}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarsApplication", "CarsApplication\CarsApplication.csproj", "{1B29051C-EE8E-4699-94DD-B2502C7A54C9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Api.Common.IntegrationTests", "Infrastructure.Api.Common.IntegrationTests\Infrastructure.Api.Common.IntegrationTests.csproj", "{AE57212B-9A30-4577-A795-7B411621BCDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarsApi", "CarsApi\CarsApi.csproj", "{B76CF102-CE56-4321-9060-F81E63B982D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application.Interfaces", "Application.Interfaces\Application.Interfaces.csproj", "{23FF9513-1B26-41F4-A7FE-1D8A9F0808AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntegrationTesting.WebApi.Common", "IntegrationTesting.WebApi.Common\IntegrationTesting.WebApi.Common.csproj", "{A7CA7AD7-70CA-43F0-BE73-75A01342D571}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.WebApi.Generators", "Infrastructure.WebApi.Generators\Infrastructure.WebApi.Generators.csproj", "{7AB39FD6-660F-4400-9955-B92684378492}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -78,6 +96,60 @@ Global {7154EBD1-642F-4161-AA25-F1DE82E6930E}.Release|Any CPU.Build.0 = Release|Any CPU {7154EBD1-642F-4161-AA25-F1DE82E6930E}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU {7154EBD1-642F-4161-AA25-F1DE82E6930E}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {E343B553-2D44-4BA2-AEF0-3B1F7D2DBCF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E343B553-2D44-4BA2-AEF0-3B1F7D2DBCF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E343B553-2D44-4BA2-AEF0-3B1F7D2DBCF3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E343B553-2D44-4BA2-AEF0-3B1F7D2DBCF3}.Release|Any CPU.Build.0 = Release|Any CPU + {E343B553-2D44-4BA2-AEF0-3B1F7D2DBCF3}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {E343B553-2D44-4BA2-AEF0-3B1F7D2DBCF3}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {F9B4357E-7FD0-45FA-87B9-44D7EEB974C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F9B4357E-7FD0-45FA-87B9-44D7EEB974C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F9B4357E-7FD0-45FA-87B9-44D7EEB974C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F9B4357E-7FD0-45FA-87B9-44D7EEB974C5}.Release|Any CPU.Build.0 = Release|Any CPU + {F9B4357E-7FD0-45FA-87B9-44D7EEB974C5}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {F9B4357E-7FD0-45FA-87B9-44D7EEB974C5}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {AC380EA5-16A1-4713-99B4-F259F5397F30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC380EA5-16A1-4713-99B4-F259F5397F30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC380EA5-16A1-4713-99B4-F259F5397F30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC380EA5-16A1-4713-99B4-F259F5397F30}.Release|Any CPU.Build.0 = Release|Any CPU + {AC380EA5-16A1-4713-99B4-F259F5397F30}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {AC380EA5-16A1-4713-99B4-F259F5397F30}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {1B29051C-EE8E-4699-94DD-B2502C7A54C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B29051C-EE8E-4699-94DD-B2502C7A54C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B29051C-EE8E-4699-94DD-B2502C7A54C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B29051C-EE8E-4699-94DD-B2502C7A54C9}.Release|Any CPU.Build.0 = Release|Any CPU + {1B29051C-EE8E-4699-94DD-B2502C7A54C9}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {1B29051C-EE8E-4699-94DD-B2502C7A54C9}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {AE57212B-9A30-4577-A795-7B411621BCDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE57212B-9A30-4577-A795-7B411621BCDA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE57212B-9A30-4577-A795-7B411621BCDA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE57212B-9A30-4577-A795-7B411621BCDA}.Release|Any CPU.Build.0 = Release|Any CPU + {AE57212B-9A30-4577-A795-7B411621BCDA}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {AE57212B-9A30-4577-A795-7B411621BCDA}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {B76CF102-CE56-4321-9060-F81E63B982D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B76CF102-CE56-4321-9060-F81E63B982D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B76CF102-CE56-4321-9060-F81E63B982D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B76CF102-CE56-4321-9060-F81E63B982D6}.Release|Any CPU.Build.0 = Release|Any CPU + {B76CF102-CE56-4321-9060-F81E63B982D6}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {B76CF102-CE56-4321-9060-F81E63B982D6}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {23FF9513-1B26-41F4-A7FE-1D8A9F0808AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23FF9513-1B26-41F4-A7FE-1D8A9F0808AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23FF9513-1B26-41F4-A7FE-1D8A9F0808AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23FF9513-1B26-41F4-A7FE-1D8A9F0808AE}.Release|Any CPU.Build.0 = Release|Any CPU + {23FF9513-1B26-41F4-A7FE-1D8A9F0808AE}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {23FF9513-1B26-41F4-A7FE-1D8A9F0808AE}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {A7CA7AD7-70CA-43F0-BE73-75A01342D571}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7CA7AD7-70CA-43F0-BE73-75A01342D571}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7CA7AD7-70CA-43F0-BE73-75A01342D571}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7CA7AD7-70CA-43F0-BE73-75A01342D571}.Release|Any CPU.Build.0 = Release|Any CPU + {A7CA7AD7-70CA-43F0-BE73-75A01342D571}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {A7CA7AD7-70CA-43F0-BE73-75A01342D571}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {7AB39FD6-660F-4400-9955-B92684378492}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7AB39FD6-660F-4400-9955-B92684378492}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7AB39FD6-660F-4400-9955-B92684378492}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7AB39FD6-660F-4400-9955-B92684378492}.Release|Any CPU.Build.0 = Release|Any CPU + {7AB39FD6-660F-4400-9955-B92684378492}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {7AB39FD6-660F-4400-9955-B92684378492}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F5C77A86-38AF-40E4-82FC-617E624B2754} = {508E7DA4-4DF2-4201-955D-CCF70C41AD05} @@ -100,5 +172,12 @@ Global {7154EBD1-642F-4161-AA25-F1DE82E6930E} = {5838EE94-374F-4A6F-A231-1BC1C87985F4} {E78C7FBE-ADD0-4124-A273-5D5AC0C02B27} = {0358DED1-114C-4EFB-98C7-3D6B50A127DF} {B68592DF-E8E8-452A-A46F-5C8ECB178FDF} = {0358DED1-114C-4EFB-98C7-3D6B50A127DF} + {E343B553-2D44-4BA2-AEF0-3B1F7D2DBCF3} = {B68592DF-E8E8-452A-A46F-5C8ECB178FDF} + {F9B4357E-7FD0-45FA-87B9-44D7EEB974C5} = {B68592DF-E8E8-452A-A46F-5C8ECB178FDF} + {1B29051C-EE8E-4699-94DD-B2502C7A54C9} = {57FDFB31-D6B6-4369-A78C-6F3D3AEA0D79} + {B76CF102-CE56-4321-9060-F81E63B982D6} = {57FDFB31-D6B6-4369-A78C-6F3D3AEA0D79} + {23FF9513-1B26-41F4-A7FE-1D8A9F0808AE} = {BA1AEAEC-68CD-4855-A8CB-0DC2070B6A8C} + {A7CA7AD7-70CA-43F0-BE73-75A01342D571} = {5838EE94-374F-4A6F-A231-1BC1C87985F4} + {7AB39FD6-660F-4400-9955-B92684378492} = {B68592DF-E8E8-452A-A46F-5C8ECB178FDF} EndGlobalSection EndGlobal diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 6381246a..8976fe59 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -1,2 +1,26 @@  - True \ No newline at end of file + Required + Required + Required + Required + True + True + True + True + True + True + AtLineStart + True + 2.0 + InCSharpExceptStringLiterals + False + + + TESTINGONLY + True + #if TESTINGONLY + $SELECTION$$END$ + #endif + True + True + True \ No newline at end of file