diff --git a/README.md b/README.md index 7ab4a283..65fee2e7 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,18 @@ # SaaStack -Are you about to build a new SaaS product from scratch and do that on .NET? +Are you about to build a new SaaS product from scratch? On .NET? -Then, start with SaaStack. +Then, try starting with SaaStack codebase template. -It is a complete "codebase template" for building real-world, fully featured SaaS web products. +It is a complete template for building real-world, fully featured SaaS web products. Ready to build, test, and deploy into a cloud provider of your choice (e.g., Azure, AWS, Google Cloud, etc.) -> Don't spend months building all this stuff from scratch. You and your team don't need to. We've done all that for you already; just take a look, see hat is there and take it from here. You can always change it the way you like it as you proceed, you are not locked into anyone else framework. +> Don't spend months building all this stuff from scratch. You and your team don't need to. We've done all that for you already; just take a look, see what is already there and take it from here. You can always change it the way you like it as you proceed, you are not locked into anyone else's framework. > > This is not some code sample like those you would download to learn a new technology or see in demos online. This is way more comprehensive, way more contextualized, and way more realistic about the complexities you are going to encounter in reality. -> This template contains a partial (but fully functional) SaaS product that you can deploy from day one and start building your product on. But it is not yet complete. That part is up to you. +> This template contains a partial (but fully functional) SaaS product that you can deploy from day one and start building your product on. But it is not yet complete. That next part is up to you. The codebase demonstrates common architectural styles that you are going to need in your product in the long run, such as: @@ -42,7 +42,7 @@ or if you prefer AWS: ## Who is it for? -This starter template is NOT for everyone, nor for EVERY software project, nor for EVERY skill level. +This starter template is NOT for everyone, nor for EVERY software project, nor for EVERY skill level. We need to say that because all software products are different, there is not one silver bullet for all of them. * The people using this template must have some experience applying "first principles" of building new software products from scratch because it is a starter template that can (and should) be modified to suit your context. It is a far better starting point than building everything from scratch again. @@ -126,7 +126,7 @@ The starter template also takes care of these specific kinds of things: * It integrates product usage metrics to monitor and measure the actual usage of your product (e.g., MixPanel, Google Analytics, Application Insights, Amazon XRay, etc.) * It integrates crash analytics and structured logging so you can plug in your own preferred monitoring (e.g., Application Insights, CloudWatch, Sentry.io, etc.). * It uses dependency injection extensively so that all modules and components remain testable and configurable. - * It defines standard and unified configuration patterns (e.g., using appsettings.json) to load tenanted or non-tenanted runtime settings. + * It defines standard and unified configuration patterns (e.g., using `appsettings.json`) to load tenanted or non-tenanted runtime settings. * Application * Supports one or more applications, agnostic to infrastructure interop (i.e., allows you to expose each application as a REST API (default) or as a reliable Queue, or any other kind of infrastructure) * Supports transaction scripts + anemic domain model or Domain Driven Design diff --git a/README_DERIVATIVE.md b/README_DERIVATIVE.md index 9a728be9..15da0bd1 100644 --- a/README_DERIVATIVE.md +++ b/README_DERIVATIVE.md @@ -106,6 +106,14 @@ Now, test that LocalStack works by running: `localstack start` > When testing, Docker will need to be running for LocalStack to be used +### External Adapter Testing + +> You only need to perform this step once, prior to running any of the `Integration.External` tests against 3rd party adapters (e.g., Flagsmith, Twillio, etc) + +In the `Infrastructure.Shared.IntegrationTests` project, create a new file called `appsettings.Testing.local.json` and fill out the empty placeholders you see in `appsettings.TestingOnly.json` with values from service accounts that you have created for testing those 3rd party services. + +> DO NOT add this file to source control! + # Build & Deploy When pushed, all branches will be built and tested with GitHub actions diff --git a/docs/design-principles/0080-ports-and-adapters.md b/docs/design-principles/0080-ports-and-adapters.md index f4b0559f..1c3d757f 100644 --- a/docs/design-principles/0080-ports-and-adapters.md +++ b/docs/design-principles/0080-ports-and-adapters.md @@ -1,5 +1,291 @@ # Ports and Adapters +The underlying architectural style in this codebase is the Ports and Adapters. This means that the application is designed to drive and be driven by the domain model, and the domain model is not dependent on any external infrastructure. + +![Ports and Adapters](../images/Ports-And-Adapters.png) + +In this way, we can re-focus on the core domain model and be protected by changes in infrastructure. + +Possibly the most powerful effect of this approach is that any data, any service, and interaction that is needed by the core domain is accessible by a domain specific "port" and can be implemented by one or more "adapters" connecting to other components or external systems. For the developer, this liberates them to tackle a complex interaction in testable steps. + +This is achieved by using the [Adapter Pattern](https://en.wikipedia.org/wiki/Adapter_pattern) to connect the domain model to the external infrastructure. + +In general, this de-coupling pattern can also be applied in any layer of the code, but it is particularly useful in the "Application Layer" and in the "Infrastructure Layers". The pattern can also be used in composing infrastructure components together to offer several abstraction layers, between the logical and physical layers. + +> In other words, you don't need to go from a single "port" to a single "adapter" representing physical infrastructure (like a database). Your "port" can be implemented by an "adapter" that exposes a lower level "port" that can be implemented by a lower level "adapter". + +A "port" defines the data and behavior required by the consumer of it. Thus the consumer is always protected from changes in any implementation of the "port". +> This pattern is also known as the "[Plug-in Pattern](https://en.wikipedia.org/wiki/Plug-in_(computing))", which is common in reusable libraries or frameworks that offer extensibility points for those using them. + +A "port," in code is simply defined in an interface. +> It is designed to be as small as possible, and as specific as possible, adhering to the [ISP principle](https://en.wikipedia.org/wiki/SOLID). + +A "service" that implements the "port", is known as an "adapter" + +An "adapter" is realized as a concrete class, that simply implements the "port" interface. +> An adapter, in code, rarely ever uses the term "adapter" as part of its name or identifier. Rather, it is usually named after the behavior it provides, the 3rd party library it wraps, or the 3rd party service it integrates with. + +Whenever a port is required to be used by a component, it declares the port in its constructor, and dependency injection is used to inject a real adapter into the component at runtime (depending on what is currently registered in the container at that time). + +An "adapter" class typically implements a specific kind of behavior that is self-describing in the class, or it will wrap a third-party library to implement this behavior, or it will wrap a 3rd party SDK that relays the information to a remote 3rd party system. In general, these "adapters" facilitate access to infrastructure components, such as databases, message queues, or machine infrastructure like clocks, disks, encryption, configuration, etc, or access to remote 3rd party services. Any I/O of any kind. Sometimes, they provide access to other subdomains and deployed modules in the same codebase. Essentially they help the developer separate multiple concerns into services that can be independently tested and developed. + +This concrete "adapter" is then registered in the dependency injection container at runtime, for injection to where ever the port is required. +> In this way, the consumer of the port is completely de-coupled from the implementation of the port, and the implementation of the port is completely de-coupled from the consumer of the port. + +What this also enables is that the code for the port and code for the adapter(s) can be kept in different libraries, further reducing the coupling of code in reusable libraries in the code. This leads to far better maintainability and portability of shared libraries in the code. Which is a key enabler for modular monoliths, when they have to be split up. + ## Design Principles -## Implementation \ No newline at end of file +* We want to maintain high levels of de-coupling for performing any actions outside subdomain aggregates in the "Domain Layer". This means that access to any data outside of a subdomain requires the use of a "port" to obtain it, which provides data that can be presented to the aggregate in the subdomain. This is the primary job of the "Application Layer". +* We want to be able to swap out the implementation of any "adapter" (something that provides/processes its own data) without changing the domain model or requiring a change in it. +* We want to be able to unit test the domain model in isolation without requiring any external infrastructure. (A domain model should only require data and, in some rare cases, require "domain services" which can be mocked in order to be tested). +* We want to be able to test the real infrastructural "adapters" in isolation also, without needing to use the domain model. +* If the real infrastructure "adapters" require external 3rd party services to run properly, then we want to be able to test them in isolation as well. (using testing-only configurations) +* Given that we can reliably unit test the domain model independently, and we can reliably unit test the infrastructure components independently and given that we can integration test the domain model with "stubbed" infrastructure "adapters" (that can "fake" the behavior of real infrastructure "adapter"), then we can then be very confident that the system will work as expected when it is deployed with real infrastructure "adapters" in staging/production environments. In fact, if this is the case, then the only unknown should be whether the staging/production configuration used by the real infrastructure "adapter" is valid or not in the staging/production environments. If it is valid, then the system should work in staging/production as designed. +* When we run the code locally in the local environment (for manual testing or demos, etc.) we want to avoid having to install any infrastructural components that cannot be swapped out for adapters that run in memory or on disk on the local machine. For example, we don't want to install databases, or data servers of any kind. +* When we run the code in automated testing we also do not have to install any infrastructural components, especially if they require network communication. Local automated testing and automated testing in CI should be offline, and trivial to configure. + +## Implementation + +### Application Services + +Many of the ports and adapters in the codebase are to be found in the "services" used by the classes in the "Application Layer" (not exclusively). + +> They are often known more commonly as "services" borrowed from other, more traditional architectures. +> +> There are examples of the same mechanism of ports and adapters all over the "Infrastructure Layer" too. +> +> Ports and adapters (as a pattern) are implemented at "Domain Services" in the "Domain Layer" + +The main class in the "Application Layer" of any subdomain (e.g., `CarsApplication`) defines new "ports" (e.g., `ICarRepository`) to either access data from other components or to delegate certain operations to other components. Ports are also the prescribed way to communicate with other subdomains. + +Typically, the main "Application Layer" class consumes (or "drives") these "ports". + +> This is an important mechanism to separate concerns of the classes in the application layer, whose primary purpose is to obtain data for the aggregate of the "Domain Layer" to operate. + +Conversely, the classes in the "Application Layer" of a given module (e.g., `CarsApplication`) are, in fact, "adapters" themselves. They implement the declared "port" (e.g., `ICarsApplication`) that is "driven" by the upstream API class that consumes this "port". + +#### Unit Testing + +Ports are never unit tested, as they are interfaces. + +Most "adapters" are unit tested. Or if not, they are covered in integration tests. Sometimes both are necessarily used. + +There are some notable exceptions to that unit testing rule: + +* API "service" classes (define the API) - they implement the two-way adapter to the internet. They are typically not unit tested because they simply delegate calls to the underlying domain-specific "Application Layer". Unit tests here would not achieve much that API integration tests wouldn't verify. In some rare cases, where there is less-trivial processing or mapping of data, there may be unit tests to cover that logic. +* Data "Repositories" (used by the "Application Layer") - they implement a two-way adapter to some kind of data store. They are typically not unit tested because they simply delegate calls to underlying generic "ports". Unit testing here would not achieve much that integration tests wouldn't verify. +* Inter-module "Service Clients" (used by the "Application Layer") - like repositories above, they implement two-way adapters to another subdomain. They are typically not unit tested because they simply delegate calls to underlying generic "ports" (e.g., an application interface like `ICarsApplication` or an HTTP service client). Unit testing here would not achieve much that integration tests wouldn't verify. + +> Repositories are only referred to as "repositories" for historical reason; in general,l they are just a specialization of a "service". + +### 3rd Party Services + +Adapters to 3rd party remote systems (a.k.a "3rd party integrations") are a special kind of "adapter" requiring additional and special treatment in the codebase, so that they are properly tested and configured correctly for use in local environments, as well as in staging/production environments. + +The main difference between them is that they: + +1. Typically require the creation of accounts in the 3rd party online system (e.g., register an account with a 3rd party service and obtain an API key, a client ID, a client secret, etc). In these cases, they require separate accounts for testing-only and separate accounts for staging/production use. Testing-only accounts can never lead observers to compromising staging/production accounts. Some 3rd party providers mitigate this with sandbox environments, but not all provide that service. +2. They often offer the use of a 3rd party SDK, a 3rd party library, or a 3rd party service to communicate with the 3rd party system. These can be very useful sometimes, depending on their sophistication (particularly with caching, retry policies, etc), but they must be configurable to point to local "stubbed" versions of them in order to be used in local development environments. +3. They require connection configuration (and often secrets) to access the 3rd party system, which often differs between local, staging, and production environments. Details for staging/production environments can never be hard-coded into the codebase, and must be configurable at deployment time. +4. They require us to build one or more "stub" implementations of the 3rd party system so that they can be used in local development and in automated testing without requiring access to the real 3rd party system. + +In practice, to build one of these 3rd party adapters, you need to take extra time and care to provide (at least) these six things: + +1. An "adapter" to the real 3rd party system. +2. A number of accounts with the 3rd party +3. Integration tests that test the adapter against the real 3rd party system, using real configuration. +4. Register the adapter in the dependency injection container. +5. A stub adapter that can be used in local/CI automated testing. +6. A stub API that can be used in local development. + +#### Adapter + +Most external adapters for 3rd party integrations, that you build, should be built and maintained in the `Infrastructure.Shared` project in a folder called: `ApplicationServices/External`. +> This single project would be the only project that would take a dependency on any 3rd party NuGet packages required by these adapters, which should be kept to a minimum. + +Your adapter will likely act as an HTTPS "Service Client" to a remote 3rd party service (in the cloud or deployed in your cloud, a.k.a on-premise). Thus, the name of your adapter is likely to follow the naming pattern: `VendorHttpServiceClient`. + +Your adapter can use the vendor-provided SDK library (from NuGet), but consider these challenges: + +1. If the adapter has significant complexity to it, and/or your adapter has behavior that you feel should be unit tested, then using the SDK library will make it very hard to unit test the adapter since you cannot be accessing a remote system during unit testing. +2. If this adapter is going to be used in local development (i.e., it is injected into the DI container in local development), then the SDK will be required to be configured to direct all its HTTP traffic to a stub API that you must provide (refer to step 6 above). + +If you want to unit test your adapter (not always necessary), then an easy technique to use is to wrap the SDK code inside another port (e.g. , `IVendorServiceClient`) and then implement that adapter using the vendor-provided SDK, with an internal constructor used only for testing. This will allow you to unit test your original adapter, by injecting a `new Mock()`. + +For example, + +```c# +public class VendorHttpServiceClient : IMyPort +{ + private readonly IVendorServiceClient _serviceClient; + + // used to inject into DI container + public MyAdapter(IRecorder recorder): this(recorder, new VendorServiceClient() + { + } + + // used only for unit testing + internal MyAdapter(IRecorder recorder, IVendorServiceClient serviceClient) + { + _serviceClient = serviceClient; + } + + // remaining methods of the IMyPort interface +} +``` + +If you are plugging in your adapter for local development and automated testing, then you will be providing a stub API (as per step 6 above). In this case, the vendor SDK will need to be able to send its HTTP requests to another URL other than the one in the cloud where their service is hosted (e.g., `https://api.vendor.com`). The SDK must support a way for you to change that `BaseUrl`. + +If not, you have very few good choices other than to: + +1. Inject a different implementation of the `IVendorServiceClient` above. +2. Forgo using the vendor SDK altogether, and instead use the `ApiServiceClient` and send the HTTP requests yourself to the remote vendor API. This is more work, but it is the only way to ensure that the adapter can be used in local development and automated testing properly. + +#### Accounts with 3rd party + +In general, most 3rd party vendor-provided services will require you to register and create accounts with them to gain access to their public APIs. +> Note: some of the services require being paid for, which is another hurdle. + +For most of these integrations, you are going to need to create at least two accounts with the 3rd party: + +1. One account called "testingonly" that will be used for local development and automated testing. +2. One/more account(s) for "staging/production" that is used for staging and production environments. + +> Some vendors provide sandboxes for testing, which can be used in place of the "testingonly" account. This is a great feature, but not all vendors provide this. + +Regardless, the credentials, access, and configurations between these accounts must be managed separately. + +You must never expose staging/production credentials or configuration outside your organization. This is a serious security risk. + +You can, however, expose testing-only credentials and configuration inside your organization, but only if they cannot lead to those used in staging/production environments. Separate secured and protected accounts are strongly recommended. + +> In this SaaStack codebase template, the contributors have registered testing-only accounts with various vendors, and we have hard-coded some of those credentials in the various `appsettings.Testing.json` files of the various adapter integration testing projects, in the codebase. This is a security risk, but it is acceptable for the purposes of this template since this cannot lead to any staging/production environments. These accounts can be compromised with little exposure. In a real derivative project, you are using, if this code was exposed outside your organization (for example, in open source), you should not do this. If the codebase is not exposed outside your own organization, there is also some risk in exposing these to those with access to your code. You might consider using secrets files, environment variables, a secret manager, a key vault, or a configuration service to manage this sensitive configuration. + +#### Integration Testing + +Integration tests are REQUIRED to test the real adapter against the real 3rd party vendor systems, using real configuration, to ensure that the adapter works as designed. + +These integration tests are different than others, in many ways: + +1. They are of a different category called "Integration.External" +2. They should not be executed frequently by the team, like other integration tests are, since they are testing against real 3rd party systems, can be slow, can be rate limited, and can incur costs to your organization (depending on the 3rd party system, and pricing). +3. They should be executed infrequently in CI builds, perhaps once a week/month or whenever the code in the adapters is changed. +4. They can fail from time to time, depending on how well-managed the 3rd party vendor is at maintaining their systems. When these tests do break, it's a pretty clear indicator that the 3rd party service has changed from what it used to be, and your team needs to know ASAP. Or perhaps your adapters are now broken and need to be fixed. + +#### Configuration + +The adapter must be configurable at runtime, so that it can be used in local development, automated testing, and in staging/production environments. + +This configuration is likely to be kept in `appsettings.json` files in each of the host projects that use the adapter (e.g., `ApiHost1.csproj`). + +Configuration for each of these adapters is done under the `ApplicationServices` section of the `appsettings.json` file, usually under a key named after the vendor. + +For example, + + ```json + { + "ApplicationServices": { + "Vendor": { + "BaseUrl": "https://api.vendor.com", + "ClientId": "client-id", + "ClientSecret": "client-secret" + } + } + } + ``` + +Now, from a security perspective and a testing perspective, you do not want to define any real configuration settings here since that would expose them to anyone who can see the code. + +In automated "External" Integration testing (as described above), the configuration used there to talk to a real online service, is kept in `appsettings.Testing.json` (and in `appsettings.testing.local.json`) in the testing project. + +In automated "API" Integration testing, your adapter is going to be swapped out for a "stubbed" adapter that is injected at testing time, using overrides in the integration testing project so that your adapter is not used at all. + +Configuration in the host project is there for running the adapter in local development and in staging/production environments. + +For staging/production environments, your automated deployment process should be substituting/replacing the configuration in `appsettings.json` just before deployment time with the correct configuration to real 3rd party systems. + +But for local development (manual debugging and testing), you need settings here that can be used in your adapter, to talk to a stub API (see below). This means that you want predominantly empty placeholder or testing entries in `appsettings.json` that have no sensitive values. + +> Remember: to update all staging/production configurations to your CI/CD systems so that you don't forget those settings on the next deployment of code. + +#### Register in Dependency Injection Container + +The adapter must be registered in the dependency injection container, so that it can be injected into consumer classes that need it at runtime. That can be done in one of two places: + +1. In the `HostExtensions.cs` file of the `Infrastructure.Web.Hosting.Common` project, in the `ConfigureApiHost` method. +2. In the respective `ISubDomainModule` class of the subdomain that uses the adapter, in the `ISubDomainModule.RegisterServices` method. + +Furthermore, you can use compiler compilation directives to register this adapter in different build configurations or different hosting environments. + +For example, if you want it only to be used in local development and automated testing, or never in either of those, you can use the `#if TESTINGONLY` directive. + +For example, if you want it only to be used in an AZURE deployment, not an AWS deployment, or never in either of those, you can use the `#if HOSTEDONAZURE || HOSTEDONAWS` directives. + +#### Stub Adapter + +In automated "API" Integration testing, we do NOT want to be using the real adapter at all. Instead, we want to use a programmable "stub," so we can control its behavior, and we don't want to use online access to real systems across the network. + +Depending on which subdomain the adapter is used in, you will need to provide a stub adapter that can be used in automated "API" Integration testing, rather than using the real adapter at all. + +This "stub" adapter replaces your adapter, and provides a fake implementation of the real adapter. Often providing limited data or default behavior, enough to test the API under test. + +You have two choices depending on the scope of your adapter. + +1. If your adapter is used by all subdomains and all hosts (i.e., it is used everywhere), then you can create a "stub" adapter in the `IntegrationTesting.Web.Api.Common` project in the `Stubs` folder, and you can inject it into the `WebApiSetup` in the `ConfigureWebHost` method along with the other global adapters. +2. If your adapter is only used in one (or more) specific subdomains, then you can create the "stub" adapter in the integration testing project of that subdomain (i.e., the `SubdomainInfrastructure.IntegrationTests` project), in a folder called `Stubs`, and you can inject that sub into the `OverrideDependencies` method of your test class. + +For example, if you take a look at the `StubFeatureFlags` in the `IntegrationTesting.Web.Api.Common` project, you can see how it is injected into the `WebApiSetup` in the `ConfigureWebHost` method. + +For example, if you look at the `StubEmailDeliveryService` in the `AncillaryInfrastructure.IntegrationTests` project, you can see how it is injected into the `OverrideDependencies` method of the `EmailsApiSpec` class. + +#### Stub API + +In local development, we DO want to be using the real adapter, but we want to have a programmable 3rd party "stub" API so that we can control its behavior, and we don't want to use online access to real systems across the network. + +This "stub" API stands in for the real 3rd party API, and the real adapter communicates with this "stub" API. + +> This additional step is only necessary for adapters that access 3rd party online systems. If your adapter does not access a 3rd party online system, then you do not need to provide a stub API. + +To make this work, we need the configuration defined in the `appsettings.json` file of the Host project to point to the "stub" API instead of pointing to the real 3rd party system. This API will always be hosted by the `TestingStubApiHost` project at `https://localhost:5656`. + +For example, the `BaseUrl` configuration setting in the `appsettings.json` file of the Host project for your adapter could be set to `https://localhost:5656/vendor`. + +```json +{ + "ApplicationServices": { + "Vendor": { + "BaseUrl": "https://localhost:5656/vendor/" + } + } +} +``` + +In the `TestingStubApiHost` project, in the `Api` folder: + +1. Create a class that derives from `StubApiBase` (e.g., `StubVendorApi`). +2. Consider applying a `WebServiceAttribute` to define a prefix for this API (e.g. `[WebService("/vendor")]`) to separate this API from the other vendors that will also be hosted at `https://localhost:5656`. +3. Implement the HTTP endpoints that your adapter uses, and provide empty or default responses that your adapter expects to receive. (same as the way we implement any endpoints) +4. The Request types, Response types (and all complex data types that are used in the request and response) should all be defined in the `Infrastructure.Web.Api.Operations.Shared` project in a subfolder of the `3rdParties` folder named after the vendor (i.e., `3rdParties/Vendor`). These types follow the same patterns as requests and responses for all other API operations in the codebase. Except that these ones may use additional JSON attributes to match the real 3rd party APIs. (e.g., `JsonPropertyName` and `JsonConverter` attributes). +5. Make sure that you trace out (using the `IRecorder`) each and every request to your Stub API (follow other examples) so that you can visually track when the API is called (by your adapter) in local testing. You can see this output in the console output for the `TestingStubApiHost` project. + +For example, + +```csharp +[WebService("/vendor")] +public class StubVendorApi : StubApiBase +{ + public StubVendorApi(IRecorder recorder, IConfigurationSettings settings) : base(recorder, settings) + { + } + + public async Task> GetData( + VendorGetDataRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, "StubVendor: GetData"); + return () => + new Result(new VendorGetDataResponse("data")); + } +} +``` \ No newline at end of file diff --git a/docs/design-principles/0120-feature-flagging.md b/docs/design-principles/0120-feature-flagging.md new file mode 100644 index 00000000..088e5a76 --- /dev/null +++ b/docs/design-principles/0120-feature-flagging.md @@ -0,0 +1,59 @@ +# Feature Flagging + +## Design Principles + +* We want to be able to deploy code which includes features/code that we dont want visible/available/enabled for end users. +* We want to be able to progressively roll-out certain features to specific users, or segments of the market to manage any risk of deploying new features +* This optionality can be attributed to all end-users, or specific end-users, or even all users within a specific tenant +* We want those features to be configured externally to the running system, without changing what has been deployed +* We want to have those flags managed separately to our system, so that we don't have to build this kind of infrastructure ourselves + +## Implementation + +We have provided a service called `IFeatureFlags` that is available in any component of the architecture. + +> That service will be implemented by an adapter to a 3rd party external system such as FlagSmith, GitLab, LaunchDarkly etc. + +We have also provided an API to access this capability from the BEFFE, so flags can be shared in the Frontend JS app. + +The interface `IFeatureFlags` provides methods to query flags in the system, using pre-defined flags in the code, that should be represented in the 3rd party system. + +For example, + +```c# +public class MyClass +{ + private readonly IFeatureFlags _featureFlags; + + public MyClass(IFeatureFlags featureFlags) + { + _featureFlags = featureFlags; + } + + public void DoSomethingForAllUsers() + { + if (_featureFlags.IsEnabled(Flag.MyFeature)) + { + ...do somethign with this feature + } + } + + public void DoSomethingForTheCallerUser(ICallerContext caller) + { + if (_featureFlags.IsEnabled(Flag.MyFeature, caller)) + { + ...do somethign with this feature + } + } +} +``` + +Where `MyFeature` is defined as a flag in `FeatureFlags.resx` file in the `Common` project. + +### Defining flags + +In code, flags are defined in the `FeatureFlags.resx` file in the `Common` project. + +A source generator runs every build to translate those entries in the resource file to instances of the `Flags` class, to provide a typed collection of flags for use in code. + +> This provides an easy way for intellisense to offer you the possible flags in the codebase to avoid using flags that no longer exist. \ No newline at end of file diff --git a/docs/design-principles/README.md b/docs/design-principles/README.md index aa8adb01..64183db3 100644 --- a/docs/design-principles/README.md +++ b/docs/design-principles/README.md @@ -8,4 +8,7 @@ * [Dependency Injection](0060-dependency-injection.md) how you implement DI * [Persistence](0070-persistence.md) how you design your repository layer, and promote domain events * [Ports and Adapters](0080-ports-and-adapters.md) how we keep infrastructure components at arms length, and testable, and how we integrate with any 3rd party system -* [Backend for Frontend](0900-back-end-for-front-end.md) the web server that is tailored for a web UI, and brokers the backend \ No newline at end of file +* [Authentication and Authorization](0090-authentication-authorization.md) how we authenticate and authorize users +* [Email Delivery](0100-email-delivery.md) how we send emails and deliver them asynchronously and reliably +* [Backend for Frontend](0110-back-end-for-front-end.md) the BEFFE web server that is tailored for a web UI, and brokers secure access to the backend +* [Feature Flagging](0120-feature-flagging.md) how we enable and disable features at runtime \ No newline at end of file diff --git a/docs/images/Ports-And-Adapters.png b/docs/images/Ports-And-Adapters.png new file mode 100644 index 00000000..5ab7622d Binary files /dev/null and b/docs/images/Ports-And-Adapters.png differ diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx index 4728b56b..09afbd57 100644 Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ diff --git a/src/.gitignore b/src/.gitignore index e6521214..5c9a62d2 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -174,4 +174,9 @@ Desktop.ini $RECYCLE.BIN/ # Mac desktop service store files -.DS_Store \ No newline at end of file +.DS_Store + +# Local configuration +local.settings.json +appsettings.local.json +appsettings.Testing.local.json diff --git a/src/.idea/.idea.SaaStack/.idea/inspectionProfiles/Project_Default.xml b/src/.idea/.idea.SaaStack/.idea/inspectionProfiles/Project_Default.xml index 85ed4b44..37844881 100644 --- a/src/.idea/.idea.SaaStack/.idea/inspectionProfiles/Project_Default.xml +++ b/src/.idea/.idea.SaaStack/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,14 @@ \ No newline at end of file diff --git a/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs b/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs index 1ad11e88..1635825f 100644 --- a/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs +++ b/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs @@ -3,6 +3,7 @@ using Application.Persistence.Shared.ReadModels; using Common; using Common.Configuration; +using Common.FeatureFlags; using Common.Recording; using Infrastructure.Common.Recording; using Infrastructure.Hosting.Common; @@ -24,6 +25,7 @@ public static void AddDependencies(this IServiceCollection services, IConfigurat services.AddHttpClient(); services.AddSingleton(new AspNetConfigurationSettings(configuration)); services.AddSingleton(); + services.AddSingleton(); #if TESTINGONLY services.AddSingleton(new NullCrashReporter()); diff --git a/src/AncillaryApplication.UnitTests/FeatureFlagsApplicationSpec.cs b/src/AncillaryApplication.UnitTests/FeatureFlagsApplicationSpec.cs new file mode 100644 index 00000000..3e34afca --- /dev/null +++ b/src/AncillaryApplication.UnitTests/FeatureFlagsApplicationSpec.cs @@ -0,0 +1,85 @@ +using Application.Interfaces; +using Common; +using Common.FeatureFlags; +using FluentAssertions; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace AncillaryApplication.UnitTests; + +[Trait("Category", "Unit")] +public class FeatureFlagsApplicationSpec +{ + private readonly FeatureFlagsApplication _application; + private readonly Mock _caller; + private readonly Mock _featuresService; + + public FeatureFlagsApplicationSpec() + { + var recorder = new Mock(); + _caller = new Mock(); + _caller.Setup(cc => cc.IsAuthenticated).Returns(true); + _caller.Setup(cc => cc.CallerId).Returns("acallerid"); + _caller.Setup(cc => cc.TenantId).Returns("atenantid"); + _featuresService = new Mock(); + _featuresService.Setup(fs => fs.GetFlagAsync(It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new FeatureFlag + { + Name = "aname", + IsEnabled = true + }); + _application = new FeatureFlagsApplication(recorder.Object, _featuresService.Object); + } + + [Fact] + public async Task WhenGetFeatureFlag_ThenReturns() + { + var result = + await _application.GetFeatureFlagAsync(_caller.Object, "aname", null, "auserid", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be("aname"); + result.Value.IsEnabled.Should().BeTrue(); + _featuresService.Verify(fs => fs.GetFlagAsync(It.Is(flag => flag.Name == "aname"), Optional.None, + "auserid", It.IsAny())); + } + + [Fact] + public async Task WhenGetFeatureFlagForCaller_ThenReturns() + { + var result = + await _application.GetFeatureFlagForCallerAsync(_caller.Object, "aname", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be("aname"); + result.Value.IsEnabled.Should().BeTrue(); + _featuresService.Verify(fs => fs.GetFlagAsync(It.Is(flag => + flag.Name == "aname" + ), "atenantid", "acallerid", It.IsAny())); + } + + [Fact] + public async Task WhenGetAllFeatureFlags_ThenReturns() + { + _featuresService.Setup(fs => fs.GetAllFlagsAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() + { + Name = "aname", + IsEnabled = true + } + }); + + var result = + await _application.GetAllFeatureFlagsAsync(_caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Count.Should().Be(1); + result.Value[0].Name.Should().Be("aname"); + result.Value[0].IsEnabled.Should().BeTrue(); + _featuresService.Verify(fs => fs.GetAllFlagsAsync(It.IsAny())); + } +} \ No newline at end of file diff --git a/src/AncillaryApplication/FeatureFlagsApplication.cs b/src/AncillaryApplication/FeatureFlagsApplication.cs index fbfa8524..d3f3d342 100644 --- a/src/AncillaryApplication/FeatureFlagsApplication.cs +++ b/src/AncillaryApplication/FeatureFlagsApplication.cs @@ -28,8 +28,24 @@ public async Task, Error>> GetAllFeatureFlagsAsync(ICal return flags.Value.ToList(); } + public async Task> GetFeatureFlagForCallerAsync(ICallerContext context, string name, + CancellationToken cancellationToken) + { + var flag = await _featureFlags.GetFlagAsync(new Flag(name), context, cancellationToken); + if (!flag.IsSuccessful) + { + return flag.Error; + } + + _recorder.TraceInformation(context.ToCall(), + "Feature flag {Name} was retrieved for user {User} in tenant {Tenant}", name, context.CallerId, + context.TenantId ?? "none"); + + return flag.Value; + } + public async Task> GetFeatureFlagAsync(ICallerContext context, string name, - string? tenantId, string? userId, CancellationToken cancellationToken) + string? tenantId, string userId, CancellationToken cancellationToken) { var flag = await _featureFlags.GetFlagAsync(new Flag(name), tenantId, userId, cancellationToken); if (!flag.IsSuccessful) @@ -37,7 +53,7 @@ public async Task> GetFeatureFlagAsync(ICallerContext return flag.Error; } - _recorder.TraceInformation(context.ToCall(), "Feature flag {Name} was retrieved", name); + _recorder.TraceInformation(context.ToCall(), "Feature flag {Name} was retrieved for user {User}", name, userId); return flag.Value; } diff --git a/src/AncillaryApplication/IFeatureFlagsApplication.cs b/src/AncillaryApplication/IFeatureFlagsApplication.cs new file mode 100644 index 00000000..c92f9f12 --- /dev/null +++ b/src/AncillaryApplication/IFeatureFlagsApplication.cs @@ -0,0 +1,17 @@ +using Application.Interfaces; +using Common; +using Common.FeatureFlags; + +namespace AncillaryApplication; + +public interface IFeatureFlagsApplication +{ + Task, Error>> GetAllFeatureFlagsAsync(ICallerContext context, + CancellationToken cancellationToken); + + Task> GetFeatureFlagAsync(ICallerContext context, string name, string? tenantId, + string userId, CancellationToken cancellationToken); + + Task> GetFeatureFlagForCallerAsync(ICallerContext context, string name, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs b/src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs index 4b99fb48..33f31260 100644 --- a/src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs +++ b/src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs @@ -183,7 +183,7 @@ public void WhenSucceededDelivery_ThenDelivered() root.Delivered.Should().BeNear(DateTime.UtcNow); root.Events.Last().Should().BeOfType(); } - + private static QueuedMessageId CreateMessageId() { var messageId = new MessageQueueIdFactory().Create("aqueuename"); diff --git a/src/AncillaryInfrastructure.IntegrationTests/FeatureFlagsApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/FeatureFlagsApiSpec.cs new file mode 100644 index 00000000..2a55a976 --- /dev/null +++ b/src/AncillaryInfrastructure.IntegrationTests/FeatureFlagsApiSpec.cs @@ -0,0 +1,62 @@ +using System.Net; +using ApiHost1; +using Common.FeatureFlags; +using FluentAssertions; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using IntegrationTesting.WebApi.Common; +using IntegrationTesting.WebApi.Common.Stubs; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace AncillaryInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.Web")] +[Collection("API")] +public class FeatureFlagsApiSpec : WebApiSpec +{ + private readonly StubFeatureFlags _featureFlags; + + public FeatureFlagsApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + _featureFlags = setup.GetRequiredService().As(); + _featureFlags.Reset(); + } + + [Fact] + public async Task WhenGetAllFeatureFlags_ThenReturnsFlags() + { +#if TESTINGONLY + var request = new GetAllFeatureFlagsRequest(); + + var result = await Api.GetAsync(request, req => req.SetHMACAuth(request, "asecret")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Content.Value.Flags.Count.Should().Be(0); +#endif + } + + [Fact] + public async Task WhenGetFeatureFlag_ThenReturnsFlag() + { +#if TESTINGONLY + var request = new GetFeatureFlagForCallerRequest + { + Name = Flag.TestingOnly.Name + }; + + var result = await Api.GetAsync(request, req => req.SetHMACAuth(request, "asecret")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Content.Value.Flag!.Name.Should().Be(Flag.TestingOnly.Name); + _featureFlags.LastGetFlag.Should().Be(Flag.TestingOnly.Name); +#endif + } + + private static void OverrideDependencies(IServiceCollection services) + { + // nothing here yet + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure.UnitTests/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidatorSpec.cs b/src/AncillaryInfrastructure.UnitTests/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidatorSpec.cs new file mode 100644 index 00000000..010dffcc --- /dev/null +++ b/src/AncillaryInfrastructure.UnitTests/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidatorSpec.cs @@ -0,0 +1,40 @@ +using AncillaryInfrastructure.Api.FeatureFlags; +using FluentAssertions; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using UnitTesting.Common.Validation; +using Xunit; + +namespace AncillaryInfrastructure.UnitTests.Api.FeatureFlags; + +[Trait("Category", "Unit")] +public class GetFeatureFlagForCallerRequestValidatorSpec +{ + private readonly GetFeatureFlagForCallerRequest _dto; + private readonly GetFeatureFlagForCallerRequestValidator _validator; + + public GetFeatureFlagForCallerRequestValidatorSpec() + { + _validator = new GetFeatureFlagForCallerRequestValidator(); + _dto = new GetFeatureFlagForCallerRequest + { + Name = "aname" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenNameIsEmpty_ThenThrows() + { + _dto.Name = string.Empty; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.GetFeatureFlagRequestValidator_InvalidName); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure.UnitTests/Api/FeatureFlags/GetFeatureFlagRequestValidatorSpec.cs b/src/AncillaryInfrastructure.UnitTests/Api/FeatureFlags/GetFeatureFlagRequestValidatorSpec.cs new file mode 100644 index 00000000..0b64a442 --- /dev/null +++ b/src/AncillaryInfrastructure.UnitTests/Api/FeatureFlags/GetFeatureFlagRequestValidatorSpec.cs @@ -0,0 +1,90 @@ +using AncillaryInfrastructure.Api.FeatureFlags; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using FluentAssertions; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using Moq; +using UnitTesting.Common.Validation; +using Xunit; + +namespace AncillaryInfrastructure.UnitTests.Api.FeatureFlags; + +[Trait("Category", "Unit")] +public class GetFeatureFlagRequestValidatorSpec +{ + private readonly GetFeatureFlagRequest _dto; + private readonly Mock _idFactory; + private readonly GetFeatureFlagRequestValidator _validator; + + public GetFeatureFlagRequestValidatorSpec() + { + _idFactory = new Mock(); + _idFactory.Setup(idf => idf.IsValid(It.IsAny())) + .Returns(true); + _validator = new GetFeatureFlagRequestValidator(_idFactory.Object); + _dto = new GetFeatureFlagRequest + { + Name = "aname", + UserId = "auserid" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenNameIsEmpty_ThenThrows() + { + _dto.Name = string.Empty; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.GetFeatureFlagRequestValidator_InvalidName); + } + + [Fact] + public void WhenTenantIdIsEmpty_ThenSucceeds() + { + _dto.TenantId = string.Empty; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenUserIdIsEmpty_ThenThrows() + { + _dto.UserId = string.Empty; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.GetFeatureFlagRequestValidator_InvalidUserId); + } + + [Fact] + public void WhenTenantIdIsNotValid_ThenThrows() + { + _idFactory.Setup(idf => idf.IsValid("notavalidid".ToId())) + .Returns(false); + _dto.TenantId = "notavalidid"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.GetFeatureFlagRequestValidator_InvalidTenantId); + } + + [Fact] + public void WhenUserIdIsNotValid_ThenThrows() + { + _idFactory.Setup(idf => idf.IsValid("notavalidid".ToId())) + .Returns(false); + _dto.UserId = "notavalidid"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.GetFeatureFlagRequestValidator_InvalidUserId); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/AncillaryModule.cs b/src/AncillaryInfrastructure/AncillaryModule.cs index a2570f1c..9adffd04 100644 --- a/src/AncillaryInfrastructure/AncillaryModule.cs +++ b/src/AncillaryInfrastructure/AncillaryModule.cs @@ -44,6 +44,7 @@ public Action RegisterServices return (_, services) => { services.RegisterUnshared(); + services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(c => new UsageMessageQueue(c.Resolve(), c.Resolve(), diff --git a/src/AncillaryInfrastructure/Api/FeatureFlags/FeatureFlagsApi.cs b/src/AncillaryInfrastructure/Api/FeatureFlags/FeatureFlagsApi.cs new file mode 100644 index 00000000..39ac5f3c --- /dev/null +++ b/src/AncillaryInfrastructure/Api/FeatureFlags/FeatureFlagsApi.cs @@ -0,0 +1,48 @@ +using AncillaryApplication; +using Common.FeatureFlags; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; + +namespace AncillaryInfrastructure.Api.FeatureFlags; + +public class FeatureFlagsApi : IWebApiService +{ + private readonly ICallerContextFactory _contextFactory; + private readonly IFeatureFlagsApplication _featureFlagsApplication; + + public FeatureFlagsApi(ICallerContextFactory contextFactory, IFeatureFlagsApplication featureFlagsApplication) + { + _contextFactory = contextFactory; + _featureFlagsApplication = featureFlagsApplication; + } + + public async Task> Get(GetFeatureFlagRequest request, + CancellationToken cancellationToken) + { + var flag = await _featureFlagsApplication.GetFeatureFlagAsync(_contextFactory.Create(), + request.Name, request.TenantId, request.UserId, cancellationToken); + + return () => flag.HandleApplicationResult(f => new GetFeatureFlagResponse { Flag = f }); + } + + public async Task> GetForCaller( + GetFeatureFlagForCallerRequest request, + CancellationToken cancellationToken) + { + var flag = await _featureFlagsApplication.GetFeatureFlagForCallerAsync(_contextFactory.Create(), + request.Name, cancellationToken); + + return () => flag.HandleApplicationResult(f => new GetFeatureFlagResponse { Flag = f }); + } + + public async Task, GetAllFeatureFlagsResponse>> GetAll( + GetAllFeatureFlagsRequest request, + CancellationToken cancellationToken) + { + var flags = await _featureFlagsApplication.GetAllFeatureFlagsAsync(_contextFactory.Create(), cancellationToken); + + return () => flags.HandleApplicationResult(f => new GetAllFeatureFlagsResponse { Flags = f }); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidator.cs b/src/AncillaryInfrastructure/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidator.cs new file mode 100644 index 00000000..6f8cec81 --- /dev/null +++ b/src/AncillaryInfrastructure/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; + +namespace AncillaryInfrastructure.Api.FeatureFlags; + +public class GetFeatureFlagForCallerRequestValidator : AbstractValidator +{ + public GetFeatureFlagForCallerRequestValidator() + { + RuleFor(req => req.Name) + .NotEmpty() + .WithMessage(Resources.GetFeatureFlagRequestValidator_InvalidName); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/Api/FeatureFlags/GetFeatureFlagRequestValidator.cs b/src/AncillaryInfrastructure/Api/FeatureFlags/GetFeatureFlagRequestValidator.cs new file mode 100644 index 00000000..f6392fd6 --- /dev/null +++ b/src/AncillaryInfrastructure/Api/FeatureFlags/GetFeatureFlagRequestValidator.cs @@ -0,0 +1,24 @@ +using Common.Extensions; +using Domain.Common.Identity; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; + +namespace AncillaryInfrastructure.Api.FeatureFlags; + +public class GetFeatureFlagRequestValidator : AbstractValidator +{ + public GetFeatureFlagRequestValidator(IIdentifierFactory idFactory) + { + RuleFor(req => req.Name) + .NotEmpty() + .WithMessage(Resources.GetFeatureFlagRequestValidator_InvalidName); + RuleFor(req => req.TenantId) + .IsEntityId(idFactory) + .When(req => req.TenantId.HasValue()) + .WithMessage(Resources.GetFeatureFlagRequestValidator_InvalidTenantId); + RuleFor(req => req.UserId) + .IsEntityId(idFactory) + .WithMessage(Resources.GetFeatureFlagRequestValidator_InvalidUserId); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/Resources.Designer.cs b/src/AncillaryInfrastructure/Resources.Designer.cs index 0c2c8f94..20a1f49c 100644 --- a/src/AncillaryInfrastructure/Resources.Designer.cs +++ b/src/AncillaryInfrastructure/Resources.Designer.cs @@ -76,5 +76,32 @@ internal static string AnyRecordingEventNameValidator_InvalidEventName { return ResourceManager.GetString("AnyRecordingEventNameValidator_InvalidEventName", resourceCulture); } } + + /// + /// Looks up a localized string similar to The 'Name' is either missing or invalid. + /// + internal static string GetFeatureFlagRequestValidator_InvalidName { + get { + return ResourceManager.GetString("GetFeatureFlagRequestValidator_InvalidName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'TenantId' is not a valid identifier. + /// + internal static string GetFeatureFlagRequestValidator_InvalidTenantId { + get { + return ResourceManager.GetString("GetFeatureFlagRequestValidator_InvalidTenantId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'UserId' is not a valid identifier. + /// + internal static string GetFeatureFlagRequestValidator_InvalidUserId { + get { + return ResourceManager.GetString("GetFeatureFlagRequestValidator_InvalidUserId", resourceCulture); + } + } } } diff --git a/src/AncillaryInfrastructure/Resources.resx b/src/AncillaryInfrastructure/Resources.resx index 835d2865..03d1a8f9 100644 --- a/src/AncillaryInfrastructure/Resources.resx +++ b/src/AncillaryInfrastructure/Resources.resx @@ -30,4 +30,13 @@ The 'EventName' is either missing or invalid + + The 'Name' is either missing or invalid + + + The 'TenantId' is not a valid identifier + + + The 'UserId' is not a valid identifier + \ No newline at end of file diff --git a/src/ApiHost1/Properties/launchSettings.json b/src/ApiHost1/Properties/launchSettings.json index 9be669ee..cd0cea57 100644 --- a/src/ApiHost1/Properties/launchSettings.json +++ b/src/ApiHost1/Properties/launchSettings.json @@ -27,9 +27,13 @@ "ASPNETCORE_ENVIRONMENT": "Production" } }, - "ApiHandler-SourceGenerators-Development-Development": { + "Api-SourceGenerators-Development": { "commandName": "DebugRoslynComponent", "targetProject": "../ApiHost1/ApiHost1.csproj" + }, + "Common-SourceGenerators-Development": { + "commandName": "DebugRoslynComponent", + "targetProject": "../Common/Common.csproj" } } } diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json index b9cdf03f..18ac6229 100644 --- a/src/ApiHost1/appsettings.json +++ b/src/ApiHost1/appsettings.json @@ -28,6 +28,10 @@ "SSOUserTokens": { "AesSecret": "V7z5SZnhHRa7z68adsvazQjeIbSiWWcR+4KuAUikhe0=::u4ErEVotb170bM8qKWyT8A==" } + }, + "Flagsmith": { + "BaseUrl": "https://localhost:5656/flagsmith/", + "EnvironmentKey": "" } }, "Hosts": { diff --git a/src/Application.Common.UnitTests/CallerContextExtensionsSpec.cs b/src/Application.Common.UnitTests/Extensions/CallerContextExtensionsSpec.cs similarity index 96% rename from src/Application.Common.UnitTests/CallerContextExtensionsSpec.cs rename to src/Application.Common.UnitTests/Extensions/CallerContextExtensionsSpec.cs index 2d6d5bd4..e842ee2e 100644 --- a/src/Application.Common.UnitTests/CallerContextExtensionsSpec.cs +++ b/src/Application.Common.UnitTests/Extensions/CallerContextExtensionsSpec.cs @@ -6,7 +6,7 @@ using Moq; using Xunit; -namespace Application.Common.UnitTests; +namespace Application.Common.UnitTests.Extensions; [Trait("Category", "Unit")] public class CallerContextExtensionsSpec diff --git a/src/Application.Common.UnitTests/Extensions/FeatureFlagExtensionsSpec.cs b/src/Application.Common.UnitTests/Extensions/FeatureFlagExtensionsSpec.cs new file mode 100644 index 00000000..76f6899f --- /dev/null +++ b/src/Application.Common.UnitTests/Extensions/FeatureFlagExtensionsSpec.cs @@ -0,0 +1,116 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Common; +using Common.FeatureFlags; +using FluentAssertions; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace Application.Common.UnitTests.Extensions; + +[Trait("Category", "Unit")] +public class FeatureFlagExtensionsSpec +{ + private readonly Mock _caller = new(); + private readonly Mock _featureFlags = new(); + + public FeatureFlagExtensionsSpec() + { + _caller.Setup(x => x.IsAuthenticated) + .Returns(true); + _caller.Setup(x => x.CallerId) + .Returns("auserid"); + _caller.Setup(x => x.TenantId) + .Returns("atenantid"); +#if TESTINGONLY + _featureFlags.Setup(ff => ff.IsEnabled(Flag.TestingOnly)) + .Returns(true); + _featureFlags.Setup(ff => ff.IsEnabled(Flag.TestingOnly, It.IsAny())) + .Returns(true); + _featureFlags.Setup(ff => ff.IsEnabled(Flag.TestingOnly, It.IsAny>(), It.IsAny())) + .Returns(true); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncAndNotAuthenticated_ThenGetsFlagForAllUsers() + { +#if TESTINGONLY + _caller.Setup(x => x.IsAuthenticated) + .Returns(false); + + var result = await _featureFlags.Object.GetFlagAsync(Flag.TestingOnly, _caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + _featureFlags.Verify(x => x.GetFlagAsync(Flag.TestingOnly, Optional.None, Optional.None, + It.IsAny())); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncAndAuthenticatedButNoTenant_ThenGetsFlagForUser() + { +#if TESTINGONLY + _caller.Setup(x => x.TenantId) + .Returns((string?)null); + + var result = await _featureFlags.Object.GetFlagAsync(Flag.TestingOnly, _caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + _featureFlags.Verify(x => + x.GetFlagAsync(Flag.TestingOnly, Optional.None, "auserid", It.IsAny())); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncAndAuthenticated_ThenGetsFlagForUser() + { +#if TESTINGONLY + var result = await _featureFlags.Object.GetFlagAsync(Flag.TestingOnly, _caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + _featureFlags.Verify(x => + x.GetFlagAsync(Flag.TestingOnly, "atenantid", "auserid", It.IsAny())); +#endif + } + + [Fact] + public void WhenIsEnabledAndNotAuthenticated_ThenIsEnabled() + { +#if TESTINGONLY + _caller.Setup(x => x.IsAuthenticated) + .Returns(false); + + var result = _featureFlags.Object.IsEnabled(Flag.TestingOnly, _caller.Object); + + result.Should().BeTrue(); + _featureFlags.Verify(x => x.IsEnabled(Flag.TestingOnly)); +#endif + } + + [Fact] + public void WhenIsEnabledAndAuthenticatedButNoTenant_ThenIsEnabled() + { +#if TESTINGONLY + _caller.Setup(x => x.TenantId) + .Returns((string?)null); + + var result = _featureFlags.Object.IsEnabled(Flag.TestingOnly, _caller.Object); + + result.Should().BeTrue(); + _featureFlags.Verify(x => x.IsEnabled(Flag.TestingOnly, "auserid")); +#endif + } + + [Fact] + public void WhenIsEnabledAndAuthenticatedAndTenant_ThenIsEnabled() + { +#if TESTINGONLY + var result = _featureFlags.Object.IsEnabled(Flag.TestingOnly, _caller.Object); + + result.Should().BeTrue(); + _featureFlags.Verify(x => x.IsEnabled(Flag.TestingOnly, "atenantid", "auserid")); +#endif + } +} \ No newline at end of file diff --git a/src/Application.Common/Extensions/FeatureFlagsExtensions.cs b/src/Application.Common/Extensions/FeatureFlagsExtensions.cs new file mode 100644 index 00000000..108c4dea --- /dev/null +++ b/src/Application.Common/Extensions/FeatureFlagsExtensions.cs @@ -0,0 +1,47 @@ +using Application.Interfaces; +using Common; +using Common.Extensions; +using Common.FeatureFlags; + +namespace Application.Common.Extensions; + +public static class FeatureFlagsExtensions +{ + /// + /// Returns the specified feature for the + /// + public static async Task> GetFlagAsync(this IFeatureFlags featureFlags, Flag flag, + ICallerContext caller, CancellationToken cancellationToken) + { + if (!caller.IsAuthenticated) + { + return await featureFlags.GetFlagAsync(flag, Optional.None, Optional.None, + cancellationToken); + } + + if (caller.TenantId.HasValue()) + { + return await featureFlags.GetFlagAsync(flag, caller.TenantId, caller.CallerId, cancellationToken); + } + + return await featureFlags.GetFlagAsync(flag, Optional.None, caller.CallerId, cancellationToken); + } + + /// + /// Whether the specified feature is enabled for the + /// + public static bool IsEnabled(this IFeatureFlags featureFlags, Flag flag, ICallerContext caller) + { + if (!caller.IsAuthenticated) + { + return featureFlags.IsEnabled(flag); + } + + if (caller.TenantId.HasValue()) + { + return featureFlags.IsEnabled(flag, caller.TenantId, caller.CallerId); + } + + return featureFlags.IsEnabled(flag, caller.CallerId); + } +} \ No newline at end of file diff --git a/src/AzureFunctions.Api.WorkerHost/AzureFunctions.Api.WorkerHost.csproj b/src/AzureFunctions.Api.WorkerHost/AzureFunctions.Api.WorkerHost.csproj index 9a883f53..b01763ee 100644 --- a/src/AzureFunctions.Api.WorkerHost/AzureFunctions.Api.WorkerHost.csproj +++ b/src/AzureFunctions.Api.WorkerHost/AzureFunctions.Api.WorkerHost.csproj @@ -38,6 +38,7 @@ PreserveNewest + Never diff --git a/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs b/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs index b648033c..877a47ef 100644 --- a/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs +++ b/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs @@ -3,6 +3,7 @@ using Application.Persistence.Shared.ReadModels; using Common; using Common.Configuration; +using Common.FeatureFlags; using Common.Recording; using Infrastructure.Common.Recording; using Infrastructure.Hosting.Common; @@ -27,6 +28,7 @@ public static void AddDependencies(this IServiceCollection services, HostBuilder services.AddHttpClient(); services.AddSingleton(new AspNetConfigurationSettings(context.Configuration)); services.AddSingleton(); + services.AddSingleton(); #if TESTINGONLY services.AddSingleton(new NullCrashReporter()); diff --git a/src/Common.UnitTests/Extensions/StringExtensionsSpec.cs b/src/Common.UnitTests/Extensions/StringExtensionsSpec.cs index a5cbb39e..7677d71e 100644 --- a/src/Common.UnitTests/Extensions/StringExtensionsSpec.cs +++ b/src/Common.UnitTests/Extensions/StringExtensionsSpec.cs @@ -443,6 +443,118 @@ public void WhenToTitleCaseWithWords_ThenCases() result.Should().Be("Aword1 Aword2 Aword3"); } + [Fact] + public void WhenToTitleCaseWithConcatenatedWords_ThenCases() + { + var result = "AwordAword2Aword3".ToTitleCase(); + + result.Should().Be("Awordaword2aword3"); + } + + [Fact] + public void WhenToTitleCaseWithTitleCased_ThenCases() + { + var result = "Awordaword2aword3".ToTitleCase(); + + result.Should().Be("Awordaword2aword3"); + } + + [Fact] + public void WhenToCamelCaseWithSingleLowercasedWord_ThenCases() + { + var result = "aword".ToCamelCase(); + + result.Should().Be("aword"); + } + + [Fact] + public void WhenToCamelCaseWithSingleTitleCasedWord_ThenCases() + { + var result = "Aword".ToCamelCase(); + + result.Should().Be("aword"); + } + + [Fact] + public void WhenToCamelCaseWithLowercasedWords_ThenCases() + { + var result = "aword aword2 aword3".ToCamelCase(); + + result.Should().Be("awordaword2aword3"); + } + + [Fact] + public void WhenToCamelCaseWithTitleCasedWords_ThenCases() + { + var result = "Aword Aword2 Aword3".ToCamelCase(); + + result.Should().Be("awordAword2Aword3"); + } + + [Fact] + public void WhenToCamelCaseWithConcatenatedWords_ThenCases() + { + var result = "AwordAword2Aword3".ToCamelCase(); + + result.Should().Be("awordAword2Aword3"); + } + + [Fact] + public void WhenToCamelCaseWithCamelcased_ThenCases() + { + var result = "awordAword2Aword3".ToCamelCase(); + + result.Should().Be("awordAword2Aword3"); + } + + [Fact] + public void WhenToSnakeCaseWithSingleLowercasedWord_ThenCases() + { + var result = "aword".ToSnakeCase(); + + result.Should().Be("aword"); + } + + [Fact] + public void WhenToSnakeCaseWithSingleTitleCasedWord_ThenCases() + { + var result = "Aword".ToSnakeCase(); + + result.Should().Be("aword"); + } + + [Fact] + public void WhenToSnakeCaseWithLowercasedWords_ThenCases() + { + var result = "aword aword2 aword3".ToSnakeCase(); + + result.Should().Be("aword_aword2_aword3"); + } + + [Fact] + public void WhenToSnakeCaseWithTitleCasedWords_ThenCases() + { + var result = "Aword Aword2 Aword3".ToSnakeCase(); + + result.Should().Be("aword_aword2_aword3"); + } + + [Fact] + public void WhenToSnakeCaseWithConcatenatedWords_ThenCases() + { + var result = "AwordAword2Aword3".ToSnakeCase(); + + result.Should().Be("aword_aword2_aword3"); + } + + [Fact] + public void WhenToSnakeCaseWithSnakeCased_ThenCases() + { + var result = "aword_aword2_aword3".ToSnakeCase(); + + result.Should().Be("aword_aword2_aword3"); + } + private class SerializableClass { public string? AProperty { get; set; } diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index 0941d7aa..9dec4a9c 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -3,7 +3,10 @@ net7.0 true - COMMON_PROJECT + $(DefineConstants);COMMON_PROJECT + + true + Generated @@ -19,11 +22,23 @@ + + + + + + + + ResXFileCodeGenerator Resources.Designer.cs + + ResXFileCodeGenerator + FeatureFlags.Designer.cs + @@ -31,6 +46,11 @@ True Resources.resx + + True + True + FeatureFlags.resx + diff --git a/src/Common/Extensions/StringExtensions.cs b/src/Common/Extensions/StringExtensions.cs index 01c48c4e..ba36d420 100644 --- a/src/Common/Extensions/StringExtensions.cs +++ b/src/Common/Extensions/StringExtensions.cs @@ -1,15 +1,20 @@ -using System.Diagnostics; #if COMMON_PROJECT +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using JetBrains.Annotations; + #elif GENERATORS_WEB_API_PROJECT using System.Runtime.Serialization; using System.Runtime.Serialization.Json; using System.Text; +#elif GENERATORS_COMMON_PROJECT +using System.Globalization; +using System.Text; #endif namespace Common.Extensions; @@ -127,7 +132,7 @@ public static bool HasValue([NotNullWhen(true)] this string? value) { return !string.IsNullOrEmpty(value) && !string.IsNullOrWhiteSpace(value); } -#elif GENERATORS_WEB_API_PROJECT +#elif GENERATORS_WEB_API_PROJECT || GENERATORS_COMMON_PROJECT /// /// Whether the string value contains no value: it is either: null, empty or only whitespaces /// @@ -223,7 +228,40 @@ public static bool ToBoolOrDefault(this string value, bool defaultValue) return defaultValue; } +#endif +#if COMMON_PROJECT + /// + /// Returns the specified in camelCase. i.e. first letter is lower case + /// + public static string ToCamelCase(this string value) + { + if (value.HasNoValue()) + { + return value; + } + + return JsonNamingPolicy.CamelCase + .ConvertName(value) + .Replace(" ", string.Empty); + } +#elif GENERATORS_COMMON_PROJECT + /// + /// Returns the specified in camelCase. i.e. first letter is lower case + /// + public static string ToCamelCase(this string value) + { + if (value.HasNoValue()) + { + return value; + } + var titleCase = value.ToTitleCase() + .Replace(" ", string.Empty); + + return char.ToLowerInvariant(titleCase[0]) + titleCase.Substring(1); + } +#endif +#if COMMON_PROJECT /// /// Converts the to a integer value /// @@ -307,15 +345,79 @@ public static int ToIntOrDefault(this string? value, int defaultValue) return result; } #endif -#if COMMON_PROJECT +#if COMMON_PROJECT || GENERATORS_COMMON_PROJECT + /// + /// Returns the specified in snake_case. i.e. lower case with underscores for upper cased + /// letters + /// + public static string ToSnakeCase(this string value) + { + if (value.HasNoValue()) + { + return value; + } + + value = value + .Replace(" ", "_") + .ToCamelCase(); + + var builder = new StringBuilder(); + var isFirstCharacter = true; + var lastCharWasUnderscore = false; + foreach (var charValue in value) + { + if (isFirstCharacter) + { + isFirstCharacter = false; + builder.Append(char.ToLower(charValue)); + continue; + } + + if (IsIgnoredCharacter(charValue)) + { + builder.Append(charValue); + if (charValue == '_') + { + lastCharWasUnderscore = true; + } + } + else + { + if (lastCharWasUnderscore) + { + builder.Append(char.ToLower(charValue)); + lastCharWasUnderscore = false; + } + else + { + builder.Append('_'); + builder.Append(char.ToLower(charValue)); + } + } + } + + return builder.ToString(); + + static bool IsIgnoredCharacter(char charValue) + { + return char.IsDigit(charValue) + || (char.IsLetter(charValue) && char.IsLower(charValue)) + || charValue == '_'; + } + } +#endif +#if COMMON_PROJECT || GENERATORS_COMMON_PROJECT /// /// Returns the specified in title-case. i.e. first letter of words are capitalized /// public static string ToTitleCase(this string value) { - return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(value).Replace("_", string.Empty); + return CultureInfo.InvariantCulture.TextInfo + .ToTitleCase(value) + .Replace("_", string.Empty); } - +#endif +# if COMMON_PROJECT /// /// Returns the specified including only letters (no numbers, or whitespace) /// @@ -329,6 +431,14 @@ public static string TrimNonAlpha(this string value) return value.ReplaceWith(@"[^\p{L}]", string.Empty); } + /// + /// Returns the specified without any leading slashes + /// + public static string WithoutLeadingSlash(this string path) + { + return path.TrimStart('/'); + } + /// /// Returns the specified without any trailing slashes /// @@ -336,5 +446,13 @@ public static string WithoutTrailingSlash(this string path) { return path.TrimEnd('/'); } + + /// + /// Returns the specified including a trailing slash + /// + public static string WithTrailingSlash(this string path) + { + return path.TrimEnd('/') + '/'; + } #endif } \ No newline at end of file diff --git a/src/Common/FeatureFlags/FeatureFlag.cs b/src/Common/FeatureFlags/FeatureFlag.cs new file mode 100644 index 00000000..82dddb95 --- /dev/null +++ b/src/Common/FeatureFlags/FeatureFlag.cs @@ -0,0 +1,11 @@ +namespace Common.FeatureFlags; + +/// +/// The definition of a feature flag +/// +public class FeatureFlag +{ + public bool IsEnabled { get; set; } + + public required string Name { get; set; } +} \ No newline at end of file diff --git a/src/Common/FeatureFlags/FeatureFlags.Designer.cs b/src/Common/FeatureFlags/FeatureFlags.Designer.cs new file mode 100644 index 00000000..72c5c389 --- /dev/null +++ b/src/Common/FeatureFlags/FeatureFlags.Designer.cs @@ -0,0 +1,71 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Common.FeatureFlags { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class FeatureFlags { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal FeatureFlags() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Common.FeatureFlags", typeof(FeatureFlags).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to a feature flag. + /// + internal static string AFeatureFlag { + get { + return ResourceManager.GetString("AFeatureFlag", resourceCulture); + } + } + } +} diff --git a/src/Common/FeatureFlags/FeatureFlags.resx b/src/Common/FeatureFlags/FeatureFlags.resx new file mode 100644 index 00000000..4edcaafc --- /dev/null +++ b/src/Common/FeatureFlags/FeatureFlags.resx @@ -0,0 +1,29 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + a feature flag + + \ No newline at end of file diff --git a/src/Common/FeatureFlags/Flag.cs b/src/Common/FeatureFlags/Flag.cs new file mode 100644 index 00000000..ebdd46eb --- /dev/null +++ b/src/Common/FeatureFlags/Flag.cs @@ -0,0 +1,24 @@ +namespace Common.FeatureFlags; + +/// +/// Defines a feature flag. +/// New feature flag values should be added to the file, +/// and they will be source generated into into this class at build time +/// +#if GENERATORS_COMMON_PROJECT +public class Flag +#else +public partial class Flag +#endif +{ +#if TESTINGONLY + public static readonly Flag TestingOnly = new("testingonly"); +#endif + + public Flag(string name) + { + Name = name; + } + + public string Name { get; } +} \ No newline at end of file diff --git a/src/Common/FeatureFlags/IFeatureFlags.cs b/src/Common/FeatureFlags/IFeatureFlags.cs new file mode 100644 index 00000000..e1683279 --- /dev/null +++ b/src/Common/FeatureFlags/IFeatureFlags.cs @@ -0,0 +1,35 @@ +namespace Common.FeatureFlags; + +/// +/// Defines a service that provides feature flags +/// +public interface IFeatureFlags +{ + /// + /// Returns all available feature flags + /// + /// + Task, Error>> GetAllFlagsAsync(CancellationToken cancellationToken); + + /// + /// Returns the feature flag and its state + /// + Task> GetFlagAsync(Flag flag, Optional tenantId, Optional userId, + CancellationToken cancellationToken); + + /// + /// Whether the is enabled + /// + bool IsEnabled(Flag flag); + + /// + /// Whether the is enabled for the specified + /// + bool IsEnabled(Flag flag, string userId); + + /// + /// Whether the is enabled for the specified of the + /// specified in that tenant. + /// + bool IsEnabled(Flag flag, Optional tenantId, string userId); +} \ No newline at end of file diff --git a/src/Common/Generated/Tools.Generators.Common/Tools.Generators.Common.FeatureFlagGenerator/FeatureFlags/Flag.g.cs b/src/Common/Generated/Tools.Generators.Common/Tools.Generators.Common.FeatureFlagGenerator/FeatureFlags/Flag.g.cs new file mode 100644 index 00000000..106a0a44 --- /dev/null +++ b/src/Common/Generated/Tools.Generators.Common/Tools.Generators.Common.FeatureFlagGenerator/FeatureFlags/Flag.g.cs @@ -0,0 +1,11 @@ +// +using Common.FeatureFlags; + +namespace Common.FeatureFlags; + +/// +partial class Flag +{ + public static Flag AFeatureFlag = new Flag("a_feature_flag"); + +} diff --git a/src/Common/Generated/Tools.Generators.Common/Tools.Generators.Common.FeatureFlagGenerator/Flag.g.cs b/src/Common/Generated/Tools.Generators.Common/Tools.Generators.Common.FeatureFlagGenerator/Flag.g.cs new file mode 100644 index 00000000..5cdde53c --- /dev/null +++ b/src/Common/Generated/Tools.Generators.Common/Tools.Generators.Common.FeatureFlagGenerator/Flag.g.cs @@ -0,0 +1,11 @@ +// +using Common; + +namespace Common; + +/// +partial class Flag +{ + public static Flag AFeatureFlag = new Flag("a_feature_flag"); + +} diff --git a/src/Infrastructure.Hosting.Common/EmptyFeatureFlags.cs b/src/Infrastructure.Hosting.Common/EmptyFeatureFlags.cs new file mode 100644 index 00000000..ee507a7e --- /dev/null +++ b/src/Infrastructure.Hosting.Common/EmptyFeatureFlags.cs @@ -0,0 +1,41 @@ +using Common; +using Common.FeatureFlags; + +namespace Infrastructure.Hosting.Common; + +/// +/// Provides a that has no feature flags, and all are enabled +/// +public class EmptyFeatureFlags : IFeatureFlags +{ + public Task, Error>> GetAllFlagsAsync(CancellationToken cancellationToken) + { + return Task.FromResult, Error>>(new List()); + } + + public async Task> GetFlagAsync(Flag flag, Optional tenantId, + Optional userId, CancellationToken cancellationToken) + { + await Task.CompletedTask; + return new Result(new FeatureFlag + { + Name = flag.Name, + IsEnabled = true + }); + } + + public bool IsEnabled(Flag flag) + { + return true; + } + + public bool IsEnabled(Flag flag, string userId) + { + return true; + } + + public bool IsEnabled(Flag flag, Optional tenantId, string userId) + { + return true; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common/Infrastructure.Hosting.Common.csproj b/src/Infrastructure.Hosting.Common/Infrastructure.Hosting.Common.csproj index cef14330..b0cec146 100644 --- a/src/Infrastructure.Hosting.Common/Infrastructure.Hosting.Common.csproj +++ b/src/Infrastructure.Hosting.Common/Infrastructure.Hosting.Common.csproj @@ -36,8 +36,4 @@ - - - - diff --git a/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/FlagsmithHttpServiceClientSpec.cs b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/FlagsmithHttpServiceClientSpec.cs new file mode 100644 index 00000000..7407bb19 --- /dev/null +++ b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/FlagsmithHttpServiceClientSpec.cs @@ -0,0 +1,244 @@ +using Common; +using Common.Configuration; +using Common.Extensions; +using Common.FeatureFlags; +using Common.Recording; +using Domain.Interfaces; +using FluentAssertions; +using Infrastructure.Shared.ApplicationServices.External; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Shared.IntegrationTests.ApplicationServices.External; + +[Trait("Category", "Integration.External")] +[Collection("External")] +public class FlagsmithHttpServiceClientSpec : ExternalApiSpec +{ + private const string TestTenant1 = "atenant1"; + private const string TestTenant2 = "atenant2"; + private const string TestUser1 = "auser1"; + private const string TestUser2 = "auser2"; + private static bool _isInitialized; + private readonly FlagsmithHttpServiceClient _serviceClient; + + public FlagsmithHttpServiceClientSpec(ExternalApiSetup setup) : base(setup, OverrideDependencies) + { + var settings = setup.GetRequiredService(); + _serviceClient = new FlagsmithHttpServiceClient(NullRecorder.Instance, settings, new TestHttpClientFactory()); + if (!_isInitialized) + { + _isInitialized = true; + SetupEnvironmentAsync().GetAwaiter().GetResult(); + } + } + + [Fact] + public async Task WhenGetAllFlags_ThenReturnsFlags() + { +#if TESTINGONLY + var result = await _serviceClient.GetAllFlagsAsync(); + + result.Should().BeSuccess(); + result.Value.Count.Should().Be(1); + result.Value[0].Name.Should().Be(Flag.TestingOnly.Name); + result.Value[0].IsEnabled.Should().BeFalse(); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncForAnUnknownFeature_ThenReturnsError() + { + var result = await _serviceClient.GetFlagAsync(new Flag("unknown"), Optional.None, + Optional.None, CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound, + Resources.FlagsmithHttpServiceClient_UnknownFeature.Format("unknown")); + } + + [Fact] + public async Task WhenGetFlagAsyncForKnownFeatureWithNoOverrides_ThenReturnsDefaultFlag() + { +#if TESTINGONLY + var result = await _serviceClient.GetFlagAsync(Flag.TestingOnly, Optional.None, Optional.None, + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be(Flag.TestingOnly.Name); + result.Value.IsEnabled.Should().BeFalse(); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncForNoUserIdentity_ThenReturnsDefaultFlag() + { +#if TESTINGONLY + var result = await _serviceClient.GetFlagAsync(Flag.TestingOnly, "anunknowntenantid", Optional.None, + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be(Flag.TestingOnly.Name); + result.Value.IsEnabled.Should().BeFalse(); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncForAnUnknownUserIdentity_ThenReturnsDefaultFlag() + { +#if TESTINGONLY + var result = await _serviceClient.GetFlagAsync(Flag.TestingOnly, Optional.None, "anunknownuserid", + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be(Flag.TestingOnly.Name); + result.Value.IsEnabled.Should().BeFalse(); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncForAnonymousUserIdentity_ThenReturnsDefaultFlag() + { +#if TESTINGONLY + var result = await _serviceClient.GetFlagAsync(Flag.TestingOnly, Optional.None, + CallerConstants.AnonymousUserId, + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be(Flag.TestingOnly.Name); + result.Value.IsEnabled.Should().BeFalse(); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncForAOverriddenUserIdentity_ThenReturnsOverriddenFlag() + { +#if TESTINGONLY + var result = await _serviceClient.GetFlagAsync(Flag.TestingOnly, Optional.None, TestUser1, + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be(Flag.TestingOnly.Name); + result.Value.IsEnabled.Should().BeTrue(); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncForAnUnknownMembershipIdentity_ThenReturnsDefaultFlag() + { +#if TESTINGONLY + var result = + await _serviceClient.GetFlagAsync(Flag.TestingOnly, TestTenant2, TestUser2, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be(Flag.TestingOnly.Name); + result.Value.IsEnabled.Should().BeFalse(); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncForAOverriddenMembershipIdentity_ThenReturnsOverriddenFlag() + { +#if TESTINGONLY + var result = + await _serviceClient.GetFlagAsync(Flag.TestingOnly, TestTenant1, TestUser1, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be(Flag.TestingOnly.Name); + result.Value.IsEnabled.Should().BeTrue(); +#endif + } + + [Fact] + public void WhenIsEnabledForUnknownFeature_ThenReturnsFalse() + { + var result = _serviceClient.IsEnabled(new Flag("unknown")); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsEnabledForUnknownFeatureWithNoOverrides_ThenReturnsFalse() + { +#if TESTINGONLY + var result = _serviceClient.IsEnabled(Flag.TestingOnly); + + result.Should().BeFalse(); +#endif + } + + [Fact] + public void WhenIsEnabledForNoIdentity_ThenReturnsFalse() + { +#if TESTINGONLY + var result = _serviceClient.IsEnabled(Flag.TestingOnly, "anunknowntenantid", Optional.None); + + result.Should().BeFalse(); +#endif + } + + [Fact] + public void WhenIsEnabledForUnknownIdentity_ThenReturnsFalse() + { +#if TESTINGONLY + var result = _serviceClient.IsEnabled(Flag.TestingOnly, "anunknownuserid"); + + result.Should().BeFalse(); +#endif + } + + [Fact] + public void WhenIsEnabledForAnonymousUserIdentity_ThenReturnsFalse() + { +#if TESTINGONLY + var result = _serviceClient.IsEnabled(Flag.TestingOnly, CallerConstants.AnonymousUserId); + + result.Should().BeFalse(); +#endif + } + + [Fact] + public void WhenIsEnabledForOverriddenIdentity_ThenReturnsTrue() + { +#if TESTINGONLY + var result = _serviceClient.IsEnabled(Flag.TestingOnly, TestUser1); + + result.Should().BeTrue(); +#endif + } + + [Fact] + public void WhenIsEnabledForUnknownMembershipIdentity_ThenReturnsFalse() + { +#if TESTINGONLY + var result = _serviceClient.IsEnabled(Flag.TestingOnly, TestTenant2, TestUser2); + + result.Should().BeFalse(); +#endif + } + + [Fact] + public void WhenIsEnabledForOverriddenMembershipIdentity_ThenReturnsTrue() + { +#if TESTINGONLY + var result = _serviceClient.IsEnabled(Flag.TestingOnly, TestTenant1, TestUser1); + + result.Should().BeTrue(); +#endif + } + + private static void OverrideDependencies(IServiceCollection services) + { + } + + private async Task SetupEnvironmentAsync() + { +#if TESTINGONLY + await _serviceClient.DestroyAllFeaturesAsync(); + await _serviceClient.DestroyAllIdentitiesAsync(); + await _serviceClient.CreateFeatureAsync(Flag.TestingOnly, false); + await _serviceClient.CreateIdentityAsync(TestUser1, Flag.TestingOnly, true); +#endif + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared.IntegrationTests/Infrastructure.Shared.IntegrationTests.csproj b/src/Infrastructure.Shared.IntegrationTests/Infrastructure.Shared.IntegrationTests.csproj new file mode 100644 index 00000000..9fcd56b8 --- /dev/null +++ b/src/Infrastructure.Shared.IntegrationTests/Infrastructure.Shared.IntegrationTests.csproj @@ -0,0 +1,27 @@ + + + + net7.0 + true + + + + + + + + + + + + + + Always + + + Always + Never + + + + diff --git a/src/Infrastructure.Shared.IntegrationTests/TestHttpClientFactory.cs b/src/Infrastructure.Shared.IntegrationTests/TestHttpClientFactory.cs new file mode 100644 index 00000000..14fc03c2 --- /dev/null +++ b/src/Infrastructure.Shared.IntegrationTests/TestHttpClientFactory.cs @@ -0,0 +1,9 @@ +namespace Infrastructure.Shared.IntegrationTests; + +public class TestHttpClientFactory : IHttpClientFactory +{ + public HttpClient CreateClient(string name) + { + return new HttpClient(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json b/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json new file mode 100644 index 00000000..457919ed --- /dev/null +++ b/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json @@ -0,0 +1,26 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "ApplicationServices": { + "Persistence": { + "LocalMachineJsonFileStore": { + "RootPath": "./saastack/testing/external" + } + }, + "Flagsmith": { + "BaseUrl": "https://edge.api.flagsmith.com/api/v1/", + "EnvironmentKey": "", + "TestingOnly": { + "BaseUrl": "https://api.flagsmith.com/api/v1/", + "ApiToken": "", + "ProjectId": 0, + "EnvironmentApiKey": "" + } + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/External/FlagsmithHttpServiceClient.TestingOnly.cs b/src/Infrastructure.Shared/ApplicationServices/External/FlagsmithHttpServiceClient.TestingOnly.cs new file mode 100644 index 00000000..59d68bcf --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/FlagsmithHttpServiceClient.TestingOnly.cs @@ -0,0 +1,137 @@ +#if TESTINGONLY +using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; +using Infrastructure.Web.Interfaces.Clients; +using Flag = Common.FeatureFlags.Flag; + +namespace Infrastructure.Shared.ApplicationServices.External; + +/// +partial class FlagsmithHttpServiceClient +{ + private const string TestingOnlyApiTokenSettingName = "ApplicationServices:Flagsmith:TestingOnly:ApiToken"; + private const string TestingOnlyEnvironmentApiKeySettingName = + "ApplicationServices:Flagsmith:TestingOnly:EnvironmentApiKey"; + private const string TestingOnlyPrivateApiUrlSettingName = "ApplicationServices:Flagsmith:TestingOnly:BaseUrl"; + private const string TestingOnlyProjectIdSettingName = "ApplicationServices:Flagsmith:TestingOnly:ProjectId"; + private readonly TestingOnlyConfiguration _testingConfiguration; + private readonly IServiceClient _testingOnlyClient; + + public async Task CreateFeatureAsync(Flag flag, bool enabled) + { + var featureCreated = await _testingOnlyClient.PostAsync(null, new FlagsmithCreateFeatureRequest + { + ProjectId = _testingConfiguration.ProjectId, + Name = flag.Name + }, req => AddApiToken(req, _testingConfiguration)); + var feature = featureCreated.Value; + + if (enabled) + { + var featureSetRetrieved = await _testingOnlyClient.GetAsync(null, new FlagsmithGetFeatureStatesRequest + { + EnvironmentApiKey = _testingConfiguration.EnvironmentApiKey, + Feature = feature.Id + }, req => AddApiToken(req, _testingConfiguration)); + + var featureStateId = featureSetRetrieved.Value.Results[0].Id; + await _testingOnlyClient.PatchAsync(null, new FlagsmithCreateFeatureStateRequest + { + EnvironmentApiKey = _testingConfiguration.EnvironmentApiKey, + FeatureStateId = featureStateId, + Feature = feature.Id, + Enabled = enabled + }, req => AddApiToken(req, _testingConfiguration)); + } + } + + public async Task CreateIdentityAsync(string name, Flag flag, bool enabled) + { + var identityCreated = await _testingOnlyClient.PostAsync(null, new FlagsmithCreateEdgeIdentityRequest + { + EnvironmentApiKey = _testingConfiguration.EnvironmentApiKey, + Identifier = name + }, req => AddApiToken(req, _testingConfiguration)); + + if (enabled) + { + var featuresRetrieved = await _testingOnlyClient.GetAsync(null, new FlagsmithGetFeaturesRequest + { + ProjectId = _testingConfiguration.ProjectId + }, req => AddApiToken(req, _testingConfiguration)); + + var featureId = featuresRetrieved.Value.Results.Single(feat => feat.Name == flag.Name).Id; + await _testingOnlyClient.PostAsync(null, + new FlagsmithCreateEdgeIdentityFeatureStateRequest + { + EnvironmentApiKey = _testingConfiguration.EnvironmentApiKey, + IdentityUuid = identityCreated.Value.IdentityUuid!, + Feature = featureId, + Enabled = enabled + }, req => AddApiToken(req, _testingConfiguration)); + } + } + + public async Task DestroyAllFeaturesAsync() + { + var featuresRetrieved = await _testingOnlyClient.GetAsync(null, new FlagsmithGetFeaturesRequest + { + ProjectId = _testingConfiguration.ProjectId + }, req => AddApiToken(req, _testingConfiguration)); + + var allFeatures = featuresRetrieved.Value.Results; + foreach (var feature in allFeatures) + { + await DestroyFeatureAsync(feature.Id); + } + } + + public async Task DestroyAllIdentitiesAsync() + { + var identitiesRetrieved = await _testingOnlyClient.GetAsync(null, new FlagsmithGetEdgeIdentitiesRequest + { + EnvironmentApiKey = _testingConfiguration.EnvironmentApiKey + }, req => AddApiToken(req, _testingConfiguration)); + + var allIdentities = identitiesRetrieved.Value.Results; + foreach (var identity in allIdentities) + { + await DestroyIdentityAsync(identity.IdentityUuid!); + } + } + + private async Task DestroyFeatureAsync(int featureId) + { + await _testingOnlyClient.DeleteAsync(null, new FlagsmithDeleteFeatureRequest + { + ProjectId = _testingConfiguration.ProjectId, + FeatureId = featureId + }, req => AddApiToken(req, _testingConfiguration)); + } + + private async Task DestroyIdentityAsync(string identityUuid) + { + await _testingOnlyClient.DeleteAsync(null, new FlagsmithDeleteEdgeIdentitiesRequest + { + EnvironmentApiKey = _testingConfiguration.EnvironmentApiKey, + IdentityUuid = identityUuid + }, req => AddApiToken(req, _testingConfiguration)); + } + + private static void AddApiToken(HttpRequestMessage req, TestingOnlyConfiguration configuration) + { + req.Headers.Add(HttpHeaders.Authorization, $"Token {configuration.ApiToken}"); + } + + public class TestingOnlyConfiguration + { + public required string ApiToken { get; init; } + + public required string ApiUrl { get; init; } + + public required string EnvironmentApiKey { get; init; } + + public required int ProjectId { get; init; } + } +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/External/FlagsmithHttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/FlagsmithHttpServiceClient.cs new file mode 100644 index 00000000..d05a01ef --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/FlagsmithHttpServiceClient.cs @@ -0,0 +1,211 @@ +using System.Text.Json; +using Common; +using Common.Configuration; +using Common.Extensions; +using Common.FeatureFlags; +using Domain.Interfaces; +using Flagsmith; +using Infrastructure.Web.Common.Clients; +using Flag = Common.FeatureFlags.Flag; + +namespace Infrastructure.Shared.ApplicationServices.External; + +/// +/// Provides an adapter to the feature flagging services of FlagSmith +/// +/// Note: Flagsmith already supports caching and optimizations like LocalEvaluation to limit the number of network +/// calls made, so we don't need to implement explicit caching. +/// Note: For AWS when running this process in a Serverless environment like AWS Lambda, +/// Flagsmith's local evaluation mode is not likely to work very well since it expects to be connected to the API at +/// all times, and Lambdas will shutdown automatically. See +/// Overview +/// * When calling for a flag that does not exist, we will return +/// +/// * When calling for a flag that does not exist, we will return +/// +/// * We never want to ask for the flag for the anonymous user () +/// Flagsmith configuration: +/// 1. In flagsmith, we assume that there might be an identity for each user of interest, where its name is the ID of +/// the user, and it has a trait called 'type' with a value of "user" +/// 2. In flagsmith, we assume that for every membership to a tenant, the user will have a trait that will be called +/// the ID of the tenant and have a value of "tenant" +/// 3. When we ask for a flag for a userId, we create the identity with its respective traits +/// (if they don't already exist), and use the result. +/// +public partial class FlagsmithHttpServiceClient : IFeatureFlags +{ + private const string BaseUrlSettingName = "ApplicationServices:Flagsmith:BaseUrl"; + private const string EnvironmentKeySettingName = "ApplicationServices:Flagsmith:EnvironmentKey"; + private const bool FlagEnabledWhenNotExists = false; + private const string FlagsmithApiUrl = "https://edge.api.flagsmith.com/api/v1/"; + private const string TraitNameForIdentityType = "type"; + private const string TraitValueForTenancyMembership = "tenant"; + private const string TraitValueForUser = "user"; + private const string UnknownFeatureName = "_unknown"; + private readonly FlagsmithClient _client; + private readonly IRecorder _recorder; + + public FlagsmithHttpServiceClient(IRecorder recorder, IConfigurationSettings settings, + IHttpClientFactory httpClientFactory) + { + _recorder = recorder; + var apiUrl = settings.Platform.GetString(BaseUrlSettingName, FlagsmithApiUrl); + var environmentKey = settings.Platform.GetString(EnvironmentKeySettingName); +#if TESTINGONLY + _testingConfiguration = new TestingOnlyConfiguration + { + ApiUrl = settings.Platform.GetString(TestingOnlyPrivateApiUrlSettingName, string.Empty), + ApiToken = settings.Platform.GetString(TestingOnlyApiTokenSettingName, string.Empty), + ProjectId = (int)settings.Platform.GetNumber(TestingOnlyProjectIdSettingName, 0), + EnvironmentApiKey = settings.Platform.GetString(TestingOnlyEnvironmentApiKeySettingName, string.Empty) + }; +#endif + var httpClient = httpClientFactory.CreateClient("Flagsmith"); + _client = new FlagsmithClient(new FlagsmithConfiguration + { + EnvironmentKey = environmentKey, + ApiUrl = apiUrl, + Retries = 1, + CacheConfig = new CacheConfig(true) + { + DurationInMinutes = 5 + }, +#if TESTINGONLY || HOSTEDONAWS + EnableClientSideEvaluation = false, +#elif HOSTEDONAZURE + EnableClientSideEvaluation = true, +#endif + DefaultFlagHandler = _ => new Flagsmith.Flag(new Feature(UnknownFeatureName, -1), false, null, -1) + }, httpClient); +#if TESTINGONLY + _testingOnlyClient = + new ApiServiceClient(httpClientFactory, JsonSerializerOptions.Default, _testingConfiguration.ApiUrl); +#endif + } + + public async Task, Error>> GetAllFlagsAsync( + CancellationToken cancellationToken = default) + { + var environmentFlags = await _client.GetEnvironmentFlags(); + var allFlags = environmentFlags!.AllFlags().Select(flag => new FeatureFlag + { + Name = flag.GetFeatureName(), + IsEnabled = flag.Enabled + }) + .ToList(); + + _recorder.TraceInformation(null, "Fetched all feature flags from FlagSmith API"); + return allFlags; + } + + public async Task> GetFlagAsync(Flag flag, Optional tenantId, + Optional userId, CancellationToken cancellationToken) + { + IFlags? featureFlags; + if (userId.HasValue && userId != CallerConstants.AnonymousUserId) + { + if (tenantId.HasValue) + { + featureFlags = await QueryForUserMembershipAsync(tenantId, userId, cancellationToken); + } + else + { + featureFlags = await QueryForUserAsync(userId, cancellationToken); + } + } + else + { + featureFlags = await _client.GetEnvironmentFlags(); + } + + var featureFlag = await featureFlags.GetFlag(flag.Name); + if (IsDefaultFeatureFlag(featureFlag)) + { + return Error.EntityNotFound(Resources.FlagsmithHttpServiceClient_UnknownFeature.Format(flag.Name)); + } + + _recorder.TraceInformation(null, "Fetched feature flag for {Name}, for {User} from FlagSmith API", + flag.Name, userId.HasValue + ? userId + : "allusers"); + + return new FeatureFlag + { + Name = featureFlag.GetFeatureName(), + IsEnabled = featureFlag.Enabled + }; + } + + public bool IsEnabled(Flag flag) + { + var featureFlag = GetFlagAsync(flag, Optional.None, Optional.None, CancellationToken.None) + .GetAwaiter().GetResult(); + if (!featureFlag.IsSuccessful) + { + return FlagEnabledWhenNotExists; + } + + return featureFlag.Value.IsEnabled; + } + + public bool IsEnabled(Flag flag, string userId) + { + var featureFlag = GetFlagAsync(flag, Optional.None, userId, CancellationToken.None).GetAwaiter() + .GetResult(); + if (!featureFlag.IsSuccessful) + { + return FlagEnabledWhenNotExists; + } + + return featureFlag.Value.IsEnabled; + } + + public bool IsEnabled(Flag flag, Optional tenantId, string userId) + { + var featureFlag = GetFlagAsync(flag, tenantId, userId, CancellationToken.None).GetAwaiter().GetResult(); + if (!featureFlag.IsSuccessful) + { + return FlagEnabledWhenNotExists; + } + + return featureFlag.Value.IsEnabled; + } + + private static bool IsDefaultFeatureFlag(IFlag featureFlag) + { + return featureFlag.NotExists() + || featureFlag.getFeatureId() == -1 + || featureFlag.GetFeatureName() == UnknownFeatureName; + } + + private async Task QueryForUserAsync(string userId, CancellationToken cancellationToken) + { + var traits = new List + { + new Trait(TraitNameForIdentityType, TraitValueForUser) + }; + + return await QueryIdentityFlags(userId, traits, cancellationToken); + } + + private async Task QueryForUserMembershipAsync(string tenantId, string userId, + CancellationToken cancellationToken) + { + var userTraits = new List + { + new Trait(TraitNameForIdentityType, TraitValueForUser), + new Trait(tenantId, TraitValueForTenancyMembership) + }; + + return await QueryIdentityFlags(userId, userTraits, cancellationToken); + } + + private async Task QueryIdentityFlags(string identity, List traits, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + // Note: Will create this identity in Flagsmith if it does not yet exist! + // Note: will add the traits to the identity if they do not exist! + return await _client.GetIdentityFlags(identity, traits); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs index 0d28c638..23485cd4 100644 --- a/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs +++ b/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs @@ -3,7 +3,7 @@ using Application.Resources.Shared; using Common; using Common.Extensions; -using Infrastructure.Web.Api.Operations.Shared._3rdParties; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.OAuth2; using Infrastructure.Web.Interfaces.Clients; namespace Infrastructure.Shared.ApplicationServices; diff --git a/src/Infrastructure.Shared/Infrastructure.Shared.csproj b/src/Infrastructure.Shared/Infrastructure.Shared.csproj index 3475be91..528576b9 100644 --- a/src/Infrastructure.Shared/Infrastructure.Shared.csproj +++ b/src/Infrastructure.Shared/Infrastructure.Shared.csproj @@ -4,10 +4,28 @@ net7.0 + + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + <_Parameter1>Infrastructure.Shared.IntegrationTests + @@ -25,13 +43,4 @@ - - - - - - - - - diff --git a/src/Infrastructure.Shared/Resources.Designer.cs b/src/Infrastructure.Shared/Resources.Designer.cs index 7e7ebbfd..543aa3d5 100644 --- a/src/Infrastructure.Shared/Resources.Designer.cs +++ b/src/Infrastructure.Shared/Resources.Designer.cs @@ -58,5 +58,14 @@ internal Resources() { resourceCulture = value; } } + + /// + /// Looks up a localized string similar to The feature '{0}' has not be defined in Flagsmith. + /// + internal static string FlagsmithHttpServiceClient_UnknownFeature { + get { + return ResourceManager.GetString("FlagsmithHttpServiceClient_UnknownFeature", resourceCulture); + } + } } } diff --git a/src/Infrastructure.Shared/Resources.resx b/src/Infrastructure.Shared/Resources.resx index 755958fe..ab22ccfe 100644 --- a/src/Infrastructure.Shared/Resources.resx +++ b/src/Infrastructure.Shared/Resources.resx @@ -24,4 +24,7 @@ PublicKeyToken=b77a5c561934e089 + + The feature '{0}' has not be defined in Flagsmith + \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Common.UnitTests/Extensions/HttpRequestExtensionsSpec.cs b/src/Infrastructure.Web.Api.Common.UnitTests/Extensions/HttpRequestExtensionsSpec.cs index 7b60515a..c7df6618 100644 --- a/src/Infrastructure.Web.Api.Common.UnitTests/Extensions/HttpRequestExtensionsSpec.cs +++ b/src/Infrastructure.Web.Api.Common.UnitTests/Extensions/HttpRequestExtensionsSpec.cs @@ -28,6 +28,20 @@ public async Task WhenVerifyHMACSignatureAsyncAndWrongSignature_ThenReturnsFalse result.Should().BeFalse(); } + [Fact] + public async Task WhenVerifyHMACSignatureAsyncAndEmptyJson_ThenReturnsTrue() + { + var body = Encoding.UTF8.GetBytes(RequestExtensions.EmptyRequestJson); + var signature = new HMACSigner(body, "asecret").Sign(); + var httpRequest = new Mock(); + httpRequest.Setup(hr => hr.Body) + .Returns(new MemoryStream(body)); + + var result = await httpRequest.Object.VerifyHMACSignatureAsync(signature, "asecret", CancellationToken.None); + + result.Should().BeTrue(); + } + [Fact] public async Task WhenVerifyHMACSignatureAsyncAndCorrectSignature_ThenReturnsTrue() { diff --git a/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs b/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs index e9ebe3ed..3e14f70b 100644 --- a/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs +++ b/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs @@ -228,13 +228,20 @@ public static void SetRequestId(this HttpRequestMessage message, ICallContext co } /// - /// Whether the specified HMAC signature represents the signature of the contents of the inbound request + /// Whether the specified HMAC signature represents the signature of the contents of the inbound request, + /// signed by the method /// public static async Task VerifyHMACSignatureAsync(this HttpRequest request, string signature, string secret, CancellationToken cancellationToken) { var body = await request.Body.ReadFullyAsync(cancellationToken); - request.RewindBody(); // need to do this for later middleware + request.RewindBody(); // HACK: need to do this for later middleware + + if (body.Length == 0) + { + body = Encoding.UTF8.GetBytes(RequestExtensions + .EmptyRequestJson); //HACK: we assume that an empty JSON request was signed + } var signer = new HMACSigner(body, secret); var verifier = new HMACVerifier(signer); diff --git a/src/Infrastructure.Web.Api.Common/Extensions/RequestExtensions.cs b/src/Infrastructure.Web.Api.Common/Extensions/RequestExtensions.cs index 354b1297..57562bae 100644 --- a/src/Infrastructure.Web.Api.Common/Extensions/RequestExtensions.cs +++ b/src/Infrastructure.Web.Api.Common/Extensions/RequestExtensions.cs @@ -9,7 +9,7 @@ namespace Infrastructure.Web.Api.Common.Extensions; public static class RequestExtensions { - private const string EmptyRequestJson = "{}"; + public const string EmptyRequestJson = "{}"; private const char RouteSegmentDelimiter = '/'; /// diff --git a/src/Infrastructure.Web.Api.Interfaces/WebServiceAttribute.cs b/src/Infrastructure.Web.Api.Interfaces/WebServiceAttribute.cs new file mode 100644 index 00000000..969ba5b0 --- /dev/null +++ b/src/Infrastructure.Web.Api.Interfaces/WebServiceAttribute.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Infrastructure.Web.Api.Interfaces; + +/// +/// Provides a declarative way to define a web API service +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public class WebServiceAttribute : Attribute +{ + public WebServiceAttribute( +#if !NETSTANDARD2_0 + [StringSyntax("Route")] +#endif + string basePath) + { + BasePath = basePath; + } + + public string BasePath { get; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateEdgeIdentityFeatureStateRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateEdgeIdentityFeatureStateRequest.cs new file mode 100644 index 00000000..de178ee1 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateEdgeIdentityFeatureStateRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/environments/{EnvironmentApiKey}/edge-identities/{IdentityUuid}/edge-featurestates/", ServiceOperation.Post)] +public class + FlagsmithCreateEdgeIdentityFeatureStateRequest : IWebRequest +{ + [JsonPropertyName("enabled")] public bool Enabled { get; set; } + + public required string EnvironmentApiKey { get; set; } + + [JsonPropertyName("feature")] public int Feature { get; set; } + + public required string IdentityUuid { get; set; } +} + +public class FlagsmithCreateEdgeIdentityFeatureStateResponse : IWebResponse +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateEdgeIdentityRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateEdgeIdentityRequest.cs new file mode 100644 index 00000000..fcfa300f --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateEdgeIdentityRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/environments/{EnvironmentApiKey}/edge-identities/", ServiceOperation.Post)] +[UsedImplicitly] +public class FlagsmithCreateEdgeIdentityRequest : IWebRequest +{ + public required string EnvironmentApiKey { get; set; } + + [JsonPropertyName("identifier")] public required string Identifier { get; set; } + + [JsonPropertyName("traits")] public List? Traits { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateEdgeIdentityResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateEdgeIdentityResponse.cs new file mode 100644 index 00000000..39b18dab --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateEdgeIdentityResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +public class FlagsmithCreateEdgeIdentityResponse : IWebResponse +{ + [JsonPropertyName("identifier")] public string? Identifier { get; set; } + + [JsonPropertyName("identity_uuid")] public string? IdentityUuid { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureRequest.cs new file mode 100644 index 00000000..adc13db2 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/projects/{ProjectId}/features/", ServiceOperation.Post)] +public class FlagsmithCreateFeatureRequest : IWebRequest +{ + [JsonPropertyName("name")] public required string Name { get; set; } + + public required int ProjectId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureResponse.cs new file mode 100644 index 00000000..1fc5f73c --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +public class FlagsmithCreateFeatureResponse : IWebResponse +{ + [JsonPropertyName("id")] public int Id { get; set; } + + [JsonPropertyName("name")] public string? Name { get; set; } + + [JsonPropertyName("type")] public string? Type { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureStateRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureStateRequest.cs new file mode 100644 index 00000000..a714b4e7 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureStateRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/environments/{EnvironmentApiKey}/featurestates/{FeatureStateId}/", ServiceOperation.PutPatch)] +public class FlagsmithCreateFeatureStateRequest : IWebRequest +{ + [JsonPropertyName("enabled")] public bool Enabled { get; set; } + + public required string EnvironmentApiKey { get; set; } + + [JsonPropertyName("feature")] public int Feature { get; set; } + + public int FeatureStateId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureStateResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureStateResponse.cs new file mode 100644 index 00000000..0f4c6e8a --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateFeatureStateResponse.cs @@ -0,0 +1,7 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +public class FlagsmithCreateFeatureStateResponse : IWebResponse +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateIdentityRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateIdentityRequest.cs new file mode 100644 index 00000000..886e4429 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateIdentityRequest.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/identities/", ServiceOperation.Post)] +[UsedImplicitly] +public class FlagsmithCreateIdentityRequest : IWebRequest +{ + [JsonPropertyName("identifier")] public required string Identifier { get; set; } + + [JsonPropertyName("traits")] public required List Traits { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateIdentityResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateIdentityResponse.cs new file mode 100644 index 00000000..46dd39b9 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateIdentityResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +public class FlagsmithCreateIdentityResponse : IWebResponse +{ + [JsonPropertyName("flags")] public List Flags { get; set; } = new(); + + [JsonPropertyName("identifier")] public string? Identifier { get; set; } + + [JsonPropertyName("traits")] public List Traits { get; set; } = new(); +} + +[UsedImplicitly] +public class FlagsmithTrait +{ + [JsonPropertyName("trait_key")] public string? Key { get; set; } + + [JsonPropertyName("trait_value")] public object? Value { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithDeleteEdgeIdentitiesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithDeleteEdgeIdentitiesRequest.cs new file mode 100644 index 00000000..ce5419ff --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithDeleteEdgeIdentitiesRequest.cs @@ -0,0 +1,11 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/environments/{EnvironmentApiKey}/edge-identities/{IdentityUuid}/", ServiceOperation.Delete)] +public class FlagsmithDeleteEdgeIdentitiesRequest : IWebRequest +{ + public required string EnvironmentApiKey { get; set; } + + public required string IdentityUuid { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithDeleteFeatureRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithDeleteFeatureRequest.cs new file mode 100644 index 00000000..dc1d677b --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithDeleteFeatureRequest.cs @@ -0,0 +1,11 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/projects/{ProjectId}/features/{FeatureId}/", ServiceOperation.Delete)] +public class FlagsmithDeleteFeatureRequest : IWebRequest +{ + public required int FeatureId { get; set; } + + public required int ProjectId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEdgeIdentitiesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEdgeIdentitiesRequest.cs new file mode 100644 index 00000000..5c340c98 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEdgeIdentitiesRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/environments/{EnvironmentApiKey}/edge-identities/", ServiceOperation.Get)] +public class FlagsmithGetEdgeIdentitiesRequest : IWebRequest +{ + public required string EnvironmentApiKey { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEdgeIdentitiesResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEdgeIdentitiesResponse.cs new file mode 100644 index 00000000..83d6cf62 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEdgeIdentitiesResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +public class FlagsmithGetEdgeIdentitiesResponse : IWebResponse +{ + [JsonPropertyName("results")] public List Results { get; set; } = new(); +} + +[UsedImplicitly] +public class FlagsmithEdgeIdentity +{ + [JsonPropertyName("identifier")] public string? Identifier { get; set; } + + [JsonPropertyName("identity_uuid")] public string? IdentityUuid { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEnvironmentFlagsRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEnvironmentFlagsRequest.cs new file mode 100644 index 00000000..e8c5c986 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEnvironmentFlagsRequest.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/flags/", ServiceOperation.Get)] +[UsedImplicitly] +public class FlagsmithGetEnvironmentFlagsRequest : IWebRequest +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEnvironmentFlagsResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEnvironmentFlagsResponse.cs new file mode 100644 index 00000000..700db8d0 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEnvironmentFlagsResponse.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +public class FlagsmithGetEnvironmentFlagsResponse : List, IWebResponse +{ + public FlagsmithGetEnvironmentFlagsResponse() + { + } + + public FlagsmithGetEnvironmentFlagsResponse(List flags) : base(flags) + { + } +} + +public class FlagsmithFlag +{ + [JsonPropertyName("enabled")] public bool Enabled { get; set; } + + [JsonPropertyName("feature")] public FlagsmithFeature? Feature { get; set; } + + [JsonPropertyName("id")] public int? Id { get; set; } + + [JsonPropertyName("feature_state_value")] + public string? Value { get; set; } +} + +public class FlagsmithFeature +{ + [JsonPropertyName("id")] public int Id { get; set; } + + [JsonPropertyName("name")] public string? Name { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeatureStatesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeatureStatesRequest.cs new file mode 100644 index 00000000..1d05edcb --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeatureStatesRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/environments/{EnvironmentApiKey}/featurestates/", ServiceOperation.Post)] +public class FlagsmithGetFeatureStatesRequest : IWebRequest +{ + public required string EnvironmentApiKey { get; set; } + + [JsonPropertyName("feature")] public int Feature { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeatureStatesResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeatureStatesResponse.cs new file mode 100644 index 00000000..06f72fad --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeatureStatesResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +public class FlagsmithGetFeatureStatesResponse : IWebResponse +{ + [JsonPropertyName("results")] + // ReSharper disable once CollectionNeverUpdated.Global + public List Results { get; set; } = new(); +} + +[UsedImplicitly] +public class FlagsmithFeatureState +{ + public int Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeaturesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeaturesRequest.cs new file mode 100644 index 00000000..4803b822 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeaturesRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +[Route("/projects/{ProjectId}/features/", ServiceOperation.Get)] +public class FlagsmithGetFeaturesRequest : IWebRequest +{ + public int ProjectId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeaturesResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeaturesResponse.cs new file mode 100644 index 00000000..7311102b --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetFeaturesResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +public class FlagsmithGetFeaturesResponse : IWebResponse +{ + [JsonPropertyName("results")] public List Results { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensRequest.cs similarity index 90% rename from src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensRequest.cs rename to src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensRequest.cs index f490d50b..ce42f4f1 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensRequest.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Infrastructure.Web.Api.Interfaces; -namespace Infrastructure.Web.Api.Operations.Shared._3rdParties; +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.OAuth2; [Route("/auth/token", ServiceOperation.Post)] public class ExchangeOAuth2CodeForTokensRequest : UnTenantedRequest diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensResponse.cs similarity index 86% rename from src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensResponse.cs rename to src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensResponse.cs index f654e26e..a5a295cb 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensResponse.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensResponse.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Infrastructure.Web.Api.Interfaces; -namespace Infrastructure.Web.Api.Operations.Shared._3rdParties; +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.OAuth2; public class ExchangeOAuth2CodeForTokensResponse : IWebResponse { diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetAllFeatureFlagsRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetAllFeatureFlagsRequest.cs new file mode 100644 index 00000000..7646ae63 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetAllFeatureFlagsRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Ancillary; + +[Route("/flags", ServiceOperation.Get, AccessType.HMAC)] +[Authorize(Roles.Platform_ServiceAccount)] +public class GetAllFeatureFlagsRequest : UnTenantedRequest +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetAllFeatureFlagsResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetAllFeatureFlagsResponse.cs new file mode 100644 index 00000000..e165cf82 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetAllFeatureFlagsResponse.cs @@ -0,0 +1,9 @@ +using Common.FeatureFlags; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Ancillary; + +public class GetAllFeatureFlagsResponse : IWebResponse +{ + public List Flags { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagForCallerRequest.cs new file mode 100644 index 00000000..2f15fa73 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagForCallerRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Ancillary; + +[Route("/flags/{Name}", ServiceOperation.Get)] +public class GetFeatureFlagForCallerRequest : UnTenantedRequest +{ + public required string Name { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagRequest.cs new file mode 100644 index 00000000..94ae7f00 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagRequest.cs @@ -0,0 +1,14 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Ancillary; + +[Route("/flags/{UserId}/{Name}", ServiceOperation.Get, AccessType.HMAC)] +[Authorize(Roles.Platform_ServiceAccount)] +public class GetFeatureFlagRequest : UnTenantedRequest +{ + public required string Name { get; set; } + + public string? TenantId { get; set; } + + public required string UserId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagResponse.cs new file mode 100644 index 00000000..1df2a466 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagResponse.cs @@ -0,0 +1,9 @@ +using Common.FeatureFlags; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Ancillary; + +public class GetFeatureFlagResponse : IWebResponse +{ + public FeatureFlag? Flag { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetAllFeatureFlagsRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetAllFeatureFlagsRequest.cs new file mode 100644 index 00000000..a2740555 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetAllFeatureFlagsRequest.cs @@ -0,0 +1,8 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; + +[Route("/flags", ServiceOperation.Get)] +public class GetAllFeatureFlagsRequest : UnTenantedRequest +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetAllFeatureFlagsResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetAllFeatureFlagsResponse.cs new file mode 100644 index 00000000..c858bd32 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetAllFeatureFlagsResponse.cs @@ -0,0 +1,9 @@ +using Common.FeatureFlags; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; + +public class GetAllFeatureFlagsResponse : IWebResponse +{ + public List Flags { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetFeatureFlagForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetFeatureFlagForCallerRequest.cs new file mode 100644 index 00000000..a239eb91 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetFeatureFlagForCallerRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; + +[Route("/flags/{Name}", ServiceOperation.Get)] +public class GetFeatureFlagForCallerRequest : UnTenantedRequest +{ + public required string Name { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetFeatureFlagResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetFeatureFlagResponse.cs new file mode 100644 index 00000000..4f474815 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetFeatureFlagResponse.cs @@ -0,0 +1,9 @@ +using Common.FeatureFlags; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; + +public class GetFeatureFlagResponse : IWebResponse +{ + public FeatureFlag? Flag { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Common/Clients/ApiServiceClient.cs b/src/Infrastructure.Web.Common/Clients/ApiServiceClient.cs index 0a79f336..6870fe87 100644 --- a/src/Infrastructure.Web.Common/Clients/ApiServiceClient.cs +++ b/src/Infrastructure.Web.Common/Clients/ApiServiceClient.cs @@ -35,7 +35,7 @@ protected ApiServiceClient(IHttpClientFactory clientFactory, JsonSerializerOptio _retryPolicy = ApiClientRetryPolicies.CreateRetryWithExponentialBackoffAndJitter(retryCount); } - public async Task> DeleteAsync(ICallerContext context, + public async Task> DeleteAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() @@ -46,7 +46,7 @@ protected ApiServiceClient(IHttpClientFactory clientFactory, JsonSerializerOptio cancellationToken ?? CancellationToken.None); } - public async Task> GetAsync(ICallerContext context, + public async Task> GetAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() @@ -57,7 +57,7 @@ public async Task> GetAsync(ICalle cancellationToken ?? CancellationToken.None); } - public async Task> PatchAsync(ICallerContext context, + public async Task> PatchAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() @@ -68,7 +68,7 @@ public async Task> PatchAsync(ICal cancellationToken ?? CancellationToken.None); } - public async Task> PostAsync(ICallerContext context, + public async Task> PostAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() @@ -79,7 +79,7 @@ public async Task> PostAsync(ICall cancellationToken ?? CancellationToken.None); } - public async Task> PutAsync(ICallerContext context, + public async Task> PutAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() @@ -90,7 +90,7 @@ public async Task> PutAsync(ICalle cancellationToken ?? CancellationToken.None); } - public async Task FireAsync(ICallerContext context, IWebRequestVoid request, + public async Task FireAsync(ICallerContext? context, IWebRequestVoid request, Action? requestFilter = null, CancellationToken? cancellationToken = null) { using var client = CreateJsonClient(context, requestFilter, out var modifiedRequestFilter); @@ -98,7 +98,7 @@ await _retryPolicy.ExecuteAsync(async ct => await client.SendOneWayAsync(request cancellationToken ?? CancellationToken.None); } - public async Task FireAsync(ICallerContext context, IWebRequest request, + public async Task FireAsync(ICallerContext? context, IWebRequest request, Action? requestFilter, CancellationToken? cancellationToken = null) where TResponse : IWebResponse { @@ -108,7 +108,7 @@ await _retryPolicy.ExecuteAsync( cancellationToken ?? CancellationToken.None); } - protected virtual JsonClient CreateJsonClient(ICallerContext context, + protected virtual JsonClient CreateJsonClient(ICallerContext? context, Action? inboundRequestFilter, out Action modifiedRequestFilter) { diff --git a/src/Infrastructure.Web.Common/Clients/InterHostServiceClient.cs b/src/Infrastructure.Web.Common/Clients/InterHostServiceClient.cs index 8fa71447..c228366a 100644 --- a/src/Infrastructure.Web.Common/Clients/InterHostServiceClient.cs +++ b/src/Infrastructure.Web.Common/Clients/InterHostServiceClient.cs @@ -23,7 +23,7 @@ public InterHostServiceClient(IHttpClientFactory clientFactory, JsonSerializerOp { } - protected override JsonClient CreateJsonClient(ICallerContext context, + protected override JsonClient CreateJsonClient(ICallerContext? context, Action? inboundRequestFilter, out Action modifiedRequestFilter) { @@ -50,13 +50,19 @@ protected override JsonClient CreateJsonClient(ICallerContext context, return client; } - private static void AddCorrelationId(HttpRequestMessage message, ICallerContext context) + private static void AddCorrelationId(HttpRequestMessage message, ICallerContext? context) { - message.SetRequestId(context.ToCall()); + if (context.Exists()) + { + message.SetRequestId(context.ToCall()); + } } - private static void AddCallerAuthorization(HttpRequestMessage message, ICallerContext context) + private static void AddCallerAuthorization(HttpRequestMessage message, ICallerContext? context) { - message.SetAuthorization(context); + if (context.Exists()) + { + message.SetAuthorization(context); + } } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Common/Clients/JsonClient.cs b/src/Infrastructure.Web.Common/Clients/JsonClient.cs index c9bb93e2..61727858 100644 --- a/src/Infrastructure.Web.Common/Clients/JsonClient.cs +++ b/src/Infrastructure.Web.Common/Clients/JsonClient.cs @@ -249,14 +249,17 @@ public async Task SendOneWayAsync(IWebRequest request, Action SendRequestAsync(HttpMethod method, IWebRequest request, Action? requestFilter, CancellationToken? cancellationToken = default) { var requestUri = request.GetRequestInfo().Route; - var content = new StringContent(request.SerializeToJson(), new MediaTypeHeaderValue(HttpContentTypes.Json)); + + var content = method == HttpMethod.Post || method == HttpMethod.Put || method == HttpMethod.Patch + ? new StringContent(request.SerializeToJson(), new MediaTypeHeaderValue(HttpContentTypes.Json)) + : null; return await SendRequestAsync(method, requestUri, content, requestFilter, cancellationToken); } @@ -268,7 +271,7 @@ private async Task SendRequestAsync(HttpMethod method, stri var request = new HttpRequestMessage { Method = method, - RequestUri = new Uri(_client.BaseAddress!, requestUri), + RequestUri = new Uri(_client.BaseAddress!, requestUri.WithoutLeadingSlash()), Content = requestContent, Headers = { { HttpHeaders.Accept, HttpContentTypes.Json } } }; diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs index 7af268e2..728ebf27 100644 --- a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs @@ -5,6 +5,7 @@ using Common; using Common.Configuration; using Common.Extensions; +using Common.FeatureFlags; using Domain.Common; using Domain.Common.Identity; using Domain.Interfaces; @@ -22,6 +23,7 @@ using Infrastructure.Interfaces; using Infrastructure.Persistence.Common.ApplicationServices; using Infrastructure.Persistence.Interfaces; +using Infrastructure.Shared.ApplicationServices.External; using Infrastructure.Web.Api.Common; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Common.Validation; @@ -110,6 +112,7 @@ public static WebApplication ConfigureApiHost(this WebApplicationBuilder appBuil void RegisterSharedServices() { appBuilder.Services.AddHttpContextAccessor(); + appBuilder.Services.AddSingleton(); } void RegisterConfiguration(bool isMultiTenanted) diff --git a/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj b/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj index ca0b9edd..f5032c76 100644 --- a/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj +++ b/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Infrastructure.Web.Interfaces/Clients/IServiceClient.cs b/src/Infrastructure.Web.Interfaces/Clients/IServiceClient.cs index 780d12ac..30fb8ffe 100644 --- a/src/Infrastructure.Web.Interfaces/Clients/IServiceClient.cs +++ b/src/Infrastructure.Web.Interfaces/Clients/IServiceClient.cs @@ -9,26 +9,28 @@ namespace Infrastructure.Web.Interfaces.Clients; /// public interface IServiceClient : IFireAndForgetServiceClient { - Task> DeleteAsync(ICallerContext context, + Task> DeleteAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new(); - Task> GetAsync(ICallerContext context, IWebRequest request, + Task> GetAsync(ICallerContext? context, + IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new(); - Task> PatchAsync(ICallerContext context, + Task> PatchAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new(); - Task> PostAsync(ICallerContext context, + Task> PostAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new(); - Task> PutAsync(ICallerContext context, IWebRequest request, + Task> PutAsync(ICallerContext? context, + IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new(); } @@ -38,10 +40,10 @@ Task> PutAsync(ICallerContext cont /// public interface IFireAndForgetServiceClient { - Task FireAsync(ICallerContext context, IWebRequestVoid request, + Task FireAsync(ICallerContext? context, IWebRequestVoid request, Action? requestFilter = null, CancellationToken? cancellationToken = null); - Task FireAsync(ICallerContext context, IWebRequest request, + Task FireAsync(ICallerContext? context, IWebRequest request, Action? requestFilter, CancellationToken? cancellationToken = null) where TResponse : IWebResponse; } \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.IntegrationTests/FeatureFlagsApiSpec.cs b/src/Infrastructure.Web.Website.IntegrationTests/FeatureFlagsApiSpec.cs new file mode 100644 index 00000000..41e3ce47 --- /dev/null +++ b/src/Infrastructure.Web.Website.IntegrationTests/FeatureFlagsApiSpec.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Common.FeatureFlags; +using FluentAssertions; +using Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; +using Infrastructure.Web.Api.Operations.Shared.TestingOnly; +using Infrastructure.Web.Hosting.Common.Pipeline; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using WebsiteHost; +using Xunit; +using GetFeatureFlagResponse = Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd.GetFeatureFlagResponse; +using Task = System.Threading.Tasks.Task; + +namespace Infrastructure.Web.Website.IntegrationTests; + +[Trait("Category", "Integration.Web")] +[Collection("API")] +public class FeatureFlagsApiSpec : WebApiSpec +{ + private readonly CSRFMiddleware.ICSRFService _csrfService; + private readonly JsonSerializerOptions _jsonOptions; + + public FeatureFlagsApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + StartupServer(); + StartupServer(); + _csrfService = setup.GetRequiredService(); +#if TESTINGONLY + HttpApi.PostEmptyJsonAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)).GetAwaiter() + .GetResult(); +#endif + _jsonOptions = setup.GetRequiredService(); + } + + [Fact] + public async Task WhenGetAllFeatureFlags_ThenReturnsFlags() + { +#if TESTINGONLY + var request = new GetAllFeatureFlagsRequest(); + + var result = await HttpApi.GetAsync(request.MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + var flags = (await result.Content.ReadFromJsonAsync(_jsonOptions))!.Flags; + flags.Count.Should().Be(2); + flags[0].Name.Should().Be(Flag.TestingOnly.Name); + flags[1].Name.Should().Be(Flag.AFeatureFlag.Name); +#endif + } + + [Fact] + public async Task WhenGetFeatureFlag_ThenReturnsFlag() + { +#if TESTINGONLY + var request = new GetFeatureFlagForCallerRequest + { + Name = Flag.TestingOnly.Name + }; + + var result = await HttpApi.GetAsync(request.MakeApiRoute()); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + var flag = (await result.Content.ReadFromJsonAsync(_jsonOptions))!.Flag!; + flag.Name.Should().Be(Flag.TestingOnly.Name); +#endif + } + + private static void OverrideDependencies(IServiceCollection services) + { + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.IntegrationTests/Infrastructure.Web.Website.IntegrationTests.csproj b/src/Infrastructure.Web.Website.IntegrationTests/Infrastructure.Web.Website.IntegrationTests.csproj index ece0ef9e..2c8f1bc3 100644 --- a/src/Infrastructure.Web.Website.IntegrationTests/Infrastructure.Web.Website.IntegrationTests.csproj +++ b/src/Infrastructure.Web.Website.IntegrationTests/Infrastructure.Web.Website.IntegrationTests.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Infrastructure.Web.Website.IntegrationTests/WebsiteTestingExtensions.cs b/src/Infrastructure.Web.Website.IntegrationTests/WebsiteTestingExtensions.cs index 7bb32692..924771be 100644 --- a/src/Infrastructure.Web.Website.IntegrationTests/WebsiteTestingExtensions.cs +++ b/src/Infrastructure.Web.Website.IntegrationTests/WebsiteTestingExtensions.cs @@ -138,4 +138,5 @@ public static void WithCSRF(this HttpRequestMessage message, CookieContainer coo var origin = $"{message.RequestUri.Scheme}{Uri.SchemeDelimiter}{message.RequestUri.Authority}"; message.Headers.Add(HttpHeaders.Origin, origin); } + } \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.UnitTests/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidatorSpec.cs b/src/Infrastructure.Web.Website.UnitTests/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidatorSpec.cs new file mode 100644 index 00000000..d5858469 --- /dev/null +++ b/src/Infrastructure.Web.Website.UnitTests/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidatorSpec.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; +using UnitTesting.Common.Validation; +using WebsiteHost; +using WebsiteHost.Api.FeatureFlags; +using Xunit; + +namespace Infrastructure.Web.Website.UnitTests.Api.FeatureFlags; + +[Trait("Category", "Unit")] +public class GetFeatureFlagForCallerRequestValidatorSpec +{ + private readonly GetFeatureFlagForCallerRequest _dto; + private readonly GetFeatureFlagForCallerRequestValidator _validator; + + public GetFeatureFlagForCallerRequestValidatorSpec() + { + _validator = new GetFeatureFlagForCallerRequestValidator(); + _dto = new GetFeatureFlagForCallerRequest + { + Name = "aname" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenNameIsEmpty_ThenThrows() + { + _dto.Name = string.Empty; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.GetFeatureFlagForCallerRequestValidator_InvalidName); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.UnitTests/Application/FeatureFlagsApplicationSpec.cs b/src/Infrastructure.Web.Website.UnitTests/Application/FeatureFlagsApplicationSpec.cs new file mode 100644 index 00000000..82fa19a6 --- /dev/null +++ b/src/Infrastructure.Web.Website.UnitTests/Application/FeatureFlagsApplicationSpec.cs @@ -0,0 +1,81 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Common.FeatureFlags; +using FluentAssertions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using Infrastructure.Web.Interfaces.Clients; +using Moq; +using UnitTesting.Common; +using WebsiteHost.Application; +using Xunit; + +namespace Infrastructure.Web.Website.UnitTests.Application; + +[Trait("Category", "Unit")] +public class FeatureFlagsApplicationSpec +{ + private readonly FeatureFlagsApplication _application; + private readonly Mock _caller; + private readonly Mock _serviceClient; + + public FeatureFlagsApplicationSpec() + { + var hostSettings = new Mock(); + _caller = new Mock(); + _serviceClient = new Mock(); + _application = new FeatureFlagsApplication(_serviceClient.Object, hostSettings.Object); + } + + [Fact] + public async Task WhenGetFeatureFlagForCaller_ThenReturns() + { + _serviceClient.Setup(sc => sc.GetAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new GetFeatureFlagResponse + { + Flag = new FeatureFlag + { + Name = "aname", + IsEnabled = true + } + }); + + var result = + await _application.GetFeatureFlagForCallerAsync(_caller.Object, "aname", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be("aname"); + result.Value.IsEnabled.Should().BeTrue(); + _serviceClient.Verify(sc => sc.GetAsync(_caller.Object, It.Is(req => + req.Name == "aname" + ), It.IsAny>(), It.IsAny())); + } + + [Fact] + public async Task WhenGetAllFeatureFlags_ThenReturns() + { + _serviceClient.Setup(sc => sc.GetAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new GetAllFeatureFlagsResponse + { + Flags = new List + { + new() + { + Name = "aname", + IsEnabled = true + } + } + }); + + var result = + await _application.GetAllFeatureFlagsAsync(_caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Count.Should().Be(1); + result.Value[0].Name.Should().Be("aname"); + result.Value[0].IsEnabled.Should().BeTrue(); + _serviceClient.Verify(sc => sc.GetAsync(_caller.Object, It.IsAny(), + It.IsAny>(), It.IsAny())); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/Stubs/StubServiceClient.cs b/src/Infrastructure.Worker.Api.IntegrationTests/Stubs/StubServiceClient.cs index 96b0d480..dda5b2e7 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/Stubs/StubServiceClient.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/Stubs/StubServiceClient.cs @@ -9,14 +9,14 @@ public class StubServiceClient : IServiceClient { public Optional LastPostedMessage { get; private set; } = Optional.None; - public Task FireAsync(ICallerContext context, IWebRequestVoid request, + public Task FireAsync(ICallerContext? context, IWebRequestVoid request, Action? requestFilter = null, CancellationToken? cancellationToken = null) { throw new NotImplementedException(); } - public Task FireAsync(ICallerContext context, IWebRequest request, + public Task FireAsync(ICallerContext? context, IWebRequest request, Action? requestFilter, CancellationToken? cancellationToken = null) where TResponse : IWebResponse @@ -24,7 +24,7 @@ public Task FireAsync(ICallerContext context, IWebRequest throw new NotImplementedException(); } - public Task> DeleteAsync(ICallerContext context, + public Task> DeleteAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() @@ -32,7 +32,7 @@ public Task FireAsync(ICallerContext context, IWebRequest throw new NotImplementedException(); } - public Task> GetAsync(ICallerContext context, + public Task> GetAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() @@ -40,7 +40,7 @@ public Task> GetAsync(ICallerConte throw new NotImplementedException(); } - public Task> PatchAsync(ICallerContext context, + public Task> PatchAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() @@ -48,7 +48,7 @@ public Task> PatchAsync(ICallerCon throw new NotImplementedException(); } - public Task> PostAsync(ICallerContext context, + public Task> PostAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() @@ -58,7 +58,7 @@ public Task> PostAsync(ICallerCont return Task.FromResult>(new TResponse()); } - public Task> PutAsync(ICallerContext context, + public Task> PutAsync(ICallerContext? context, IWebRequest request, Action? requestFilter = null, CancellationToken? cancellationToken = null) where TResponse : IWebResponse, new() diff --git a/src/IntegrationTesting.WebApi.Common/ExternalApiSpec.cs b/src/IntegrationTesting.WebApi.Common/ExternalApiSpec.cs new file mode 100644 index 00000000..8d9e2932 --- /dev/null +++ b/src/IntegrationTesting.WebApi.Common/ExternalApiSpec.cs @@ -0,0 +1,120 @@ +using Common.Configuration; +using Common.Extensions; +using Infrastructure.Hosting.Common; +using Infrastructure.Hosting.Common.Extensions; +using JetBrains.Annotations; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace IntegrationTesting.WebApi.Common; + +/// +/// Provides an xUnit collection for running "External" tests together +/// +[CollectionDefinition("External", DisableParallelization = false)] +public class AllExternalSpecs : ICollectionFixture +{ +} + +/// +/// Provides an xUnit class fixture for external integration testing APIs +/// +[UsedImplicitly] +public class ExternalApiSetup : IDisposable +{ + private IHost? _host; + private Action? _overridenTestingDependencies; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + if (_host.Exists()) + { + _host.StopAsync().GetAwaiter().GetResult(); + _host.Dispose(); + } + } + } + + public TService GetRequiredService() + where TService : notnull + { + if (_host.NotExists()) + { + throw new InvalidOperationException("Host has not be started yet!"); + } + + return _host.Services.Resolve(); + } + + public void OverrideTestingDependencies(Action overrideDependencies) + { + _overridenTestingDependencies = overrideDependencies; + } + + public void Start() + { + _host = new HostBuilder() + .ConfigureAppConfiguration(builder => + { + builder + .AddJsonFile("appsettings.Testing.json", true) + .AddJsonFile("appsettings.Testing.local.json", true); + }) + .ConfigureServices((context, services) => + { + services.AddSingleton(new AspNetConfigurationSettings(context.Configuration)); + if (_overridenTestingDependencies.Exists()) + { + _overridenTestingDependencies.Invoke(services); + } + }) + .Build(); + _host.Start(); + } +} + +/// +/// Provides an xUnit class fixture for external integration testing APIs +/// +public abstract class ExternalApiSpec : IClassFixture, IDisposable +{ + protected readonly ExternalApiSetup Setup; + + protected ExternalApiSpec(ExternalApiSetup setup, Action? overrideDependencies = null) + { + if (overrideDependencies.Exists()) + { + setup.OverrideTestingDependencies(overrideDependencies); + } + + setup.Start(); + Setup = setup; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + if (Setup is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} \ 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 index 6ceaaa09..b39a9a3b 100644 --- a/src/IntegrationTesting.WebApi.Common/IntegrationTesting.WebApi.Common.csproj +++ b/src/IntegrationTesting.WebApi.Common/IntegrationTesting.WebApi.Common.csproj @@ -7,6 +7,7 @@ + diff --git a/src/IntegrationTesting.WebApi.Common/Stubs/StubFeatureFlags.cs b/src/IntegrationTesting.WebApi.Common/Stubs/StubFeatureFlags.cs new file mode 100644 index 00000000..b0b63f10 --- /dev/null +++ b/src/IntegrationTesting.WebApi.Common/Stubs/StubFeatureFlags.cs @@ -0,0 +1,49 @@ +using Common; +using Common.FeatureFlags; + +namespace IntegrationTesting.WebApi.Common.Stubs; + +/// +/// Provides a stub for testing +/// +public class StubFeatureFlags : IFeatureFlags +{ + public string? LastGetFlag { get; private set; } + + public Task, Error>> GetAllFlagsAsync(CancellationToken cancellationToken) + { + return Task.FromResult( + new Result, Error>((IReadOnlyList)new List())); + } + + public Task> GetFlagAsync(Flag flag, Optional tenantId, Optional userId, + CancellationToken cancellationToken) + { + LastGetFlag = flag.Name; + return Task.FromResult(new Result(new FeatureFlag + { + Name = flag.Name, + IsEnabled = true + })); + } + + public bool IsEnabled(Flag flag) + { + throw new NotImplementedException(); + } + + public bool IsEnabled(Flag flag, string userId) + { + throw new NotImplementedException(); + } + + public bool IsEnabled(Flag flag, Optional tenantId, string userId) + { + throw new NotImplementedException(); + } + + public void Reset() + { + LastGetFlag = null; + } +} \ No newline at end of file diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index 723c61c5..7bd5d010 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -6,6 +6,7 @@ using Application.Services.Shared; using Common; using Common.Extensions; +using Common.FeatureFlags; using FluentAssertions; using Infrastructure.Web.Api.Operations.Shared.Identities; using Infrastructure.Web.Api.Operations.Shared.TestingOnly; @@ -87,6 +88,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) .ConfigureTestServices(services => { services.AddSingleton(); + services.AddSingleton(); if (_overridenTestingDependencies.Exists()) { _overridenTestingDependencies.Invoke(services); diff --git a/src/SaaStack.sln b/src/SaaStack.sln index 6df7db04..8e6c2d51 100644 --- a/src/SaaStack.sln +++ b/src/SaaStack.sln @@ -298,6 +298,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools.Generators.Web.Api.Au EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{D3B68FF7-293B-4458-B8D8-49D3DF59B495}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools.Generators.Common", "Tools.Generators.Common\Tools.Generators.Common.csproj", "{578736A6-7CE1-408D-8217-468F35861F5B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools.Generators.Common.UnitTests", "Tools.Generators.Common.UnitTests\Tools.Generators.Common.UnitTests.csproj", "{6C654E34-B698-4F23-8757-D50C85F51F5B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Shared.IntegrationTests", "Infrastructure.Shared.IntegrationTests\Infrastructure.Shared.IntegrationTests.csproj", "{A4E40A61-6C36-4C1E-B5D5-68546B2387C3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -929,6 +935,24 @@ Global {E081B52F-A4AC-47A0-B03C-F23BF34CE1E7}.Release|Any CPU.Build.0 = Release|Any CPU {E081B52F-A4AC-47A0-B03C-F23BF34CE1E7}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU {E081B52F-A4AC-47A0-B03C-F23BF34CE1E7}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {578736A6-7CE1-408D-8217-468F35861F5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {578736A6-7CE1-408D-8217-468F35861F5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {578736A6-7CE1-408D-8217-468F35861F5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {578736A6-7CE1-408D-8217-468F35861F5B}.Release|Any CPU.Build.0 = Release|Any CPU + {578736A6-7CE1-408D-8217-468F35861F5B}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {578736A6-7CE1-408D-8217-468F35861F5B}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {6C654E34-B698-4F23-8757-D50C85F51F5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C654E34-B698-4F23-8757-D50C85F51F5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C654E34-B698-4F23-8757-D50C85F51F5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C654E34-B698-4F23-8757-D50C85F51F5B}.Release|Any CPU.Build.0 = Release|Any CPU + {6C654E34-B698-4F23-8757-D50C85F51F5B}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {6C654E34-B698-4F23-8757-D50C85F51F5B}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {A4E40A61-6C36-4C1E-B5D5-68546B2387C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4E40A61-6C36-4C1E-B5D5-68546B2387C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4E40A61-6C36-4C1E-B5D5-68546B2387C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4E40A61-6C36-4C1E-B5D5-68546B2387C3}.Release|Any CPU.Build.0 = Release|Any CPU + {A4E40A61-6C36-4C1E-B5D5-68546B2387C3}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {A4E40A61-6C36-4C1E-B5D5-68546B2387C3}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F5C77A86-38AF-40E4-82FC-617E624B2754} = {508E7DA4-4DF2-4201-955D-CCF70C41AD05} @@ -1072,5 +1096,8 @@ Global {D3B68FF7-293B-4458-B8D8-49D3DF59B495} = {4B1A213C-36A7-41A7-BFC7-B3CFF5795912} {11F60901-1E1C-4B1B-83E8-261269D2681B} = {D3B68FF7-293B-4458-B8D8-49D3DF59B495} {BC14CDD1-E127-4DF7-A1B3-55164CA8D1A4} = {D3B68FF7-293B-4458-B8D8-49D3DF59B495} + {578736A6-7CE1-408D-8217-468F35861F5B} = {BAE0D6F2-6920-4B02-9F30-D71B04B7170D} + {6C654E34-B698-4F23-8757-D50C85F51F5B} = {A25A3BA8-5602-4825-9595-2CF96B166920} + {A4E40A61-6C36-4C1E-B5D5-68546B2387C3} = {9B6B0235-BD3F-4604-8E93-B0112A241C63} EndGlobalSection EndGlobal diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 19cb77d3..faee57f0 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -822,6 +822,8 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True @@ -908,7 +910,9 @@ public void When$condition$_Then$outcome$() True True True + True True + True True True True @@ -981,6 +985,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -999,8 +1004,11 @@ public void When$condition$_Then$outcome$() True True True + True True True + True + True True True True @@ -1016,6 +1024,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True diff --git a/src/TestingStubApiHost/Api/StubFlagsmithApi.cs b/src/TestingStubApiHost/Api/StubFlagsmithApi.cs new file mode 100644 index 00000000..4ebf3c22 --- /dev/null +++ b/src/TestingStubApiHost/Api/StubFlagsmithApi.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using Common; +using Common.Configuration; +using Common.FeatureFlags; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.Flagsmith; + +namespace TestingStubApiHost.Api; + +[WebService("/flagsmith")] +public class StubFlagsmithApi : StubApiBase +{ + private static readonly List Flags = GetAllFlags(); + + public StubFlagsmithApi(IRecorder recorder, IConfigurationSettings settings) : base(recorder, settings) + { + } + + public async Task> CreateIdentity( + FlagsmithCreateIdentityRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, "StubFlagsmith: CreateIdentity"); + return () => + new PostResult(new FlagsmithCreateIdentityResponse + { + Flags = Flags, + Identifier = request.Identifier, + Traits = request.Traits + }); + } + + public async Task> GetEnvironmentFlags( + FlagsmithGetEnvironmentFlagsRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, "StubFlagsmith: GetEnvironmentFlags"); + return () => + new Result(new FlagsmithGetEnvironmentFlagsResponse(Flags)); + } + + private static List GetAllFlags() + { + var allFlags = typeof(Flag).GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(Flag)) + .Select(f => (Flag)f.GetValue(null)!) + .ToList(); + + var counter = 1000; + return allFlags.Select(f => new FlagsmithFlag + { + Id = null, + Enabled = false, + Value = null, + Feature = new FlagsmithFeature + { + Id = ++counter, + Name = f.Name + } + }).ToList(); + } +} \ No newline at end of file diff --git a/src/TestingStubApiHost/appsettings.json b/src/TestingStubApiHost/appsettings.json index 7f8b5c95..512a07fa 100644 --- a/src/TestingStubApiHost/appsettings.json +++ b/src/TestingStubApiHost/appsettings.json @@ -11,7 +11,7 @@ "ApplicationServices": { "Persistence": { "LocalMachineJsonFileStore": { - "RootPath": "./saastack/stubs" + "RootPath": "./saastack/bananas" } } } diff --git a/src/Tools.Generators.Common.UnitTests/FeatureFlagGeneratorSpec.cs b/src/Tools.Generators.Common.UnitTests/FeatureFlagGeneratorSpec.cs new file mode 100644 index 00000000..1933ed95 --- /dev/null +++ b/src/Tools.Generators.Common.UnitTests/FeatureFlagGeneratorSpec.cs @@ -0,0 +1,97 @@ +using System.Reflection; +using System.Text; +using FluentAssertions; +using JetBrains.Annotations; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace Tools.Generators.Common.UnitTests; + +[UsedImplicitly] +public class FeatureFlagGeneratorSpec +{ + private static readonly string[] + AdditionalCompilationAssemblies = + { "System.Runtime.dll", "netstandard.dll" }; //HACK: required to analyze custom attributes + + private static CSharpCompilation CreateCompilation() + { + var assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + + var references = new List + { + MetadataReference.CreateFromFile(typeof(FeatureFlagGenerator).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location) + }; + AdditionalCompilationAssemblies.ToList() + .ForEach(item => references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, item)))); + var compilation = CSharpCompilation.Create("compilation", + new[] + { + CSharpSyntaxTree.ParseText("") + }, + references, + new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + + return compilation; + } + + [Trait("Category", "Unit")] + public class GivenFeatureFlagsResources + { + private GeneratorDriver _driver; + + public GivenFeatureFlagsResources() + { + var generator = new FeatureFlagGenerator(); + var additionalText = new ResourcesFile(); + _driver = CSharpGeneratorDriver.Create(new[] { generator }, new[] { additionalText }); + } + + [Fact] + public void WhenGenerate_ThenGenerates() + { + var result = Generate(CreateCompilation()); + + result.Should().StartWith( + """ + // + using Common.FeatureFlags; + + namespace Common.FeatureFlags; + + /// + partial class Flag + { + public static Flag AFeatureFlag = new Flag("a_feature_flag"); + + } + """); + } + + private string Generate(CSharpCompilation compilation) + { + _driver = _driver.RunGeneratorsAndUpdateCompilation(compilation, out var _, out var _); + return _driver.GetRunResult().Results[0].GeneratedSources[0].SourceText.ToString(); + } + } +} + +public class ResourcesFile : AdditionalText +{ + public override string Path => "FeatureFlags.resx"; + + public override SourceText GetText(CancellationToken cancellationToken = new()) + { + return SourceText.From(""" + + + + a feature flag + + + """, Encoding.UTF8); + } +} \ No newline at end of file diff --git a/src/Tools.Generators.Common.UnitTests/Tools.Generators.Common.UnitTests.csproj b/src/Tools.Generators.Common.UnitTests/Tools.Generators.Common.UnitTests.csproj new file mode 100644 index 00000000..6c3a8c22 --- /dev/null +++ b/src/Tools.Generators.Common.UnitTests/Tools.Generators.Common.UnitTests.csproj @@ -0,0 +1,21 @@ + + + + net7.0 + true + + + + + + + + + + + + + + + + diff --git a/src/Tools.Generators.Common/FeatureFlagGenerator.cs b/src/Tools.Generators.Common/FeatureFlagGenerator.cs new file mode 100644 index 00000000..27dccee4 --- /dev/null +++ b/src/Tools.Generators.Common/FeatureFlagGenerator.cs @@ -0,0 +1,95 @@ +using System.Text; +using System.Xml; +using Common.Extensions; +using Common.FeatureFlags; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Tools.Generators.Common; + +/// +/// A source generator for converting to feature flag values +/// +[Generator] +public class FeatureFlagGenerator : ISourceGenerator +{ + private const string Filename = "FeatureFlags\\Flag.g.cs"; + + public void Initialize(GeneratorInitializationContext context) + { + // No initialization + } + + public void Execute(GeneratorExecutionContext context) + { + var assemblyNamespace = $"{typeof(Flag).Namespace}"; + var classUsingNamespaces = $"using {typeof(Flag).Namespace};"; + + var fileSource = BuildFile(context, assemblyNamespace, classUsingNamespaces, context.CancellationToken); + + context.AddSource(Filename, SourceText.From(fileSource, Encoding.UTF8)); + + return; + + static Dictionary GetFlagResources(GeneratorExecutionContext context, + CancellationToken cancellationToken) + { + var resourceFile = context.AdditionalFiles + .FirstOrDefault(af => af.Path.EndsWith(".resx")); + if (resourceFile is null) + { + return new Dictionary(); + } + + var xml = resourceFile.GetText(cancellationToken)!; + var xmlDocument = new XmlDocument(); + xmlDocument.LoadXml(xml.ToString()); + var root = xmlDocument.DocumentElement!; + + var values = root.SelectNodes("/root/data")! + .Cast() + .Select(node => + { + var name = node.Attributes!["name"].Value; + var value = node.SelectSingleNode("value")?.InnerText; + return new KeyValuePair(name, value ?? name); + }); + + return values.ToDictionary(pair => pair.Key, pair => pair.Value); + } + + static string BuildFlagDefinitions(Dictionary flags) + { + var builder = new StringBuilder(); + const string className = nameof(Flag); + foreach (var flag in flags) + { + builder.AppendFormat($" public static {className} {{0}} = new {className}(\"{{1}}\");", flag.Key, + flag.Value.ToSnakeCase()); + builder.AppendLine(); + } + + return builder.ToString(); + } + + static string BuildFile(GeneratorExecutionContext context, string assemblyNamespace, + string allUsingNamespaces, CancellationToken cancellationToken) + { + const string className = nameof(Flag); + var allFlags = GetFlagResources(context, cancellationToken); + var flagDefinitions = BuildFlagDefinitions(allFlags); + + return $@"// +{allUsingNamespaces} + +namespace {assemblyNamespace}; + +/// +partial class {className} +{{ +{flagDefinitions} +}} +"; + } + } +} \ No newline at end of file diff --git a/src/Tools.Generators.Common/README.md b/src/Tools.Generators.Common/README.md new file mode 100644 index 00000000..34ee68db --- /dev/null +++ b/src/Tools.Generators.Common/README.md @@ -0,0 +1,43 @@ +# Source Generator + +This source generator project is only meant to be included by the `Common` project only. + +It's job is to convert all `FeatureFlags` definition (found in the assembly) into instances of the `Flag` class. + +# Development Workarounds + +Source Generators are required to run to build the rest of the codebase. + +Source Generators have to be built in NETSTANDARD2.0 for them to run in Visual Studio, but this is not the case to run in JetBrains Rider. +> This constraint exists to support source generators working in older versions of the .NET Framework, and will exist until Microsoft fix the issue Visual Studio. This is another reason to use JetBrains Rider as the preferred IDE for working with this codebase. + +C# Source Generators have difficulties running in any IDE if the code used in them references code in other projects in the solution, and they also suffer problems if they reference any nuget packages. + +This is especially problematic when those referenced projects have transient dependencies to types in ASP.NET + +If any dependencies are taken, special workarounds (in the project file of this project) are required in order for this source generators to work properly. + +We are avoiding including certain types from any projects in this solution (e.g. from the `Common` project) even though we need it in the code of the Source generator, since that project is dependent on types in AspNet framework. + +To workaround this, we have file-linked certain source files from projects in the solution, so that we can use those symbols in the Source Generator code. + +We have had to hardcode certain other types to avoid referencing AspNet, and these cannot be tracked by tooling if they are 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. + +# Debugging Generators + +You can debug the analyzers easily from the unit tests. + +You can debug your source generator by setting a breakpoint in the code, and then running the `Common-SourceGenerators-Development` run configuration from the `ApiHost1` project with the debugger. (found in the `launchSettings.json` file in any executable project). + + +> Warning: C# source generators are heavily cached. If you try to debug new code that you've added you may need to clear the caches from the old code being used. Otherwise you breakpoints may not hit. + +The most reliable way to reset the generators: + +1. Restart Jetbrains Rider +2. Kill any remaining `.Net Host (dotnet.exe)` processes on your machine, and any remaining `Jetbrains Rider` processes on your machine +3. Restart Rider +4. Set your breakpoints +5. Start debugging the `Common-SourceGenerators-Development` run configuration \ No newline at end of file diff --git a/src/Tools.Generators.Common/Tools.Generators.Common.csproj b/src/Tools.Generators.Common/Tools.Generators.Common.csproj new file mode 100644 index 00000000..d6e5e61e --- /dev/null +++ b/src/Tools.Generators.Common/Tools.Generators.Common.csproj @@ -0,0 +1,32 @@ + + + + netstandard2.0 + $(DefineConstants);GENERATORS_COMMON_PROJECT + latest + enable + true + true + true + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + Reference\Common\FeatureFlags\Flag.cs + + + Reference\Common\Extensions\StringExtensions.cs + + + diff --git a/src/Tools.Generators.Web.Api.Authorization/README.md b/src/Tools.Generators.Web.Api.Authorization/README.md index f4ca48f1..1b712671 100644 --- a/src/Tools.Generators.Web.Api.Authorization/README.md +++ b/src/Tools.Generators.Web.Api.Authorization/README.md @@ -29,7 +29,7 @@ We have had to hardcode certain other types to avoid referencing AspNet, and the You can debug the analyzers easily from the unit tests. -You can debug your source generator by setting a breakpoint in the code, and then running the `SourceGenerators-Development-Development` run configuration from the `ApiHost1` project with the debugger. (found in the `launchSettings.json` file in any executable project). +You can debug your source generator by setting a breakpoint in the code, and then running the `Api-SourceGenerators-Development` run configuration from the `ApiHost1` project with the debugger. (found in the `launchSettings.json` file in any executable project). > Warning: C# source generators are heavily cached. If you try to debug new code that you've added you may need to clear the caches from the old code being used. Otherwise you breakpoints may not hit. @@ -40,4 +40,4 @@ The most reliable way to reset the generators: 2. Kill any remaining `.Net Host (dotnet.exe)` processes on your machine, and any remaining `Jetbrains Rider` processes on your machine 3. Restart Rider 4. Set your breakpoints -5. Start debugging the `SourceGenerators-Development-Development` run configuration \ No newline at end of file +5. Start debugging the `Api-SourceGenerators-Development` run configuration \ No newline at end of file diff --git a/src/Tools.Generators.Web.Api.Authorization/Tools.Generators.Web.Api.Authorization.csproj b/src/Tools.Generators.Web.Api.Authorization/Tools.Generators.Web.Api.Authorization.csproj index d74aaccd..4769c66e 100644 --- a/src/Tools.Generators.Web.Api.Authorization/Tools.Generators.Web.Api.Authorization.csproj +++ b/src/Tools.Generators.Web.Api.Authorization/Tools.Generators.Web.Api.Authorization.csproj @@ -2,7 +2,7 @@ netstandard2.0 - GENERATORS_WEB_API_PROJECT + $(DefineConstants);GENERATORS_WEB_API_PROJECT latest enable true diff --git a/src/Tools.Generators.Web.Api.UnitTests/MinimalApiMediatRGeneratorSpec.cs b/src/Tools.Generators.Web.Api.UnitTests/MinimalApiMediatRGeneratorSpec.cs index bccf0e27..601282de 100644 --- a/src/Tools.Generators.Web.Api.UnitTests/MinimalApiMediatRGeneratorSpec.cs +++ b/src/Tools.Generators.Web.Api.UnitTests/MinimalApiMediatRGeneratorSpec.cs @@ -5,7 +5,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Xunit; -using Api_MinimalApiMediatRGenerator = Generators::Tools.Generators.Web.Api.MinimalApiMediatRGenerator; +using MinimalApiMediatRGenerator = Generators::Tools.Generators.Web.Api.MinimalApiMediatRGenerator; namespace Tools.Generators.Web.Api.UnitTests; @@ -22,7 +22,7 @@ private static CSharpCompilation CreateCompilation(string sourceCode) var references = new List { - MetadataReference.CreateFromFile(typeof(Api_MinimalApiMediatRGenerator).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MinimalApiMediatRGenerator).Assembly.Location), MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location) }; AdditionalCompilationAssemblies.ToList() @@ -45,7 +45,7 @@ public class GivenAServiceClass public GivenAServiceClass() { - var generator = new Api_MinimalApiMediatRGenerator(); + var generator = new MinimalApiMediatRGenerator(); _driver = CSharpGeneratorDriver.Create(generator); } @@ -821,6 +821,90 @@ public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + } + [WebService("aprefix")] + public class AServiceClass : IWebApiService + { + public async Task AMethod(ARequest request, CancellationToken cancellationToken) + { + return ""; + } + } + """); + + var result = Generate(compilation); + + result.Should().Be( + """ + // + using System.Threading; + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Builder; + using Infrastructure.Web.Api.Interfaces; + using Infrastructure.Web.Api.Common.Extensions; + + namespace compilation + { + public static class MinimalApiRegistration + { + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + { + var aserviceclassGroup = app.MapGroup("aprefix") + .WithGroupName("AServiceClass") + .RequireCors("__DefaultCorsPolicy") + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); + #if TESTINGONLY + aserviceclassGroup.MapGet("aroute", + async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => + await mediator.Send(request, global::System.Threading.CancellationToken.None)) + .RequireAuthorization("Token") + .RequireCallerAuthorization("POLICY:{|Features|:{|Platform|:[|basic_features|]},|Roles|:{|Platform|:[|standard|]}}"); + #endif + + } + } + } + + namespace ANamespace.AServiceClassMediatRHandlers + { + #if TESTINGONLY + public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + public async Task Handle(global::ANamespace.ARequest request, global::System.Threading.CancellationToken cancellationToken) + { + var api = new global::ANamespace.AServiceClass(); + var result = await api.AMethod(request, cancellationToken); + return result.HandleApiResult(global::Infrastructure.Web.Api.Interfaces.ServiceOperation.Get); + } + } + #endif + + } + + + """); + } + private string Generate(CSharpCompilation compilation) { _driver = _driver.RunGeneratorsAndUpdateCompilation(compilation, out var _, out var _); diff --git a/src/Tools.Generators.Web.Api.UnitTests/WebApiAssemblyVisitorSpec.cs b/src/Tools.Generators.Web.Api.UnitTests/WebApiAssemblyVisitorSpec.cs index b92cd3e8..96c76d38 100644 --- a/src/Tools.Generators.Web.Api.UnitTests/WebApiAssemblyVisitorSpec.cs +++ b/src/Tools.Generators.Web.Api.UnitTests/WebApiAssemblyVisitorSpec.cs @@ -217,7 +217,7 @@ public GivenAServiceClass() } [Fact] - public void WhenVisitNamedTypeAndNoMethods_ThenCreatesNoRegistrations() + public void WhenVisitNamedTypeAndHasNoMethods_ThenCreatesNoRegistrations() { var type = SetupServiceClass(_compilation); @@ -272,7 +272,7 @@ public void WhenVisitNamedTypeAndVoidReturnType_ThenCreatesNoRegistrations() } [Fact] - public void WhenVisitNamedTypeAndHasNoParameters_ThenCreatesNoRegistrations() + public void WhenVisitNamedTypeAndOperationHasNoParameters_ThenCreatesNoRegistrations() { var taskMetadata = _compilation.GetTypeByMetadataName(typeof(Task<>).FullName!)!; var type = SetupServiceClass(_compilation); @@ -290,7 +290,7 @@ public void WhenVisitNamedTypeAndHasNoParameters_ThenCreatesNoRegistrations() } [Fact] - public void WhenVisitNamedTypeAndHasWrongFirstParameter_ThenCreatesNoRegistrations() + public void WhenVisitNamedTypeAndOperationHasWrongFirstParameter_ThenCreatesNoRegistrations() { var taskMetadata = _compilation.GetTypeByMetadataName(typeof(Task<>).FullName!)!; var type = SetupServiceClass(_compilation); @@ -311,7 +311,7 @@ public void WhenVisitNamedTypeAndHasWrongFirstParameter_ThenCreatesNoRegistratio } [Fact] - public void WhenVisitNamedTypeAndHasWrongSecondParameter_ThenCreatesNoRegistrations() + public void WhenVisitNamedTypeAndOperationHasWrongSecondParameter_ThenCreatesNoRegistrations() { var requestMetadata = _compilation.GetTypeByMetadataName(typeof(IWebRequest).FullName!)!; var stringMetadata = _compilation.GetTypeByMetadataName(typeof(string).FullName!)!; @@ -336,7 +336,7 @@ public void WhenVisitNamedTypeAndHasWrongSecondParameter_ThenCreatesNoRegistrati } [Fact] - public void WhenVisitNamedTypeAndHasNoAttributes_ThenCreatesNoRegistrations() + public void WhenVisitNamedTypeAndRequestDtoHasNoAttributes_ThenCreatesNoRegistrations() { var requestMetadata = _compilation.GetTypeByMetadataName(typeof(IWebRequest).FullName!)!; var cancellationTokenMetadata = _compilation.GetTypeByMetadataName(typeof(CancellationToken).FullName!)!; @@ -361,11 +361,12 @@ public void WhenVisitNamedTypeAndHasNoAttributes_ThenCreatesNoRegistrations() _visitor.OperationRegistrations.Should().BeEmpty(); } + [Trait("Category", "Unit")] public class GivenAServiceOperation { [Fact] - public void WhenVisitNamedTypeAndHasRouteAttribute_ThenCreatesRegistration() + public void WhenVisitNamedTypeAndRequestDtoHasRouteAttribute_ThenCreatesRegistration() { var compilation = CreateCompilation(""" using System; @@ -419,7 +420,7 @@ public string AMethod(ARequest request) } [Fact] - public void WhenVisitNamedTypeAndHasASingleAuthorizeAttribute_ThenCreatesRegistration() + public void WhenVisitNamedTypeAndRequestDtoHasASingleAuthorizeAttribute_ThenCreatesRegistration() { var compilation = CreateCompilation(""" using System; @@ -474,7 +475,7 @@ public string AMethod(ARequest request) } [Fact] - public void WhenVisitNamedTypeAndHasManyAuthorizeAttributes_ThenCreatesRegistration() + public void WhenVisitNamedTypeAndRequestDtoHasManyAuthorizeAttributes_ThenCreatesRegistration() { var compilation = CreateCompilation(""" using System; @@ -530,6 +531,42 @@ public string AMethod(ARequest request) registration.ResponseDtoType.Name.Should().Be("AResponse"); registration.ResponseDtoType.Namespace.Should().Be("ANamespace"); } + + [Fact] + public void WhenVisitNamedTypeAndClassHasRouteAttribute_ThenCreatesRegistration() + { + var compilation = CreateCompilation(""" + using System; + using Infrastructure.Web.Api.Interfaces; + + namespace ANamespace; + + public class AResponse : IWebResponse + { + } + [Infrastructure.Web.Api.Interfaces.RouteAttribute("aroute", ServiceOperation.Get)] + public class ARequest : IWebRequest + { + } + [Infrastructure.Web.Api.Interfaces.WebServiceAttribute("aprefix")] + public class AServiceClass : Infrastructure.Web.Api.Interfaces.IWebApiService + { + public string AMethod(ARequest request) + { + return ""; + } + } + """); + + var serviceClass = compilation.GetTypeByMetadataName("ANamespace.AServiceClass")!; + var visitor = new WebApiAssemblyVisitor(CancellationToken.None, compilation); + + visitor.VisitNamedType(serviceClass); + + visitor.OperationRegistrations.Count.Should().Be(1); + var registration = visitor.OperationRegistrations.First(); + registration.Class.BasePath.Should().Be("aprefix"); + } } private static Mock SetupServiceClass(CSharpCompilation compilation) @@ -547,6 +584,7 @@ private static Mock SetupServiceClass(CSharpCompilation compil .Returns("adisplaystring"); type.Setup(t => t.ContainingNamespace).Returns(@namespace.Object); type.Setup(t => t.Name).Returns("aname"); + type.Setup(t => t.GetAttributes()).Returns(ImmutableArray.Empty); return type; } diff --git a/src/Tools.Generators.Web.Api/MinimalApiMediatRGenerator.cs b/src/Tools.Generators.Web.Api/MinimalApiMediatRGenerator.cs index 5f7dfeef..23465d74 100644 --- a/src/Tools.Generators.Web.Api/MinimalApiMediatRGenerator.cs +++ b/src/Tools.Generators.Web.Api/MinimalApiMediatRGenerator.cs @@ -98,7 +98,11 @@ private static void BuildEndpointRegistrations( { var serviceClassName = serviceRegistrations.Key.Name; var groupName = $"{serviceClassName.ToLowerInvariant()}Group"; - endpointRegistrations.AppendLine($@" var {groupName} = app.MapGroup(string.Empty) + var basePath = serviceRegistrations.FirstOrDefault()?.Class.BasePath; + var prefix = basePath.HasValue() + ? $"\"{basePath}\"" + : "string.Empty"; + endpointRegistrations.AppendLine($@" var {groupName} = app.MapGroup({prefix}) .WithGroupName(""{serviceClassName}"") .RequireCors(""{WebHostingConstants.DefaultCORSPolicyName}"") .AddEndpointFilter() diff --git a/src/Tools.Generators.Web.Api/README.md b/src/Tools.Generators.Web.Api/README.md index 6171466e..917a04c0 100644 --- a/src/Tools.Generators.Web.Api/README.md +++ b/src/Tools.Generators.Web.Api/README.md @@ -29,7 +29,7 @@ We have had to hardcode certain other types to avoid referencing AspNet, and the You can debug the analyzers easily from the unit tests. -You can debug your source generator by setting a breakpoint in the code, and then running the `SourceGenerators-Development-Development` run configuration from the `ApiHost1` project with the debugger. (found in the `launchSettings.json` file in any executable project). +You can debug your source generator by setting a breakpoint in the code, and then running the `Api-SourceGenerators-Development` run configuration from the `ApiHost1` project with the debugger. (found in the `launchSettings.json` file in any executable project). > Warning: C# source generators are heavily cached. If you try to debug new code that you've added you may need to clear the caches from the old code being used. Otherwise you breakpoints may not hit. @@ -40,4 +40,4 @@ The most reliable way to reset the generators: 2. Kill any remaining `.Net Host (dotnet.exe)` processes on your machine, and any remaining `Jetbrains Rider` processes on your machine 3. Restart Rider 4. Set your breakpoints -5. Start debugging the `SourceGenerators-Development-Development` run configuration \ No newline at end of file +5. Start debugging the `Api-SourceGenerators-Development` run configuration \ No newline at end of file diff --git a/src/Tools.Generators.Web.Api/Tools.Generators.Web.Api.csproj b/src/Tools.Generators.Web.Api/Tools.Generators.Web.Api.csproj index 88ed7351..d7d0ac87 100644 --- a/src/Tools.Generators.Web.Api/Tools.Generators.Web.Api.csproj +++ b/src/Tools.Generators.Web.Api/Tools.Generators.Web.Api.csproj @@ -2,7 +2,7 @@ netstandard2.0 - GENERATORS_WEB_API_PROJECT + $(DefineConstants);GENERATORS_WEB_API_PROJECT latest enable true @@ -40,6 +40,9 @@ Reference\Infrastructure.Web.Api.Interfaces\IWebResponse.cs + + Reference\Infrastructure.Web.Api.Interfaces\WebServiceAttribute.cs + Reference\Infrastructure.Web.Api.Interfaces\RouteAttribute.cs diff --git a/src/Tools.Generators.Web.Api/WebApiAssemblyVisitor.cs b/src/Tools.Generators.Web.Api/WebApiAssemblyVisitor.cs index 9280bb0d..da142172 100644 --- a/src/Tools.Generators.Web.Api/WebApiAssemblyVisitor.cs +++ b/src/Tools.Generators.Web.Api/WebApiAssemblyVisitor.cs @@ -35,6 +35,7 @@ public class WebApiAssemblyVisitor : SymbolVisitor private readonly INamedTypeSymbol _voidSymbol; private readonly INamedTypeSymbol _webRequestInterfaceSymbol; private readonly INamedTypeSymbol _webRequestResponseInterfaceSymbol; + private readonly INamedTypeSymbol _webserviceAttributeSymbol; public WebApiAssemblyVisitor(CancellationToken cancellationToken, Compilation compilation) { @@ -42,6 +43,7 @@ public WebApiAssemblyVisitor(CancellationToken cancellationToken, Compilation co _serviceInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebApiService).FullName!)!; _webRequestInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebRequest).FullName!)!; _webRequestResponseInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebRequest<>).FullName!)!; + _webserviceAttributeSymbol = compilation.GetTypeByMetadataName(typeof(WebServiceAttribute).FullName!)!; _routeAttributeSymbol = compilation.GetTypeByMetadataName(typeof(RouteAttribute).FullName!)!; _authorizeAttributeSymbol = compilation.GetTypeByMetadataName(typeof(AuthorizeAttribute).FullName!)!; _authorizeAttributeRolesSymbol = compilation.GetTypeByMetadataName(typeof(Roles).FullName!)!; @@ -159,11 +161,13 @@ private void AddRegistration(INamedTypeSymbol symbol) var usingNamespaces = symbol.GetUsingNamespaces(); var constructors = GetConstructors(); var serviceName = GetServiceName(); + var basePath = GetBasePath(); var classRegistration = new ApiServiceClassRegistration { TypeName = serviceName, Constructors = constructors, - UsingNamespaces = usingNamespaces + UsingNamespaces = usingNamespaces, + BasePath = basePath }; var methods = GetServiceOperationMethods(); @@ -212,6 +216,16 @@ private void AddRegistration(INamedTypeSymbol symbol) return; + string? GetBasePath() + { + if (!HasWebServiceAttribute(symbol, out var attributeData)) + { + return null; + } + + return attributeData!.ConstructorArguments[0].Value!.ToString()!; + } + TypeName GetServiceName() { return new TypeName(symbol.ContainingNamespace.ToDisplayString(), symbol.Name); @@ -400,6 +414,13 @@ bool HasWrongSetOfParameters(IMethodSymbol method) return false; } + // We assume that the class can be decorated with an optional WebServiceAttribute + bool HasWebServiceAttribute(ITypeSymbol classSymbol, out AttributeData? webServiceAttribute) + { + webServiceAttribute = classSymbol.GetAttribute(_webserviceAttributeSymbol); + return webServiceAttribute is not null; + } + // We assume that the request DTO it is decorated with one RouteAttribute bool HasRouteAttribute(IMethodSymbol method, out AttributeData? routeAttribute) { @@ -513,6 +534,8 @@ public override int GetHashCode() public record ApiServiceClassRegistration { + public string? BasePath { get; set; } + public IEnumerable Constructors { get; set; } = new List(); public TypeName TypeName { get; set; } = null!; diff --git a/src/WebsiteHost/Api/FeatureFlags/FeatureFlagsApi.cs b/src/WebsiteHost/Api/FeatureFlags/FeatureFlagsApi.cs new file mode 100644 index 00000000..1634a39e --- /dev/null +++ b/src/WebsiteHost/Api/FeatureFlags/FeatureFlagsApi.cs @@ -0,0 +1,39 @@ +using Common.FeatureFlags; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; +using WebsiteHost.Application; + +namespace WebsiteHost.Api.FeatureFlags; + +public class FeatureFlagsApi : IWebApiService +{ + private readonly ICallerContextFactory _contextFactory; + private readonly IFeatureFlagsApplication _featureFlagsApplication; + + public FeatureFlagsApi(ICallerContextFactory contextFactory, IFeatureFlagsApplication featureFlagsApplication) + { + _contextFactory = contextFactory; + _featureFlagsApplication = featureFlagsApplication; + } + + public async Task> GetForCaller( + GetFeatureFlagForCallerRequest request, + CancellationToken cancellationToken) + { + var flag = await _featureFlagsApplication.GetFeatureFlagForCallerAsync(_contextFactory.Create(), + request.Name, cancellationToken); + + return () => flag.HandleApplicationResult(f => new GetFeatureFlagResponse { Flag = f }); + } + + public async Task, GetAllFeatureFlagsResponse>> GetAll( + GetAllFeatureFlagsRequest request, + CancellationToken cancellationToken) + { + var flags = await _featureFlagsApplication.GetAllFeatureFlagsAsync(_contextFactory.Create(), cancellationToken); + + return () => flags.HandleApplicationResult(f => new GetAllFeatureFlagsResponse { Flags = f }); + } +} \ No newline at end of file diff --git a/src/WebsiteHost/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidator.cs b/src/WebsiteHost/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidator.cs new file mode 100644 index 00000000..5fc50380 --- /dev/null +++ b/src/WebsiteHost/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; + +namespace WebsiteHost.Api.FeatureFlags; + +public class GetFeatureFlagForCallerRequestValidator : AbstractValidator +{ + public GetFeatureFlagForCallerRequestValidator() + { + RuleFor(req => req.Name) + .NotEmpty() + .WithMessage(Resources.GetFeatureFlagForCallerRequestValidator_InvalidName); + } +} \ No newline at end of file diff --git a/src/WebsiteHost/Api/Recording/RecordingApi.cs b/src/WebsiteHost/Api/Recording/RecordingApi.cs index eb3f8b40..8c172cac 100644 --- a/src/WebsiteHost/Api/Recording/RecordingApi.cs +++ b/src/WebsiteHost/Api/Recording/RecordingApi.cs @@ -11,12 +11,15 @@ namespace WebsiteHost.Api.Recording; public sealed class RecordingApi : IWebApiService { private readonly ICallerContextFactory _contextFactory; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly IRecordingApplication _recordingApplication; - public RecordingApi(ICallerContextFactory contextFactory, IRecordingApplication recordingApplication) + public RecordingApi(ICallerContextFactory contextFactory, IRecordingApplication recordingApplication, + IHttpContextAccessor httpContextAccessor) { _contextFactory = contextFactory; _recordingApplication = recordingApplication; + _httpContextAccessor = httpContextAccessor; } public async Task RecordCrash(RecordCrashRequest request, @@ -33,8 +36,7 @@ public async Task RecordMeasurement(RecordMeasureRequest request CancellationToken cancellationToken) { var result = await _recordingApplication.RecordMeasurementAsync(_contextFactory.Create(), request.EventName, - request.Additional, - cancellationToken); + request.Additional, _httpContextAccessor.ToClientDetails(), cancellationToken); return () => result.Match(() => new Result(), error => new Result(error)); @@ -44,7 +46,7 @@ public async Task RecordPageView(RecordPageViewRequest request, CancellationToken cancellationToken) { var result = await _recordingApplication.RecordPageViewAsync(_contextFactory.Create(), request.Path, - cancellationToken); + _httpContextAccessor.ToClientDetails(), cancellationToken); return () => result.Match(() => new Result(), error => new Result(error)); @@ -67,10 +69,29 @@ public async Task RecordUsage(RecordUseRequest request, CancellationToken cancellationToken) { var result = await _recordingApplication.RecordUsageAsync(_contextFactory.Create(), request.EventName, - request.Additional, - cancellationToken); + request.Additional, _httpContextAccessor.ToClientDetails(), cancellationToken); return () => result.Match(() => new Result(), error => new Result(error)); } +} + +internal static class HttpContextConversionExtensions +{ + public static ClientDetails ToClientDetails(this IHttpContextAccessor contextAccessor) + { + if (contextAccessor.HttpContext.NotExists()) + { + return new ClientDetails(); + } + + var context = contextAccessor.HttpContext; + + return new ClientDetails + { + IpAddress = context.Connection.RemoteIpAddress?.ToString(), + UserAgent = context.Request.Headers.UserAgent.ToString(), + Referer = context.Request.Headers.Referer.ToString() + }; + } } \ No newline at end of file diff --git a/src/WebsiteHost/Application/FeatureFlagsApplication.cs b/src/WebsiteHost/Application/FeatureFlagsApplication.cs new file mode 100644 index 00000000..228f84d7 --- /dev/null +++ b/src/WebsiteHost/Application/FeatureFlagsApplication.cs @@ -0,0 +1,54 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Common; +using Common.FeatureFlags; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using Infrastructure.Web.Common.Extensions; +using Infrastructure.Web.Interfaces.Clients; + +namespace WebsiteHost.Application; + +public class FeatureFlagsApplication : IFeatureFlagsApplication +{ + private readonly string _hmacSecret; + private readonly IServiceClient _serviceClient; + + public FeatureFlagsApplication(IServiceClient serviceClient, IHostSettings hostSettings) + { + _serviceClient = serviceClient; + _hmacSecret = hostSettings.GetAncillaryApiHostHmacAuthSecret(); + } + + public async Task> GetFeatureFlagForCallerAsync(ICallerContext context, string name, + CancellationToken cancellationToken) + { + var request = new GetFeatureFlagForCallerRequest + { + Name = name, + }; + + var retrieved = await _serviceClient.GetAsync(context, request, null, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error.ToError(); + } + + return retrieved.Value.Flag!; + } + + public async Task, Error>> GetAllFeatureFlagsAsync(ICallerContext context, + CancellationToken cancellationToken) + { + var request = new GetAllFeatureFlagsRequest(); + + var retrieved = await _serviceClient.GetAsync(context, request, req => req.SetHMACAuth(request, _hmacSecret), + cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error.ToError(); + } + + return retrieved.Value.Flags; + } +} \ No newline at end of file diff --git a/src/WebsiteHost/Application/IFeatureFlagsApplication.cs b/src/WebsiteHost/Application/IFeatureFlagsApplication.cs new file mode 100644 index 00000000..4977c83e --- /dev/null +++ b/src/WebsiteHost/Application/IFeatureFlagsApplication.cs @@ -0,0 +1,14 @@ +using Application.Interfaces; +using Common; +using Common.FeatureFlags; + +namespace WebsiteHost.Application; + +public interface IFeatureFlagsApplication +{ + Task, Error>> GetAllFeatureFlagsAsync(ICallerContext context, + CancellationToken cancellationToken); + + Task> GetFeatureFlagForCallerAsync(ICallerContext context, string name, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/WebsiteHost/Application/IRecordingApplication.cs b/src/WebsiteHost/Application/IRecordingApplication.cs index 32ac9ce3..44a14a94 100644 --- a/src/WebsiteHost/Application/IRecordingApplication.cs +++ b/src/WebsiteHost/Application/IRecordingApplication.cs @@ -9,13 +9,23 @@ public interface IRecordingApplication Task> RecordCrashAsync(ICallerContext context, string message, CancellationToken cancellationToken); Task> RecordMeasurementAsync(ICallerContext context, string eventName, - Dictionary? additional, CancellationToken cancellationToken); + Dictionary? additional, ClientDetails clientDetails, CancellationToken cancellationToken); - Task> RecordPageViewAsync(ICallerContext context, string path, CancellationToken cancellationToken); + Task> RecordPageViewAsync(ICallerContext context, string path, ClientDetails clientDetails, + CancellationToken cancellationToken); Task> RecordTraceAsync(ICallerContext context, RecorderTraceLevel level, string messageTemplate, List? arguments, CancellationToken cancellationToken); Task> RecordUsageAsync(ICallerContext context, string eventName, - Dictionary? additional, CancellationToken cancellationToken); + Dictionary? additional, ClientDetails clientDetails, CancellationToken cancellationToken); +} + +public class ClientDetails +{ + public string? IpAddress { get; set; } + + public string? Referer { get; set; } + + public string? UserAgent { get; set; } } \ No newline at end of file diff --git a/src/WebsiteHost/Application/RecordingApplication.cs b/src/WebsiteHost/Application/RecordingApplication.cs index b2bd1ab6..8692020e 100644 --- a/src/WebsiteHost/Application/RecordingApplication.cs +++ b/src/WebsiteHost/Application/RecordingApplication.cs @@ -10,13 +10,11 @@ namespace WebsiteHost.Application; public class RecordingApplication : IRecordingApplication { - private readonly IHttpContextAccessor _httpContextAccessor; private readonly IRecorder _recorder; - public RecordingApplication(IRecorder recorder, IHttpContextAccessor httpContextAccessor) + public RecordingApplication(IRecorder recorder) { _recorder = recorder; - _httpContextAccessor = httpContextAccessor; } public Task> RecordCrashAsync(ICallerContext context, string message, @@ -29,10 +27,10 @@ public Task> RecordCrashAsync(ICallerContext context, string messa } public Task> RecordMeasurementAsync(ICallerContext context, string eventName, - Dictionary? additional, + Dictionary? additional, ClientDetails clientDetails, CancellationToken cancellationToken) { - var more = AddClientContext((additional.Exists() + var more = AddClientContext(clientDetails, (additional.Exists() ? additional .Where(pair => pair.Value.Exists()) .ToDictionary(pair => pair.Key, pair => pair.Value) @@ -42,12 +40,12 @@ public Task> RecordMeasurementAsync(ICallerContext context, string return Task.FromResult(Result.Ok); } - public Task> RecordPageViewAsync(ICallerContext context, string path, + public Task> RecordPageViewAsync(ICallerContext context, string path, ClientDetails clientDetails, CancellationToken cancellationToken) { const string eventName = UsageConstants.Events.Web.WebPageVisit; - var additional = AddClientContext(new Dictionary + var additional = AddClientContext(clientDetails, new Dictionary { { UsageConstants.Properties.Path, path } }); @@ -58,10 +56,10 @@ public Task> RecordPageViewAsync(ICallerContext context, string pa } public Task> RecordUsageAsync(ICallerContext context, string eventName, - Dictionary? additional, + Dictionary? additional, ClientDetails clientDetails, CancellationToken cancellationToken) { - var more = AddClientContext((additional.Exists() + var more = AddClientContext(clientDetails, (additional.Exists() ? additional .Where(pair => pair.Value.Exists()) .ToDictionary(pair => pair.Key, pair => pair.Value) @@ -84,37 +82,38 @@ public Task> RecordTraceAsync(ICallerContext context, RecorderTrac case RecorderTraceLevel.Debug: _recorder.TraceDebug(context.ToCall(), messageTemplate, args); return Task.FromResult(Result.Ok); + case RecorderTraceLevel.Information: _recorder.TraceInformation(context.ToCall(), messageTemplate, args); return Task.FromResult(Result.Ok); + case RecorderTraceLevel.Warning: _recorder.TraceWarning(context.ToCall(), messageTemplate, args); return Task.FromResult(Result.Ok); + case RecorderTraceLevel.Error: _recorder.TraceError(context.ToCall(), messageTemplate, args); return Task.FromResult(Result.Ok); + default: _recorder.TraceInformation(context.ToCall(), messageTemplate, args); return Task.FromResult(Result.Ok); } } - private Dictionary AddClientContext(IDictionary additional) + private static Dictionary AddClientContext(ClientDetails clientDetails, + IDictionary additional) { - var ipAddress = _httpContextAccessor.HttpContext!.Connection.RemoteIpAddress?.ToString(); - var userAgent = _httpContextAccessor.HttpContext.Request.Headers.UserAgent.ToString(); - var referredBy = _httpContextAccessor.HttpContext.Request.Headers.Referer.ToString(); - var more = new Dictionary(additional); more.TryAdd(UsageConstants.Properties.Timestamp, DateTime.UtcNow); - more.TryAdd(UsageConstants.Properties.IpAddress, ipAddress.HasValue() - ? ipAddress + more.TryAdd(UsageConstants.Properties.IpAddress, clientDetails.IpAddress.HasValue() + ? clientDetails.IpAddress : "unknown"); - more.TryAdd(UsageConstants.Properties.UserAgent, userAgent.HasValue() - ? userAgent + more.TryAdd(UsageConstants.Properties.UserAgent, clientDetails.UserAgent.HasValue() + ? clientDetails.UserAgent : "unknown"); - more.TryAdd(UsageConstants.Properties.ReferredBy, referredBy.HasValue() - ? referredBy + more.TryAdd(UsageConstants.Properties.ReferredBy, clientDetails.Referer.HasValue() + ? clientDetails.Referer : "unknown"); more.TryAdd(UsageConstants.Properties.Component, UsageConstants.Components.BackEndForFrontEndWebHost); diff --git a/src/WebsiteHost/BackEndForFrontEndModule.cs b/src/WebsiteHost/BackEndForFrontEndModule.cs index 038e1488..7a46835d 100644 --- a/src/WebsiteHost/BackEndForFrontEndModule.cs +++ b/src/WebsiteHost/BackEndForFrontEndModule.cs @@ -51,6 +51,7 @@ public Action RegisterServices return (_, services) => { services.AddControllers(); + services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(c => diff --git a/src/WebsiteHost/Resources.Designer.cs b/src/WebsiteHost/Resources.Designer.cs index e0a5ec3f..5fad7217 100644 --- a/src/WebsiteHost/Resources.Designer.cs +++ b/src/WebsiteHost/Resources.Designer.cs @@ -104,6 +104,15 @@ internal static string AuthenticateRequestValidator_InvalidUsername { } } + /// + /// Looks up a localized string similar to The 'Name' is either missing or invalid. + /// + internal static string GetFeatureFlagForCallerRequestValidator_InvalidName { + get { + return ResourceManager.GetString("GetFeatureFlagForCallerRequestValidator_InvalidName", resourceCulture); + } + } + /// /// Looks up a localized string similar to The file '{0}' cannot be found in the directory {rootPath}. Please make sure you have pre-built the JS application by running `npm run build`. /// diff --git a/src/WebsiteHost/Resources.resx b/src/WebsiteHost/Resources.resx index 12b52196..2c785ff4 100644 --- a/src/WebsiteHost/Resources.resx +++ b/src/WebsiteHost/Resources.resx @@ -60,4 +60,7 @@ The file '{0}' cannot be found in the directory {rootPath}. Please make sure you have pre-built the JS application by running `npm run build` + + The 'Name' is either missing or invalid + \ No newline at end of file