From d2ed803c1f92922ff46155bd33e7864d5a7b2cbe Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Wed, 14 Feb 2024 12:26:07 +1300 Subject: [PATCH] Created FeatureFlagging services, APIs and source generator, and Flagsmith adapter, with stubs, and docs. #4 --- README_DERIVATIVE.md | 8 + .../0080-ports-and-adapters.md | 248 ++++++++- .../0120-feature-flagging.md | 51 ++ src/.gitignore | 7 +- .../inspectionProfiles/Project_Default.xml | 8 + .../HostExtensions.cs | 2 + .../FeatureFlagsApplicationSpec.cs | 84 +++ .../FeatureFlagsApplication.cs | 20 +- .../IFeatureFlagsApplication.cs | 17 + .../EmailDeliverRootSpec.cs | 2 +- .../FeatureFlagsApiSpec.cs | 62 +++ ...eatureFlagForCallerRequestValidatorSpec.cs | 40 ++ .../GetFeatureFlagRequestValidatorSpec.cs | 90 ++++ .../AncillaryModule.cs | 1 + .../Api/FeatureFlags/FeatureFlagsApi.cs | 48 ++ ...GetFeatureFlagForCallerRequestValidator.cs | 14 + .../GetFeatureFlagRequestValidator.cs | 24 + .../Resources.Designer.cs | 27 + src/AncillaryInfrastructure/Resources.resx | 9 + src/ApiHost1/Properties/launchSettings.json | 6 +- src/ApiHost1/appsettings.json | 4 + .../Extensions/FeatureFlagsExtensions.cs | 31 ++ .../HostExtensions.cs | 2 + .../Extensions/StringExtensionsSpec.cs | 112 ++++ src/Common/Common.csproj | 22 +- src/Common/Extensions/StringExtensions.cs | 112 +++- src/Common/FeatureFlags/FeatureFlag.cs | 11 + .../FeatureFlags/FeatureFlags.Designer.cs | 71 +++ src/Common/FeatureFlags/FeatureFlags.resx | 29 ++ src/Common/FeatureFlags/Flag.cs | 24 + src/Common/FeatureFlags/IFeatureFlags.cs | 35 ++ .../FeatureFlags/Flag.g.cs | 11 + .../Flag.g.cs | 11 + .../EmptyFeatureFlags.cs | 41 ++ .../Infrastructure.Hosting.Common.csproj | 4 - .../FlagsmithHttpServiceClientSpec.cs | 169 ++++++ ...rastructure.Shared.IntegrationTests.csproj | 26 + .../TestHttpClientFactory.cs | 9 + .../appsettings.Testing.json | 26 + .../External/FlagsmithHttpServiceClient.cs | 480 ++++++++++++++++++ .../OAuth2HttpServiceClient.cs | 2 +- .../Infrastructure.Shared.csproj | 26 +- .../Resources.Designer.cs | 9 + src/Infrastructure.Shared/Resources.resx | 3 + .../WebServiceAttribute.cs | 21 + .../FlagsmithCreateIdentityRequest.cs | 14 + .../FlagsmithCreateIdentityResponse.cs | 22 + .../FlagsmithGetEnvironmentFlagsRequest.cs | 10 + .../FlagsmithGetEnvironmentFlagsResponse.cs | 34 ++ .../ExchangeOAuth2CodeForTokensRequest.cs | 2 +- .../ExchangeOAuth2CodeForTokensResponse.cs | 2 +- .../Ancillary/GetAllFeatureFlagsRequest.cs | 9 + .../Ancillary/GetAllFeatureFlagsResponse.cs | 9 + .../GetFeatureFlagForCallerRequest.cs | 9 + .../Ancillary/GetFeatureFlagRequest.cs | 14 + .../Ancillary/GetFeatureFlagResponse.cs | 9 + .../GetAllFeatureFlagsRequest.cs | 8 + .../GetAllFeatureFlagsResponse.cs | 9 + .../GetFeatureFlagForCallerRequest.cs | 9 + .../GetFeatureFlagResponse.cs | 9 + .../Extensions/HostExtensions.cs | 3 + .../Infrastructure.Web.Hosting.Common.csproj | 1 + .../FeatureFlagsApiSpec.cs | 75 +++ ...ucture.Web.Website.IntegrationTests.csproj | 1 + .../WebsiteTestingExtensions.cs | 1 + ...eatureFlagForCallerRequestValidatorSpec.cs | 41 ++ .../FeatureFlagsApplicationSpec.cs | 81 +++ .../ExternalApiSpec.cs | 120 +++++ .../IntegrationTesting.WebApi.Common.csproj | 1 + .../Stubs/StubFeatureFlags.cs | 49 ++ .../WebApiSpec.cs | 2 + src/SaaStack.sln | 27 + src/SaaStack.sln.DotSettings | 9 + .../Api/StubFlagsmithApi.cs | 62 +++ src/TestingStubApiHost/appsettings.json | 2 +- .../FeatureFlagGeneratorSpec.cs | 97 ++++ .../Tools.Generators.Common.UnitTests.csproj | 21 + .../FeatureFlagGenerator.cs | 95 ++++ src/Tools.Generators.Common/README.md | 43 ++ .../Tools.Generators.Common.csproj | 32 ++ .../README.md | 4 +- ...ls.Generators.Web.Api.Authorization.csproj | 2 +- .../MinimalApiMediatRGeneratorSpec.cs | 90 +++- .../WebApiAssemblyVisitorSpec.cs | 54 +- .../MinimalApiMediatRGenerator.cs | 6 +- src/Tools.Generators.Web.Api/README.md | 4 +- .../Tools.Generators.Web.Api.csproj | 5 +- .../WebApiAssemblyVisitor.cs | 25 +- .../Api/FeatureFlags/FeatureFlagsApi.cs | 39 ++ ...GetFeatureFlagForCallerRequestValidator.cs | 14 + src/WebsiteHost/Api/Recording/RecordingApi.cs | 33 +- .../Application/FeatureFlagsApplication.cs | 54 ++ .../Application/IFeatureFlagsApplication.cs | 14 + .../Application/IRecordingApplication.cs | 16 +- .../Application/RecordingApplication.cs | 39 +- src/WebsiteHost/BackEndForFrontEndModule.cs | 1 + src/WebsiteHost/Resources.Designer.cs | 9 + src/WebsiteHost/Resources.resx | 3 + 98 files changed, 3326 insertions(+), 77 deletions(-) create mode 100644 docs/design-principles/0120-feature-flagging.md create mode 100644 src/AncillaryApplication.UnitTests/FeatureFlagsApplicationSpec.cs create mode 100644 src/AncillaryApplication/IFeatureFlagsApplication.cs create mode 100644 src/AncillaryInfrastructure.IntegrationTests/FeatureFlagsApiSpec.cs create mode 100644 src/AncillaryInfrastructure.UnitTests/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidatorSpec.cs create mode 100644 src/AncillaryInfrastructure.UnitTests/Api/FeatureFlags/GetFeatureFlagRequestValidatorSpec.cs create mode 100644 src/AncillaryInfrastructure/Api/FeatureFlags/FeatureFlagsApi.cs create mode 100644 src/AncillaryInfrastructure/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidator.cs create mode 100644 src/AncillaryInfrastructure/Api/FeatureFlags/GetFeatureFlagRequestValidator.cs create mode 100644 src/Application.Common/Extensions/FeatureFlagsExtensions.cs create mode 100644 src/Common/FeatureFlags/FeatureFlag.cs create mode 100644 src/Common/FeatureFlags/FeatureFlags.Designer.cs create mode 100644 src/Common/FeatureFlags/FeatureFlags.resx create mode 100644 src/Common/FeatureFlags/Flag.cs create mode 100644 src/Common/FeatureFlags/IFeatureFlags.cs create mode 100644 src/Common/Generated/Tools.Generators.Common/Tools.Generators.Common.FeatureFlagGenerator/FeatureFlags/Flag.g.cs create mode 100644 src/Common/Generated/Tools.Generators.Common/Tools.Generators.Common.FeatureFlagGenerator/Flag.g.cs create mode 100644 src/Infrastructure.Hosting.Common/EmptyFeatureFlags.cs create mode 100644 src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/FlagsmithHttpServiceClientSpec.cs create mode 100644 src/Infrastructure.Shared.IntegrationTests/Infrastructure.Shared.IntegrationTests.csproj create mode 100644 src/Infrastructure.Shared.IntegrationTests/TestHttpClientFactory.cs create mode 100644 src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json create mode 100644 src/Infrastructure.Shared/ApplicationServices/External/FlagsmithHttpServiceClient.cs create mode 100644 src/Infrastructure.Web.Api.Interfaces/WebServiceAttribute.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateIdentityRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithCreateIdentityResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEnvironmentFlagsRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Flagsmith/FlagsmithGetEnvironmentFlagsResponse.cs rename src/Infrastructure.Web.Api.Operations.Shared/3rdParties/{ => OAuth2}/ExchangeOAuth2CodeForTokensRequest.cs (90%) rename src/Infrastructure.Web.Api.Operations.Shared/3rdParties/{ => OAuth2}/ExchangeOAuth2CodeForTokensResponse.cs (86%) create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetAllFeatureFlagsRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetAllFeatureFlagsResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagForCallerRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/Ancillary/GetFeatureFlagResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetAllFeatureFlagsRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetAllFeatureFlagsResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetFeatureFlagForCallerRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/GetFeatureFlagResponse.cs create mode 100644 src/Infrastructure.Web.Website.IntegrationTests/FeatureFlagsApiSpec.cs create mode 100644 src/Infrastructure.Web.Website.UnitTests/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidatorSpec.cs create mode 100644 src/Infrastructure.Web.Website.UnitTests/Application/FeatureFlagsApplicationSpec.cs create mode 100644 src/IntegrationTesting.WebApi.Common/ExternalApiSpec.cs create mode 100644 src/IntegrationTesting.WebApi.Common/Stubs/StubFeatureFlags.cs create mode 100644 src/TestingStubApiHost/Api/StubFlagsmithApi.cs create mode 100644 src/Tools.Generators.Common.UnitTests/FeatureFlagGeneratorSpec.cs create mode 100644 src/Tools.Generators.Common.UnitTests/Tools.Generators.Common.UnitTests.csproj create mode 100644 src/Tools.Generators.Common/FeatureFlagGenerator.cs create mode 100644 src/Tools.Generators.Common/README.md create mode 100644 src/Tools.Generators.Common/Tools.Generators.Common.csproj create mode 100644 src/WebsiteHost/Api/FeatureFlags/FeatureFlagsApi.cs create mode 100644 src/WebsiteHost/Api/FeatureFlags/GetFeatureFlagForCallerRequestValidator.cs create mode 100644 src/WebsiteHost/Application/FeatureFlagsApplication.cs create mode 100644 src/WebsiteHost/Application/IFeatureFlagsApplication.cs 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..826e1681 100644 --- a/docs/design-principles/0080-ports-and-adapters.md +++ b/docs/design-principles/0080-ports-and-adapters.md @@ -1,5 +1,251 @@ # Ports and Adapters +The underlying architectural style in this codebase is the Ports and Adapters. This means that the application is designed to be driven by the domain model, and the domain model is not dependent on any external infrastructure. + +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 this general 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 cascaded between components to offer several abstraction layers. + +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. +> A 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, or 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 it 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" requires 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 ain 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 to 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 + +TBD + +### Adapters to 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 with 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, or a client ID, or a client secret, etc). In these cases, they require a separate accounts for testingonly and separate accounts for staging/production use. Testingonly accounts can never lead to compromising staging/production accounts. Some 3rd party providers mitigate this with sandbox environments, but not all do. +2. They often offer the use of a 3rd party SDK, or a 3rd party library, or a 3rd party service, to communicate with the 3rd party system. These can be very useful sometimes, dependencing 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 is often different 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 in 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 a HTTPS "ServiceClient" 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 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 the use of 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 used in local development (i.e. it is injected into the DI container in local development), then the SDK will be required to 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 a 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 to be used in 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 few great 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 is used for local development and automated testing. +2. One/more account(s) for "staging/production" that are 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 `testingonly` 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 `testingonly` accounts with various vendors, and we have hard-coded some of those credentials 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 that 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 some risk also in exposing these to those with access to your code. You might consider using secrets files, or environment variables, or a secret manager, or a key vault, or a configuration service, to manage this sensitive configuration. + +### Integration Tests + +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, and can be slow, and 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? or whenever the code in the adapters is changed. +4. They can fail for time to time, depending on how well managed the 3rd party vendor is at maintaining their systems. When these tests do break, its 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 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 adapter 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 that 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 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 are 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 to only 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 to only 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 behaviour 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 you 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 we can control its behaviour 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..c30d6b50 --- /dev/null +++ b/docs/design-principles/0120-feature-flagging.md @@ -0,0 +1,51 @@ +# 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, specific end-users, or a specific end-user, 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 DoSomething() + { + if (_featureFlags.IsEnabled(Flag.MyFeature)) + { + ...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/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..8a09b8f4 --- /dev/null +++ b/src/AncillaryApplication.UnitTests/FeatureFlagsApplicationSpec.cs @@ -0,0 +1,84 @@ +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.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/Extensions/FeatureFlagsExtensions.cs b/src/Application.Common/Extensions/FeatureFlagsExtensions.cs new file mode 100644 index 00000000..0a25014d --- /dev/null +++ b/src/Application.Common/Extensions/FeatureFlagsExtensions.cs @@ -0,0 +1,31 @@ +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) + { + return await featureFlags.GetFlagAsync(flag, caller.TenantId, caller.CallerId, cancellationToken); + } + + /// + /// Whether the specified feature is enabled for the + /// + public static bool IsEnabled(this IFeatureFlags featureFlags, Flag flag, ICallerContext caller) + { + 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/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..af9c6e02 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) /// 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..9d09e723 --- /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 and optionally for the + /// specified in that tenant. + /// + bool IsEnabled(Flag flag, string tenantId, Optional 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..f634430f --- /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, string tenantId, Optional 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..dc683273 --- /dev/null +++ b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/FlagsmithHttpServiceClientSpec.cs @@ -0,0 +1,169 @@ +using Common; +using Common.Configuration; +using Common.Extensions; +using Common.FeatureFlags; +using Common.Recording; +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 WhenGetFlagAsyncForKnownFeatureWithNoAudiences_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 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 WhenGetFlagAsyncForAPredefinedUserIdentity_ThenReturnsOverwrittenFlag() + { +#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 WhenGetFlagAsyncForAnUnknownTenantIdentity_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 WhenGetFlagAsyncForAPredefinedTenantIdentity_ThenReturnsOverwrittenFlag() + { + //Note: for some unexplained reason this test fails occasionally, + // since after the SetupEnvironmentAsync() method succeeds TestTenant2 at Flagsmith has not overriden the flag! + // This is not a problem with any of this code, but something flaky at Flagsmith, + // that randomly fails to override the flag for testTenant2 sometimes? +#if TESTINGONLY + var result = await _serviceClient.GetFlagAsync(Flag.TestingOnly, TestTenant2, Optional.None, + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be(Flag.TestingOnly.Name); + result.Value.IsEnabled.Should().BeTrue(); +#endif + } + + [Fact] + public async Task WhenGetFlagAsyncForAnExistingButDefaultMembershipIdentity_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 WhenGetFlagAsyncForAPredefinedMembershipIdentity_ThenReturnsOverwrittenFlag() + { +#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 + } + + 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); + await _serviceClient.CreateIdentityAsync(TestTenant2, 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..262c93f7 --- /dev/null +++ b/src/Infrastructure.Shared.IntegrationTests/Infrastructure.Shared.IntegrationTests.csproj @@ -0,0 +1,26 @@ + + + + net7.0 + true + + + + + + + + + + + + + + Always + + + Always + + + + 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..8ace1635 --- /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": "", + "EnvironmentApiKey": "" + } + } + } +} \ 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..f7cc35be --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/FlagsmithHttpServiceClient.cs @@ -0,0 +1,480 @@ +using Common; +using Common.Configuration; +using Common.Extensions; +using Common.FeatureFlags; +using Flagsmith; +using JetBrains.Annotations; +using Flag = Common.FeatureFlags.Flag; +#if TESTINGONLY +using System.Net.Http.Json; +using Infrastructure.Web.Api.Common; +#endif + +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 +/// 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 there might be an identity for each tenant of interest, where its name is the ID of +/// the tenant, and it has a trait called 'type' with a value of "tenant" +/// 3. 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" +/// 4. We assume that if we ask for a flag for either a tenantId OR userId (exclusively), then we look for either of +/// those identities (and create them with their respective traits if they don't already exist), and use either result. +/// 5. We assume that if we ask for a flag for both a tenantId AND userId, then we look for (and create) +/// the tenant identity first, and then look for (and create) the user identity next (with the tenancy trait), +/// and then use the user identity result. +/// +public class FlagsmithHttpServiceClient : IFeatureFlags +{ + private const string BaseUrlSettingName = "ApplicationServices:Flagsmith:BaseUrl"; + private const string EnvironmentKeySettingName = "ApplicationServices:Flagsmith:EnvironmentKey"; + private const string FlagsmithApiUrl = "https://edge.api.flagsmith.com/api/v1/"; + 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 const string TraitNameForIdentityType = "type"; + private const string TraitValueForTenancyMembership = "tenant"; + private const string TraitValueForTenant = "tenant"; + private const string TraitValueForUser = "user"; + private const string UnknownFeatureName = "_unknown"; + private readonly FlagsmithClient _client; +#if TESTINGONLY + private readonly TestingOnlyConfiguration _configuration; +#endif + private readonly HttpClient _httpClient; + 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 + _configuration = new TestingOnlyConfiguration + { + ApiUrl = settings.Platform.GetString(TestingOnlyPrivateApiUrlSettingName, string.Empty), + ApiToken = settings.Platform.GetString(TestingOnlyApiTokenSettingName, string.Empty), + ProjectId = settings.Platform.GetString(TestingOnlyProjectIdSettingName, string.Empty), + EnvironmentApiKey = settings.Platform.GetString(TestingOnlyEnvironmentApiKeySettingName, string.Empty) + }; +#endif + _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); + } + + 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 (tenantId.HasValue) + { + if (userId.HasValue) + { + featureFlags = await QueryForUserMembershipAsync(tenantId, userId, cancellationToken); + } + else + { + featureFlags = await QueryForTenantAsync(tenantId, cancellationToken); + } + } + else + { + if (userId.HasValue) + { + 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 {Tenant}:{User} from FlagSmith API", + flag.Name, tenantId.HasValue + ? tenantId + : "alltenants", 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 false; + } + + 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 false; + } + + return featureFlag.Value.IsEnabled; + } + + public bool IsEnabled(Flag flag, string tenantId, Optional userId) + { + var featureFlag = GetFlagAsync(flag, tenantId, userId, CancellationToken.None).GetAwaiter().GetResult(); + if (!featureFlag.IsSuccessful) + { + return false; + } + + return featureFlag.Value.IsEnabled; + } + +#if TESTINGONLY + public async Task CreateFeatureAsync(Flag flag, bool enabled) + { + var postRequest = new HttpRequestMessage(HttpMethod.Post, + $"{_configuration.ApiUrl}projects/{_configuration.ProjectId}/features/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + }, + Content = JsonContent.Create(new + { + name = flag.Name + }) + }; + var createdResponse = await _httpClient.SendAsync(postRequest); + var feature = await createdResponse.Content.ReadFromJsonAsync(); + + if (enabled) + { + var featureStateGetRequest = new HttpRequestMessage(HttpMethod.Get, + $"{_configuration.ApiUrl}environments/{_configuration.EnvironmentApiKey}/featurestates/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + }, + Content = JsonContent.Create(new + { + feature = feature!.Id + }) + }; + var getResponse = await _httpClient.SendAsync(featureStateGetRequest); + var featureStates = await getResponse.Content.ReadFromJsonAsync(); + var featureStateId = featureStates!.Results[0].Id; + + var featureStatePatchRequest = new HttpRequestMessage(HttpMethod.Patch, + $"{_configuration.ApiUrl}environments/{_configuration.EnvironmentApiKey}/featurestates/{featureStateId}/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + }, + Content = JsonContent.Create(new + { + feature = feature.Id, + enabled + }) + }; + await _httpClient.SendAsync(featureStatePatchRequest); + } + } +#endif + +#if TESTINGONLY + public async Task CreateIdentityAsync(string name, Flag flag, bool enabled) + { + var createRequest = new HttpRequestMessage(HttpMethod.Post, + $"{_configuration.ApiUrl}environments/{_configuration.EnvironmentApiKey}/edge-identities/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + }, + Content = JsonContent.Create(new + { + identifier = name + }) + }; + + var createdResponse = await _httpClient.SendAsync(createRequest); + var identity = await createdResponse.Content.ReadFromJsonAsync(); + + if (enabled) + { + var featuresGetRequest = new HttpRequestMessage(HttpMethod.Get, + $"{_configuration.ApiUrl}projects/{_configuration.ProjectId}/features/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + } + }; + var getResponse = await _httpClient.SendAsync(featuresGetRequest); + var features = await getResponse.Content.ReadFromJsonAsync(); + + var featureId = features!.Results.Single(feat => feat.Name == flag.Name).Id; + var featureStatePostRequest = new HttpRequestMessage(HttpMethod.Post, + $"{_configuration.ApiUrl}environments/{_configuration.EnvironmentApiKey}/edge-identities/{identity!.Identity_Uuid}/edge-featurestates/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + }, + Content = JsonContent.Create(new + { + feature = featureId, + enabled + }) + }; + await _httpClient.SendAsync(featureStatePostRequest); + } + } +#endif + +#if TESTINGONLY + public async Task DestroyAllFeaturesAsync() + { + var request = new HttpRequestMessage(HttpMethod.Get, + $"{_configuration.ApiUrl}projects/{_configuration.ProjectId}/features/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + } + }; + + var getResponse = await _httpClient.SendAsync(request); + var allFeatures = await getResponse.Content.ReadFromJsonAsync(); + foreach (var feature in allFeatures!.Results) + { + await DestroyFeatureAsync(feature.Id); + } + } +#endif + +#if TESTINGONLY + public async Task DestroyAllIdentitiesAsync() + { + var request = + new HttpRequestMessage(HttpMethod.Get, + $"{_configuration.ApiUrl}environments/{_configuration.EnvironmentApiKey}/edge-identities/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + } + }; + + var getResponse = await _httpClient.SendAsync(request); + var allIdentities = await getResponse.Content.ReadFromJsonAsync(); + foreach (var identity in allIdentities!.Results) + { + await DestroyIdentityAsync(identity.Identity_Uuid); + } + } +#endif + + 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 QueryForTenantAsync(string tenantId, CancellationToken cancellationToken) + { + var traits = new List + { + new Trait(TraitNameForIdentityType, TraitValueForTenant) + }; + + return await QueryIdentityFlags(tenantId, traits, cancellationToken); + } + + private async Task QueryForUserMembershipAsync(string tenantId, string userId, + CancellationToken cancellationToken) + { + var tenancyTraits = new List + { + new Trait(TraitNameForIdentityType, TraitValueForTenant) + }; + await QueryIdentityFlags(tenantId, tenancyTraits, 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); + } + +#if TESTINGONLY + private async Task DestroyFeatureAsync(int featureId) + { + var request = new HttpRequestMessage(HttpMethod.Delete, + $"{_configuration.ApiUrl}projects/{_configuration.ProjectId}/features/{featureId}/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + } + }; + + await _httpClient.SendAsync(request); + } +#endif + +#if TESTINGONLY + private async Task DestroyIdentityAsync(string identityUuid) + { + var request = new HttpRequestMessage(HttpMethod.Delete, + $"{_configuration.ApiUrl}environments/{_configuration.EnvironmentApiKey}/edge-identities/{identityUuid}/") + { + Headers = + { + { HttpHeaders.Authorization, $"Token {_configuration.ApiToken}" } + } + }; + + await _httpClient.SendAsync(request); + } +#endif + + [UsedImplicitly] + public class FlagsmithFeatureList + { + // ReSharper disable once CollectionNeverUpdated.Global + public required List Results { get; [UsedImplicitly] set; } + } + + [UsedImplicitly] + public class FlagsmithFeature + { + public int Id { get; [UsedImplicitly] set; } + + public required string Name { get; [UsedImplicitly] set; } + + [UsedImplicitly] public required string Type { get; set; } + } + + [UsedImplicitly] + public class FlagsmithEdgeIdentityList + { + // ReSharper disable once CollectionNeverUpdated.Global + public required List Results { get; [UsedImplicitly] set; } + } + + [UsedImplicitly] + public class FlagsmithEdgeIdentity + { + [UsedImplicitly] public required string Identifier { get; set; } + + // ReSharper disable once InconsistentNaming + public required string Identity_Uuid { get; [UsedImplicitly] set; } + } + + [UsedImplicitly] + public class FlagsmithFeatureStateList + { + // ReSharper disable once CollectionNeverUpdated.Global + public required List Results { get; [UsedImplicitly] set; } + } + + [UsedImplicitly] + public class FlagsmithFeatureState + { + public required int Id { get; [UsedImplicitly] set; } + } + + public class TestingOnlyConfiguration + { + public required string ApiToken { get; set; } + + public required string ApiUrl { get; set; } + + public required string EnvironmentApiKey { get; set; } + + public required string ProjectId { get; set; } + } +} \ 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..59f90010 100644 --- a/src/Infrastructure.Shared/Infrastructure.Shared.csproj +++ b/src/Infrastructure.Shared/Infrastructure.Shared.csproj @@ -4,10 +4,27 @@ net7.0 + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + <_Parameter1>Infrastructure.Shared.IntegrationTests + @@ -25,13 +42,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.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/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..b10a6e98 --- /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; } + + [JsonPropertyName("identifier")] public string? Identifier { get; set; } + + [JsonPropertyName("traits")] public List? Traits { get; set; } +} + +[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/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/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.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.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/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..75edb458 --- /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, string tenantId, Optional 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