diff --git a/.github/actions/full-release/action.yml b/.github/actions/full-release/action.yml index 3eeb004e..71a46caf 100644 --- a/.github/actions/full-release/action.yml +++ b/.github/actions/full-release/action.yml @@ -1,6 +1,9 @@ name: Build, Test, and Publish description: 'Execute the full release process for a workspace.' inputs: + assembly_key_path_pair: + description: 'The assembly key path pair file string.' + required: true workspace_path: description: 'Path to the workspace being released.' required: true @@ -64,7 +67,7 @@ runs: /production/common/releasing/digicert/client_cert_password = DIGICERT_CLIENT_CERT_PASSWORD, /production/common/releasing/digicert/code_signing_cert_sha1_hash = DIGICERT_CODE_SIGNING_CERT_SHA1_HASH, /production/common/releasing/nuget/api_key = NUGET_API_KEY' - s3_path_pairs: 'launchdarkly-releaser/dotnet/LaunchDarkly.snk = LaunchDarkly.snk' + s3_path_pairs: ${{ inputs.assembly_key_path_pair }} - name: Release build uses: ./.github/actions/build-release diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index be6bcb98..69086c55 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -24,6 +24,10 @@ jobs: package-sdk-server-tag_name: ${{ steps.release.outputs['pkgs/sdk/server--tag_name'] }} package-sdk-server-telemetry-released: ${{ steps.release.outputs['pkgs/telemetry--release_created'] }} package-sdk-server-telemetry-tag_name: ${{ steps.release.outputs['pkgs/telemetry--tag_name'] }} + package-shared-common-released: ${{ steps.release.outputs['pkgs/shared/common--release_created'] }} + package-shared-common-tag_name: ${{ steps.release.outputs['pkgs/shared/common--tag_name'] }} + package-shared-common-json-net-released: ${{ steps.release.outputs['pkgs/shared/common-json-net--release_created'] }} + package-shared-common-json-net-tag_name: ${{ steps.release.outputs['pkgs/shared/common-json-net--tag_name'] }} tag_name: ${{ steps.release.outputs.tag_name }} steps: @@ -115,3 +119,23 @@ jobs: dry_run: false generate_provenance: true tag_name: ${{ needs.release-please.outputs.package-sdk-server-dynamodb-tag_name }} + + release-shared-common: + needs: release-please + if: ${{ needs.release-please.outputs.package-shared-common-released == 'true'}} + uses: ./.github/workflows/release.yml + with: + package_path: pkgs/shared/common + dry_run: false + generate_provenance: true + tag_name: ${{ needs.release-please.outputs.package-shared-common-tag_name }} + + release-shared-common-json-net: + needs: release-please + if: ${{ needs.release-please.outputs.package-shared-common-json-net-released == 'true'}} + uses: ./.github/workflows/release.yml + with: + package_path: pkgs/shared/common-json-net + dry_run: false + generate_provenance: true + tag_name: ${{ needs.release-please.outputs.package-shared-common-json-net-tag_name }} diff --git a/.github/workflows/release-sdk-client.yml b/.github/workflows/release-sdk-client.yml index 0af6e5c4..04ffdb82 100644 --- a/.github/workflows/release-sdk-client.yml +++ b/.github/workflows/release-sdk-client.yml @@ -151,7 +151,7 @@ jobs: with: aws_assume_role: ${{ vars.AWS_ROLE_ARN }} ssm_parameter_pairs: '/production/common/releasing/nuget/api_key = NUGET_API_KEY' - s3_path_pairs: 'launchdarkly-releaser/dotnet/LaunchDarkly.ClientSdk.snk = LaunchDarkly.ClientSdk.snk' + s3_path_pairs: ${{ env.ASSEMBLY_KEY_PATH_PAIR }} - name: Publish Nupkg id: publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43828e57..7eebb56f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ on: - pkgs/sdk/server - pkgs/sdk/server-ai - pkgs/telemetry + - pkgs/shared/common + - pkgs/shared/common-json-net dry_run: description: 'Is this a dry run. If so no package will be published.' type: boolean @@ -61,6 +63,7 @@ jobs: - uses: ./.github/actions/full-release id: full-release with: + assembly_key_path_pair: ${{ env.ASSEMBLY_KEY_PATH_PAIR }} workspace_path: ${{ env.WORKSPACE_PATH }} project_file: ${{ env.PROJECT_FILE }} build_output_path: ${{ env.BUILD_OUTPUT_PATH }} diff --git a/.github/workflows/server-shared-common-json-net.yml b/.github/workflows/server-shared-common-json-net.yml new file mode 100644 index 00000000..e5a50d76 --- /dev/null +++ b/.github/workflows/server-shared-common-json-net.yml @@ -0,0 +1,48 @@ +name: LaunchDarkly.CommonSdk.JsonNet CI + +on: + push: + branches: [ main, 'feat/**' ] + paths: + - '.github/**' + - 'global.example.json' + - 'pkgs/shared/common-json-net/**' + - '!**.md' + pull_request: + branches: [ main, 'feat/**' ] + paths: + - '.github/**' + - 'global.example.json' + - 'pkgs/shared/common-json-net/**' + - '!**.md' + +jobs: + build-and-test: + strategy: + matrix: + include: + - os: ubuntu-latest + framework: netstandard2.0 + test_framework: net8.0 + - os: windows-latest + framework: net462 + test_framework: net462 + + runs-on: ${{ matrix.os }} + + defaults: + run: + shell: ${{ matrix.os == 'windows-latest' && 'powershell' || 'bash' }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Env from project's Env file + shell: bash + run: echo "$(cat pkgs/shared/common-json-net/github_actions.env)" >> $GITHUB_ENV + + - uses: ./.github/actions/ci + with: + project_file: ${{ env.PROJECT_FILE }} + test_project_file: ${{ env.TEST_PROJECT_FILE }} + target_test_framework: ${{ matrix.test_framework }} \ No newline at end of file diff --git a/.github/workflows/server-shared-common.yml b/.github/workflows/server-shared-common.yml new file mode 100644 index 00000000..5e432805 --- /dev/null +++ b/.github/workflows/server-shared-common.yml @@ -0,0 +1,48 @@ +name: LaunchDarkly.CommonSdk CI + +on: + push: + branches: [ main, 'feat/**' ] + paths: + - '.github/**' + - 'global.example.json' + - 'pkgs/shared/common/**' + - '!**.md' + pull_request: + branches: [ main, 'feat/**' ] + paths: + - '.github/**' + - 'global.example.json' + - 'pkgs/shared/common/**' + - '!**.md' + +jobs: + build-and-test: + strategy: + matrix: + include: + - os: ubuntu-latest + framework: netstandard2.0 + test_framework: net8.0 + - os: windows-latest + framework: net462 + test_framework: net462 + + runs-on: ${{ matrix.os }} + + defaults: + run: + shell: ${{ matrix.os == 'windows-latest' && 'powershell' || 'bash' }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Env from project's Env file + shell: bash + run: echo "$(cat pkgs/shared/common/github_actions.env)" >> $GITHUB_ENV + + - uses: ./.github/actions/ci + with: + project_file: ${{ env.PROJECT_FILE }} + test_project_file: ${{ env.TEST_PROJECT_FILE }} + target_test_framework: ${{ matrix.test_framework }} \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5fb1fc8d..38c0ce7b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -5,5 +5,7 @@ "pkgs/sdk/server": "8.8.0", "pkgs/sdk/client": "5.4.0", "pkgs/telemetry": "1.1.0", - "pkgs/sdk/server-ai": "0.7.0" + "pkgs/sdk/server-ai": "0.7.0", + "pkgs/shared/common": "7.0.0", + "pkgs/shared/common-json-net": "7.0.0" } diff --git a/pkgs/dotnet-server-sdk-consul/github_actions.env b/pkgs/dotnet-server-sdk-consul/github_actions.env index 24f6bb9e..82d75180 100644 --- a/pkgs/dotnet-server-sdk-consul/github_actions.env +++ b/pkgs/dotnet-server-sdk-consul/github_actions.env @@ -2,4 +2,5 @@ WORKSPACE_PATH=pkgs/dotnet-server-sdk-consul PROJECT_FILE=pkgs/dotnet-server-sdk-consul/src/LaunchDarkly.ServerSdk.Consul.csproj BUILD_OUTPUT_PATH=pkgs/dotnet-server-sdk-consul/src/bin/Release/ BUILD_OUTPUT_DLL_NAME=LaunchDarkly.ServerSdk.Consul.dll -TEST_PROJECT_FILE=pkgs/dotnet-server-sdk-consul/test/LaunchDarkly.ServerSdk.Consul.Tests.csproj \ No newline at end of file +TEST_PROJECT_FILE=pkgs/dotnet-server-sdk-consul/test/LaunchDarkly.ServerSdk.Consul.Tests.csproj +ASSEMBLY_KEY_PATH_PAIR='launchdarkly-releaser/dotnet/LaunchDarkly.Consul.snk = LaunchDarkly.Consul.snk' \ No newline at end of file diff --git a/pkgs/dotnet-server-sdk-dynamodb/github_actions.env b/pkgs/dotnet-server-sdk-dynamodb/github_actions.env index 8b160dc0..5e4aadfb 100644 --- a/pkgs/dotnet-server-sdk-dynamodb/github_actions.env +++ b/pkgs/dotnet-server-sdk-dynamodb/github_actions.env @@ -2,4 +2,5 @@ WORKSPACE_PATH=pkgs/dotnet-server-sdk-dynamodb PROJECT_FILE=pkgs/dotnet-server-sdk-dynamodb/src/LaunchDarkly.ServerSdk.DynamoDB.csproj BUILD_OUTPUT_PATH=pkgs/dotnet-server-sdk-dynamodb/src/bin/Release/ BUILD_OUTPUT_DLL_NAME=LaunchDarkly.ServerSdk.DynamoDB.dll -TEST_PROJECT_FILE=pkgs/dotnet-server-sdk-dynamodb/test/LaunchDarkly.ServerSdk.DynamoDB.Tests.csproj \ No newline at end of file +TEST_PROJECT_FILE=pkgs/dotnet-server-sdk-dynamodb/test/LaunchDarkly.ServerSdk.DynamoDB.Tests.csproj +ASSEMBLY_KEY_PATH_PAIR='launchdarkly-releaser/dotnet/LaunchDarkly.DynamoDB.snk = LaunchDarkly.DynamoDB.snk' \ No newline at end of file diff --git a/pkgs/dotnet-server-sdk-redis/github_actions.env b/pkgs/dotnet-server-sdk-redis/github_actions.env index 90a389ae..cb51da10 100644 --- a/pkgs/dotnet-server-sdk-redis/github_actions.env +++ b/pkgs/dotnet-server-sdk-redis/github_actions.env @@ -2,4 +2,5 @@ WORKSPACE_PATH=pkgs/dotnet-server-sdk-redis PROJECT_FILE=pkgs/dotnet-server-sdk-redis/src/LaunchDarkly.ServerSdk.Redis.csproj BUILD_OUTPUT_PATH=pkgs/dotnet-server-sdk-redis/src/bin/Release/ BUILD_OUTPUT_DLL_NAME=LaunchDarkly.ServerSdk.Redis.dll -TEST_PROJECT_FILE=pkgs/dotnet-server-sdk-redis/test/LaunchDarkly.ServerSdk.Redis.Tests.csproj \ No newline at end of file +TEST_PROJECT_FILE=pkgs/dotnet-server-sdk-redis/test/LaunchDarkly.ServerSdk.Redis.Tests.csproj +ASSEMBLY_KEY_PATH_PAIR='launchdarkly-releaser/dotnet/LaunchDarkly.Redis.snk = LaunchDarkly.Redis.snk' \ No newline at end of file diff --git a/pkgs/sdk/client/github_actions.env b/pkgs/sdk/client/github_actions.env index 2aa97f98..8461048b 100644 --- a/pkgs/sdk/client/github_actions.env +++ b/pkgs/sdk/client/github_actions.env @@ -5,3 +5,4 @@ BUILD_OUTPUT_DLL_NAME=LaunchDarkly.ClientSdk.dll TEST_PROJECT_FILE=pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/LaunchDarkly.ClientSdk.Tests.csproj CONTRACT_TEST_PROJECT_FILE=pkgs/sdk/client/contract-tests/TestService.csproj CONTRACT_TEST_DLL_FILE=pkgs/sdk/client/contract-tests/bin/debug/net8.0/ContractTestService.dll +ASSEMBLY_KEY_PATH_PAIR='launchdarkly-releaser/dotnet/LaunchDarkly.ClientSdk.snk = LaunchDarkly.ClientSdk.snk' diff --git a/pkgs/sdk/server-ai/github_actions.env b/pkgs/sdk/server-ai/github_actions.env index d672a472..2cd036b4 100644 --- a/pkgs/sdk/server-ai/github_actions.env +++ b/pkgs/sdk/server-ai/github_actions.env @@ -3,3 +3,4 @@ PROJECT_FILE=pkgs/sdk/server-ai/src/LaunchDarkly.ServerSdk.Ai.csproj BUILD_OUTPUT_PATH=pkgs/sdk/server-ai/src/bin/Release/ BUILD_OUTPUT_DLL_NAME=LaunchDarkly.ServerSdk.Ai.dll TEST_PROJECT_FILE=pkgs/sdk/server-ai/test/LaunchDarkly.ServerSdk.Ai.Tests.csproj +ASSEMBLY_KEY_PATH_PAIR='launchdarkly-releaser/dotnet/LaunchDarkly.snk = LaunchDarkly.snk' diff --git a/pkgs/sdk/server/github_actions.env b/pkgs/sdk/server/github_actions.env index 75cc44d9..769b7ccb 100644 --- a/pkgs/sdk/server/github_actions.env +++ b/pkgs/sdk/server/github_actions.env @@ -5,3 +5,4 @@ BUILD_OUTPUT_DLL_NAME=LaunchDarkly.ServerSdk.dll TEST_PROJECT_FILE=pkgs/sdk/server/test/LaunchDarkly.ServerSdk.Tests.csproj CONTRACT_TEST_PROJECT_FILE=pkgs/sdk/server/contract-tests/TestService.csproj CONTRACT_TEST_DLL_FILE=pkgs/sdk/server/contract-tests/bin/debug/net8.0/ContractTestService.dll +ASSEMBLY_KEY_PATH_PAIR='launchdarkly-releaser/dotnet/LaunchDarkly.snk = LaunchDarkly.snk' diff --git a/pkgs/shared/common-json-net/CHANGELOG.md b/pkgs/shared/common-json-net/CHANGELOG.md new file mode 100644 index 00000000..5037bda1 --- /dev/null +++ b/pkgs/shared/common-json-net/CHANGELOG.md @@ -0,0 +1,394 @@ +# Change log + +All notable changes to `LaunchDarkly.CommonSdk` will be documented in this file. For full release notes for the projects that depend on this project, see their respective changelogs. This file describes changes only to the common code. This project adheres to [Semantic Versioning](http://semver.org). + +## [7.0.0] - 2023-10-17 +### Changed: +- IEnvironmentReporter now reports nullable values. + +## [6.2.0] - 2023-10-10 +### Added: +- Adds locale to auto environment attribute layer. + +## [6.1.0] - 2023-10-10 +### Added: +- Adds ApplicationInfo and EnvironmentReporter and respective builders. + +## [6.0.1] - 2023-04-04 +### Fixed: +- Fixed an issue with generating the `FullyQualifiedKey`. The key generation was not sorted by the kind, so the key was not stable depending on the order of the context construction. This also affected the generation of the secure mode hash for mulit-contexts. + +## [6.0.0] - 2022-12-01 +This major version release of `LaunchDarkly.CommonSdk` corresponds to the upcoming v7.0.0 release of the LaunchDarkly server-side .NET SDK (`LaunchDarkly.ServerSdk`) and the v3.0.0 release of the LaunchDarkly client-side .NET SDK (`LaunchDarkly.ClientSdk`), and cannot be used with earlier SDK versions. + +### Added: +- In `LaunchDarkly.Sdk`, the types `Context` and `ContextKind` define the new "context" model. "Contexts" are a replacement for the earlier concept of "users"; they can be populated with attributes in more or less the same way as before, but they also support new behaviors. More information about these features will be included in the release notes for the `LaunchDarkly.ServerSdk` 7.0.0 and `LaunchDarkly.ClientSdk` 3.0.0 releases. + +### Changed: +- .NET Core 2.1, .NET Framework 4.5.2, .NET Framework 4.6.1, and .NET 5.0 are now unsupported. The minimum platform versions are now .NET Core 3.1, .NET Framework 4.6.2, .NET 6.0, and .NET Standard 2.0. +- It was previously allowable to set a user key to an empty string. In the new context model, the key is not allowed to be empty. Trying to use an empty key will cause evaluations to fail and return the default value. +- There is no longer such a thing as a `Secondary` meta-attribute that affects percentage rollouts. If you set an attribute with that name in a `Context`, it will simply be a custom attribute like any other. +- The `Anonymous` attribute in `LDUser` is now a simple boolean, with no distinction between a false state and a null state. +- There is no longer a dependency on `LaunchDarkly.JsonStream`. This package existed because some platforms did not support the `System.Text.Json` API, but that is no longer the case and the SDK now uses `System.Text.Json` directly for all of its JSON operations. +- If you are using the package `LaunchDarkly.CommonSdk.JsonNet` for interoperability with the Json.NET library, you must update this to the latest major version. + +### Removed: +- Removed all types, fields, and methods that were deprecated as of the most recent release. +- Removed the `Secondary` meta-attribute in `User` and `UserBuilder`. + +## [5.5.0] - 2022-02-02 +### Added: +- `UnixMillisecondTime` now has a JSON converter like other `LaunchDarkly.Sdk` types. + +### Fixed: +- When using `LaunchDarkly.CommonSdk.JsonNet`, nullable value types such as `EvaluationReason?` were not being serialized correctly. + +## [5.4.1] - 2021-11-02 +### Fixed: +- Copying a user with `User.Builder(existingUser)` was incorrectly changing the default `null` value of `AnonymousOptional` to `false`. This normally has no significance since LaunchDarkly treats those two values the same, but it could have broken tests that expected a copied user to be equal. + +## [5.4.0] - 2021-10-22 +### Added: +- `LdValue.ObjectBuilder.Remove`. +- User builder `Custom` overloads for `long` and `double`. + +### Changed: +- Added more doc comment text about numeric precision issues with JSON numbers. +- Updated `LaunchDarkly.JsonStream` to 1.0.3. + +## [5.3.0] - 2021-10-14 +### Added: +- Convenience methods for working with JSON object and array values: `LdValue.Dictionary`, `LdValue.List`, `LdValue.ObjectBuilder.Set`, and `LdValue.ObjectBuilder.Copy`. + +## [5.2.1] - 2021-10-05 +### Changed: +- Changed dependency version for `System.Collections.Immutable` to 1.7.1, to match the version used by `LaunchDarkly.ServerSdk`. This has no effect on SDK functionality, but it reduces the chance that a binding redirect will be required to reconcile dependency versions in .NET Framework. + +## [5.2.0] - 2021-07-19 +### Added: +- In `EvaluationReason`, added optional status information related to the new big segments feature. + +## [5.1.0] - 2021-06-17 +### Added: +- The SDK now supports the ability to control the proportion of traffic allocation to an experiment. This works in conjunction with a new platform feature now available to early access customers. + +## [5.0.2] - 2021-06-07 +### Fixed: +- Updated the minimum dependency version for `LaunchDarkly.JsonStream` to exclude versions that have a known JSON parsing bug. + +## [5.0.1] - 2021-02-02 +### Fixed: +- Updated dependencies in `LaunchDarkly.CommonSdk.JsonNet` to the correct versions. + +## [5.0.0] - 2021-02-02 +### Added: +- `LaunchDarkly.Sdk.Json` namespace with JSON serialization helpers. Also, there is now a separate package defined in this repo, `LaunchDarkly.CommonSdk.JsonNet`, for interoperability with `Newtonsoft.Json`. +- `UnixMillisecondTime` type, a convenient wrapper for the date/time format that is used by LaunchDarkly services. Applications normally won't need to use this unless they are interacting directly with the analytics event system. +- `LdValue` now has `==` and `!=` operators. +- Releases now publish [Source Link](https://github.com/dotnet/sourcelink/blob/master/README.md) data. + +### Changed: +- The base namespace is now `LaunchDarkly.Sdk` rather than `LaunchDarkly.Client`. +- `EvaluationReason` is now a struct. +- `EvaluationReasonKind` and `EvaluationErrorKind` enum names now use regular .NET-style capitalization (`RuleMatch`) instead of Java-style capitalization (`RULE_MATCH`). +- JSON-serializable types (`User`, etc.) now automatically encode and decode correctly with `System.Text.Json`. + +### Removed: +- `EvaluationReason` subclasses. +- There is no longer a package dependency on `Newtonsoft.Json`. +- Non-public helpers used by SDKs have been removed, and are now in `LaunchDarkly.InternalSdk` instead. + +## [4.3.1] - 2020-01-15 +### Fixed: +- A bug in the SDK prevented the sending of events from being retried after a failure. The SDK now retries once after an event flush fails as was intended. +- The SDK now specifies a uniquely identifiable request header when sending events to LaunchDarkly to ensure that events are only processed once, even if the SDK sends them two times due to a failed initial attempt. + +## [4.3.0] - 2020-01-13 +### Added: +- `EvaluationReason` static methods and properties for creating reason instances. +- `LdValue` helpers for dealing with array/object values, without having to use an intermediate `List` or `Dictionary`: `BuildArray`, `BuildObject`, `Count`, `Get`. +- `LdValue.Parse()`. +- `IUserBuilder.Secondary` is a new name for `SecondaryKey` (for consistency with other SDKs), and allows you to make the `secondary` attribute private. +- `User.Secondary` (same as `SecondaryKey`). + +### Changed: +- `EvaluationReason` properties all exist on the base class now, so for instance you do not need to cast to `RuleMatch` to get the `RuleId` property. This is in preparation for a future API change in which `EvaluationReason` will become a struct instead of a base class. + +### Fixed: +- Improved memory usage and performance when processing analytics events: the SDK now encodes event data to JSON directly, instead of creating intermediate objects and serializing them via reflection. +- When parsing arbitrary JSON values, the SDK now always stores them internally as `LdValue` rather than `JToken`. This means that no additional copying step is required when the application accesses that value, if it is of a complex type. +- `LdValue.Equals()` incorrectly returned true for object (dictionary) values that were not equal. + +### Deprecated: +- `EvaluationReason` subclasses. Use only the base class properties and methods to ensure compatibility with future versions. +- `IUserBuilder.SecondaryKey`, `User.SecondaryKey`. + + +## [4.2.1] - 2019-10-23 +### Fixed: +- The JSON serialization of `User` was producing an extra `Anonymous` property in addition to `anonymous`. If Newtonsoft.Json was configured globally to force all properties to lowercase, this would cause an exception when serializing a user since the two properties would end up with the same name. + +## [4.2.0] - 2019-10-10 +### Added: +- Added `LaunchDarkly.Logging.ConsoleAdapter` as a convenience for quickly enabling console logging; this is equivalent to `Common.Logging.Simple.ConsoleOutLoggerFactoryAdapter`, but the latter is not available on some platforms. + +## [4.1.0] - 2019-10-07 +### Added: +- `IUserBuilder.AnonymousOptional` and `User.AnonymousOption` allow treating the `Anonymous` property as nullable (necessary for consistency with other SDKs). See note about this under Fixed. + +### Fixed: +- `IUserBuilder` was incorrectly setting the user's `Anonymous` property to `null` even if it had been explicitly set to `false`. Null and false behave the same in terms of LaunchDarkly's user indexing behavior, but currently it is possible to create a feature flag rule that treats them differently. So `IUserBuilder.Anonymous(false)` now correctly sets it to `false`. +- `LdValue.Convert.Long` was mistakenly converting to an `int` rather than a `long`. ([#32](https://github.com/launchdarkly/dotnet-sdk-common/issues/32)) + +## [4.0.1] - 2019-09-13 +_The 4.0.0 release was broken._ + +### Added: +- `LdValue` now has methods for converting to and from complex types (list, dictionary). + +### Changed: +- `ImmutableJsonValue` is now called `LdValue`. +- All public APIs now use `ImmutableJsonValue` instead of `JToken`. + +### Removed: +- Public `ImmutableJsonValue` methods and properties that refer to `JToken`, `JObject`, or `JArray`. + +## [3.1.0] - 2019-08-30 +### Added: +- `SetOffline` method in `IEventProcessor`/`DefaultEventProcessor`. +- XML documentation comments are now included in the package for all target frameworks. Previously they were only included for .NET Standard 1.4. + +## [3.0.0] - 2019-08-09 +### Added: +- `User.Builder` provides a fluent builder pattern for constructing `User` objects. This is now the only method for building a user if you want to set any properties other than the `Key`. +- The `ImmutableJsonValue` type provides a wrapper for the Newtonsoft.Json types that prevents accidentally modifying JSON object properties or array values that are shared by other objects. +- Helper type `ValueType`/`ValueTypes` for use by the SDK `Variation` methods. +- Internal interfaces for configuring specific components, like `IEventProcessorConfiguration`. These replace `IBaseConfiguration`. + +### Changed: +- `User` objects are now immutable. +- In `User`, `IpAddress` has been renamed to `IPAddress` (standard .NET capitalization for two-letter acronyms). +- Custom attributes in `User.Custom` now use the type `ImmutableJsonValue` instead of `JToken`. +- Uses of mutable `IDictionary` and `ISet` in the configuration and user objects have been changed to immutable types. + +### Removed: +- `UserExtensions` (use `User.Builder`). +- `User` constructors (use `User.WithKey` or `User.Builder`). +- `User` property setters. +- `IBaseConfiguration` and `ICommonLdClient` interfaces. + +### Fixed: +- No longer assumes that we are overriding the `HttpMessageHandler` (if it is null in the configuration, just use the default `HttpClient` constructor). This is important for Xamarin. + +## [2.11.1] - 2020-11-05 +### Changed: +- Updated the `LaunchDarkly.EventSource` dependency to a version that has a specific target for .NET Standard 2.0. Previously, that package targeted only .NET Standard 1.4 and .NET Framework 4.5. There is no functional difference between these targets, but .NET Core application developers may wish to avoid linking to any .NET Standard 1.x assemblies on general principle. + +## [2.11.0] - 2020-01-31 +### Added: +- `DefaultEventProcessor` now supports sending diagnostic data to LaunchDarkly regarding the OS version, performance statistics, etc. The exact implementation of this is determined by the platform-specific SDKs (.NET or Xamarin). +- The SDK now specifies a uniquely identifiable request header when sending events to LaunchDarkly to ensure that events are only processed once, even if the SDK sends them two times due to a failed initial attempt. + +## [2.10.1] - 2020-01-15 +### Fixed: +- A bug in the SDK prevented the sending of events from being retried after a failure. The SDK now retries once after an event flush fails as was intended. +- The SDK now specifies a uniquely identifiable request header when sending events to LaunchDarkly to ensure that events are only processed once, even if the SDK sends them two times due to a failed initial attempt. + +## [2.10.0] - 2020-01-03 +### Added: +- `IUserBuilder.Secondary` is a new name for `SecondaryKey` (for consistency with other SDKs), and allows you to make the `secondary` attribute private. +- `User.Secondary` (same as `SecondaryKey`). + +### Deprecated: +- `IUserBuilder.SecondaryKey`, `User.SecondaryKey`. + + +## [2.9.2] - 2019-11-12 +### Fixed: +- `LdValue.Equals()` incorrectly returned true for object (dictionary) values that were not equal. +- Summary events incorrectly had `unknown:true` for all evaluation errors, rather than just for "flag not found" errors (bug introduced in 2.9.0, not used in any current SDK). + +## [2.9.1] - 2019-11-08 +### Fixed: +- Fixed an exception when serializing user custom attributes in events (bug in 2.9.0). + +## [2.9.0] - 2019-11-08 +### Added: +- `EvaluationReason` static methods and properties for creating reason instances. +- `LdValue` helpers for dealing with array/object values, without having to use an intermediate `List` or `Dictionary`: `BuildArray`, `BuildObject`, `Count`, `Get`. +- `LdValue.Parse()`. It is also possible to use `Newtonsoft.Json.JsonConvert` to parse or serialize `LdValue`, but since the implementation may change in the future, using the type's own methods is preferable. + +### Changed: +- `EvaluationReason` properties all exist on the base class now, so for instance you do not need to cast to `RuleMatch` to get the `RuleId` property. This is in preparation for a future API change in which `EvaluationReason` will become a struct instead of a base class. + +### Fixed: +- Improved memory usage and performance when processing analytics events: the SDK now encodes event data to JSON directly, instead of creating intermediate objects and serializing them via reflection. + +### Deprecated: +- `EvaluationReason` subclasses. Use only the base class properties and methods to ensure compatibility with future versions. + +## [2.8.0] - 2019-10-10 +### Added: +- Added `LaunchDarkly.Logging.ConsoleAdapter` as a convenience for quickly enabling console logging; this is equivalent to `Common.Logging.Simple.ConsoleOutLoggerFactoryAdapter`, but the latter is not available on some platforms. + +## [2.7.0] - 2019-10-03 +### Added: +- `IUserBuilder.AnonymousOptional` allows setting the `Anonymous` property to `null` (necessary for consistency with other SDKs). See note about this under Fixed. + +### Fixed: +- `IUserBuilder` was incorrectly setting the user's `Anonymous` property to `null` even if it had been explicitly set to `false`. Null and false behave the same in terms of LaunchDarkly's user indexing behavior, but currently it is possible to create a feature flag rule that treats them differently. So `IUserBuilder.Anonymous(false)` now correctly sets it to `false`, just as the deprecated method `UserExtensions.WithAnonymous(false)` would. +- `LdValue.Convert.Long` was mistakenly converting to an `int` rather than a `long`. ([#32](https://github.com/launchdarkly/dotnet-sdk-common/issues/32)) + +## [2.6.1] - 2019-09-12 +### Fixed: +- A packaging error made the `LaunchDarkly.CommonSdk.StrongName` package unusable in 2.6.0. + +## [2.6.0] - 2019-09-12 +### Added: +- Value type `LdValue`, to be used in place of `JToken` whenever possible. + +### Changed: +- All event-related code except for public properties now uses `LdValue`. + +### Removed: +- Internal helper type `ValueType`, unnecessary now because we can use `LdValue.Convert`. + +## [2.5.1] - 2019-08-30 +### Fixed: +- Many improvements to XML documentation comments. + +## [2.5.0] - 2019-08-30 +### Added: +- Internal helper types `ValueType` and `ValueTypes`. +- XML documentation comments are now included in the package for all target frameworks. Previously they were only included for .NET Standard 1.4. + +### Changed: +- Internal types are now sealed. +- Changed some internal classes to structs for efficiency. + +### Deprecated: +- `IBaseConfiguration` and `ICommonLdClient` interfaces. + +## [2.4.0] - 2019-07-31 +### Added: +- `IBaseConfiguration.EventCapacity` and `IBaseConfiguration.EventFlushInterval`. +- `UserBuilder.Key` setter. + +### Deprecated: +- `IBaseConfiguration.SamplingInterval`. +- `IBaseConfiguration.EventQueueCapacity` (now a synonym for `EventCapacity`). +- `IBaseConfiguration.EventQueueFrequency` (now a synonym for `EventFlushInterval`). + +## [2.3.0] - 2019-07-23 +### Deprecated: +- `User` constructors. +- `User.Custom` and `User.PrivateAttributeNames` will be changed to immutable collections in the future. + +## [2.2.0] - 2019-07-23 +### Added: +- `User.Builder` provides a fluent builder pattern for constructing `User` objects. This is now the preferred method for building a user, rather than setting `User` properties directly or using `UserExtension` methods like `AndName()` that modify the existing user object. +- `User.IPAddress` is equivalent to `User.IpAddress`, but has the standard .NET capitalization for two-letter acronyms. + +### Deprecated: +- `User.IpAddress` (use `IPAddress`). +- All `UserExtension` methods are now deprecated. The setters for all `User` properties should also be considered deprecated, although C# does not allow these to be marked with `[Obsolete]`. + +## [2.1.2] - 2019-05-10 +### Fixed: +- Fixed a build error that caused classes to be omitted from `LaunchDarkly.CommonSdk.StrongName`. + +## [2.1.1] - 2019-05-10 +### Changed: +- The package and assembly name are now `LaunchDarkly.CommonSdk`, and the `InternalsVisibleTo` directives now refer to `LaunchDarkly.ServerSdk` and `LaunchDarkly.XamarinSdk`. There are no other changes. All future releases of the LaunchDarkly server-side .NET SDK and client-side Xamarin SDK will use the new package names, and no further updates of the old `LaunchDarkly.Common` package will be published. + +## [2.1.0] - 2019-04-16 +### Added: +- Added support for planned future LaunchDarkly features related to analytics events and experimentation (metric values). + +## [2.0.0] - 2019-03-26 +### Added: +- Added support for planned future LaunchDarkly features related to analytics events and experimentation. +- It is now possible to deserialize evaluation reasons from JSON (this is used by the Xamarin client). + +### Changed: +- The `IFlagEventProperties` interface was extended and modified to support the aforementioned features. + +### Fixed: +- Under some circumstances, a `CancellationTokenSource` might not be disposed of after making an HTTP request, which could cause a timer object to be leaked. + +## [1.2.3] - 2018-01-14 +### Fixed: +- The assemblies in this package now have Authenticode signatures. + +## [1.2.2] - 2018-01-09 + +This release was an error. It works, but there are no changes from 1.2.1 except for using a newer version of `dotnet-eventsource`, which was also an unintended re-release of the previous version. + +## [1.2.1] - 2018-12-17 + +### Changed +The only changes in this version are to the build: + +- What is published to NuGet is now the Release configuration, without debug information. +- The Debug configuration (the default) no longer performs strong-name signing. This makes local development easier. +- `LaunchDarkly.Common` now has an `InternalsVisibleTo` directive for an _unsigned_ version of the `LaunchDarkly.Client` unit tests. Again this is to support local development, since the client will be unsigned by default as well. + +## [1.2.0] - 2018-10-24 + +### Changed +- The non-strong-named version of this library (`LaunchDarkly.Common`) can now be used with a non-strong-named version of `LaunchDarkly.Client`, which does not normally exist but could be built as part of a fork of the SDK. + +- Previously, the delay before stream reconnect attempts would increase exponentially only if the previous connection could not be made at all or returned an HTTP error; if it received an HTTP 200 status, the delay would be reset to the minimum even if the connection then immediately failed. Now, if the stream connection fails after it has been up for less than a minute, the reconnect delay will continue to increase. (changed in `LaunchDarkly.EventSource` 3.2.0) + +### Fixed + +- Fixed an [unobserved exception](https://blogs.msdn.microsoft.com/pfxteam/2011/09/28/task-exception-handling-in-net-4-5/) that could occur following a stream timeout, which could cause a crash in .NET 4.0. (fixed in `LaunchDarkly.EventSource` 3.2.0) + +- A `NullReferenceException` could sometimes be logged if a stream connection failed. (fixed in `LaunchDarkly.EventSource` 3.2.0) + +## [1.1.1] - 2018-08-29 + +Incorporates the fix from 1.0.6 that was not included in 1.1.0. + +## [1.1.0] - 2018-08-22 + +### Added +- New `EvaluationDetail` and `EvaluationReason` classes will be used in future SDK versions that support capturing evaluation reasons. + +## [1.0.6] - 2018-08-30 + +### Fixed +- Updated LaunchDarkly.EventSource to fix a bug that prevented the client from reconnecting to the stream if it received an HTTP error status from the server (as opposed to simply losing the connection). + +## [1.0.5] - 2018-08-14 + +### Fixed +- The reconnection attempt counter is no longer shared among all StreamManager instances. Previously, if you connected to more than one stream, all but the first would behave as if they were reconnecting and would have a backoff delay. + +## [1.0.4] - 2018-08-02 + +### Changed +- Updated the dependency on `LaunchDarkly.EventSource`, which no longer has package references to System assemblies. + +## [1.0.3] - 2018-07-27 + +### Changed +- The package `LaunchDarkly.Common` is no longer strong-named. Instead, we are now building two packages: `LaunchDarkly.Common` and `LaunchDarkly.Common.StrongName`. This is because the Xamarin project requires an unsigned version of the package, whereas the main .NET SDK uses the signed one. +- The project now uses a framework reference (`Reference`) instead of a package reference (`PackageReference`) to refer to `System.Net.Http`. An unnecessary reference to `System.Runtime` was removed. +- The stream processor now propagates an exception out of its initialization `Task` if it encounters an unrecoverable error. + +## [1.0.2] - 2018-07-24 + +''This release is broken and should not be used.'' + +## [1.0.1] - 2018-07-02 + +### Changed +- When targeting .NET 4.5, the dependency on `Newtonsoft.Json` now has a minimum version of 6.0.1 rather than 9.0.1. This should not affect any applications that specify a higher version for this assembly. + +### Removed +- The `Identify` method is no longer part of `ILdCommonClient`, since it does not have the same signature in the Xamarin client as in the server-side .NET SDK. + +## [1.0.0] - 2018-06-26 + +Initial release, corresponding to .net-client version 5.1.0. diff --git a/pkgs/shared/common-json-net/CONTRIBUTING.md b/pkgs/shared/common-json-net/CONTRIBUTING.md new file mode 100644 index 00000000..05830545 --- /dev/null +++ b/pkgs/shared/common-json-net/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to the LaunchDarkly SDK .NET Common Code + +LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/sdk/concepts/contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this SDK. + +## Submitting bug reports and feature requests + +In general, issues should be filed in the issue trackers for the [.NET server-side SDK](https://github.com/launchdarkly/dotnet-server-sdk/issues) or the [.NET client-side SDK](https://github.com/launchdarkly/dotnet-client-sdk/issues) rather than in this repository, unless you have a specific implementation issue regarding the code in this repository. + +## Submitting pull requests + +We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. + +## Build instructions + +### Prerequisites + +To set up your SDK build time environment, you must [download .NET Core and follow the instructions](https://dotnet.microsoft.com/download) (make sure you have 2.0 or higher). + +### Building + +To install all required packages: + +``` +dotnet restore +``` + +Then, to build the SDK without running any tests: + +``` +dotnet build src/LaunchDarkly.CommonSdk.csproj -f netstandard2.0 +``` + +### Testing + +To run all unit tests: + +``` +dotnet test test/LaunchDarkly.CommonSdk.Tests.csproj +``` diff --git a/pkgs/shared/common-json-net/LaunchDarkly.CommonSdk.JsonNet.sln b/pkgs/shared/common-json-net/LaunchDarkly.CommonSdk.JsonNet.sln new file mode 100644 index 00000000..bdd1bbaf --- /dev/null +++ b/pkgs/shared/common-json-net/LaunchDarkly.CommonSdk.JsonNet.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26730.16 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.CommonSdk.JsonNet", "src\LaunchDarkly.CommonSdk.JsonNet.csproj", "{8BFB500A-D88B-4213-8099-3B45A2B430FA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.CommonSdk.JsonNet.Tests", "test\LaunchDarkly.CommonSdk.JsonNet.Tests.csproj", "{867B496F-0D48-4A85-B237-D8DC132F0673}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8BFB500A-D88B-4213-8099-3B45A2B430FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BFB500A-D88B-4213-8099-3B45A2B430FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BFB500A-D88B-4213-8099-3B45A2B430FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BFB500A-D88B-4213-8099-3B45A2B430FA}.Release|Any CPU.Build.0 = Release|Any CPU + {867B496F-0D48-4A85-B237-D8DC132F0673}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {867B496F-0D48-4A85-B237-D8DC132F0673}.Debug|Any CPU.Build.0 = Debug|Any CPU + {867B496F-0D48-4A85-B237-D8DC132F0673}.Release|Any CPU.ActiveCfg = Release|Any CPU + {867B496F-0D48-4A85-B237-D8DC132F0673}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D7F58FE1-D8E9-46EB-AE7F-4B5A388418AC} + EndGlobalSection +EndGlobal diff --git a/pkgs/shared/common-json-net/README.md b/pkgs/shared/common-json-net/README.md new file mode 100644 index 00000000..68cc717c --- /dev/null +++ b/pkgs/shared/common-json-net/README.md @@ -0,0 +1,34 @@ +# LaunchDarkly SDK Json.NET Adapter + +The add-on package `LaunchDarkly.CommonSdk.JsonNet` allows JSON-serializable data types from LaunchDarkly .NET SDKs, such as `User` and `LdValue`, to be encoded and decoded correctly by the [Json.NET](https://www.newtonsoft.com/json) library (`Newtonsoft.Json`). + +Earlier versions of the LaunchDarkly SDKs used Json.NET internally, so nothing additional was needed to make this work. However, in later SDK releases, the Json.NET dependency was removed and so these types do not contain the `[JsonConverter]` annotation that would tell Json.NET how to encode and decode them. + +It is always possible to encode or decode these types explicitly using the `LaunchDarkly.Sdk.Json.LdJsonSerialization` class. But if you want them to be handled automatically by code that uses Json.NET, just do the following: + +1. Install the package `LaunchDarkly.CommonSdk.JsonNet`. + +2. Define a `JsonSerializerSettings` object that includes the LaunchDarkly JSON converter: + +```csharp + var settings = new Newtonsoft.Json.JsonSerializerSettings + { + Converters = new List + { + LaunchDarkly.Sdk.Json.LdJsonNet.Converter + // you may add any other custom converters you want here + } + }; +``` + +3. You can reference this configuration in any individual Json.NET operation: + +```csharp + var json = JsonConvert.SerializeObject(someObject, settings); +``` + +4. Or, to make these settings the default for all Json.NET operations: + +```csharp + JsonConvert.DefaultSettings = () => settings; +``` diff --git a/pkgs/shared/common-json-net/github_actions.env b/pkgs/shared/common-json-net/github_actions.env new file mode 100644 index 00000000..0b58e1e0 --- /dev/null +++ b/pkgs/shared/common-json-net/github_actions.env @@ -0,0 +1,6 @@ +WORKSPACE_PATH=pkgs/shared/common-json-net +PROJECT_FILE=pkgs/shared/common-json-net/src/LaunchDarkly.CommonSdk.JsonNet.csproj +BUILD_OUTPUT_PATH=pkgs/shared/common-json-net/src/bin/Release/ +BUILD_OUTPUT_DLL_NAME=LaunchDarkly.CommonSdk.JsonNet.dll +TEST_PROJECT_FILE=pkgs/shared/common-json-net/test/LaunchDarkly.CommonSdk.JsonNet.Tests.csproj +ASSEMBLY_KEY_PATH_PAIR='launchdarkly-releaser/dotnet/LaunchDarkly.CommonSdk.snk = LaunchDarkly.CommonSdk.snk' \ No newline at end of file diff --git a/pkgs/shared/common-json-net/src/LaunchDarkly.CommonSdk.JsonNet.csproj b/pkgs/shared/common-json-net/src/LaunchDarkly.CommonSdk.JsonNet.csproj new file mode 100644 index 00000000..8b020d1b --- /dev/null +++ b/pkgs/shared/common-json-net/src/LaunchDarkly.CommonSdk.JsonNet.csproj @@ -0,0 +1,51 @@ + + + 7.0.0 + + netstandard2.0;net462;net8.0 + $(BUILDFRAMEWORKS) + portable + LaunchDarkly.CommonSdk.JsonNet + Library + 7.3 + LaunchDarkly.CommonSdk.JsonNet + Integration between LaunchDarkly SDKs and Json.NET (Newtonsoft.Json) + LaunchDarkly + LaunchDarkly + LaunchDarkly + Copyright 2020 LaunchDarkly + Apache-2.0 + https://github.com/launchdarkly/dotnet-core + https://github.com/launchdarkly/dotnet-core + main + true + snupkg + bin\$(Configuration)\$(TargetFramework)\LaunchDarkly.CommonSdk.JsonNet.xml + LaunchDarkly.Sdk.Json + + + 1570,1571,1572,1573,1574,1580,1581,1584,1591,1710,1711,1712 + + + + + + + + + + + + + + + + + ../../../../LaunchDarkly.CommonSdk.snk + true + + diff --git a/pkgs/shared/common-json-net/src/LdJsonNet.cs b/pkgs/shared/common-json-net/src/LdJsonNet.cs new file mode 100644 index 00000000..f0858bdb --- /dev/null +++ b/pkgs/shared/common-json-net/src/LdJsonNet.cs @@ -0,0 +1,143 @@ +using System; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace LaunchDarkly.Sdk.Json +{ + /// + /// Integration between the LaunchDarkly SDKs and Json.NET (Newtonsoft.Json). + /// + /// + /// Earlier versions of the LaunchDarkly SDKs used Json.NET internally, so SDK types like + /// LdValue and User that define their own custom serializations would automatically + /// use that custom logic when being serialized or deserialized with Json.NET. However, the SDKs + /// now use a different framework internally, and Json.NET will no longer handle those types + /// correctly without a separate configuration step; see . + /// + public static class LdJsonNet + { + // The IJsonSerializable interface, which is what tells us whether this is one of the SDK's + // serializable types, is defined in the main LaunchDarkly.CommonSdk package. However, we + // don't actually need any code in that package in order to make this adapter work; all we + // really need is System.Text.Json. So it's preferable to avoid having a dependency on + // LaunchDarkly.CommonSdk from LaunchDarkly.CommonSdk.JsonNet (it would make our release + // process less convenient and potentially cause version conflicts); instead, we can just + // look up the interface type dynamically like this. + internal static readonly Type IJsonSerializableType = DetectSerializableInterfaceType(); + + /// + /// A JsonConverter that allows Json.NET to serialize and deserialize LaunchDarkly + /// SDK types. + /// + /// + /// + /// This converter tells Newtonsoft.Json.JsonConvert how to use the appropriate logic + /// LaunchDarkly SDK types like User (more generally, any SDK type that has the marker + /// interface LaunchDarkly.Sdk.Json.IJsonSerializable). There are several ways to use it: + /// + /// + /// 1. Pass it as a parameter to an individual `SerializeObject` or `DeserializeObject` call, + /// if the top-level type you are serializing is one of the SDK types. + /// + /// + /// + /// var user = LaunchDarkly.Sdk.User.WithKey("user-key"); + /// var userJson = JsonConvert.SerializeObject(user, + /// LaunchDarkly.Sdk.Json.LdJsonNet.Converter); + /// + /// + /// + /// 2. Add it to the list of converters in a JsonSerializerSettings instance and pass + /// those settings. This works even if the SDK types are contained in some other type. + /// + /// + /// + /// var settings = new JsonSerializerSettings + /// { + /// Converters = new List<JsonConverter> + /// { + /// LaunchDarkly.Sdk.Json.LdJsonNet.Converter + /// // and any other custom converters you may have + /// } + /// }; + /// var myObject = new MyClass { User = user }; + /// var json = JsonConvert.SerializeObject(myObject, settings); + /// + /// + /// 3. Same as 2, but modifying the global default settings. + /// + /// + /// JsonConvert.DefaultSettings = () => new JsonSerializerSettings + /// { + /// Converters = new List<JsonConverter> + /// { + /// LaunchDarkly.Sdk.Json.LdJsonNet.Converter + /// // and any other custom converters you may have + /// } + /// }; + /// var myObject = new MyClass { User = user }; + /// var json = JsonConvert.SerializeObject(myObject); + /// + /// + /// + public static JsonConverter Converter => + IJsonSerializableType is null ? + throw new InvalidOperationException("LdJsonNet.Converter cannot be used unless a LaunchDarkly .NET SDK is present") : + JsonConverterFactory.Instance; + + private static Type DetectSerializableInterfaceType() + { + try + { + var sdkCommonAssembly = Assembly.Load("LaunchDarkly.CommonSdk"); + var t = sdkCommonAssembly.GetType("LaunchDarkly.Sdk.Json.IJsonSerializable"); + return t; + } + catch { } + return null; + } + } + + internal class JsonConverterFactory : JsonConverter + { + internal static readonly JsonConverter Instance = new JsonConverterFactory(); + + // Json.NET has idiosyncratic default behavior, e.g. it will try to convert strings to DateTime + // instances if it thinks they look like dates. That's not what we want, so we'll configure our + // own Serializer instance here. + internal static readonly JsonSerializer DefaultSerializer = new JsonSerializer() + { + DateParseHandling = DateParseHandling.None + }; + + public override bool CanConvert(Type objectType) => + LdJsonNet.IJsonSerializableType.IsAssignableFrom(objectType) || + objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Nullable<>) && + LdJsonNet.IJsonSerializableType.IsAssignableFrom(Nullable.GetUnderlyingType(objectType)); + + public override bool CanRead => true; + + public override bool CanWrite => true; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + objectType = Nullable.GetUnderlyingType(objectType); + if (reader.TokenType == JsonToken.Null) + { + return null; + } + } + var raw = DefaultSerializer.Deserialize(reader); + return System.Text.Json.JsonSerializer.Deserialize(raw.Value.ToString(), objectType); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + string json = System.Text.Json.JsonSerializer.Serialize(value); + writer.WriteRawValue(json); + } + } +} diff --git a/pkgs/shared/common-json-net/test/LaunchDarkly.CommonSdk.JsonNet.Tests.csproj b/pkgs/shared/common-json-net/test/LaunchDarkly.CommonSdk.JsonNet.Tests.csproj new file mode 100644 index 00000000..5b8057a0 --- /dev/null +++ b/pkgs/shared/common-json-net/test/LaunchDarkly.CommonSdk.JsonNet.Tests.csproj @@ -0,0 +1,21 @@ + + + netcoreapp3.1;net462;net6.0 + $(TESTFRAMEWORK) + LaunchDarkly.CommonSdk.JsonNet.Tests + LaunchDarkly.Sdk.Json + + + + + + + + + + + + + + + diff --git a/pkgs/shared/common-json-net/test/LdJsonNetTest.cs b/pkgs/shared/common-json-net/test/LdJsonNetTest.cs new file mode 100644 index 00000000..5bc083ef --- /dev/null +++ b/pkgs/shared/common-json-net/test/LdJsonNetTest.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Xunit; + +namespace LaunchDarkly.Sdk.Json +{ + public class LdJsonNetTest + { + private static readonly AttributeRef ExpectedAttributeRef = AttributeRef.FromLiteral("a"); + private const string ExpectedAttributeRefJson = @"""a"""; + private static readonly Context ExpectedContext = Context.New("user-key"); + private const string ExpectedContextJson = @"{""kind"":""user"",""key"":""user-key""}"; + private static readonly EvaluationReason ExpectedEvaluationReason = EvaluationReason.OffReason; + private const string ExpectedEvaluationReasonJson = @"{""kind"":""OFF""}"; + private static readonly UnixMillisecondTime ExpectedUnixTime = UnixMillisecondTime.OfMillis(123456789); + private const string ExpectedUnixTimeJson = "123456789"; + private static readonly User ExpectedUser = User.WithKey("user-key"); + private const string ExpectedUserJson = @"{""key"":""user-key""}"; + private static readonly LdValue ExpectedValue = LdValue.Of(true); + private const string ExpectedValueJson = "true"; + + // The reason for the "ObjectWithNullable..." classes is to test the serialization of nullable variants + // of value types. For any value type T, if we pass a "T?" value to SerializeObject, the type of the + // parameter in that method is actually "object" and so what is really passed is either a T or a plain + // old null-- it doesn't really see a "T?". But if it's in a property like this, it really will detect + // the type. + + private sealed class ObjectWithNullableAttributeRef + { + public AttributeRef? attr { get; set; } + } + + private sealed class ObjectWithNullableContext + { + public Context? context { get; set; } + } + + private sealed class ObjectWithNullableReason + { + public EvaluationReason? reason { get; set; } + } + + private sealed class ObjectWithNullableTime + { + public UnixMillisecondTime? time { get; set; } // see above + } + + private sealed class ObjectWithNullableValue + { + public LdValue? value { get; set; } // see above + // "LdValue?" is a bit pointless, since an LdValue can already encode null, but it is a struct so this should work + } + + [Fact] + public void SerializeWithExplicitConverter() + { + Assert.Equal(ExpectedAttributeRefJson, JsonConvert.SerializeObject(ExpectedAttributeRef, LdJsonNet.Converter)); + Assert.Equal(ExpectedContextJson, JsonConvert.SerializeObject(ExpectedContext, LdJsonNet.Converter)); + Assert.Equal(ExpectedEvaluationReasonJson, JsonConvert.SerializeObject(ExpectedEvaluationReason, LdJsonNet.Converter)); + Assert.Equal(ExpectedUnixTimeJson, JsonConvert.SerializeObject(ExpectedUnixTime, LdJsonNet.Converter)); + Assert.Equal(ExpectedUserJson, JsonConvert.SerializeObject(ExpectedUser, LdJsonNet.Converter)); + Assert.Equal(ExpectedValueJson, JsonConvert.SerializeObject(ExpectedValue, LdJsonNet.Converter)); + } + + [Fact] + public void SerializeWithConverterInSettings() + { + var settings = new JsonSerializerSettings + { + Converters = new List { LdJsonNet.Converter } + }; + Assert.Equal(ExpectedAttributeRefJson, JsonConvert.SerializeObject(ExpectedAttributeRef, settings)); + Assert.Equal(ExpectedContextJson, JsonConvert.SerializeObject(ExpectedContext, settings)); + Assert.Equal(ExpectedEvaluationReasonJson, JsonConvert.SerializeObject(ExpectedEvaluationReason, settings)); + Assert.Equal(ExpectedUnixTimeJson, JsonConvert.SerializeObject(ExpectedUnixTime, settings)); + Assert.Equal(ExpectedUserJson, JsonConvert.SerializeObject(ExpectedUser, settings)); + Assert.Equal(ExpectedValueJson, JsonConvert.SerializeObject(ExpectedValue, settings)); + } + + [Fact] + public void DeserializeWithExplicitConverter() + { + Assert.Equal(ExpectedAttributeRef, JsonConvert.DeserializeObject(ExpectedAttributeRefJson, LdJsonNet.Converter)); + Assert.Equal(ExpectedContext, JsonConvert.DeserializeObject(ExpectedContextJson, LdJsonNet.Converter)); + Assert.Equal(ExpectedEvaluationReason, + JsonConvert.DeserializeObject(ExpectedEvaluationReasonJson, LdJsonNet.Converter)); + Assert.Equal(ExpectedUnixTime, JsonConvert.DeserializeObject(ExpectedUnixTimeJson, LdJsonNet.Converter)); + Assert.Equal(ExpectedUser, JsonConvert.DeserializeObject(ExpectedUserJson, LdJsonNet.Converter)); + Assert.Equal(ExpectedValue, JsonConvert.DeserializeObject(ExpectedValueJson, LdJsonNet.Converter)); + } + + [Fact] + public void DeserializeWithConverterInSettings() + { + var settings = new JsonSerializerSettings + { + Converters = new List { LdJsonNet.Converter } + }; + Assert.Equal(ExpectedAttributeRef, JsonConvert.DeserializeObject(ExpectedAttributeRefJson, settings)); + Assert.Equal(ExpectedContext, JsonConvert.DeserializeObject(ExpectedContextJson, settings)); + Assert.Equal(ExpectedEvaluationReason, + JsonConvert.DeserializeObject(ExpectedEvaluationReasonJson, settings)); + Assert.Equal(ExpectedUnixTime, JsonConvert.DeserializeObject(ExpectedUnixTimeJson, settings)); + Assert.Equal(ExpectedUser, JsonConvert.DeserializeObject(ExpectedUserJson, settings)); + Assert.Equal(ExpectedValue, JsonConvert.DeserializeObject(ExpectedValueJson, settings)); + } + + [Fact] + public void NullableValueTypeIsSerializedCorrectly() + { + Assert.Equal(@"{""attr"":" + ExpectedAttributeRefJson + "}", + JsonConvert.SerializeObject(new ObjectWithNullableAttributeRef { attr = ExpectedAttributeRef }, LdJsonNet.Converter)); + Assert.Equal(@"{""attr"":null}", + JsonConvert.SerializeObject(new ObjectWithNullableAttributeRef { attr = null }, LdJsonNet.Converter)); + Assert.Equal(@"{""context"":" + ExpectedContextJson + "}", + JsonConvert.SerializeObject(new ObjectWithNullableContext { context = ExpectedContext }, LdJsonNet.Converter)); + Assert.Equal(@"{""context"":null}", + JsonConvert.SerializeObject(new ObjectWithNullableContext { context = null }, LdJsonNet.Converter)); + Assert.Equal(@"{""reason"":" + ExpectedEvaluationReasonJson + "}", + JsonConvert.SerializeObject(new ObjectWithNullableReason { reason = ExpectedEvaluationReason }, LdJsonNet.Converter)); + Assert.Equal(@"{""reason"":null}", + JsonConvert.SerializeObject(new ObjectWithNullableReason { reason = null }, LdJsonNet.Converter)); + Assert.Equal(@"{""time"":" + ExpectedUnixTimeJson + "}", + JsonConvert.SerializeObject(new ObjectWithNullableTime { time = ExpectedUnixTime }, LdJsonNet.Converter)); + Assert.Equal(@"{""time"":null}", + JsonConvert.SerializeObject(new ObjectWithNullableTime { time = null }, LdJsonNet.Converter)); + Assert.Equal(@"{""value"":" + ExpectedValueJson + "}", + JsonConvert.SerializeObject(new ObjectWithNullableValue { value = ExpectedValue }, LdJsonNet.Converter)); + Assert.Equal(@"{""value"":null}", + JsonConvert.SerializeObject(new ObjectWithNullableValue { value = null }, LdJsonNet.Converter)); + } + + [Fact] + public void NullableValueTypeIsDeserializedCorrectly() + { + Assert.Equal(ExpectedAttributeRef, + JsonConvert.DeserializeObject( + @"{""attr"":" + ExpectedAttributeRefJson + "}", + LdJsonNet.Converter).attr); + Assert.Equal(ExpectedContext, + JsonConvert.DeserializeObject( + @"{""context"":" + ExpectedContextJson + "}", + LdJsonNet.Converter).context); + Assert.Equal(ExpectedEvaluationReason, + JsonConvert.DeserializeObject( + @"{""reason"":" + ExpectedEvaluationReasonJson + "}", + LdJsonNet.Converter).reason); + Assert.Null(JsonConvert.DeserializeObject( + @"{""reason"":null}", + LdJsonNet.Converter).reason); + Assert.Equal(ExpectedUnixTime, + JsonConvert.DeserializeObject( + @"{""time"":" + ExpectedUnixTimeJson + "}", + LdJsonNet.Converter).time); + Assert.Null(JsonConvert.DeserializeObject( + @"{""time"":null}", + LdJsonNet.Converter).time); + } + } +} diff --git a/pkgs/shared/common/CHANGELOG.md b/pkgs/shared/common/CHANGELOG.md new file mode 100644 index 00000000..5037bda1 --- /dev/null +++ b/pkgs/shared/common/CHANGELOG.md @@ -0,0 +1,394 @@ +# Change log + +All notable changes to `LaunchDarkly.CommonSdk` will be documented in this file. For full release notes for the projects that depend on this project, see their respective changelogs. This file describes changes only to the common code. This project adheres to [Semantic Versioning](http://semver.org). + +## [7.0.0] - 2023-10-17 +### Changed: +- IEnvironmentReporter now reports nullable values. + +## [6.2.0] - 2023-10-10 +### Added: +- Adds locale to auto environment attribute layer. + +## [6.1.0] - 2023-10-10 +### Added: +- Adds ApplicationInfo and EnvironmentReporter and respective builders. + +## [6.0.1] - 2023-04-04 +### Fixed: +- Fixed an issue with generating the `FullyQualifiedKey`. The key generation was not sorted by the kind, so the key was not stable depending on the order of the context construction. This also affected the generation of the secure mode hash for mulit-contexts. + +## [6.0.0] - 2022-12-01 +This major version release of `LaunchDarkly.CommonSdk` corresponds to the upcoming v7.0.0 release of the LaunchDarkly server-side .NET SDK (`LaunchDarkly.ServerSdk`) and the v3.0.0 release of the LaunchDarkly client-side .NET SDK (`LaunchDarkly.ClientSdk`), and cannot be used with earlier SDK versions. + +### Added: +- In `LaunchDarkly.Sdk`, the types `Context` and `ContextKind` define the new "context" model. "Contexts" are a replacement for the earlier concept of "users"; they can be populated with attributes in more or less the same way as before, but they also support new behaviors. More information about these features will be included in the release notes for the `LaunchDarkly.ServerSdk` 7.0.0 and `LaunchDarkly.ClientSdk` 3.0.0 releases. + +### Changed: +- .NET Core 2.1, .NET Framework 4.5.2, .NET Framework 4.6.1, and .NET 5.0 are now unsupported. The minimum platform versions are now .NET Core 3.1, .NET Framework 4.6.2, .NET 6.0, and .NET Standard 2.0. +- It was previously allowable to set a user key to an empty string. In the new context model, the key is not allowed to be empty. Trying to use an empty key will cause evaluations to fail and return the default value. +- There is no longer such a thing as a `Secondary` meta-attribute that affects percentage rollouts. If you set an attribute with that name in a `Context`, it will simply be a custom attribute like any other. +- The `Anonymous` attribute in `LDUser` is now a simple boolean, with no distinction between a false state and a null state. +- There is no longer a dependency on `LaunchDarkly.JsonStream`. This package existed because some platforms did not support the `System.Text.Json` API, but that is no longer the case and the SDK now uses `System.Text.Json` directly for all of its JSON operations. +- If you are using the package `LaunchDarkly.CommonSdk.JsonNet` for interoperability with the Json.NET library, you must update this to the latest major version. + +### Removed: +- Removed all types, fields, and methods that were deprecated as of the most recent release. +- Removed the `Secondary` meta-attribute in `User` and `UserBuilder`. + +## [5.5.0] - 2022-02-02 +### Added: +- `UnixMillisecondTime` now has a JSON converter like other `LaunchDarkly.Sdk` types. + +### Fixed: +- When using `LaunchDarkly.CommonSdk.JsonNet`, nullable value types such as `EvaluationReason?` were not being serialized correctly. + +## [5.4.1] - 2021-11-02 +### Fixed: +- Copying a user with `User.Builder(existingUser)` was incorrectly changing the default `null` value of `AnonymousOptional` to `false`. This normally has no significance since LaunchDarkly treats those two values the same, but it could have broken tests that expected a copied user to be equal. + +## [5.4.0] - 2021-10-22 +### Added: +- `LdValue.ObjectBuilder.Remove`. +- User builder `Custom` overloads for `long` and `double`. + +### Changed: +- Added more doc comment text about numeric precision issues with JSON numbers. +- Updated `LaunchDarkly.JsonStream` to 1.0.3. + +## [5.3.0] - 2021-10-14 +### Added: +- Convenience methods for working with JSON object and array values: `LdValue.Dictionary`, `LdValue.List`, `LdValue.ObjectBuilder.Set`, and `LdValue.ObjectBuilder.Copy`. + +## [5.2.1] - 2021-10-05 +### Changed: +- Changed dependency version for `System.Collections.Immutable` to 1.7.1, to match the version used by `LaunchDarkly.ServerSdk`. This has no effect on SDK functionality, but it reduces the chance that a binding redirect will be required to reconcile dependency versions in .NET Framework. + +## [5.2.0] - 2021-07-19 +### Added: +- In `EvaluationReason`, added optional status information related to the new big segments feature. + +## [5.1.0] - 2021-06-17 +### Added: +- The SDK now supports the ability to control the proportion of traffic allocation to an experiment. This works in conjunction with a new platform feature now available to early access customers. + +## [5.0.2] - 2021-06-07 +### Fixed: +- Updated the minimum dependency version for `LaunchDarkly.JsonStream` to exclude versions that have a known JSON parsing bug. + +## [5.0.1] - 2021-02-02 +### Fixed: +- Updated dependencies in `LaunchDarkly.CommonSdk.JsonNet` to the correct versions. + +## [5.0.0] - 2021-02-02 +### Added: +- `LaunchDarkly.Sdk.Json` namespace with JSON serialization helpers. Also, there is now a separate package defined in this repo, `LaunchDarkly.CommonSdk.JsonNet`, for interoperability with `Newtonsoft.Json`. +- `UnixMillisecondTime` type, a convenient wrapper for the date/time format that is used by LaunchDarkly services. Applications normally won't need to use this unless they are interacting directly with the analytics event system. +- `LdValue` now has `==` and `!=` operators. +- Releases now publish [Source Link](https://github.com/dotnet/sourcelink/blob/master/README.md) data. + +### Changed: +- The base namespace is now `LaunchDarkly.Sdk` rather than `LaunchDarkly.Client`. +- `EvaluationReason` is now a struct. +- `EvaluationReasonKind` and `EvaluationErrorKind` enum names now use regular .NET-style capitalization (`RuleMatch`) instead of Java-style capitalization (`RULE_MATCH`). +- JSON-serializable types (`User`, etc.) now automatically encode and decode correctly with `System.Text.Json`. + +### Removed: +- `EvaluationReason` subclasses. +- There is no longer a package dependency on `Newtonsoft.Json`. +- Non-public helpers used by SDKs have been removed, and are now in `LaunchDarkly.InternalSdk` instead. + +## [4.3.1] - 2020-01-15 +### Fixed: +- A bug in the SDK prevented the sending of events from being retried after a failure. The SDK now retries once after an event flush fails as was intended. +- The SDK now specifies a uniquely identifiable request header when sending events to LaunchDarkly to ensure that events are only processed once, even if the SDK sends them two times due to a failed initial attempt. + +## [4.3.0] - 2020-01-13 +### Added: +- `EvaluationReason` static methods and properties for creating reason instances. +- `LdValue` helpers for dealing with array/object values, without having to use an intermediate `List` or `Dictionary`: `BuildArray`, `BuildObject`, `Count`, `Get`. +- `LdValue.Parse()`. +- `IUserBuilder.Secondary` is a new name for `SecondaryKey` (for consistency with other SDKs), and allows you to make the `secondary` attribute private. +- `User.Secondary` (same as `SecondaryKey`). + +### Changed: +- `EvaluationReason` properties all exist on the base class now, so for instance you do not need to cast to `RuleMatch` to get the `RuleId` property. This is in preparation for a future API change in which `EvaluationReason` will become a struct instead of a base class. + +### Fixed: +- Improved memory usage and performance when processing analytics events: the SDK now encodes event data to JSON directly, instead of creating intermediate objects and serializing them via reflection. +- When parsing arbitrary JSON values, the SDK now always stores them internally as `LdValue` rather than `JToken`. This means that no additional copying step is required when the application accesses that value, if it is of a complex type. +- `LdValue.Equals()` incorrectly returned true for object (dictionary) values that were not equal. + +### Deprecated: +- `EvaluationReason` subclasses. Use only the base class properties and methods to ensure compatibility with future versions. +- `IUserBuilder.SecondaryKey`, `User.SecondaryKey`. + + +## [4.2.1] - 2019-10-23 +### Fixed: +- The JSON serialization of `User` was producing an extra `Anonymous` property in addition to `anonymous`. If Newtonsoft.Json was configured globally to force all properties to lowercase, this would cause an exception when serializing a user since the two properties would end up with the same name. + +## [4.2.0] - 2019-10-10 +### Added: +- Added `LaunchDarkly.Logging.ConsoleAdapter` as a convenience for quickly enabling console logging; this is equivalent to `Common.Logging.Simple.ConsoleOutLoggerFactoryAdapter`, but the latter is not available on some platforms. + +## [4.1.0] - 2019-10-07 +### Added: +- `IUserBuilder.AnonymousOptional` and `User.AnonymousOption` allow treating the `Anonymous` property as nullable (necessary for consistency with other SDKs). See note about this under Fixed. + +### Fixed: +- `IUserBuilder` was incorrectly setting the user's `Anonymous` property to `null` even if it had been explicitly set to `false`. Null and false behave the same in terms of LaunchDarkly's user indexing behavior, but currently it is possible to create a feature flag rule that treats them differently. So `IUserBuilder.Anonymous(false)` now correctly sets it to `false`. +- `LdValue.Convert.Long` was mistakenly converting to an `int` rather than a `long`. ([#32](https://github.com/launchdarkly/dotnet-sdk-common/issues/32)) + +## [4.0.1] - 2019-09-13 +_The 4.0.0 release was broken._ + +### Added: +- `LdValue` now has methods for converting to and from complex types (list, dictionary). + +### Changed: +- `ImmutableJsonValue` is now called `LdValue`. +- All public APIs now use `ImmutableJsonValue` instead of `JToken`. + +### Removed: +- Public `ImmutableJsonValue` methods and properties that refer to `JToken`, `JObject`, or `JArray`. + +## [3.1.0] - 2019-08-30 +### Added: +- `SetOffline` method in `IEventProcessor`/`DefaultEventProcessor`. +- XML documentation comments are now included in the package for all target frameworks. Previously they were only included for .NET Standard 1.4. + +## [3.0.0] - 2019-08-09 +### Added: +- `User.Builder` provides a fluent builder pattern for constructing `User` objects. This is now the only method for building a user if you want to set any properties other than the `Key`. +- The `ImmutableJsonValue` type provides a wrapper for the Newtonsoft.Json types that prevents accidentally modifying JSON object properties or array values that are shared by other objects. +- Helper type `ValueType`/`ValueTypes` for use by the SDK `Variation` methods. +- Internal interfaces for configuring specific components, like `IEventProcessorConfiguration`. These replace `IBaseConfiguration`. + +### Changed: +- `User` objects are now immutable. +- In `User`, `IpAddress` has been renamed to `IPAddress` (standard .NET capitalization for two-letter acronyms). +- Custom attributes in `User.Custom` now use the type `ImmutableJsonValue` instead of `JToken`. +- Uses of mutable `IDictionary` and `ISet` in the configuration and user objects have been changed to immutable types. + +### Removed: +- `UserExtensions` (use `User.Builder`). +- `User` constructors (use `User.WithKey` or `User.Builder`). +- `User` property setters. +- `IBaseConfiguration` and `ICommonLdClient` interfaces. + +### Fixed: +- No longer assumes that we are overriding the `HttpMessageHandler` (if it is null in the configuration, just use the default `HttpClient` constructor). This is important for Xamarin. + +## [2.11.1] - 2020-11-05 +### Changed: +- Updated the `LaunchDarkly.EventSource` dependency to a version that has a specific target for .NET Standard 2.0. Previously, that package targeted only .NET Standard 1.4 and .NET Framework 4.5. There is no functional difference between these targets, but .NET Core application developers may wish to avoid linking to any .NET Standard 1.x assemblies on general principle. + +## [2.11.0] - 2020-01-31 +### Added: +- `DefaultEventProcessor` now supports sending diagnostic data to LaunchDarkly regarding the OS version, performance statistics, etc. The exact implementation of this is determined by the platform-specific SDKs (.NET or Xamarin). +- The SDK now specifies a uniquely identifiable request header when sending events to LaunchDarkly to ensure that events are only processed once, even if the SDK sends them two times due to a failed initial attempt. + +## [2.10.1] - 2020-01-15 +### Fixed: +- A bug in the SDK prevented the sending of events from being retried after a failure. The SDK now retries once after an event flush fails as was intended. +- The SDK now specifies a uniquely identifiable request header when sending events to LaunchDarkly to ensure that events are only processed once, even if the SDK sends them two times due to a failed initial attempt. + +## [2.10.0] - 2020-01-03 +### Added: +- `IUserBuilder.Secondary` is a new name for `SecondaryKey` (for consistency with other SDKs), and allows you to make the `secondary` attribute private. +- `User.Secondary` (same as `SecondaryKey`). + +### Deprecated: +- `IUserBuilder.SecondaryKey`, `User.SecondaryKey`. + + +## [2.9.2] - 2019-11-12 +### Fixed: +- `LdValue.Equals()` incorrectly returned true for object (dictionary) values that were not equal. +- Summary events incorrectly had `unknown:true` for all evaluation errors, rather than just for "flag not found" errors (bug introduced in 2.9.0, not used in any current SDK). + +## [2.9.1] - 2019-11-08 +### Fixed: +- Fixed an exception when serializing user custom attributes in events (bug in 2.9.0). + +## [2.9.0] - 2019-11-08 +### Added: +- `EvaluationReason` static methods and properties for creating reason instances. +- `LdValue` helpers for dealing with array/object values, without having to use an intermediate `List` or `Dictionary`: `BuildArray`, `BuildObject`, `Count`, `Get`. +- `LdValue.Parse()`. It is also possible to use `Newtonsoft.Json.JsonConvert` to parse or serialize `LdValue`, but since the implementation may change in the future, using the type's own methods is preferable. + +### Changed: +- `EvaluationReason` properties all exist on the base class now, so for instance you do not need to cast to `RuleMatch` to get the `RuleId` property. This is in preparation for a future API change in which `EvaluationReason` will become a struct instead of a base class. + +### Fixed: +- Improved memory usage and performance when processing analytics events: the SDK now encodes event data to JSON directly, instead of creating intermediate objects and serializing them via reflection. + +### Deprecated: +- `EvaluationReason` subclasses. Use only the base class properties and methods to ensure compatibility with future versions. + +## [2.8.0] - 2019-10-10 +### Added: +- Added `LaunchDarkly.Logging.ConsoleAdapter` as a convenience for quickly enabling console logging; this is equivalent to `Common.Logging.Simple.ConsoleOutLoggerFactoryAdapter`, but the latter is not available on some platforms. + +## [2.7.0] - 2019-10-03 +### Added: +- `IUserBuilder.AnonymousOptional` allows setting the `Anonymous` property to `null` (necessary for consistency with other SDKs). See note about this under Fixed. + +### Fixed: +- `IUserBuilder` was incorrectly setting the user's `Anonymous` property to `null` even if it had been explicitly set to `false`. Null and false behave the same in terms of LaunchDarkly's user indexing behavior, but currently it is possible to create a feature flag rule that treats them differently. So `IUserBuilder.Anonymous(false)` now correctly sets it to `false`, just as the deprecated method `UserExtensions.WithAnonymous(false)` would. +- `LdValue.Convert.Long` was mistakenly converting to an `int` rather than a `long`. ([#32](https://github.com/launchdarkly/dotnet-sdk-common/issues/32)) + +## [2.6.1] - 2019-09-12 +### Fixed: +- A packaging error made the `LaunchDarkly.CommonSdk.StrongName` package unusable in 2.6.0. + +## [2.6.0] - 2019-09-12 +### Added: +- Value type `LdValue`, to be used in place of `JToken` whenever possible. + +### Changed: +- All event-related code except for public properties now uses `LdValue`. + +### Removed: +- Internal helper type `ValueType`, unnecessary now because we can use `LdValue.Convert`. + +## [2.5.1] - 2019-08-30 +### Fixed: +- Many improvements to XML documentation comments. + +## [2.5.0] - 2019-08-30 +### Added: +- Internal helper types `ValueType` and `ValueTypes`. +- XML documentation comments are now included in the package for all target frameworks. Previously they were only included for .NET Standard 1.4. + +### Changed: +- Internal types are now sealed. +- Changed some internal classes to structs for efficiency. + +### Deprecated: +- `IBaseConfiguration` and `ICommonLdClient` interfaces. + +## [2.4.0] - 2019-07-31 +### Added: +- `IBaseConfiguration.EventCapacity` and `IBaseConfiguration.EventFlushInterval`. +- `UserBuilder.Key` setter. + +### Deprecated: +- `IBaseConfiguration.SamplingInterval`. +- `IBaseConfiguration.EventQueueCapacity` (now a synonym for `EventCapacity`). +- `IBaseConfiguration.EventQueueFrequency` (now a synonym for `EventFlushInterval`). + +## [2.3.0] - 2019-07-23 +### Deprecated: +- `User` constructors. +- `User.Custom` and `User.PrivateAttributeNames` will be changed to immutable collections in the future. + +## [2.2.0] - 2019-07-23 +### Added: +- `User.Builder` provides a fluent builder pattern for constructing `User` objects. This is now the preferred method for building a user, rather than setting `User` properties directly or using `UserExtension` methods like `AndName()` that modify the existing user object. +- `User.IPAddress` is equivalent to `User.IpAddress`, but has the standard .NET capitalization for two-letter acronyms. + +### Deprecated: +- `User.IpAddress` (use `IPAddress`). +- All `UserExtension` methods are now deprecated. The setters for all `User` properties should also be considered deprecated, although C# does not allow these to be marked with `[Obsolete]`. + +## [2.1.2] - 2019-05-10 +### Fixed: +- Fixed a build error that caused classes to be omitted from `LaunchDarkly.CommonSdk.StrongName`. + +## [2.1.1] - 2019-05-10 +### Changed: +- The package and assembly name are now `LaunchDarkly.CommonSdk`, and the `InternalsVisibleTo` directives now refer to `LaunchDarkly.ServerSdk` and `LaunchDarkly.XamarinSdk`. There are no other changes. All future releases of the LaunchDarkly server-side .NET SDK and client-side Xamarin SDK will use the new package names, and no further updates of the old `LaunchDarkly.Common` package will be published. + +## [2.1.0] - 2019-04-16 +### Added: +- Added support for planned future LaunchDarkly features related to analytics events and experimentation (metric values). + +## [2.0.0] - 2019-03-26 +### Added: +- Added support for planned future LaunchDarkly features related to analytics events and experimentation. +- It is now possible to deserialize evaluation reasons from JSON (this is used by the Xamarin client). + +### Changed: +- The `IFlagEventProperties` interface was extended and modified to support the aforementioned features. + +### Fixed: +- Under some circumstances, a `CancellationTokenSource` might not be disposed of after making an HTTP request, which could cause a timer object to be leaked. + +## [1.2.3] - 2018-01-14 +### Fixed: +- The assemblies in this package now have Authenticode signatures. + +## [1.2.2] - 2018-01-09 + +This release was an error. It works, but there are no changes from 1.2.1 except for using a newer version of `dotnet-eventsource`, which was also an unintended re-release of the previous version. + +## [1.2.1] - 2018-12-17 + +### Changed +The only changes in this version are to the build: + +- What is published to NuGet is now the Release configuration, without debug information. +- The Debug configuration (the default) no longer performs strong-name signing. This makes local development easier. +- `LaunchDarkly.Common` now has an `InternalsVisibleTo` directive for an _unsigned_ version of the `LaunchDarkly.Client` unit tests. Again this is to support local development, since the client will be unsigned by default as well. + +## [1.2.0] - 2018-10-24 + +### Changed +- The non-strong-named version of this library (`LaunchDarkly.Common`) can now be used with a non-strong-named version of `LaunchDarkly.Client`, which does not normally exist but could be built as part of a fork of the SDK. + +- Previously, the delay before stream reconnect attempts would increase exponentially only if the previous connection could not be made at all or returned an HTTP error; if it received an HTTP 200 status, the delay would be reset to the minimum even if the connection then immediately failed. Now, if the stream connection fails after it has been up for less than a minute, the reconnect delay will continue to increase. (changed in `LaunchDarkly.EventSource` 3.2.0) + +### Fixed + +- Fixed an [unobserved exception](https://blogs.msdn.microsoft.com/pfxteam/2011/09/28/task-exception-handling-in-net-4-5/) that could occur following a stream timeout, which could cause a crash in .NET 4.0. (fixed in `LaunchDarkly.EventSource` 3.2.0) + +- A `NullReferenceException` could sometimes be logged if a stream connection failed. (fixed in `LaunchDarkly.EventSource` 3.2.0) + +## [1.1.1] - 2018-08-29 + +Incorporates the fix from 1.0.6 that was not included in 1.1.0. + +## [1.1.0] - 2018-08-22 + +### Added +- New `EvaluationDetail` and `EvaluationReason` classes will be used in future SDK versions that support capturing evaluation reasons. + +## [1.0.6] - 2018-08-30 + +### Fixed +- Updated LaunchDarkly.EventSource to fix a bug that prevented the client from reconnecting to the stream if it received an HTTP error status from the server (as opposed to simply losing the connection). + +## [1.0.5] - 2018-08-14 + +### Fixed +- The reconnection attempt counter is no longer shared among all StreamManager instances. Previously, if you connected to more than one stream, all but the first would behave as if they were reconnecting and would have a backoff delay. + +## [1.0.4] - 2018-08-02 + +### Changed +- Updated the dependency on `LaunchDarkly.EventSource`, which no longer has package references to System assemblies. + +## [1.0.3] - 2018-07-27 + +### Changed +- The package `LaunchDarkly.Common` is no longer strong-named. Instead, we are now building two packages: `LaunchDarkly.Common` and `LaunchDarkly.Common.StrongName`. This is because the Xamarin project requires an unsigned version of the package, whereas the main .NET SDK uses the signed one. +- The project now uses a framework reference (`Reference`) instead of a package reference (`PackageReference`) to refer to `System.Net.Http`. An unnecessary reference to `System.Runtime` was removed. +- The stream processor now propagates an exception out of its initialization `Task` if it encounters an unrecoverable error. + +## [1.0.2] - 2018-07-24 + +''This release is broken and should not be used.'' + +## [1.0.1] - 2018-07-02 + +### Changed +- When targeting .NET 4.5, the dependency on `Newtonsoft.Json` now has a minimum version of 6.0.1 rather than 9.0.1. This should not affect any applications that specify a higher version for this assembly. + +### Removed +- The `Identify` method is no longer part of `ILdCommonClient`, since it does not have the same signature in the Xamarin client as in the server-side .NET SDK. + +## [1.0.0] - 2018-06-26 + +Initial release, corresponding to .net-client version 5.1.0. diff --git a/pkgs/shared/common/CONTRIBUTING.md b/pkgs/shared/common/CONTRIBUTING.md new file mode 100644 index 00000000..05830545 --- /dev/null +++ b/pkgs/shared/common/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to the LaunchDarkly SDK .NET Common Code + +LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/sdk/concepts/contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this SDK. + +## Submitting bug reports and feature requests + +In general, issues should be filed in the issue trackers for the [.NET server-side SDK](https://github.com/launchdarkly/dotnet-server-sdk/issues) or the [.NET client-side SDK](https://github.com/launchdarkly/dotnet-client-sdk/issues) rather than in this repository, unless you have a specific implementation issue regarding the code in this repository. + +## Submitting pull requests + +We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. + +## Build instructions + +### Prerequisites + +To set up your SDK build time environment, you must [download .NET Core and follow the instructions](https://dotnet.microsoft.com/download) (make sure you have 2.0 or higher). + +### Building + +To install all required packages: + +``` +dotnet restore +``` + +Then, to build the SDK without running any tests: + +``` +dotnet build src/LaunchDarkly.CommonSdk.csproj -f netstandard2.0 +``` + +### Testing + +To run all unit tests: + +``` +dotnet test test/LaunchDarkly.CommonSdk.Tests.csproj +``` diff --git a/pkgs/shared/common/LaunchDarkly.CommonSdk.pk b/pkgs/shared/common/LaunchDarkly.CommonSdk.pk new file mode 100644 index 00000000..56c654d4 Binary files /dev/null and b/pkgs/shared/common/LaunchDarkly.CommonSdk.pk differ diff --git a/pkgs/shared/common/LaunchDarkly.CommonSdk.sln b/pkgs/shared/common/LaunchDarkly.CommonSdk.sln new file mode 100644 index 00000000..57ec0d8b --- /dev/null +++ b/pkgs/shared/common/LaunchDarkly.CommonSdk.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26730.16 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.CommonSdk", "src\LaunchDarkly.CommonSdk.csproj", "{97897D61-FEDF-4ABD-AD53-F4A43D788225}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LaunchDarkly.CommonSdk.Tests", "test\LaunchDarkly.CommonSdk.Tests.csproj", "{9F8D6033-F6C3-462B-B728-550C5172206D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {97897D61-FEDF-4ABD-AD53-F4A43D788225}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97897D61-FEDF-4ABD-AD53-F4A43D788225}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97897D61-FEDF-4ABD-AD53-F4A43D788225}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97897D61-FEDF-4ABD-AD53-F4A43D788225}.Release|Any CPU.Build.0 = Release|Any CPU + {9F8D6033-F6C3-462B-B728-550C5172206D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F8D6033-F6C3-462B-B728-550C5172206D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F8D6033-F6C3-462B-B728-550C5172206D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F8D6033-F6C3-462B-B728-550C5172206D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D7F58FE1-D8E9-46EB-AE7F-4B5A388418AC} + EndGlobalSection +EndGlobal diff --git a/pkgs/shared/common/README.md b/pkgs/shared/common/README.md new file mode 100644 index 00000000..c9b31ce6 --- /dev/null +++ b/pkgs/shared/common/README.md @@ -0,0 +1,39 @@ +# LaunchDarkly SDK .NET Common Code + +[![NuGet](https://img.shields.io/nuget/v/LaunchDarkly.CommonSdk.svg?style=flat-square)](https://www.nuget.org/packages/LaunchDarkly.CommonSdk/) +[![CircleCI](https://circleci.com/gh/launchdarkly/dotnet-sdk-common.svg?style=shield)](https://circleci.com/gh/launchdarkly/dotnet-sdk-common) + +This project contains .NET classes and interfaces that are shared between the LaunchDarkly .NET server-side and client-side SDKs in this repository. + +## Contributing + +See [Contributing](./CONTRIBUTING.md). + +## Signing + +The published version of this assembly is digitally signed with Authenticode and [strong-named](https://docs.microsoft.com/en-us/dotnet/framework/app-domains/strong-named-assemblies). Building the code locally in the default Debug configuration does not use strong-naming and does not require a key file. The public key file is in this repository at `LaunchDarkly.CommonSdk.pk` as well as here: + +``` +Public Key: +0024000004800000940000000602000000240000525341310004000001000100 +250509411af6d31f2abfc9b33d02b01c6ad14fd5c7f83cc6135f499ebb0ec8f3 +4e05c59e49232f5a7d75d5761281610219d323043936d55c19bb26f1dd86bdc7 +6ab178015e78b54aef9cbdc824db2afcf7250292ae3d8d9c4522bcc3a4fc4831 +d4b4320e820f32e024ad50a786f86d37ea45e0c25ec431a7a0f3e93575a0d2ad + +Public Key Token: 45ef1738a929a7df +``` + +## About LaunchDarkly + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates diff --git a/pkgs/shared/common/github_actions.env b/pkgs/shared/common/github_actions.env new file mode 100644 index 00000000..2dd2b711 --- /dev/null +++ b/pkgs/shared/common/github_actions.env @@ -0,0 +1,6 @@ +WORKSPACE_PATH=pkgs/shared/common +PROJECT_FILE=pkgs/shared/common/src/LaunchDarkly.CommonSdk.csproj +BUILD_OUTPUT_PATH=pkgs/shared/common/src/bin/Release/ +BUILD_OUTPUT_DLL_NAME=LaunchDarkly.CommonSdk.dll +TEST_PROJECT_FILE=pkgs/shared/common/test/LaunchDarkly.CommonSdk.Tests.csproj +ASSEMBLY_KEY_PATH_PAIR='launchdarkly-releaser/dotnet/LaunchDarkly.Common.snk = LaunchDarkly.CommonSdk.snk' \ No newline at end of file diff --git a/pkgs/shared/common/public.pk b/pkgs/shared/common/public.pk new file mode 100644 index 00000000..fea9a27e Binary files /dev/null and b/pkgs/shared/common/public.pk differ diff --git a/pkgs/shared/common/src/ApplicationInfo.cs b/pkgs/shared/common/src/ApplicationInfo.cs new file mode 100644 index 00000000..cf950b54 --- /dev/null +++ b/pkgs/shared/common/src/ApplicationInfo.cs @@ -0,0 +1,43 @@ +namespace LaunchDarkly.Sdk +{ + /// + /// An object that encapsulates application metadata. + /// + public readonly struct ApplicationInfo + { + /// + /// A unique identifier representing the application where the LaunchDarkly SDK is running. + /// + public string ApplicationId { get; } + + /// + /// A human friendly name for the application in which the LaunchDarkly SDK is running. + /// + public string ApplicationName { get; } + + /// + /// A value representing the version of the application where the LaunchDarkly SDK is running. + /// + public string ApplicationVersion { get; } + + /// + /// A human friendly name for the version of the application in which the LaunchDarkly SDK is running. + /// + public string ApplicationVersionName { get; } + + /// + /// Constructs a new ApplicationInfo instance. + /// + /// id of the application + /// name of the application + /// version of the application + /// friendly name for the version + public ApplicationInfo(string id, string name, string version, string versionName) + { + ApplicationId = id; + ApplicationName = name; + ApplicationVersion = version; + ApplicationVersionName = versionName; + } + } +} diff --git a/pkgs/shared/common/src/ApplicationInfoBuilder.cs b/pkgs/shared/common/src/ApplicationInfoBuilder.cs new file mode 100644 index 00000000..5eeb8983 --- /dev/null +++ b/pkgs/shared/common/src/ApplicationInfoBuilder.cs @@ -0,0 +1,117 @@ +using System; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Helpers; + +namespace LaunchDarkly.Sdk +{ + + /// + /// Contains methods for configuring the application metadata. Application metadata may be used in LaunchDarkly + /// analytics or other product features. + /// + public sealed class ApplicationInfoBuilder + { + private string _applicationId; + + private string _applicationName; + + private string _applicationVersion; + + private string _applicationVersionName; + + private readonly Logger _logger = Logs.Default.Logger(nameof(ApplicationInfoBuilder)); + + /// a new from the current build properties. + public ApplicationInfo Build() + { + return new ApplicationInfo(_applicationId, _applicationName, _applicationVersion, _applicationVersionName); + } + + /// + /// Sets a unique identifier representing the application where the LaunchDarkly SDK is running. + /// This can be specified as any string value as long as it only uses the following characters: ASCII + /// letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + /// ignored. + /// + /// the application identifier + /// the builder + public ApplicationInfoBuilder ApplicationId(string applicationId) + { + ValidatedThenSet("ApplicationId", s => _applicationId = s, applicationId, _logger); + return this; + } + + /// + /// Sets a human friendly name for the application in which the LaunchDarkly SDK is running. + /// + /// This can be specified as any string value as long as it only uses the following characters: ASCII + /// letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + /// ignored. + /// + /// the human friendly name + /// the builder + public ApplicationInfoBuilder ApplicationName(string applicationName) + { + ValidatedThenSet("ApplicationName", s => _applicationName = s, applicationName, _logger); + return this; + } + + /// + /// Sets a unique identifier representing the version of the application where the LaunchDarkly SDK + /// is running. + /// + /// This can be specified as any string value as long as it only uses the following characters: ASCII + /// letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + /// ignored. + /// + /// the application version + /// the builder + public ApplicationInfoBuilder ApplicationVersion(string applicationVersion) + { + ValidatedThenSet("ApplicationVersion", s => _applicationVersion = s, applicationVersion, _logger); + return this; + } + + /// + /// Sets a human friendly name for the version of the application in which the LaunchDarkly SDK is running. + /// + /// This can be specified as any string value as long as it only uses the following characters: ASCII + /// letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + /// ignored. + /// + /// the human friendly version name + /// the builder + public ApplicationInfoBuilder ApplicationVersionName(string applicationVersionName) + { + ValidatedThenSet("ApplicationVersionName", s => _applicationVersionName = s, applicationVersionName, _logger); + return this; + } + + /// + /// Validates the input and then invokes the property setter + /// + /// name of the property trying to be set + /// to be invoked when validation succeeds + /// the input to validate and then use if valid + /// logger for logging validation errors + private static void ValidatedThenSet(String propertyName, Action propertySetter, string input, Logger logger) + { + if (input == null) + { + propertySetter(null); + return; + } + + var sanitized = ValidationUtils.SanitizeSpaces(input); + var error = ValidationUtils.ValidateStringValue(sanitized); + if (error != null) + { + // intentionally ignore invalid values + logger.Warn("Issue setting {0} to value '{1}'. {2}", propertyName, sanitized, error); + return; + } + + propertySetter(sanitized); + } + } +} diff --git a/pkgs/shared/common/src/AssemblyInfo.cs b/pkgs/shared/common/src/AssemblyInfo.cs new file mode 100644 index 00000000..9133e4bd --- /dev/null +++ b/pkgs/shared/common/src/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Runtime.CompilerServices; + +#if DEBUG +// Allow unit tests to see internal classes (note, the test assembly is not signed; +// tests must be run against the Debug configuration of this assembly) +[assembly: InternalsVisibleTo("LaunchDarkly.CommonSdk.Tests")] +#endif diff --git a/pkgs/shared/common/src/AttributeRef.cs b/pkgs/shared/common/src/AttributeRef.cs new file mode 100644 index 00000000..da67ad51 --- /dev/null +++ b/pkgs/shared/common/src/AttributeRef.cs @@ -0,0 +1,375 @@ +using System; +using System.Text; +using System.Text.Json.Serialization; +using LaunchDarkly.Sdk.Json; + +namespace LaunchDarkly.Sdk +{ + /// + /// An attribute name or path expression identifying a value within a . + /// + /// + /// + /// This type is mainly intended to be used internally by LaunchDarkly SDK and service code, where + /// efficiency is a major concern so it's desirable to do any parsing or preprocessing just once. + /// Applications are unlikely to need to use the AttributeRef type directly. + /// + /// + /// It can be used to retrieve a value with , or to + /// identify an attribute or nested value that should be considered private with Builder.Private() + /// (the SDK configuration can also have a list of private attribute references). + /// + /// + /// Parsing and validation are done at the time that or + /// is called. If an AttributeRef instance was created from an + /// invalid string, or if it is an uninitialized struct (new AttributeRef()), it is + /// considered invalid and its property will return a non-null error. + /// + /// + /// The string representation of an attribute reference in LaunchDarkly JSON data uses the following + /// syntax: + /// + /// + /// + /// If the first character is not a slash, the string is interpreted literally as an attribute name. + /// An attribute name can contain any characters, but must not be empty. + /// + /// + /// If the first character is a slash, the string is interpreted as a slash-delimited path where the + /// first path component is an attribute name, and each subsequent path component is the name of a + /// property in a JSON object. Any instances of the characters "/" or "~" in a path component are + /// escaped as "~1" or "~0" respectively. This syntax deliberately resembles JSON Pointer, but no + /// JSON Pointer behaviors other than those mentioned here are supported. + /// + /// + /// + /// For example, suppose there is a context whose JSON representation looks like this: + /// + /// + /// { + /// "kind": "user", + /// "key": "value1", + /// "address": { + /// "street": { + /// "line1": "value2", + /// "line2": "value3" + /// }, + /// "city": "value4" + /// }, + /// "good/bad": "value5" + /// } + /// + /// + /// + /// The attribute references "key" and "/key" would both point to "value1". + /// + /// + /// The attribute reference "/address/street/line1" would point to "value2". + /// + /// + /// The attribute references "good/bad" and "/good~1bad" would both point to "value5". + /// + /// + /// + [JsonConverter(typeof(LdJsonConverters.AttributeRefConverter))] + public readonly struct AttributeRef : IEquatable, IJsonSerializable + { + + private readonly string _error; + private readonly string _rawPath; + private readonly string _singlePathComponent; + private readonly string[] _components; + + /// + /// True if the AttributeRef has a value, meaning that it is not an uninitialized struct + /// (new AttributeRef()). That does not guarantee that the value is valid; use + /// or to test that. + /// + /// + /// + public bool Defined => !(_rawPath is null) || !(_error is null); + + /// + /// True for a valid AttributeRef, false for an invalid AttributeRef. + /// + /// + /// + /// An AttributeRef can only be invalid for the following reasons: + /// + /// + /// The input string was empty, or consisted only of "/". + /// A slash-delimited string had a double slash causing one component + /// to be empty, such as "/a//b". + /// A slash-delimited string contained a "~" character that was not followed + /// by "0" or "1". + /// + /// + /// Otherwise, the AttributeRef is valid, but that does not guarantee that such an attribute exists + /// in any given . For instance, AttributeRef.FromLiteral("name") is a + /// valid Ref, but a specific Context might or might not have a name. + /// + /// + /// See comments on the type for more details of the attribute reference + /// syntax. + /// + /// + /// + /// + public bool Valid => Error is null; + + /// + /// Null for a valid AttributeRef, or a non-null error message for an invalid AttributeRef. + /// + /// + /// If this is null, then is true. If it is non-null, then is false. + /// + /// + /// + public string Error + { + get + { + if (_error is null && _rawPath is null) + { + return Errors.AttrEmpty; + } + return _error; + } + } + + /// + /// The number of path components in the AttributeRef. + /// + /// + /// + /// For a simple attribute reference such as "name" with no leading slash, this returns 1. + /// + /// + /// For an attribute reference with a leading slash, it is the number of slash-delimited path + /// components after the initial slash. For instance, AttributeRef.FromPath("/a/b").Depth + /// returns 2. + /// + /// + /// For an invalid attribute reference, it returns zero. + /// + /// + /// + public int Depth + { + get + { + if (!(_error is null) || (_singlePathComponent is null && _components is null)) + { + return 0; + } + if (_components is null) + { + return 1; + } + return _components.Length; + } + } + + /// + /// Creates an AttributeRef from a string. For the supported syntax and examples, see comments on the + /// type. + /// + /// + /// This method always returns an AttributeRef that preserves the original string, even if validation + /// fails, so that calling (or serializing the AttributeRef to JSON) will + /// produce the original string. If validation fails, will return a non-null + /// error and any SDK method that takes this AttributeRef as a parameter will consider it invalid. + /// + /// an attribute name or path + /// an AttributeRef + /// + public static AttributeRef FromPath(string refPath) + { + if (refPath is null || refPath == "" || refPath == "/") + { + return new AttributeRef(Errors.AttrEmpty, refPath); + } + if (refPath[0] != '/') + { + // When there is no leading slash, this is a simple attribute reference with no character escaping. + return new AttributeRef(refPath, refPath, null); + } + if (refPath.IndexOf('/', 1) < 0) + { + // There's only one segment, so this is still a simple attribute reference. However, we still may + // need to unescape special characters. + var unescaped = UnescapePath(refPath.Substring(1)); + if (unescaped is null) + { + return new AttributeRef(Errors.AttrInvalidEscape, refPath); + } + return new AttributeRef(refPath, unescaped, null); + } + var parsed = refPath.Substring(1).Split('/'); + for (var i = 0; i < parsed.Length; i++) + { + var p = parsed[i]; + if (p == "") + { + return new AttributeRef(Errors.AttrExtraSlash, refPath); + } + var unescaped = UnescapePath(p); + if (unescaped is null) + { + return new AttributeRef(Errors.AttrInvalidEscape, refPath); + } + parsed[i] = unescaped; + } + return new AttributeRef(refPath, null, parsed); + } + + /// + /// Similar to , except that it always interprets the string as a literal + /// attribute name, never as a slash-delimited path expression. There is no escaping or unescaping, + /// even if the name contains literal '/' or '~' characters. Since an attribute name can contain + /// any characters, this method always returns a valid AttributeRef unless the name is empty. + /// + /// + /// For example: AttributeRef.FromLiteral("name") is exactly equivalent to + /// AttributeRef.FromPath("name"). AttributeRef.FromLiteral("a/b") is exactly equivalent + /// to AttributeRef.FromPath("a/b") (since the syntax used by + /// treats the whole string as a literal as long as it does not start with a slash), or to + /// AttributeRef.FromPath("/a~1b"). + /// + /// an attribute name + /// an AttributeRef + /// + public static AttributeRef FromLiteral(string attributeName) + { + if (attributeName is null || attributeName == "") + { + return new AttributeRef(Errors.AttrEmpty, attributeName); + } + if (attributeName[0] != '/') + { + // When there is no leading slash, this is a simple attribute reference with no character escaping. + return new AttributeRef(attributeName, attributeName, null); + } + // If there is a leading slash, then the attribute name actually starts with a slash. To represent it + // as an AttributeRef, it'll need to be escaped. + var escapedPath = "/" + attributeName.Replace("~", "~0").Replace("/", "~1"); + return new AttributeRef(escapedPath, attributeName, null); + } + + private AttributeRef(string error, string rawPath) + { + _error = error; + _rawPath = rawPath; + _singlePathComponent = null; + _components = null; + } + + private AttributeRef(string rawPath, string singlePathComponent, string[] components) + { + _error = null; + _rawPath = rawPath; + _singlePathComponent = singlePathComponent; + _components = components; + } + + /// + /// Retrieves a single path component from the attribute reference. + /// + /// + /// + /// For a simple attribute reference such as "name" with no leading slash, if index is zero, + /// TryGetComponent returns the attribute name. + /// + /// + /// For an attribute reference with a leading slash, if index is non-negative and less than + /// , TryGetComponent returns the path component. + /// + /// + /// It returns null if the index is out of range. + /// + /// + /// the zero-based index of the desired path component + /// the path component or null + public string GetComponent(int index) + { + if (!(_error is null)) + { + return null; + } + if (index == 0 && _components is null) + { + return _singlePathComponent; + } + if (_components is null || index < 0 || index >= _components.Length) + { + return null; + } + return _components[index]; + } + + /// + public override bool Equals(object obj) => + obj is AttributeRef other && Equals(other); + + /// + public bool Equals(AttributeRef other) => + _rawPath == other._rawPath; + + /// + public override int GetHashCode() => + _rawPath is null ? 0 : _rawPath.GetHashCode(); + + /// + /// Returns the attribute reference as a string, in the same format used by + /// + /// + /// + /// If the AttributeRef was created with , this value is + /// identical to the original string. If it was created with , + /// the value may be different due to unescaping (for instance, an attribute whose name is + /// "/a" would be represented as "~1a"). For an uninitialized struct + /// (new AttributeRef()), it returns an empty string. + /// + /// the attribute reference string (guaranteed non-null) + public override string ToString() + { + return _rawPath ?? ""; + } + + private static string UnescapePath(string path) + { + // If there are no tildes then there's definitely nothing to do + if (!path.Contains("~")) + { + return path; + } + var ret = new StringBuilder(100); // arbitrary initial capacity + for (var i = 0; i < path.Length; i++) + { + var ch = path[i]; + if (ch != '~') + { + ret.Append(ch); + continue; + } + i++; + if (i >= path.Length) + { + return null; + } + switch (path[i]) + { + case '0': + ret.Append('~'); + break; + case '1': + ret.Append('/'); + break; + default: + return null; + } + } + return ret.ToString(); + } + } +} diff --git a/pkgs/shared/common/src/Context.cs b/pkgs/shared/common/src/Context.cs new file mode 100644 index 00000000..0d48a871 --- /dev/null +++ b/pkgs/shared/common/src/Context.cs @@ -0,0 +1,954 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Xml.Linq; +using LaunchDarkly.Sdk.Json; + +namespace LaunchDarkly.Sdk +{ + /// + /// A collection of attributes that can be referenced in flag evaluations and analytics events. + /// + /// + /// + /// Context is the newer replacement for the previous, less flexible type. + /// The current SDK still supports User, but Context is now the preferred model and may + /// entirely replace User in the future. + /// + /// + /// To create a Context of a single kind, such as a user, you may use + /// or when only the key matters; or, to specify other + /// attributes, use . + /// + /// + /// To create a Context with multiple kinds, use or + /// . + /// + /// + /// An uninitialized Context struct is not valid for use in any SDK operations. Also, a Context can + /// be in an error state if it was built with invalid attributes. See . + /// + /// + /// A Context can be converted to or from JSON using a standard schema; see + /// . + /// + /// + /// To learn more about contexts, read the + /// documentation. + /// + /// + [JsonConverter(typeof(LdJsonConverters.ContextConverter))] + public readonly struct Context : IEquatable, IJsonSerializable + { + private readonly string _error; + internal readonly ImmutableList _multiContexts; + internal readonly ImmutableDictionary _attributes; + internal readonly ImmutableList _privateAttributes; + + /// + /// True if this is a Context that was created with a constructor or builder (regardless of + /// whether its properties are valid), or false if it is an empty uninitialized struct. + /// + /// + /// + public bool Defined { get; } + + /// + /// True for a valid Context, false for an invalid Context. + /// + /// + /// + /// A valid Context is one that can be used in SDK operations. An invalid Context is one that is + /// missing necessary attributes or has invalid attributes, indicating an incorrect usage of the + /// SDK API. The only ways for a Context to be invalid are: + /// + /// + /// + /// It has a disallowed value for the Kind property. See . + /// + /// + /// It is a single-kind Context whose Key is empty. + /// + /// + /// It is a multi-kind Context that does not have any kinds. See . + /// + /// + /// It is a multi-kind Context where the same kind appears more than once. + /// + /// + /// It is a multi-kind Context where at least one of the nested Contexts had an error. + /// + /// + /// It was created with from a null User reference, or from a + /// User that had a null key. + /// + /// + /// It is an uninitialized struct (new Context()). + /// + /// + /// + /// Since in normal usage it is easy for applications to be sure they are using context kinds + /// correctly, and because throwing an exception is undesirable in application code that uses + /// LaunchDarkly, and because some states such as the empty value are impossible to prevent in + /// .NET, the SDK stores the error state in the Context itself and checks for such errors + /// at the time the Context is used, such as in a flag evaluation. At that point, if the Context is + /// invalid, the operation will fail in some well-defined way as described in the documentation for + /// that method, and the SDK will generally log a warning as well. But in any situation where you + /// are not sure if you have a valid Context, you can check the property. + /// + /// + /// + /// + public bool Valid => Error is null; + + /// + /// Null for a valid Context, or an error message for an invalid Context. + /// + /// + /// If this is null, then is true. If it is non-null, then is false. + /// + /// + /// + public string Error => Defined ? _error : Errors.ContextUninitialized; + + /// + /// The Context's kind attribute. + /// + /// + /// + /// Every valid Context has a non-empty kind. For multi-kind contexts, this value is + /// "multi" and the kinds within the Context can be inspected with + /// or . + /// + /// + public ContextKind Kind { get; } + + /// + /// The Context's key attribute. + /// + /// + /// + /// For a single-kind context, this value is set by a Context factory method + /// (, ), or + /// by or . + /// + /// + /// For a multi-kind context, there is no single value and return an + /// empty string. Use or + /// to inspect a Context for a particular kind, then get the from it. + /// + /// + /// This value is never null. + /// + /// + /// + public string Key { get; } + + /// + /// The Context's optional name attribute. + /// + /// + /// + /// For a single-kind context, this value is set by . + /// It is null if no value was set. + /// + /// + /// For a multi-kind context, there is no single value and returns + /// null. Use or + /// to inspect a Context for a particular kind, then get the from it. + /// + /// + /// + public string Name { get; } + + /// + /// True if this Context is only intended for flag evaluations and will not be indexed by + /// LaunchDarkly. + /// + /// + /// + /// For a single-kind context, this value is set by . + /// It is false if no value was set. + /// + /// + /// Setting Anonymous to true excludes this Context from the database that is used by the dashboard. It does + /// not exclude it from analytics event data, so it is not the same as making attributes private; all + /// non-private attributes will still be included in events and data export. There is no limitation on what + /// other attributes may be included (so, for instance, Anonymous does not mean there is no ), + /// and the Context will still have whatever you have given it. + /// + /// + /// For a multi-kind context, there is no single value and returns + /// false. Use or + /// to inspect a Context for a particular kind, then get the value from it. + /// + /// + /// + public bool Anonymous { get; } + + /// + /// A string that describes the entire Context based on Kind and Key values. + /// + /// + /// This value is used whenever LaunchDarkly needs a string identifier based on all of the Kind and + /// Key values in the context; the SDK may use this for caching previously seen contexts, for instance. + /// + public string FullyQualifiedKey { get; } + + /// + /// True for a multi-kind Context, or false for a single-kind Context. + /// + /// + /// + /// If this value is true, then is guaranteed to be "multi", and you can inspect the + /// individual Contexts for each kind with or + /// . + /// + /// + /// If this value is false, then is guaranteed to have a value that is not "multi"/ + /// + /// + public bool Multiple => !(_multiContexts is null); + + /// + /// Enumerates the names of all regular optional attributes defined on this Context. + /// + /// + /// These do not include attributes that always have a value (, , + /// ), or metadata that is not an attribute addressable in targeting rules + /// (). They include any attributes with application-defined names + /// that have a value, and also "name" if has a value. + /// + public IEnumerable OptionalAttributeNames + { + get + { + if (!(Name is null)) + { + yield return "name"; + } + if (!(_attributes is null)) + { + foreach (var entry in _attributes) + { + yield return entry.Key; + } + } + } + } + + /// + /// The list of all attribute references marked as private for this specific Context. + /// + /// + /// This includes all attribute names/paths that were specified with + /// or . + /// If there are none, it is an empty list (never null). + /// + public ImmutableList PrivateAttributes => _privateAttributes ?? + ImmutableList.Create(); + + /// + /// Returns all of the individual contexts Contained in a multi-kind Context. + /// + /// + /// + /// If this is a multi-kind Context, then it returns the individual contexts that were passed to + /// or . The + /// ordering is not guaranteed to be the same. + /// + /// + /// If this is a single-kind Context, then it returns an empty list. + /// + /// + /// + public ImmutableList MultiKindContexts => + _multiContexts ?? ImmutableList.Create(); + + /// + /// Creates a single-kind Context with a Kind of and the specified key. + /// + /// + /// To specify additional properties, use . To create a + /// multi-kind Context, use or . + /// To create a single-kind Context of a different kind than "user", use + /// . + /// + /// the context key + /// a Context + /// + /// + public static Context New(string key) => + new Context( + ContextKind.Default, + key, + null, + false, + null, + null, + false + ); + + /// + /// Creates a single-kind Context with only the Kind and Key properties specified. + /// + /// + /// To specify additional properties, use . To create a + /// multi-kind Context, use or . + /// + /// + /// + /// the context kind + /// the context key + /// a Context + public static Context New(ContextKind kind, string key) => + new Context( + kind, + key, + null, + false, + null, + null, + false + ); + + /// + /// Creates a multi-kind Context out of the specified single-kind Contexts. + /// + /// + /// + /// To create a single-kind Context, use , + /// , or . + /// + /// + /// For the returned Context to be valid, the contexts list must not be empty, and all of its + /// elements must be valid Contexts. Otherwise, the returned Context will be invalid as + /// reported by . + /// + /// + /// If only one context parameter is given, returns a single-kind + /// context (that is, just that same context) rather than a multi-kind context. + /// + /// + /// If a nested context is multi-kind, this is exactly equivalent to adding each of the + /// individual kinds from it separately. For instance, in the following example, "multi1" and + /// "multi2" end up being exactly the same: + /// + /// + /// var c1 = Context.New(ContextKind.Of("kind1"), "key1"); + /// var c2 = Context.New(ContextKind.Of("kind2"), "key2"); + /// var c3 = Context.New(ContextKind.Of("kind3"), "key3"); + /// + /// var multi1 = Context.NewMulti(c1, c2, c3); + /// + /// var c1plus2 = Context.NewMulti(c1, c2); + /// var multi2 = Context.NewMulti(c1plus2, c3); + /// + /// + /// a list of contexts + /// a multi-kind Context + /// + public static Context NewMulti(params Context[] contexts) + { + if (contexts is null || contexts.Length == 0) + { + return new Context(Errors.ContextKindMultiWithNoKinds); + } + if (contexts.Length == 1) + { + return contexts[0]; + } + if (contexts.Any(c => c.Multiple)) + { + var b = MultiBuilder(); + foreach (var c in contexts) + { + b.Add(c); + } + return b.Build(); + } + else + { + return new Context(ImmutableList.Create(contexts)); + } + } + + /// + /// Converts a User to an equivalent instance. + /// + /// + /// + /// This method is used by the SDK whenever an application passes a instance + /// to methods such as Identify. The SDK operates internally on the + /// model, which is more flexible than the older User model: a User can always be converted to a + /// Context, but not vice versa. The of the resulting Context is + /// ("user"). + /// + /// + /// Because there is some overhead to this conversion, it is more efficient for applications to + /// construct a Context and pass that to the SDK, rather than a User. This is also recommended + /// because the User type may be removed in a future version of the SDK. + /// + /// + /// If the parameter is null, or if the user has a null key, the method + /// returns a Context in an invalid state (see ). + /// + /// + /// a User object + /// a Context with the same attributes as the User + public static Context FromUser(User user) + { + if (user is null) + { + return new Context(Errors.ContextFromNullUser); + } + + ImmutableDictionary.Builder attrs = null; + foreach (var a in UserAttribute.OptionalStringAttrs) + { + if (a == UserAttribute.Name) + { + continue; + } + var value = a.BuiltInGetter(user); + if (!value.IsNull) + { + if (attrs is null) + { + attrs = ImmutableDictionary.CreateBuilder(); + } + attrs.Add(a.AttributeName, value); + } + } + foreach (var kv in user.Custom) + { + if (kv.Key != "" && !kv.Value.IsNull) + { + if (attrs is null) + { + attrs = ImmutableDictionary.CreateBuilder(); + } + attrs.Add(kv.Key, kv.Value); + } + } + ImmutableList privateAttrs; + if (user.PrivateAttributeNames.Count == 0) + { + privateAttrs = null; + } + else + { + ImmutableList.Builder privateAttrsBuilder = + ImmutableList.CreateBuilder(); + foreach (string pa in user.PrivateAttributeNames) + { + privateAttrsBuilder.Add(AttributeRef.FromLiteral(pa)); + } + privateAttrs = privateAttrsBuilder.ToImmutableList(); + } + return new Context( + ContextKind.Default, + user.Key, + user.Name, + user.Anonymous, + attrs?.ToImmutableDictionary(), + privateAttrs, + true // allow empty key for backward compatibility with user model + ); + } + + /// + /// Creates a ContextBuilder for building a Context, initializing its + /// and setting to . + /// + /// + /// + /// You may use methods to set additional attributes and/or change the + /// before calling . + /// If you do not change any values, the defaults for the Context are that its is + /// ("user"), its is set to whatever value you passed for + /// , its attribute is false, and it has no values for any + /// other attributes. + /// + /// + /// This method is for building a Context that has only a single Kind. To define a multi-kind + /// Context, use or . + /// + /// + /// If is an empty string, there is no default. A Context must have a + /// non-empty key, so if you call in this state without using + /// to set the key, you will get an invalid Context. + /// + /// + /// the context key + /// a builder + /// + /// + /// + /// + public static ContextBuilder Builder(string key) => + new ContextBuilder().Key(key); + + /// + /// Equivalent to , but sets the initial value of + /// as well as the key. + /// + /// the context kind + /// the context key + /// a builder + /// + /// + /// + /// + public static ContextBuilder Builder(ContextKind kind, string key) => + new ContextBuilder().Kind(kind).Key(key); + + /// + /// Creates a ContextBuilder whose properties are the same as an existing single-kind Context. + /// You may then change the ContextBuilder's state in any way and call + /// to create a new independent Context. + /// + /// the context to copy from + /// a builder + /// + public static ContextBuilder BuilderFromContext(Context context) => + new ContextBuilder().CopyFrom(context); + + /// + /// Creates a ContextMultiBuilder for building a Context. + /// + /// + /// This method is for building a Context athat has multiple Kind values, each with its own + /// nested Context. To define a single-kind context, use instead. + /// + /// a builder + public static ContextMultiBuilder MultiBuilder() => + new ContextMultiBuilder(); + + internal Context( + ContextKind kind, + string key, + string name, + bool anonymous, + ImmutableDictionary attributes, + ImmutableList privateAttributes, + bool allowEmptyKey + ) + { + Defined = true; + + var error = kind.Validate(); + if (error is null) + { + if (key is null || (key == "" && !allowEmptyKey)) + { + error = Errors.ContextNoKey; + } + } + + _error = error; + if (error is null) + { + Kind = kind; + Key = key; + Name = name; + Anonymous = anonymous; + _attributes = attributes; + _privateAttributes = privateAttributes; + FullyQualifiedKey = Kind.IsDefault ? key : + (Kind + ":" + EscapeKeyForFullyQualifiedKey(key)); + } + else + { + Kind = new ContextKind(); + Key = ""; + Name = null; + Anonymous = false; + _attributes = null; + _privateAttributes = null; + FullyQualifiedKey = ""; + } + _multiContexts = null; + } + + internal Context(ImmutableList contexts) + { + // Before calling this constructor, we have already verified that contexts is non-null + // and has more than one element. + Defined = true; + + List errors = null; + var duplicates = false; + for (int i = 0; i < contexts.Count; i++) + { + var c = contexts[i]; + if (c.Error != null) + { + if (errors is null) + { + errors = new List(); + } + errors.Add($"({c.Kind}) {c.Error}"); + } + else + { + for (int j = 0; j < i; j++) + { + if (c.Kind == contexts[j].Kind) + { + duplicates = true; + break; + } + } + } + } + if (duplicates) + { + if (errors is null) + { + errors = new List(); + } + errors.Add(Errors.ContextKindMultiDuplicates); + } + + _error = (errors is null || errors.Count == 0) ? null : + string.Join(", ", errors); + + if (_error is null) + { + Kind = ContextKind.Multi; + _multiContexts = contexts.OrderBy(c => c.Kind.Value).ToImmutableList(); + var buildKey = new StringBuilder(); + foreach (var c in _multiContexts) + { + if (buildKey.Length != 0) + { + buildKey.Append(':'); + } + buildKey.Append(c.Kind).Append(':').Append(EscapeKeyForFullyQualifiedKey(c.Key)); + } + FullyQualifiedKey = buildKey.ToString(); + } + else + { + Kind = new ContextKind(); + _multiContexts = null; + FullyQualifiedKey = ""; + } + + Key = ""; + Name = null; + Anonymous = false; + _attributes = null; + _privateAttributes = null; + } + + internal Context(string error) + { + Defined = true; + _error = error; + _multiContexts = null; + Kind = new ContextKind(); + Key = ""; + Name = null; + Anonymous = false; + _attributes = null; + _privateAttributes = null; + FullyQualifiedKey = ""; + } + + /// + /// Looks up the value of any attribute of the Context by name. This includes only attributes + /// that are addressable in evaluations-- not metadata such as . + /// + /// + /// + /// For a single-kind context, the attribute name can be any custom attribute that was set by methods + /// like . It can also be one of the built-in ones like + /// "kind", "key", or "name"; in such cases, it is equivalent to , + /// , or , except that the value is returned using the general-purpose + /// type. + /// + /// + /// For a multi-kind context, the only supported attribute name is "kind". Use + /// or to inspect + /// a Context for a particular kind and then get its attributes. + /// + /// + /// This method does not support complex expressions for getting individual values out of JSON objects + /// or arrays, such as "/address/street". Use with an + /// for that purpose. + /// + /// + /// If the value is found, the return value is the attribute value, using the type + /// to represent a value of any JSON type. + /// + /// + /// If there is no such attribute, the return value is . An attribute that + /// actually exists cannot have a null value. + /// + /// + /// the desired attribute name + /// the value or + /// + public LdValue GetValue(string attributeName) => + GetValue(AttributeRef.FromLiteral(attributeName)); + + /// + /// Looks up the value of any attribute of the Context, or a value contained within an + /// attribute, based on an . This includes only attributes that + /// are addressable in evaluations-- not metadata such as . + /// + /// + /// + /// This implements the same behavior that the SDK uses to resolve attribute references during a + /// flag evaluation. In a single-kind context, the can represent a + /// simple attribute name-- either a built-in one like "name" or "key", or a custom attribute + /// that was set by methods like -- or, it can be a + /// a slash-delimited path using a JSON-Pointer-like syntax. See + /// for more details. + /// + /// + /// For a multi-kind context, the only supported attribute name is "kind". Use + /// or to inspect + /// a Context for a particular kind and then get its attributes. + /// + /// + /// If the value is found, the return value is the attribute value, using the type + /// to represent a value of any JSON type). + /// + /// + /// If there is no such attribute, or if the is invalid, the return + /// value is . An attribute that actually exists cannot have a null + /// value. + /// + /// + /// an attribute reference + /// the value or + /// + public LdValue GetValue(in AttributeRef attributeRef) + { + if (!attributeRef.Valid) + { + return LdValue.Null; + } + + var name = attributeRef.GetComponent(0); + if (name is null) + { + return LdValue.Null; + } + + if (Multiple) + { + if (attributeRef.Depth == 1 && name == "kind") + { + return LdValue.Of(Kind.Value); + } + return LdValue.Null; // multi-kind context has no other addressable attributes + } + + // Look up attribute in single-kind context + var value = GetTopLevelAddressableAttributeSingleKind(name); + if (value.IsNull) + { + return value; + } + for (int i = 1; i < attributeRef.Depth; i++) + { + var component = attributeRef.GetComponent(i); + if (component is null) + { + return LdValue.Null; + } + var dict = value.Dictionary; // returns an empty dictionary if value is not an object + if (!dict.TryGetValue(component, out value)) + { + value = LdValue.Null; + } + if (value.IsNull) + { + break; + } + } + return value; + } + + /// + /// Gets the single-kind context, if any, whose matches the specified + /// value exactly. + /// + /// + /// + /// If the method is called on a single-kind context, then the specified kind must match the + /// of that context. If the method is called on a multi-kind context, then + /// the kind can match any of the individual contexts within. + /// + /// + /// the desired context kind + /// receives the context that was found, if successful + /// true if found, false if not found + public bool TryGetContextByKind(ContextKind kind, out Context context) + { + if (Multiple) + { + foreach (var c in _multiContexts) + { + if (c.Kind == kind) + { + context = c; + return true; + } + } + context = new Context(); + return false; + } + if (Kind == kind) + { + context = this; + return true; + } + context = new Context(); + return false; + } + + /// + public override bool Equals(object other) => + other is Context c && Equals(c); + + /// + public bool Equals(Context other) + { + if (!Defined || !other.Defined) + { + return Defined == other.Defined; + } + + if (Kind != other.Kind) + { + return false; + } + + if (Multiple) + { + if (other._multiContexts is null || _multiContexts.Count != other._multiContexts.Count) + { + return false; + } + foreach (var mc1 in _multiContexts) + { + if (!other.TryGetContextByKind(mc1.Kind, out var mc2) || !mc1.Equals(mc2)) + { + return false; + } + } + return true; + } + + if (Key != other.Key || Name != other.Name || Anonymous != other.Anonymous) + { + return false; + } + if (_attributes?.Count != other._attributes?.Count || + (!(_attributes is null) && !_attributes.All(kv => other._attributes.TryGetValue(kv.Key, out var v) && kv.Value.Equals(v)))) + { + return false; + } + if (_privateAttributes?.Count != other._privateAttributes?.Count || + (!(_privateAttributes is null) && !_privateAttributes.All(a => other._privateAttributes.Contains(a)))) + { + return false; + } + + return true; + } + + /// + public override int GetHashCode() + { + var hashBuilder = new HashCodeBuilder(); + if (Multiple) + { + foreach (var c in _multiContexts) + { + hashBuilder = hashBuilder.With(c); + } + } + else + { + hashBuilder = hashBuilder.With(Kind) + .With(Key) + .With(Name) + .With(Anonymous); + if (!(_attributes is null)) + { + foreach (var attr in _attributes.Keys.OrderBy(a => a)) + { + hashBuilder = hashBuilder.With(attr).With(_attributes[attr]); + } + } + if (!(_privateAttributes is null)) + { + foreach (var p in _privateAttributes.OrderBy(p => p.ToString())) + { + hashBuilder = hashBuilder.With(p); + } + } + } + return hashBuilder.Value; + } + + /// + /// Returns a string representation of the Context. + /// + /// + /// For a valid Context, this is currently defined as being the same as the JSON representation, + /// since that is the simplest way to represent all of the Context properties. However, application + /// code should not rely on always being the same as the JSON representation. + /// If you specifically want the latter, use . + /// For an invalid Context, ToString() returns a description of why it is invalid. + /// + /// a string representation + public override string ToString() + { + if (!Defined) + { + return "(uninitialized Context)"; + } + if (!(Error is null)) + { + return "(invalid Context: " + Error + ")"; + } + return LdJsonSerialization.SerializeObject(this); + } + + private LdValue GetTopLevelAddressableAttributeSingleKind(string name) + { + switch (name) + { + case "kind": + return LdValue.Of(Kind.Value); + case "key": + return LdValue.Of(Key); + case "name": + return LdValue.Of(Name); + case "anonymous": + return LdValue.Of(Anonymous); + default: + if (_attributes is null) + { + return LdValue.Null; + } + return _attributes.TryGetValue(name, out var value) ? value : LdValue.Null; + } + } + + // When building a FullyQualifiedKey, ':' and '%' are percent-escaped; we do not use a full + // URL-encoding function because implementations of this are inconsistent across platforms. + private static string EscapeKeyForFullyQualifiedKey(string key) => + key.Replace("%", "%25").Replace(":", "%3A"); + } +} diff --git a/pkgs/shared/common/src/ContextBuilder.cs b/pkgs/shared/common/src/ContextBuilder.cs new file mode 100644 index 00000000..73798093 --- /dev/null +++ b/pkgs/shared/common/src/ContextBuilder.cs @@ -0,0 +1,465 @@ +using System.Collections.Immutable; + +namespace LaunchDarkly.Sdk +{ + /// + /// A mutable object that uses the builder pattern to specify properties for a . + /// + /// + /// + /// Use this type if you need to construct a Context that has only a single kind. To define a + /// multi-kind Context, use or . + /// + /// + /// Obtain an instance of ContextBuilder by calling . Then, + /// call setter methods such as , , or + /// to specify any additional attributes. Then, call + /// to create the Context. ContextBuilder setters return a reference to the same builder, so calls can be + /// chained: + /// + /// + /// var context = Context.Builder("user-key"). + /// Name("my-name"). + /// Set("country", "us"). + /// Build(); + /// + /// + /// A ContextBuilder should not be accessed by multiple threads at once. Once you have called + /// , the resulting Context is immutable and is safe to use from multiple threads. + /// Instances created with are not affected by subsequent actions taken on the builder. + /// + /// + public sealed class ContextBuilder + { + private ContextKind _kind = ContextKind.Default; + private string _key; + private string _name; + private bool _anonymous; + private ImmutableDictionary.Builder _attributes; + private ImmutableList.Builder _privateAttributes; + private bool _allowEmptyKey; + + internal ContextBuilder() {} + + /// + /// Creates a from the current Builder properties. + /// + /// + /// + /// The Context is immutable and will not be affected by any subsequent actions on the ContextBuilder. + /// + /// + /// It is possible to specify invalid attributes for a ContextBuilder, such as an empty key. Instead + /// of throwing an exception, the ContextBuilder always returns a Context and you can check + /// to see if it has an error. See for more + /// information about invalid Context conditions. If you pass an invalid Context to an SDK method, the + /// SDK will detect this and will generally log a description of the error. + /// + /// + /// a new + public Context Build() + { + return new Context( + _kind, + _key, + _name, + _anonymous, + _attributes?.ToImmutableDictionary(), + _privateAttributes?.ToImmutableList(), + _allowEmptyKey + ); + } + + /// + /// Sets the Context's kind attribute. + /// + /// + /// + /// Every Context has a kind. Setting it to an empty string or null is equivalent to + /// ("user"). This value is case-sensitive. For validation + /// rules, see . + /// + /// + /// If the value is invalid at the time is called, you will receive an invalid + /// Context whose will describe the problem. + /// + /// + /// the context kind + /// the builder + /// + public ContextBuilder Kind(ContextKind kind) + { + _kind = kind; + return this; + } + + /// + /// Sets the Context's kind attribute. This is a shortcut for calling + /// Kind(ContextKind.Of(kindString)), since the method name already prevents + /// ambiguity about the intended type. + /// + /// the context kind + /// the builder + /// + public ContextBuilder Kind(string kindString) => Kind(ContextKind.Of(kindString)); + + /// + /// Sets the Context's key attribute. + /// + /// + /// + /// Every Context has a key, which is always a string. It cannot be an empty string, but there are no + /// other restrictions on its value. + /// + /// + /// The key attribute can be referenced by flag rules, flag target lists, and segments. + /// + /// + /// the context key + /// the builder + public ContextBuilder Key(string key) + { + _key = key; + return this; + } + + internal ContextBuilder AllowEmptyKey(bool value) + { + _allowEmptyKey = value; + return this; + } + + /// + /// Sets the Context's name attribute. + /// + /// + /// + /// This attribute is optional. It has the following special rules: + /// + /// + /// Unlike most other attributes, it is always a string if it is specified. + /// + /// The LaunchDarkly dashboard treats this attribute as the preferred display name + /// for contexts. + /// + /// + /// the name attribute (null to unset the attribute) + /// the builder + /// + public ContextBuilder Name(string name) + { + _name = name; + return this; + } + + /// + /// Sets whether the Context is only intended for flag evaluations and should not be indexed by + /// LaunchDarkly. + /// + /// + /// + /// The default value is false. False means that this Context represents an entity such as a user that + /// you want to be able to see on the LaunchDarkly dashboard. + /// + /// + /// Setting Anonymous to true excludes this Context from the database that is used by the dashboard. It does + /// not exclude it from analytics event data, so it is not the same as making attributes private; all + /// non-private attributes will still be included in events and data export. There is no limitation on what + /// other attributes may be included (so, for instance, Anonymous does not mean there is no ), + /// and the Context will still have whatever you have given it. + /// + /// + /// This value is also addressable in evaluations as the attribute name "anonymous". It is always treated as + /// a boolean true or false in evaluations. + /// + /// + /// true if the Context should be excluded from the LaunchDarkly database + /// the builder + /// + public ContextBuilder Anonymous(bool anonymous) + { + _anonymous = anonymous; + return this; + } + + /// + /// Sets the value of any attribute for the Context. + /// + /// + /// + /// This includes only attributes that are addressable in evaluations-- not metadata such as + /// . If is "private", you will + /// be setting an attribute with that name which you can use in evaluations or to record data + /// for your own purposes, but it will be unrelated to . + /// + /// + /// This method uses the type to represent a value of any JSON type: null, + /// boolean, number, string, array, or object. For all attribute names that do not have special + /// meaning to LaunchDarkly, you may use any of those types. Values of different JSON types are + /// always treated as different values: for instance, null, false, and the empty string "" are + /// not the the same, and the number 1 is not the same as the string "1". + /// + /// + /// The following attribute names have special restrictions on their value types, and any value + /// of an unsupported type will be ignored (leaving the attribute unchanged): + /// + /// + /// "kind", "key": Must be a string. See and + /// . + /// "name": Must be a string or null. See . + /// + /// "anonymous": Must be a boolean. See . + /// + /// + /// + /// The attribute name "_meta" is not allowed, because it has special meaning in the JSON + /// schema for contexts; any attempt to set an attribute with this name has no effect. Also, any + /// attempt to set an attribute with an empty or null name has no effect. + /// + /// + /// Values that are JSON arrays or objects have special behavior when referenced in flag/segment + /// rules. + /// + /// + /// A value of is equivalent to removing any current non-default value + /// of the attribute. Null is not a valid attribute value in the LaunchDarkly model; any expressions + /// in feature flags that reference an attribute with a null value will behave as if the + /// attribute did not exist. + /// + /// + /// the attribute name to set + /// the value to set + /// the builder + /// + /// + /// + /// + /// + /// + /// + /// + public ContextBuilder Set(string attributeName, LdValue value) + { + TrySet(attributeName, value); + return this; + } + + /// + /// Same as , but returns a boolean indicating whether the + /// attribute was successfully set. + /// + /// the attribute name to set + /// the value to set + /// true if successful; false if the name was invalid or the value was not an allowed + /// type for that attribute + /// + public bool TrySet(string attributeName, LdValue value) + { + if (attributeName is null || attributeName == "") + { + return false; + } + switch (attributeName) + { + case "kind": + if (!value.IsString) + { + return false; + } + Kind(value.AsString); + return true; + + case "key": + if (!value.IsString) + { + return false; + } + Key(value.AsString); + return true; + + case "name": + if (!value.IsString && !value.IsNull) + { + return false; + } + Name(value.AsString); + return true; + + case "anonymous": + if (value.Type != LdValueType.Bool) + { + return false; + } + Anonymous(value.AsBool); + return true; + + case "_meta": + return false; + + default: + if (value.IsNull) + { + _attributes?.Remove(attributeName); + } + else + { + if (_attributes is null) + { + _attributes = ImmutableDictionary.CreateBuilder(); + } + _attributes.Remove(attributeName); + _attributes.Add(attributeName, value); + } + return true; + } + } + + /// + /// Same as for a boolean value. + /// + /// the attribute name to set + /// the value to set + /// the builder + /// + public ContextBuilder Set(string attributeName, bool value) => Set(attributeName, LdValue.Of(value)); + + /// + /// Same as for an integer numeric value. + /// + /// the attribute name to set + /// the value to set + /// the builder + /// + public ContextBuilder Set(string attributeName, int value) => Set(attributeName, LdValue.Of(value)); + + /// + /// Same as for a double-precision numeric value. + /// + /// + /// Numeric values in custom attributes have some precision limitations, the same as for + /// numeric values in flag variations. For more details, see our documentation on + /// flag value types. + /// + /// the attribute name to set + /// the value to set + /// the builder + /// + public ContextBuilder Set(string attributeName, double value) => Set(attributeName, LdValue.Of(value)); + + /// + /// Same as for a long integer numeric value. + /// + /// + /// Numeric values in custom attributes have some precision limitations, the same as for + /// numeric values in flag variations. For more details, see our documentation on + /// flag value types. + /// + /// the attribute name to set + /// the value to set + /// the builder + /// + public ContextBuilder Set(string attributeName, long value) => Set(attributeName, LdValue.Of(value)); + + /// + /// Same as for a string value. + /// + /// the attribute name to set + /// the value to set + /// the builder + /// + public ContextBuilder Set(string attributeName, string value) => Set(attributeName, LdValue.Of(value)); + + /// + /// Unsets a previously set attribute value. Has no effect if no such value was set. + /// + /// the attribute name to unset + /// the builder + /// + public ContextBuilder Remove(string attributeName) + { + _attributes?.Remove(attributeName); + return this; + } + + /// + /// Designates any number of Context attributes, or properties within them, as private: that is, + /// their values will not be sent to LaunchDarkly. + /// + /// + /// TKTK: conceptual information about private attributes might be in online docs + /// + /// attribute references to mark as private + /// the builder + /// + /// + public ContextBuilder Private(params string[] attributeRefs) + { + if (!(attributeRefs is null) && attributeRefs.Length != 0) + { + if (_privateAttributes is null) + { + _privateAttributes = ImmutableList.CreateBuilder(); + } + foreach (var a in attributeRefs) + { + _privateAttributes.Add(AttributeRef.FromPath(a)); + } + } + return this; + } + + /// + /// Equivalent to , but uses the type. + /// + /// + /// Application code is unlikely to need to use the type directly; however, + /// in cases where you are constructing Contexts constructed repeatedly with the same set of private + /// attributes, if you are also using complex private attribute path references such as "/address/street", + /// converting this to an AttributeRef once and reusing it in many Private calls is slightly more + /// efficient than passing a string (since it does not need to parse the path repeatedly). + /// + /// attribute references to mark as private + /// the builder + /// + /// + public ContextBuilder Private(params AttributeRef[] attributeRefs) + { + if (!(attributeRefs is null) && attributeRefs.Length != 0) + { + if (_privateAttributes is null) + { + _privateAttributes = ImmutableList.CreateBuilder(); + } + _privateAttributes.AddRange(attributeRefs); + } + return this; + } + + internal ContextBuilder CopyFrom(Context c) + { + _kind = c.Kind; + _key = c.Key; + _name = c.Name; + _anonymous = c.Anonymous; + if (c._attributes is null) + { + _attributes = null; + } + else + { + _attributes = ImmutableDictionary.CreateBuilder(); + _attributes.AddRange(c._attributes); + } + if (c._privateAttributes is null) + { + _privateAttributes = null; + } + else + { + _privateAttributes = ImmutableList.CreateBuilder(); + _privateAttributes.AddRange(c._privateAttributes); + } + return this; + } + } +} diff --git a/pkgs/shared/common/src/ContextKind.cs b/pkgs/shared/common/src/ContextKind.cs new file mode 100644 index 00000000..c53c8d13 --- /dev/null +++ b/pkgs/shared/common/src/ContextKind.cs @@ -0,0 +1,128 @@ +using System; + +namespace LaunchDarkly.Sdk +{ + /// + /// A string value provided by the application to describe what kind of entity a + /// represents. + /// + /// + /// + /// The type is a simple wrapper for a string. Using a type that is not just string + /// makes it clearer where a context kind is expected or returned in the SDK API, so it + /// cannot be confused with other important strings such as . To + /// convert a literal string to this type, you can use the shortcut . + /// + /// + /// The meaning of the context kind is completely up to the application. Validation rules are + /// as follows: + /// + /// + /// It may only contain letters, numbers, and the characters ".", "_", and "-". + /// + /// It cannot equal the literal string "kind". + /// For a single-kind context, it cannot equal "multi". + /// + /// + /// If no kind is specified, the default is "user" (the constant ). + /// However, an uninitialized struct (new ContextKind() is invalid and has a string + /// value of "". + /// + /// + /// For a multi-kind Context (see ), the kind + /// of the top-level Context is always "multi" (the constant ); + /// there is a specific Kind for each of the Contexts contained within it. + /// + /// + /// To learn more, read the + /// documentation. + /// + /// + public readonly struct ContextKind : IEquatable + { + private const string userKind = "user"; + + /// + /// A constant for the default kind of "user". + /// + public static readonly ContextKind Default = new ContextKind(userKind); + + /// + /// A constant for the kind that all multi-kind Contexts have. + /// + public static readonly ContextKind Multi = new ContextKind("multi"); + + private readonly string _value; + + /// + /// The string value of the context kind. This is never null. + /// + public string Value => _value ?? ""; + + /// + /// Constructor from a string value. + /// + /// + /// A value of null or "" will be changed to . + /// + /// the string value + public ContextKind(string stringValue) + { + _value = string.IsNullOrEmpty(stringValue) ? userKind : stringValue; + } + + /// + /// Shortcut for calling the constructor. + /// + /// the string value + /// a wrapping this value + public static ContextKind Of(string stringValue) => + new ContextKind(stringValue); + + /// + /// True if this is equal to ("user"). + /// + public bool IsDefault => Value == userKind; + + internal string Validate() + { + switch (Value) + { + case "kind": + return Errors.ContextKindCannotBeKind; + case "multi": + return Errors.ContextKindMultiForSingle; + default: + foreach (var ch in Value) + { + if ((ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') && (ch < '0' || ch > '9') && + ch != '.' && ch != '_' && ch != '-') + { + return Errors.ContextKindInvalidChars; + } + } + return null; + } + } + + /// + public override string ToString() => Value; + + /// + public override int GetHashCode() => Value.GetHashCode(); + + /// + public override bool Equals(object obj) => + obj is ContextKind other && Value == other.Value; + + /// + public bool Equals(ContextKind other) => + Value == other.Value; + + /// + public static bool operator ==(ContextKind a, ContextKind b) => a.Value == b.Value; + + /// + public static bool operator !=(ContextKind a, ContextKind b) => a.Value != b.Value; + } +} diff --git a/pkgs/shared/common/src/ContextMultiBuilder.cs b/pkgs/shared/common/src/ContextMultiBuilder.cs new file mode 100644 index 00000000..ea8c2eb2 --- /dev/null +++ b/pkgs/shared/common/src/ContextMultiBuilder.cs @@ -0,0 +1,108 @@ +using System.Collections.Immutable; + +namespace LaunchDarkly.Sdk +{ + /// + /// A mutable object that uses the builder pattern to specify properties for a . + /// + /// + /// + /// Use this type if you need to construct a Context that has multiple Kind values, each with its + /// own nested Context. To define a single-kind context, use . + /// + /// + /// Obtain an instance of ContextMultiBuilder by calling ; then, + /// call to specify the nested Context for each kind. Add returns a + /// reference to the same builder, so calls can be chained: + /// + /// + /// var context = Context.MultiBuilder(). + /// Add(Context.New("my-user-key")). + /// Add(Context.Builder("my-org-key").Kind("organization").Build()). + /// Build(); + /// + /// + /// A ContextMultiBuilder should not be accessed by multiple threads at once. Once you have called + /// , the resulting Context is immutable and is safe to use from multiple threads. + /// Instances created with are not affected by subsequent actions taken on the builder. + /// + /// + /// + public sealed class ContextMultiBuilder + { + private readonly ImmutableList.Builder _contexts = ImmutableList.CreateBuilder(); + + /// + /// Creates a from the current Builder properties. + /// + /// + /// + /// The Context is immutable and will not be affected by any subsequent actions on the ContextBuilder. + /// + /// + /// It is possible for a ContextMultiBuilder to represent an invalid state. Instead of throwing an + /// exception, the ContextMultiBuilder always returns a Context and you can check + /// to see if it has an error. See for more information about invalid Context + /// conditions. If you pass an invalid Context to an SDK method, the SDK will detect this and will generally + /// log a description of the error. + /// + /// + /// If only one context kind was added to the builder, Build returns a single-kind Context rather + /// than a multi-kind Context. + /// + /// + /// a new + public Context Build() + { + var list = _contexts.ToImmutableList(); + if (list.IsEmpty) + { + return new Context(Errors.ContextKindMultiWithNoKinds); + } + if (list.Count == 1) + { + return list[0]; + } + return new Context(list); + } + + /// + /// Adds a nested Context for a specific kind to a MultiBuilder. + /// + /// + /// + /// It is invalid to add more than one Context with the same kind, or to add a Context that is itself + /// invalid. This error is detected when you call . + /// + /// + /// If the nested context is multi-kind, this is exactly equivalent to adding each of the + /// individual kinds from it separately. For instance, in the following example, "multi1" and + /// "multi2" end up being exactly the same: + /// + /// + /// var c1 = Context.New(ContextKind.Of("kind1"), "key1"); + /// var c2 = Context.New(ContextKind.Of("kind2"), "key2"); + /// var c3 = Context.New(ContextKind.Of("kind3"), "key3"); + /// + /// var multi1 = Context.MultiBuilder().Add(c1).Add(c2).Add(c3).Build(); + /// + /// var c1plus2 = Context.MultiBuilder().Add(c1).Add(c2).Build(); + /// var multi2 = Context.MultiBuilder().Add(c1plus2).Add(c3).Build(); + /// + /// + /// the context to add + /// the builder + public ContextMultiBuilder Add(Context context) + { + if (context.Multiple) + { + _contexts.AddRange(context.MultiKindContexts); + } + else + { + _contexts.Add(context); + } + return this; + } + } +} diff --git a/pkgs/shared/common/src/EnvReporting/ConfigLayerBuilder.cs b/pkgs/shared/common/src/EnvReporting/ConfigLayerBuilder.cs new file mode 100644 index 00000000..3e2f49ff --- /dev/null +++ b/pkgs/shared/common/src/EnvReporting/ConfigLayerBuilder.cs @@ -0,0 +1,35 @@ +using System; + +namespace LaunchDarkly.Sdk.EnvReporting +{ + /// + /// Builder class for making the configuration based for use in the + /// . + /// + public class ConfigLayerBuilder + { + + private ApplicationInfo _info; + + /// the application info that will be used by this layer when built. + public ConfigLayerBuilder SetAppInfo(ApplicationInfo info) + { + _info = info; + return this; + } + + /// + /// Builds the + /// + /// the layer + public Layer Build() + { + return Validate(_info) ? new Layer(_info, null, null, null) : new Layer(); + } + + private static bool Validate(ApplicationInfo info) + { + return info.ApplicationId != null; + } + } +} diff --git a/pkgs/shared/common/src/EnvReporting/EnvironmentReporter.cs b/pkgs/shared/common/src/EnvReporting/EnvironmentReporter.cs new file mode 100644 index 00000000..2a653904 --- /dev/null +++ b/pkgs/shared/common/src/EnvReporting/EnvironmentReporter.cs @@ -0,0 +1,24 @@ +using LaunchDarkly.Sdk.EnvReporting.LayerModels; + +namespace LaunchDarkly.Sdk.EnvReporting +{ + /// + /// An is able to report various attributes + /// of the environment in which the application is running. If a property is null, + /// it means the reporter was unable to determine the value. + /// + public interface IEnvironmentReporter + { + /// the for the application environment + ApplicationInfo? ApplicationInfo { get; } + + /// the for the application environment + OsInfo? OsInfo { get; } + + /// the for the application environment + DeviceInfo? DeviceInfo { get; } + + /// the locale for the application environment in the format languagecode2-country/regioncode2 + string Locale { get; } + } +} diff --git a/pkgs/shared/common/src/EnvReporting/EnvironmentReporterBuilder.cs b/pkgs/shared/common/src/EnvReporting/EnvironmentReporterBuilder.cs new file mode 100644 index 00000000..c27a8f14 --- /dev/null +++ b/pkgs/shared/common/src/EnvReporting/EnvironmentReporterBuilder.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Sdk.EnvReporting.LayerModels; + +namespace LaunchDarkly.Sdk.EnvReporting +{ + + + /// + /// Represents one layer of sourcing environment properties. A layer may know how to source any + /// number of properties (even possibly 0 properties in edge cases), in which case it may return null for individual + /// properties. + /// + public readonly struct Layer + { + /// + /// The application info. + /// + public ApplicationInfo? ApplicationInfo { get; } + + /// + /// The operating system info. + /// + public OsInfo? OsInfo { get; } + + /// + /// The device info. + /// + public DeviceInfo? DeviceInfo { get; } + + /// + /// The application locale in the format languagecode2-country/regioncode2. + /// + public string Locale { get; } + + /// + /// Constructs a new layer with optional property values. + /// + /// the optional ApplicationInfo. + /// the optional OsInfo. + /// the optional DeviceInfo. + /// the optional application locale. + public Layer(ApplicationInfo? appInfo, OsInfo? osInfo, DeviceInfo? deviceInfo, string locale) + { + ApplicationInfo = appInfo; + OsInfo = osInfo; + DeviceInfo = deviceInfo; + Locale = locale; + } + } + + internal class PrioritizedReporter : IEnvironmentReporter + { + public OsInfo? OsInfo { get; internal set; } + public DeviceInfo? DeviceInfo { get; internal set; } + public ApplicationInfo? ApplicationInfo { get; internal set; } + public string Locale { get; internal set; } + } + + + /// + /// EnvironmentReporterBuilder constructs an IEnvironmentReporter that is capable + /// of returning properties associated with the runtime environment of the SDK. + /// + public sealed class EnvironmentReporterBuilder + { + private Layer _configLayer; + private Layer _platformLayer; + private Layer _sdkLayer; + + private const string Unknown = "unknown"; + + /// + /// Sets the properties that come from the user-provided SDK configuration. + /// Properties in this layer will always override properties from the platform layer. + /// + /// the Layer. + /// the EnvironmentReporterBuilder. + public EnvironmentReporterBuilder SetConfigLayer(Layer config) + { + _configLayer = config; + return this; + } + + /// + /// Sets the properties that come from the platform-specific runtime information. + /// Properties in this layer will always override properties from the default layer (provisioned + /// by this build.) + /// + /// the Layer. + /// the EnvironmentReporterBuilder. + public EnvironmentReporterBuilder SetPlatformLayer(Layer platform) + { + _platformLayer = platform; + return this; + } + + /// + /// Sets the properties that come from the SDK that is using this . + /// Properties in this layer will always override properties from the default layer. + /// + /// the Layer. + public EnvironmentReporterBuilder SetSdkLayer(Layer sdkLayer) + { + _sdkLayer = sdkLayer; + return this; + } + + /// + /// Builds an IEnvironmentReporter, which can be used to obtain information about + /// the runtime environment of the SDK. + /// + /// + public IEnvironmentReporter Build() + { + var layers = new List { _configLayer, _platformLayer, _sdkLayer }; + + return new PrioritizedReporter() + { + ApplicationInfo = + layers.Select(layer => layer.ApplicationInfo) + .FirstOrDefault(prop => prop != null), + OsInfo = + layers.Select(layer => layer.OsInfo) + .FirstOrDefault(prop => prop != null), + DeviceInfo = + layers.Select(layer => layer.DeviceInfo) + .FirstOrDefault(prop => prop != null), + Locale = + layers.Select(layer => layer.Locale) + .FirstOrDefault(prop => prop != null) + }; + } + } +} diff --git a/pkgs/shared/common/src/EnvReporting/LayerModels/DeviceInfo.cs b/pkgs/shared/common/src/EnvReporting/LayerModels/DeviceInfo.cs new file mode 100644 index 00000000..e85a8309 --- /dev/null +++ b/pkgs/shared/common/src/EnvReporting/LayerModels/DeviceInfo.cs @@ -0,0 +1,30 @@ +namespace LaunchDarkly.Sdk.EnvReporting.LayerModels +{ + /// + /// An object that encapsulates application metadata. + /// + public readonly struct DeviceInfo + { + /// + /// The device's model. + /// + public string Model { get; } + + /// + /// The device's manufacturer. + /// + public string Manufacturer { get; } + + + /// + /// Constructs a new DeviceInfo instance. + /// + /// the manufacturer. + /// the model. + public DeviceInfo(string manufacturer, string model) + { + Model = model; + Manufacturer = manufacturer; + } + } +} diff --git a/pkgs/shared/common/src/EnvReporting/LayerModels/OsInfo.cs b/pkgs/shared/common/src/EnvReporting/LayerModels/OsInfo.cs new file mode 100644 index 00000000..21dbf4fb --- /dev/null +++ b/pkgs/shared/common/src/EnvReporting/LayerModels/OsInfo.cs @@ -0,0 +1,37 @@ +namespace LaunchDarkly.Sdk.EnvReporting.LayerModels +{ + /// + /// An object that encapsulates application metadata. + /// + public readonly struct OsInfo + { + /// + /// The operating system's family. + /// + public string Family { get; } + + /// + /// The operating system's name. + /// + public string Name { get; } + + /// + /// The operating system's version. + /// + public string Version { get; } + + + /// + /// Constructs a new OsInfo instance. + /// + /// the family. + /// the name. + /// the version. + public OsInfo(string family, string name, string version) + { + Family = family; + Name = name; + Version = version; + } + } +} diff --git a/pkgs/shared/common/src/Errors.cs b/pkgs/shared/common/src/Errors.cs new file mode 100644 index 00000000..4a02969e --- /dev/null +++ b/pkgs/shared/common/src/Errors.cs @@ -0,0 +1,28 @@ + +namespace LaunchDarkly.Sdk +{ + internal static class Errors + { + internal const string AttrEmpty = "attribute reference cannot be empty"; + internal const string AttrExtraSlash = "attribute reference contained a double slash or a trailing slash"; + internal const string AttrInvalidEscape = + "attribute reference contained an escape character (~) that was not followed by 0 or 1"; + + internal const string ContextUninitialized = "tried to use uninitialized Context"; + internal const string ContextFromNullUser = "tried to use a null User reference"; + internal const string ContextNoKey = "context key must not be null or empty"; + internal const string ContextKindCannotBeKind = "\"kind\" is not a valid context kind"; + internal const string ContextKindInvalidChars = "context kind contains disallowed characters"; + internal const string ContextKindMultiForSingle = "context of kind \"multi\" must be created with NewMulti or NewMultiBuilder"; + internal const string ContextKindMultiWithNoKinds = "multi-kind context must contain at least one kind"; + internal const string ContextKindMultiDuplicates = "multi-kind context cannot have same kind more than once"; + + internal const string JsonContextEmptyKind = "context kind cannot be empty"; + internal static string JsonMissingProperty(string name) => + string.Format(@"missing required property ""{0}""", name); + internal static string JsonWrongType(string name, LdValueType badType) => + string.Format(@"unsupported type ""{0}"" for property ""{1}""", badType, name); + internal static string JsonSerializeInvalidContext(string error) => + "cannot serialize invalid Context: " + error; + } +} diff --git a/pkgs/shared/common/src/EvaluationDetail.cs b/pkgs/shared/common/src/EvaluationDetail.cs new file mode 100644 index 00000000..e27a8c1e --- /dev/null +++ b/pkgs/shared/common/src/EvaluationDetail.cs @@ -0,0 +1,368 @@ +using System.Text.Json.Serialization; +using LaunchDarkly.Sdk.Json; + +namespace LaunchDarkly.Sdk +{ + /// + /// An object returned by the "variation detail" methods of the client, combining the result + /// of a flag evaluation with an explanation of how it was calculated. + /// + /// the type of the flag value + public struct EvaluationDetail + { + private readonly T _value; + private readonly int? _variationIndex; + private readonly EvaluationReason _reason; + + /// + /// The result of the flag evaluation. This will be either one of the flag's variations or the default + /// value that was specified when the flag was evaluated. + /// + public T Value => _value; + + /// + /// The index of the returned value within the flag's list of variations, e.g. 0 for the first variation - + /// or if the default value was returned. + /// + public int? VariationIndex => _variationIndex; + + /// + /// An object describing the main factor that influenced the flag evaluation value. + /// + public EvaluationReason Reason => _reason; + + /// + /// True if the flag evaluated to the default value, rather than one of its variations. + /// + public bool IsDefaultValue => _variationIndex == null; + + /// + /// Constructs a new EvaluationDetail insetance. + /// + /// the flag value + /// the variation index + /// the evaluation reason + public EvaluationDetail(T value, int? variationIndex, EvaluationReason reason) + { + _value = value; + _variationIndex = variationIndex; + _reason = reason; + } + + /// + public override bool Equals(object obj) => + obj is EvaluationDetail o && + (Value == null ? o.Value == null : Value.Equals(o.Value)) + && VariationIndex == o.VariationIndex && Reason.Equals(o.Reason); + + /// + public override int GetHashCode() => + new HashCodeBuilder().With(Value).With(VariationIndex).With(Reason).Value; + } + + /// + /// Describes the reason that a flag evaluation produced a particular value. + /// + /// + /// For converting this type to or from JSON, see . + /// + [JsonConverter(typeof(LdJsonConverters.EvaluationReasonConverter))] + public struct EvaluationReason : IJsonSerializable + { + private static readonly EvaluationReason _offInstance = + new EvaluationReason(EvaluationReasonKind.Off, null, null, null, null, false, null); + private static readonly EvaluationReason _fallthroughInstance = + new EvaluationReason(EvaluationReasonKind.Fallthrough, null, null, null, null, false, null); + private static readonly EvaluationReason _targetMatchInstance = + new EvaluationReason(EvaluationReasonKind.TargetMatch, null, null, null, null, false, null); + + private readonly EvaluationReasonKind _kind; + private readonly int? _ruleIndex; + private readonly string _ruleId; + private readonly string _prerequisiteKey; + private readonly EvaluationErrorKind? _errorKind; + private readonly bool _inExperiment; + private readonly BigSegmentsStatus? _bigSegmentsStatus; + + /// + /// An enum indicating the general category of the reason. + /// + public EvaluationReasonKind Kind => _kind; + + /// + /// The index of the rule that was matched (0 for the first), or if this is not a rule match. + /// + public int? RuleIndex => _ruleIndex; + + /// + /// The unique identifier of the rule that was matched, or if this is not a rule match. + /// + public string RuleId => _ruleId; + + /// + /// The key of the prerequisite flag that failed, if is , + /// otherwise . + /// + public string PrerequisiteKey => _prerequisiteKey; + + /// + /// Describes the type of error, if is , otherwise + /// . + /// + public EvaluationErrorKind? ErrorKind => _errorKind; + + /// + /// Whether the evaluation was part of an experiment. + /// + /// + /// This is true if the evaluation resulted in an experiment rollout and served one of the + /// variations in the experiment. Otherwise it is false. + /// + public bool InExperiment => _inExperiment; + + /// + /// Describes the validity of big segment information, if and only if the flag evaluation required querying + /// at least one big segment. Otherwise it returns . + /// + /// + /// "Big segments" are a specific kind of user segments. For more information, read the LaunchDarkly + /// documentation about user segments: https://docs.launchdarkly.com/home/users/big-segments + /// + public BigSegmentsStatus? BigSegmentsStatus => _bigSegmentsStatus; + + internal EvaluationReason( + EvaluationReasonKind kind, + int? ruleIndex, + string ruleId, + string prereqKey, + EvaluationErrorKind? errorKind, + bool inExperiment, + BigSegmentsStatus? bigSegmentsStatus + ) + { + _kind = kind; + _ruleIndex = ruleIndex; + _ruleId = ruleId; + _prerequisiteKey = prereqKey; + _errorKind = errorKind; + _inExperiment = inExperiment; + _bigSegmentsStatus = bigSegmentsStatus; + } + + /// + /// Returns an EvaluationReason of the kind . + /// + public static EvaluationReason OffReason => _offInstance; + + /// + /// Returns an EvaluationReason of the kind . + /// + public static EvaluationReason FallthroughReason => _fallthroughInstance; + + /// + /// Returns an EvaluationReason of the kind . + /// + public static EvaluationReason TargetMatchReason => _targetMatchInstance; + + /// + /// Returns an EvaluationReason of the kind . + /// + /// the rule index + /// the unique rule ID + /// a reason descriptor + public static EvaluationReason RuleMatchReason(int ruleIndex, string ruleId) => + new EvaluationReason(EvaluationReasonKind.RuleMatch, ruleIndex, ruleId, null, null, false, null); + + /// + /// Returns an EvaluationReason of the kind . + /// + /// the key of the prerequisite flag + /// a reason descriptor + public static EvaluationReason PrerequisiteFailedReason(string key) => + new EvaluationReason(EvaluationReasonKind.PrerequisiteFailed, null, null, key, null, false, null); + + /// + /// Returns an EvaluationReason of the kind . + /// + /// + /// a reason descriptor + public static EvaluationReason ErrorReason(EvaluationErrorKind errorKind) => + new EvaluationReason(EvaluationReasonKind.Error, null, null, null, errorKind, false, null); + + /// + /// Returns a copy of this EvaluationReason with a specific value added. + /// + /// the new property value + /// a reason descriptor + public EvaluationReason WithBigSegmentsStatus(BigSegmentsStatus? bigSegmentsStatus) => + new EvaluationReason(_kind, _ruleIndex, _ruleId, _prerequisiteKey, _errorKind, + _inExperiment, bigSegmentsStatus); + + /// + /// Returns a new instance with the property set to the specified + /// value, if supported. + /// + /// + /// Setting is only allowed for + /// and . For all other reason kinds, this has no effect. + /// + /// the desired value for the property + /// a copy of this instance with the property modified + public EvaluationReason WithInExperiment(bool inExperiment) + { + switch (_kind) + { + case EvaluationReasonKind.Fallthrough: + case EvaluationReasonKind.RuleMatch: + return new EvaluationReason(_kind, _ruleIndex, _ruleId, _prerequisiteKey, _errorKind, inExperiment, _bigSegmentsStatus); + default: + return this; + } + } + + /// + public override bool Equals(object obj) => + obj is EvaluationReason o && + _kind == o._kind && _ruleId == o._ruleId && _ruleIndex == o._ruleIndex && + _prerequisiteKey == o._prerequisiteKey && _errorKind == o._errorKind && + _inExperiment == o._inExperiment && _bigSegmentsStatus == o._bigSegmentsStatus; + + /// + public override int GetHashCode() => + new HashCodeBuilder().With(_kind).With(_ruleIndex).With(_ruleId).With(_prerequisiteKey) + .With(_errorKind).With(_inExperiment).With(_bigSegmentsStatus).Value; + + /// + public override string ToString() + { + var kindStr = LdJsonConverters.EvaluationReasonKindConverter.ToIdentifier(_kind); + switch (_kind) + { + case EvaluationReasonKind.RuleMatch: + return kindStr + "(" + _ruleIndex + "," + _ruleId + ")"; + case EvaluationReasonKind.PrerequisiteFailed: + return kindStr + "(" + _prerequisiteKey + ")"; + case EvaluationReasonKind.Error: + return kindStr + "(" + LdJsonConverters.EvaluationErrorKindConverter.ToIdentifier(_errorKind.Value) + ")"; + } + return kindStr; + } + } + + /// + /// Enumerated type defining the possible values of . + /// + /// + /// The JSON representation of this type, as used in LaunchDarkly analytics event data, uses + /// uppercase strings with underscores ("RULE_MATCH" rather than "RuleMatch"). + /// + [JsonConverter(typeof(LdJsonConverters.EvaluationReasonKindConverter))] + public enum EvaluationReasonKind + { + /// + /// Indicates that the flag was off and therefore returned its configured off value. + /// + Off, + + /// + /// Indicates that the flag was on but the user did not match any targets or rules. + /// + Fallthrough, + + /// + /// Indicates that the user key was specifically targeted for this flag. + /// + TargetMatch, + + /// + /// Indicates that the user matched one of the flag's rules. + /// + RuleMatch, + + /// + /// Indicates that the flag was considered off because it had at least one prerequisite flag + /// that either was off or did not return the desired variation. + /// + PrerequisiteFailed, + + /// + /// Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected + /// error. In this case the result value will be the default value that the caller passed to the client. + /// + Error + } + + /// + /// Enumerated type defining the possible values of . + /// + /// + /// The JSON representation of this type, as used in LaunchDarkly analytics event data, uses + /// uppercase strings with underscores ("FLAG_NOT_FOUND" rather than "FlagNotFound"). + /// + [JsonConverter(typeof(LdJsonConverters.EvaluationErrorKindConverter))] + public enum EvaluationErrorKind + { + /// + /// Indicates that the caller tried to evaluate a flag before the client had successfully initialized. + /// + ClientNotReady, + + /// + /// Indicates that the caller provided a flag key that did not match any known flag. + /// + FlagNotFound, + + /// + /// Indicates that the caller passed for the user parameter, or the user lacked a key. + /// + UserNotSpecified, + + /// + /// Indicates that there was an internal inconsistency in the flag data, e.g. a rule specified a nonexistent + /// variation. An error message will always be logged in this case. + /// + MalformedFlag, + + /// + /// Indicates that the result value was not of the requested type, e.g. you requested a + /// but the value was an . + /// + WrongType, + + /// + /// Indicates that an unexpected exception stopped flag evaluation; check the log for details. + /// + Exception + } + + /// + /// Defines the possible values of . + /// + [JsonConverter(typeof(LdJsonConverters.BigSegmentsStatusConverter))] + public enum BigSegmentsStatus + { + /// + /// Indicates that the big segment query involved in the flag evaluation was successful, and + /// that the segment state is considered up to date. + /// + Healthy, + + /// + /// Indicates that the big segment query involved in the flag evaluation was successful, but + /// that the segment state may not be up to date. + /// + Stale, + + /// + /// Indicates that big segments could not be queried for the flag evaluation because the SDK + /// configuration did not include a big segment store. + /// + NotConfigured, + + /// + /// Indicates that the big segment query involved in the flag evaluation failed, for instance + /// due to a database error. + /// + StoreError + } +} diff --git a/pkgs/shared/common/src/HashCodeBuilder.cs b/pkgs/shared/common/src/HashCodeBuilder.cs new file mode 100644 index 00000000..e66b8409 --- /dev/null +++ b/pkgs/shared/common/src/HashCodeBuilder.cs @@ -0,0 +1,19 @@ + +namespace LaunchDarkly.Sdk +{ + internal struct HashCodeBuilder + { + private readonly int _value; + public int Value => _value; + + internal HashCodeBuilder(int value) + { + _value = value; + } + + public HashCodeBuilder With(object o) + { + return new HashCodeBuilder(_value * 17 + (o == null ? 0 : o.GetHashCode())); + } + } +} diff --git a/pkgs/shared/common/src/Helpers/LdValueHelpers.cs b/pkgs/shared/common/src/Helpers/LdValueHelpers.cs new file mode 100644 index 00000000..01a65bcf --- /dev/null +++ b/pkgs/shared/common/src/Helpers/LdValueHelpers.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace LaunchDarkly.Sdk.Internal.Helpers +{ + // This struct simply represents a list of T as a list of U, without doing any + // copying, using a conversion function. + internal struct LdValueListConverter : IReadOnlyList + { + private readonly IList _source; + private readonly Func _converter; + + internal LdValueListConverter(IList source, Func converter) + { + _source = source; + _converter = converter; + } + + public U this[int index] + { + get + { + if (_source is null || index < 0 || index >= _source.Count) + { + throw new IndexOutOfRangeException(); + } + return _converter(_source[index]); + } + } + + public int Count => _source is null ? 0 : _source.Count; + + public IEnumerator GetEnumerator() => + _source is null ? Enumerable.Empty().GetEnumerator() : + _source.Select(_converter).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public override string ToString() + { + return "[" + string.Join(",", this) + "]"; + } + } + + // This struct simply represents a dictionary of as a dictionary of + // , without doing any copying, using a conversion function. + internal struct LdValueDictionaryConverter : IReadOnlyDictionary + { + private readonly IDictionary _source; + private readonly Func _converter; + + internal LdValueDictionaryConverter(IDictionary source, Func converter) + { + _source = source; + _converter = converter; + } + + public U this[string key] + { + get + { + // Note that JObject[key] does *not* throw a KeyNotFoundException, but we should + if (_source is null || !_source.TryGetValue(key, out var v)) + { + throw new KeyNotFoundException(); + } + return _converter(v); + } + } + + public IEnumerable Keys => + _source is null ? Enumerable.Empty() : _source.Keys; + + public IEnumerable Values => + _source is null ? Enumerable.Empty() : + _source.Values.Select(_converter); + + public int Count => _source is null ? 0 : _source.Count; + + public bool ContainsKey(string key) => + !(_source is null) && _source.TryGetValue(key, out var ignore); + + public IEnumerator> GetEnumerator() + { + if (_source is null) + { + return Enumerable.Empty>().GetEnumerator(); + } + var conv = _converter; // lambda can't use instance field + return _source.Select, KeyValuePair>( + p => new KeyValuePair(p.Key, conv(p.Value)) + ).GetEnumerator(); + } + + public bool TryGetValue(string key, out U value) + { + if (!(_source is null) && _source.TryGetValue(key, out var v)) + { + value = _converter(v); + return true; + } + value = default(U); + return false; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public override string ToString() + { + return "{" + + string.Join(",", this.Select(kv => "\"" + kv.Key + "\":" + kv.Value)) + + "}"; + } + } +} diff --git a/pkgs/shared/common/src/Helpers/ValidationUtils.cs b/pkgs/shared/common/src/Helpers/ValidationUtils.cs new file mode 100644 index 00000000..7a11780d --- /dev/null +++ b/pkgs/shared/common/src/Helpers/ValidationUtils.cs @@ -0,0 +1,45 @@ +using System.Text.RegularExpressions; + +namespace LaunchDarkly.Sdk.Helpers +{ + + /// + /// Collection of utility functions for doing validation related work. + /// + public static class ValidationUtils + { + private static readonly Regex ValidCharsRegex = new Regex("^[-a-zA-Z0-9._]+\\z"); + + /// + /// Validates that a string is non-empty, not too longer for our systems, and only contains + /// alphanumeric characters, hyphens, periods, and underscores. + /// + /// the string to validate. + /// Null if the input is valid, otherwise an error string describing the issue. + public static string ValidateStringValue(string s) + { + if (string.IsNullOrEmpty(s)) + { + return "Empty string."; + } + + if (s.Length > 64) + { + return "Longer than 64 characters."; + } + + if (!ValidCharsRegex.IsMatch(s)) + { + return "Contains invalid characters."; + } + + return null; + } + + /// A string with all spaces replaced by hyphens. + public static string SanitizeSpaces(string s) + { + return s.Replace(" ", "-"); + } + } +} diff --git a/pkgs/shared/common/src/Json/IJsonSerializable.cs b/pkgs/shared/common/src/Json/IJsonSerializable.cs new file mode 100644 index 00000000..4eff02cb --- /dev/null +++ b/pkgs/shared/common/src/Json/IJsonSerializable.cs @@ -0,0 +1,19 @@ + +namespace LaunchDarkly.Sdk.Json +{ + /// + /// A marker interface for types that define their own JSON serialization rules. + /// + /// + /// + /// Some types that are defined in the LaunchDarkly.Sdk namespaces, such as + /// and , have a standard representation + /// in JSON. The internal structures of these types do not always correspond directly to + /// the JSON schema, so reflection-based serializers will not work without custom logic. + /// + /// + /// + public interface IJsonSerializable + { + } +} diff --git a/pkgs/shared/common/src/Json/LdJsonConverters.cs b/pkgs/shared/common/src/Json/LdJsonConverters.cs new file mode 100644 index 00000000..8c336fac --- /dev/null +++ b/pkgs/shared/common/src/Json/LdJsonConverters.cs @@ -0,0 +1,436 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Xml.Linq; + +namespace LaunchDarkly.Sdk.Json +{ + /// + /// Low-level JSON custom serializations for SDK types. + /// + /// + /// + /// Applications normally will not need to reference these types; they are used automatically + /// when you call methods (or System.Text.Json + /// methods, if that API is available). They are included here for use by other LaunchDarkly + /// library code. + /// + /// + /// Some of these converters also have ReadJsonValue and WriteJsonValue methods. + /// The reason for this is that the object type used by the regular converter methods + /// causes boxing/unboxing conversions if the target type is a struct, and if the + /// overhead of these is a concern it is more efficient to call a strongly typed method. + /// + /// + /// + public static partial class LdJsonConverters + { + private static void RequireToken(ref Utf8JsonReader reader, JsonTokenType expectedType, + string propName = null) + { + if (reader.TokenType != expectedType) + { + throw new JsonException("Expected " + expectedType + ", got " + reader.TokenType + + (propName is null ? "" : (" for property \"" + propName + "\""))); + } + } + + private static bool ConsumeNull(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.Null) + { + reader.Read(); + return true; + } + return false; + } + + private static bool NextProperty(ref Utf8JsonReader reader, out string name) + { + if (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + name = reader.GetString(); + reader.Read(); + return true; + } + name = null; + return false; + } + +#pragma warning disable CS1591 // don't bother with XML comments for these low-level helpers + public sealed class EvaluationReasonConverter : JsonConverter + { + public override void Write(Utf8JsonWriter writer, EvaluationReason value, JsonSerializerOptions options) => + WriteJsonValue(value, writer); + + public override EvaluationReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + ReadJsonValue(ref reader); + + public static EvaluationReason ReadJsonValue(ref Utf8JsonReader reader) + { + EvaluationReasonKind? kind = null; + int? ruleIndex = null; + string ruleId = null; + string prerequisiteKey = null; + EvaluationErrorKind? errorKind = null; + bool inExperiment = false; + BigSegmentsStatus? bigSegmentsStatus = null; + + RequireToken(ref reader, JsonTokenType.StartObject); + while (NextProperty(ref reader, out var name)) + { + switch (name) + { + case "kind": + kind = EvaluationReasonKindConverter.FromIdentifier(reader.GetString()); + break; + case "ruleIndex": + ruleIndex = reader.GetInt32(); + break; + case "ruleId": + ruleId = reader.GetString(); + break; + case "prerequisiteKey": + prerequisiteKey = reader.GetString(); + break; + case "errorKind": + errorKind = EvaluationErrorKindConverter.FromIdentifier(reader.GetString()); + break; + case "inExperiment": + inExperiment = reader.GetBoolean(); + break; + case "bigSegmentsStatus": + bigSegmentsStatus = BigSegmentsStatusConverter.FromIdentifier(reader.GetString()); + break; + default: + reader.Skip(); + break; + } + } + + if (!kind.HasValue) + { + throw new JsonException("Missing required property: kind"); + } + + EvaluationReason reason; + switch (kind.Value) + { + case EvaluationReasonKind.Off: + reason = EvaluationReason.OffReason; + break; + case EvaluationReasonKind.Fallthrough: + reason = EvaluationReason.FallthroughReason; + break; + case EvaluationReasonKind.TargetMatch: + reason = EvaluationReason.TargetMatchReason; + break; + case EvaluationReasonKind.RuleMatch: + reason = EvaluationReason.RuleMatchReason(ruleIndex ?? 0, ruleId); + break; + case EvaluationReasonKind.PrerequisiteFailed: + reason = EvaluationReason.PrerequisiteFailedReason(prerequisiteKey); + break; + case EvaluationReasonKind.Error: + reason = EvaluationReason.ErrorReason(errorKind ?? EvaluationErrorKind.Exception); + break; + default: // shouldn't be possible, all of the enum values are accounted for + reason = new EvaluationReason(); + break; + } + if (inExperiment) + { + reason = reason.WithInExperiment(true); + } + if (bigSegmentsStatus.HasValue) + { + reason = reason.WithBigSegmentsStatus(bigSegmentsStatus); + } + return reason; + } + + public static void WriteJsonValue(EvaluationReason value, Utf8JsonWriter writer) + { + writer.WriteStartObject(); + writer.WriteString("kind", EvaluationReasonKindConverter.ToIdentifier(value.Kind)); + switch (value.Kind) + { + case EvaluationReasonKind.RuleMatch: + writer.WriteNumber("ruleIndex", value.RuleIndex ?? 0); + writer.WriteString("ruleId", value.RuleId); + break; + case EvaluationReasonKind.PrerequisiteFailed: + writer.WriteString("prerequisiteKey", value.PrerequisiteKey); + break; + case EvaluationReasonKind.Error: + writer.WritePropertyName("errorKind"); + EvaluationErrorKindConverter.WriteJsonValue(value.ErrorKind.Value, writer); + break; + } + if (value.InExperiment) + { + writer.WriteBoolean("inExperiment", true); // omit property if false + } + if (value.BigSegmentsStatus.HasValue) + { + writer.WritePropertyName("bigSegmentsStatus"); + BigSegmentsStatusConverter.WriteJsonValue(value.BigSegmentsStatus.Value, writer); + } + writer.WriteEndObject(); + } + } + + public sealed class BigSegmentsStatusConverter : JsonConverter + { + public override void Write(Utf8JsonWriter writer, BigSegmentsStatus value, JsonSerializerOptions options) => + WriteJsonValue(value, writer); + + public override BigSegmentsStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + ReadJsonValue(ref reader); + + public static BigSegmentsStatus ReadJsonValue(ref Utf8JsonReader reader) => + FromIdentifier(reader.GetString()); + + public static void WriteJsonValue(BigSegmentsStatus instance, Utf8JsonWriter writer) => + writer.WriteStringValue(ToIdentifier(instance)); + + internal static BigSegmentsStatus FromIdentifier(string value) + { + foreach (BigSegmentsStatus k in Enum.GetValues(typeof(BigSegmentsStatus))) + { + if (ToIdentifier(k) == value) + { + return k; + } + } + throw new ArgumentException("invalid BigSegmentsStatus"); + } + + internal static string ToIdentifier(BigSegmentsStatus value) + { + switch (value) + { + case BigSegmentsStatus.Healthy: + return "HEALTHY"; + case BigSegmentsStatus.Stale: + return "STALE"; + case BigSegmentsStatus.NotConfigured: + return "NOT_CONFIGURED"; + case BigSegmentsStatus.StoreError: + return "STORE_ERROR"; + default: + throw new ArgumentException(); + } + } + } + + /// + /// The JSON converter for . + /// + public sealed class EvaluationErrorKindConverter : JsonConverter + { + public override void Write(Utf8JsonWriter writer, EvaluationErrorKind value, JsonSerializerOptions options) => + WriteJsonValue(value, writer); + + public override EvaluationErrorKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + ReadJsonValue(ref reader); + + public static EvaluationErrorKind ReadJsonValue(ref Utf8JsonReader reader) => + FromIdentifier(reader.GetString()); + + public static void WriteJsonValue(EvaluationErrorKind instance, Utf8JsonWriter writer) => + writer.WriteStringValue(ToIdentifier(instance)); + + internal static EvaluationErrorKind FromIdentifier(string value) + { + foreach (EvaluationErrorKind k in Enum.GetValues(typeof(EvaluationErrorKind))) + { + if (ToIdentifier(k) == value) + { + return k; + } + } + throw new ArgumentException("invalid EvaluationErrorKind"); + } + + internal static string ToIdentifier(EvaluationErrorKind value) + { + switch (value) + { + case EvaluationErrorKind.ClientNotReady: + return "CLIENT_NOT_READY"; + case EvaluationErrorKind.FlagNotFound: + return "FLAG_NOT_FOUND"; + case EvaluationErrorKind.UserNotSpecified: + return "USER_NOT_SPECIFIED"; + case EvaluationErrorKind.MalformedFlag: + return "MALFORMED_FLAG"; + case EvaluationErrorKind.WrongType: + return "WRONG_TYPE"; + case EvaluationErrorKind.Exception: + return "EXCEPTION"; + default: + throw new ArgumentException(); + } + } + } + + /// + /// The JSON converter for . + /// + public sealed class EvaluationReasonKindConverter : JsonConverter + { + public override void Write(Utf8JsonWriter writer, EvaluationReasonKind value, JsonSerializerOptions options) => + WriteJsonValue(value, writer); + + public override EvaluationReasonKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + ReadJsonValue(ref reader); + + public EvaluationReasonKind ReadJsonValue(ref Utf8JsonReader reader) => + FromIdentifier(reader.GetString()); + + public void WriteJsonValue(EvaluationReasonKind instance, Utf8JsonWriter writer) => + writer.WriteStringValue(ToIdentifier(instance)); + + internal static EvaluationReasonKind FromIdentifier(string value) + { + foreach (EvaluationReasonKind k in Enum.GetValues(typeof(EvaluationErrorKind))) + { + if (ToIdentifier(k) == value) + { + return k; + } + } + throw new ArgumentException("invalid EvaluationReasonKind"); + } + + internal static string ToIdentifier(EvaluationReasonKind value) + { + switch (value) + { + case EvaluationReasonKind.Off: + return "OFF"; + case EvaluationReasonKind.Fallthrough: + return "FALLTHROUGH"; + case EvaluationReasonKind.TargetMatch: + return "TARGET_MATCH"; + case EvaluationReasonKind.RuleMatch: + return "RULE_MATCH"; + case EvaluationReasonKind.PrerequisiteFailed: + return "PREREQUISITE_FAILED"; + case EvaluationReasonKind.Error: + return "ERROR"; + default: + throw new ArgumentException(); + } + } + } + + /// + /// The JSON converter for . + /// + public sealed class LdValueConverter : JsonConverter + { + public override void Write(Utf8JsonWriter writer, LdValue value, JsonSerializerOptions options) => + WriteJsonValue(value, writer); + + public override LdValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + ReadJsonValue(ref reader); + + public static void WriteJsonValue(LdValue value, Utf8JsonWriter writer) + { + switch (value.Type) + { + case LdValueType.Null: + writer.WriteNullValue(); + break; + case LdValueType.Bool: + writer.WriteBooleanValue(value.AsBool); + break; + case LdValueType.Number: + var asInt = value.AsInt; + var asDouble = value.AsDouble; + if ((double)asInt == asDouble) + { + writer.WriteNumberValue(asInt); + } + else + { + writer.WriteNumberValue(asDouble); + } + break; + case LdValueType.String: + writer.WriteStringValue(value.AsString); + break; + case LdValueType.Array: + writer.WriteStartArray(); + foreach (var v in value.List) + { + WriteJsonValue(v, writer); + } + writer.WriteEndArray(); + break; + case LdValueType.Object: + writer.WriteStartObject(); + foreach (var kv in value.Dictionary) + { + writer.WritePropertyName(kv.Key); + WriteJsonValue(kv.Value, writer); + } + writer.WriteEndObject(); + break; + } + } + + public static LdValue ReadJsonValue(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return LdValue.Of(true); + case JsonTokenType.False: + return LdValue.Of(false); + case JsonTokenType.Number: + return LdValue.Of(reader.GetDouble()); + case JsonTokenType.String: + return LdValue.Of(reader.GetString()); + case JsonTokenType.StartArray: + var arrayBuilder = LdValue.BuildArray(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + arrayBuilder.Add(ReadJsonValue(ref reader)); + } + return arrayBuilder.Build(); + case JsonTokenType.StartObject: + var objectBuilder = LdValue.BuildObject(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var name = reader.GetString(); + reader.Read(); + objectBuilder.Add(name, ReadJsonValue(ref reader)); + } + return objectBuilder.Build(); + default: + return LdValue.Null; + } + } + } + + /// + /// The JSON converter for . + /// + public sealed class UnixMillisecondTimeConverter: JsonConverter + { + public override void Write(Utf8JsonWriter writer, UnixMillisecondTime value, JsonSerializerOptions options) => + writer.WriteNumberValue(value.Value); + + public override UnixMillisecondTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + UnixMillisecondTime.OfMillis(reader.GetInt64()); + } + } +#pragma warning restore CS1591 +} diff --git a/pkgs/shared/common/src/Json/LdJsonConverters_AttributeRef.cs b/pkgs/shared/common/src/Json/LdJsonConverters_AttributeRef.cs new file mode 100644 index 00000000..811a1015 --- /dev/null +++ b/pkgs/shared/common/src/Json/LdJsonConverters_AttributeRef.cs @@ -0,0 +1,37 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LaunchDarkly.Sdk.Json +{ + public static partial class LdJsonConverters + { +#pragma warning disable CS1591 // don't bother with XML comments for these low-level helpers + + /// + /// The JSON converter for . + /// + public sealed class AttributeRefConverter : JsonConverter + { + public override void Write(Utf8JsonWriter writer, AttributeRef value, JsonSerializerOptions options) + { + if (value.Defined) + { + writer.WriteStringValue(value.ToString()); + } + else + { + writer.WriteNullValue(); + } + } + + public override AttributeRef Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var maybeString = reader.GetString(); + return maybeString is null ? new AttributeRef() : AttributeRef.FromPath(maybeString); + } + } + } + +#pragma warning restore CS1591 +} diff --git a/pkgs/shared/common/src/Json/LdJsonConverters_Context.cs b/pkgs/shared/common/src/Json/LdJsonConverters_Context.cs new file mode 100644 index 00000000..25e8286d --- /dev/null +++ b/pkgs/shared/common/src/Json/LdJsonConverters_Context.cs @@ -0,0 +1,277 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LaunchDarkly.Sdk.Json +{ + public static partial class LdJsonConverters + { +#pragma warning disable CS1591 // don't bother with XML comments for these low-level helpers + /// + /// The JSON converter for . + /// + /// + /// + /// Applications should not need to use this class directly. It is used automatically in + /// System.Text.Json conversion, or if you call + /// or + /// + /// + /// LaunchDarkly's JSON schema for contexts is standardized across SDKs. There are two serialization + /// formats, depending on whether it is a single-kind context or a multi-kind context. There is also + /// a third format corresponding to how users were represented in JSON in older LaunchDarkly SDKs; + /// this format is recognized automatically and supported for deserialization, but is not supported + /// for serialization. + /// + /// + public sealed class ContextConverter : JsonConverter + { + private const string AttrKind = "kind"; + internal const string AttrKey = "key"; // also used by UserConverter + private const string AttrName = "name"; + internal const string AttrAnonymous = "anonymous"; // also used by UserConverter + private const string JsonPropMeta = "_meta"; + private const string JsonPropPrivateAttributes = "privateAttributes"; + + public override Context Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // The implementation here of unmarshaling a context/user is that we first unmarshal the + // whole JSON object into an LdValue (as a convenient way to represent arbitrary JSON data + // structures), and then decide how to translate that object into a Context. This is + // somewhat inefficient because we're producing dictionary-like structures that we don't + // intend to keep-- but a straight-ahead approach, where we would parse JSON tokens as a + // stream, is not really possible due to the details of the context JSON schema (we can't + // know what schema we're using until we see the "kind" property). And, unmarshaling context + // data from JSON is not a task applications are likely to be doing frequently enough for + // it to be performance-critical. + var objValue = LdValueConverter.ReadJsonValue(ref reader); + if (objValue.Dictionary.TryGetValue(AttrKind, out var kindValue)) + { + if (!kindValue.IsString) + { + throw WrongType(kindValue, AttrKind); + } + if (kindValue.AsString == ContextKind.Multi.Value) + { + return ReadJsonMulti(objValue); + } + return ReadJsonSingle(objValue, null); + } + return ReadJsonOldUser(objValue); + } + + public override void Write(Utf8JsonWriter writer, Context c, JsonSerializerOptions options) + { + if (!(c.Error is null)) + { + throw new JsonException(Errors.JsonSerializeInvalidContext(c.Error)); + } + if (c.Multiple) + { + writer.WriteStartObject(); + writer.WriteString(AttrKind, ContextKind.Multi.Value); + foreach (var mc in c._multiContexts) + { + writer.WritePropertyName(mc.Kind.Value); + WriteJsonSingle(mc, writer, false); + } + writer.WriteEndObject(); + } + else + { + WriteJsonSingle(c, writer, true); + } + } + + private Context ReadJsonSingle(LdValue objValue, string knownKind) + { + var builder = Context.Builder("").Kind(knownKind); + var kind = knownKind; + foreach (var kv in objValue.Dictionary) + { + if (kv.Key == JsonPropMeta) + { + RequireType(kv.Value, LdValueType.Object, true, JsonPropMeta); + var meta = kv.Value.Dictionary; + if (meta.TryGetValue(JsonPropPrivateAttributes, out var privateAttrs)) + { + RequireType(privateAttrs, LdValueType.Array, true, "{0}.{1}", JsonPropMeta, JsonPropPrivateAttributes); + for (int i = 0; i < privateAttrs.List.Count; i++) + { + var value = privateAttrs.List[i]; + RequireType(value, LdValueType.String, false, "{0}.{1}[{2}]", JsonPropMeta, JsonPropPrivateAttributes, i); + builder.Private(value.AsString); + } + } + } + else + { + if (!builder.TrySet(kv.Key, kv.Value)) + { + throw WrongType(kv.Value, kv.Key); + } + if (kv.Key == AttrKind) + { + kind = kv.Value.AsString; + } + } + } + if (kind is null) + { + throw new JsonException(Errors.JsonMissingProperty(AttrKind)); + } + if (kind == "") + { + throw new JsonException(Errors.JsonContextEmptyKind); + } + return Validate(builder.Build()); + } + + private Context Validate(Context c) => + c.Error is null ? c : throw new JsonException(c.Error); + + private Context ReadJsonMulti(LdValue objValue) + { + var builder = Context.MultiBuilder(); + foreach (var kv in objValue.Dictionary) + { + if (kv.Key != "kind") + { + builder.Add(ReadJsonSingle(kv.Value, kv.Key)); + } + } + return Validate(builder.Build()); + } + + private Context ReadJsonOldUser(LdValue objValue) + { + var builder = Context.Builder(""); + builder.AllowEmptyKey(true); + var hasKey = false; + + foreach (var kv in objValue.Dictionary) + { + switch (kv.Key) + { + case AttrAnonymous: + RequireType(kv.Value, LdValueType.Bool, true, AttrAnonymous); + builder.Anonymous(kv.Value.AsBool); + break; + + case UserConverter.JsonPropCustom: + RequireType(kv.Value, LdValueType.Object, true, UserConverter.JsonPropCustom); + foreach (var kv1 in kv.Value.Dictionary) + { + switch (kv1.Key) + { + // can't allow an old-style custom attribute to overwrite a top-level one with the same name + case AttrKind: + case AttrKey: + case AttrName: + case AttrAnonymous: + case JsonPropMeta: + break; + default: + builder.Set(kv1.Key, kv1.Value); + break; + } + } + break; + + case UserConverter.JsonPropPrivateAttributeNames: + RequireType(kv.Value, LdValueType.Array, true, UserConverter.JsonPropPrivateAttributeNames); + for (int i = 0; i < kv.Value.List.Count; i++) + { + var value = kv.Value.List[i]; + RequireType(value, LdValueType.String, false, "{0}[{1}]", UserConverter.JsonPropPrivateAttributeNames, i); + builder.Private(AttributeRef.FromLiteral(value.AsString)); + } + break; + + case AttrName: + case "firstName": + case "lastName": + case "email": + case "country": + case "ip": + case "avatar": + if (!kv.Value.IsString && !kv.Value.IsNull) + { + throw WrongType(kv.Value, kv.Key); + } + builder.Set(kv.Key, kv.Value); + break; + + default: + if (!builder.TrySet(kv.Key, kv.Value)) + { + throw WrongType(kv.Value, kv.Key); + } + if (kv.Key == AttrKey) + { + hasKey = true; + } + break; + } + } + + if (!hasKey) + { + throw new JsonException(Errors.JsonMissingProperty(AttrKey)); + } + return Validate(builder.Build()); + } + + private static void RequireType(LdValue value, LdValueType type, bool nullable, + string propNameFormat, params object[] propNameArgs) + { + if (value.Type != type && !(value.IsNull && nullable)) + { + throw WrongType(value, string.Format(propNameFormat, propNameArgs)); + } + } + + private static JsonException WrongType(LdValue value, string name) => + new JsonException(Errors.JsonWrongType(name, value.Type)); + + private void WriteJsonSingle(in Context c, Utf8JsonWriter writer, bool includeKind) + { + writer.WriteStartObject(); + if (includeKind) + { + writer.WriteString(AttrKind, c.Kind.Value); + } + writer.WriteString(AttrKey, c.Key); + if (c.Name != null) + { + writer.WriteString(AttrName, c.Name); + } + if (c.Anonymous) + { + writer.WriteBoolean(AttrAnonymous, true); + } + if (!(c._attributes is null)) + { + foreach (var kv in c._attributes) + { + writer.WritePropertyName(kv.Key); + LdValueConverter.WriteJsonValue(kv.Value, writer); + } + } + if (!(c._privateAttributes is null)) + { + writer.WriteStartObject(JsonPropMeta); + writer.WriteStartArray(JsonPropPrivateAttributes); + foreach (var pa in c._privateAttributes) + { + writer.WriteStringValue(pa.ToString()); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + writer.WriteEndObject(); + } + } + } +#pragma warning restore CS1591 +} diff --git a/pkgs/shared/common/src/Json/LdJsonConverters_User.cs b/pkgs/shared/common/src/Json/LdJsonConverters_User.cs new file mode 100644 index 00000000..00431510 --- /dev/null +++ b/pkgs/shared/common/src/Json/LdJsonConverters_User.cs @@ -0,0 +1,139 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LaunchDarkly.Sdk.Json +{ + public static partial class LdJsonConverters + { +#pragma warning disable CS1591 // don't bother with XML comments for these low-level helpers + /// + /// The JSON converter for . + /// + /// + /// + /// Applications should not need to use this class directly. It is used automatically in + /// System.Text.Json conversion, or if you call + /// or + /// + /// + /// LaunchDarkly's JSON schema for users is standardized across SDKs. It corresponds to the + /// model, rather than the richer model; any JSON + /// representation of a can also be decoded as a , + /// but not vice versa. + /// + /// + public sealed class UserConverter : JsonConverter + { + internal const string JsonPropCustom = "custom"; + internal const string JsonPropPrivateAttributeNames = "privateAttributeNames"; + + public override User Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + RequireToken(ref reader, JsonTokenType.StartObject); + var builder = User.Builder("") as UserBuilder; // casting to concrete type so we can get internal methods + string key = null; + while (NextProperty(ref reader, out var name)) + { + switch (name) + { + case "key": + builder.Key(key = reader.GetString()); + break; + case "ip": + builder.IPAddress(reader.GetString()); + break; + case "country": + builder.Country(reader.GetString()); + break; + case "firstName": + builder.FirstName(reader.GetString()); + break; + case "lastName": + builder.LastName(reader.GetString()); + break; + case "name": + builder.Name(reader.GetString()); + break; + case "avatar": + builder.Avatar(reader.GetString()); + break; + case "email": + builder.Email(reader.GetString()); + break; + case "anonymous": + builder.Anonymous(!ConsumeNull(ref reader) && reader.GetBoolean()); + break; + case "custom": + if (!ConsumeNull(ref reader)) + { + RequireToken(ref reader, JsonTokenType.StartObject, name); + while (NextProperty(ref reader, out var propName)) + { + builder.Custom(propName, LdValueConverter.ReadJsonValue(ref reader)); + } + } + break; + case "privateAttributeNames": + if (!ConsumeNull(ref reader)) + { + RequireToken(ref reader, JsonTokenType.StartArray, name); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + builder.AddPrivateAttribute(reader.GetString()); + } + } + break; + default: + reader.Skip(); + break; + } + } + if (key is null) + { + throw new JsonException(Errors.JsonMissingProperty("key")); + } + return builder.Build(); + } + + public override void Write(Utf8JsonWriter writer, User u, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("key", u.Key); + foreach (UserAttribute a in UserAttribute.OptionalStringAttrs) + { + var value = u.GetAttribute(a); + if (!value.IsNull) + { + writer.WriteString(a.AttributeName, value.AsString); + } + } + if (u.Anonymous) + { + writer.WriteBoolean("anonymous", true); + } + if (u.Custom.Count != 0) + { + writer.WriteStartObject(JsonPropCustom); + foreach (var kv in u.Custom) + { + writer.WritePropertyName(kv.Key); + LdValueConverter.WriteJsonValue(kv.Value, writer); + } + writer.WriteEndObject(); + } + if (u.PrivateAttributeNames.Count != 0) + { + writer.WriteStartArray(JsonPropPrivateAttributeNames); + foreach (var pa in u.PrivateAttributeNames) + { + writer.WriteStringValue(pa); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } + } + } +#pragma warning restore CS1591 +} diff --git a/pkgs/shared/common/src/Json/LdJsonSerialization.cs b/pkgs/shared/common/src/Json/LdJsonSerialization.cs new file mode 100644 index 00000000..4c126b9f --- /dev/null +++ b/pkgs/shared/common/src/Json/LdJsonSerialization.cs @@ -0,0 +1,58 @@ +using System; +using System.Text.Json; + +namespace LaunchDarkly.Sdk.Json +{ + /// + /// Helper methods for JSON serialization of SDK classes. + /// + /// + /// These methods can be used with any SDK type that has the + /// marker interface. + /// + public static class LdJsonSerialization + { + /// + /// Converts an object to its JSON representation. + /// + /// + /// This is exactly equivalent to the System.Text.Json method JsonSerializer.Serialize, + /// except that it only accepts LaunchDarkly types that have the + /// marker interface. It is retained for backward compatibility. + /// + /// type of the object being serialized + /// the instance to serialize + /// the object's JSON encoding as a string + public static string SerializeObject(T instance) where T : IJsonSerializable => + JsonSerializer.Serialize(instance); + + /// + /// Converts an object to its JSON representation as a UTF-8 byte array. + /// + /// + /// This is exactly equivalent to the System.Text.Json method JsonSerializer.SerializeToUtf8Bytes, + /// except that it only accepts LaunchDarkly types that have the + /// marker interface. It is retained for backward compatibility. + /// + /// type of the object being serialized + /// the instance to serialize + /// the object's JSON encoding as a byte array + public static byte[] SerializeObjectToUtf8Bytes(T instance) where T : IJsonSerializable => + JsonSerializer.SerializeToUtf8Bytes(instance); + + /// + /// Parses an object from its JSON representation. + /// + /// + /// This is exactly equivalent to the System.Text.Json method JsonSerializer.Deserialize, + /// except that it only accepts LaunchDarkly types that have the + /// marker interface. It is retained for backward compatibility. + /// + /// type of the object being deserialized + /// the object's JSON encoding as a string + /// the deserialized instance + /// if the JSON encoding was invalid + public static T DeserializeObject(string json) where T : IJsonSerializable => + JsonSerializer.Deserialize(json); + } +} diff --git a/pkgs/shared/common/src/LaunchDarkly.CommonSdk.csproj b/pkgs/shared/common/src/LaunchDarkly.CommonSdk.csproj new file mode 100644 index 00000000..bb40f0b7 --- /dev/null +++ b/pkgs/shared/common/src/LaunchDarkly.CommonSdk.csproj @@ -0,0 +1,60 @@ + + + 7.0.0 + + netstandard2.0;net462;net8.0 + $(BUILDFRAMEWORKS) + portable + LaunchDarkly.CommonSdk + Library + 7.3 + LaunchDarkly.CommonSdk + LaunchDarkly common code for .NET and Xamarin clients + LaunchDarkly + LaunchDarkly + LaunchDarkly + Copyright 2018 LaunchDarkly + Apache-2.0 + https://github.com/launchdarkly/dotnet-core + https://github.com/launchdarkly/dotnet-core + main + true + snupkg + + + 1570,1572,1573,1574,1580,1581,1591,1711,1712 + + + true + + bin\$(Configuration)\$(TargetFramework)\LaunchDarkly.CommonSdk.xml + LaunchDarkly.Sdk + + + + + + + + + + + + + + + + + + + ../../../../LaunchDarkly.CommonSdk.snk + true + + + + + + diff --git a/pkgs/shared/common/src/LdValue.cs b/pkgs/shared/common/src/LdValue.cs new file mode 100644 index 00000000..a79cbf2a --- /dev/null +++ b/pkgs/shared/common/src/LdValue.cs @@ -0,0 +1,1123 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using LaunchDarkly.Sdk.Internal.Helpers; +using LaunchDarkly.Sdk.Json; + +namespace LaunchDarkly.Sdk +{ + // Note, internal classes used here are in LdValueHelpers.cs + + /// + /// Describes the type of a JSON value. + /// + public enum LdValueType + { + /// + /// The value is null. + /// + Null, + /// + /// The value is a boolean. + /// + Bool, + /// + /// The value is numeric. JSON does not have separate types for int and float, + /// but you can convert to either. + /// + Number, + /// + /// The value is a string. + /// + String, + /// + /// The value is an array. + /// + Array, + /// + /// The value is an object (a.k.a. hash or dictionary). + /// + Object + } + + /// + /// An immutable instance of any data type that is allowed in JSON. + /// + /// + /// + /// This is used as the return type of the client's JsonVariation method, and also as + /// the type of custom attributes in and . + /// + /// + /// LaunchDarkly allows feature flag variations and custom user attributes to be of any JSON + /// type, with some restrictions (notably, regarding numeric precision). For more details, see + /// our documentation on flag + /// value types. + /// + /// + /// Note that this is a , not a class, so it is always passed by value + /// and is not nullable; JSON nulls are represented by the constant and can + /// be detected with . Whenever possible, + /// stores primitive types within the struct rather than allocating an object on the heap. + /// + /// + /// There are several ways to create an . For primitive types, + /// use the various overloads of "Of" such as ; these are very efficient + /// since they do not allocate any objects on the heap. For arrays and objects (dictionaries), + /// use , , + /// , or the corresponding + /// methods in the type-specific instances. + /// + /// + /// To convert to other types, there are the "As" properties such as + /// use the various overloads of "Of" such as ; these are very efficient + /// since they do not allocate any objects on the heap. For arrays and objects (dictionaries), + /// use or . + /// + /// + [JsonConverter(typeof(LdJsonConverters.LdValueConverter))] + public struct LdValue : IEquatable, IJsonSerializable + { + #region Private fields + + private static readonly LdValue _nullInstance = new LdValue(LdValueType.Null, false, 0, null); + + private readonly LdValueType _type; + private readonly bool _boolValue; + private readonly double _doubleValue; // all numbers are stored as double + private readonly string _stringValue; + private readonly ImmutableList _arrayValue; + private readonly ImmutableDictionary _objectValue; + + #endregion + + #region Public static properties + + /// + /// Convenience property for an that wraps a value. + /// + public static LdValue Null => _nullInstance; + + #endregion + + #region Internal/private constructors, factory, and properties + + // Constructor from a primitive type + private LdValue(LdValueType type, bool boolValue, double doubleValue, string stringValue) + { + _type = type; + _boolValue = boolValue; + _doubleValue = doubleValue; + _stringValue = stringValue; + _arrayValue = null; + _objectValue = null; + } + + // Constructor from a read-only list + private LdValue(ImmutableList list) + { + _type = LdValueType.Array; + _arrayValue = list; + _boolValue = false; + _doubleValue = 0; + _stringValue = null; + _objectValue = null; + } + + // Constructor from a read-only dictionary + private LdValue(ImmutableDictionary dict) + { + _type = LdValueType.Object; + _objectValue = dict; + _boolValue = false; + _doubleValue = 0; + _stringValue = null; + _arrayValue = null; + } + + #endregion + + #region Public factory methods + + /// + /// Initializes an from a boolean value. + /// + /// the initial value + /// a struct that wraps the value + public static LdValue Of(bool value) => + new LdValue(LdValueType.Bool, value, 0, null); + + /// + /// Initializes an from an value. + /// + /// the initial value + /// a struct that wraps the value + public static LdValue Of(int value) => + new LdValue(LdValueType.Number, false, value, null); + + /// + /// Initializes an from a value. + /// + /// + /// Numeric values in LaunchDarkly have some precision limitations. For more details, see our + /// documentation on flag + /// value types. + /// + /// the initial value + /// a struct that wraps the value + public static LdValue Of(long value) => + new LdValue(LdValueType.Number, false, value, null); + + /// + /// Initializes an from a value. + /// + /// + /// Numeric values in LaunchDarkly have some precision limitations. For more details, see our + /// documentation on flag + /// value types. + /// + /// the initial value + /// a struct that wraps the value + public static LdValue Of(float value) => + new LdValue(LdValueType.Number, false, value, null); + + /// + /// Initializes an from a value. + /// + /// + /// Numeric values in LaunchDarkly have some precision limitations. For more details, see our + /// documentation on flag + /// value types. + /// + /// the initial value + /// a struct that wraps the value + public static LdValue Of(double value) => + new LdValue(LdValueType.Number, false, value, null); + + /// + /// Initializes an from a string value. + /// + /// + /// A null string reference will be stored as rather than as a string. + /// + /// the initial value + /// a struct that wraps the value + public static LdValue Of(string value) => + value is null ? Null : new LdValue(LdValueType.String, false, 0, value); + + /// + /// Initializes an as an array, from a sequence of JSON values. + /// + /// + /// To create an array from values of some other type, use + /// + /// + /// + /// var listOfValues = new List<LdValue> { LdValue.Of(1), LdValue.Of("x") }; + /// var arrayValue = LdValue.ArrayFrom(listOfValues); + /// + /// + /// a sequence of values + /// a struct representing a JSON array, or if the parameter was null + public static LdValue ArrayFrom(IEnumerable values) => + Convert.Json.ArrayFrom(values); + + /// + /// Initializes an as an array, from a sequence of JSON values. + /// + /// + /// To create an array from values of some other type, use + /// + /// + /// + /// var arrayValue = LdValue.ArrayFrom(LdValue.Of("a"), LdValue.Of("b")); + /// + /// + /// any number of values + /// a struct representing a JSON array + public static LdValue ArrayOf(params LdValue[] values) => + Convert.Json.ArrayOf(values); + + /// + /// Starts building an array value. + /// + /// an + public static ArrayBuilder BuildArray() + { + return new ArrayBuilder(); + } + + /// + /// Initializes an as a JSON object, from a dictionary. + /// + /// + /// To use a dictionary with values of some other type, use . + /// + /// a dictionary with string keys and values of the specified type + /// a struct representing a JSON object, or if the parameter was null + public static LdValue ObjectFrom(IReadOnlyDictionary dictionary) => + Convert.Json.ObjectFrom(dictionary); + + /// + /// Starts building an object value. + /// + /// an + public static ObjectBuilder BuildObject() => + new ObjectBuilder(); + + /// + /// Parses a value from a JSON-encoded string. + /// + /// + /// + /// var myValue = LdValue.Parse("[1,2]"); + /// Assert.Equal(LdValue.BuildArray().Add(1).Add(2).Build(), myValue); // true + /// + /// + /// a JSON string + /// the equivalent + /// if the string could not be parsed as JSON + /// + public static LdValue Parse(string jsonString) => + LdJsonSerialization.DeserializeObject(jsonString); + + #endregion + + #region Public properties + + /// + /// The type of the JSON value. + /// + public LdValueType Type => _type; + + /// + /// True if the wrapped value is . + /// + public bool IsNull => Type == LdValueType.Null; + + /// + /// True if the wrapped value is numeric. + /// + public bool IsNumber => Type == LdValueType.Number; + + /// + /// True if the wrapped value is an integer. + /// + /// + /// JSON does not have separate types for integer and floating-point values; they are both just + /// numbers. returns true if and only if the actual numeric value has no + /// fractional component, so LdValue(2).IsInt and LdValue(2.0f).IsInt + /// are both true. + /// + public bool IsInt => IsNumber && (AsFloat == (float)AsInt); + + /// + /// True if the wrapped value is a string. + /// + public bool IsString => Type == LdValueType.String; + + /// + /// Gets the boolean value if this is a boolean. + /// + /// + /// + /// If the value is or is not a boolean, this returns . + /// It will never throw an exception. + /// + /// + /// This is equivalent to calling on + /// . + /// + /// + public bool AsBool => Type == LdValueType.Bool && _boolValue; + + /// + /// Gets the string value if this is a string. + /// + /// + /// + /// If the value is or is not a string, this returns . + /// It will never throw an exception. To get a JSON representation of the value as a string, use + /// instead. + /// + /// + /// This is equivalent to calling on + /// . + /// + /// + public string AsString => Type == LdValueType.String ? _stringValue : null; + + /// + /// Gets the value as an if it is numeric. + /// + /// + /// + /// If the value is or is not numeric, this returns zero. It will + /// never throw an exception. + /// + /// + /// If the value is a number but not an integer, it will be rounded toward zero. + /// + /// + /// This is equivalent to calling on + /// . + /// + /// + public int AsInt => (int)Math.Round(AsDouble, MidpointRounding.ToEven); + + /// + /// Gets the value as an if it is numeric. + /// + /// + /// + /// If the value is or is not numeric, this returns zero. It will + /// never throw an exception. + /// + /// + /// If the value is a number but not an integer, it will be rounded toward zero. + /// + /// + /// This is equivalent to calling on + /// . + /// + /// + public long AsLong => (long)Math.Round(AsDouble, MidpointRounding.ToEven); + + /// + /// Gets the value as an if it is numeric. + /// + /// + /// + /// If the value is or is not numeric, this returns zero. It will never + /// throw an exception. + /// + /// + /// This is equivalent to calling on + /// . + /// + /// + public float AsFloat => (float)AsDouble; + + /// + /// Gets the value as an if it is numeric. + /// + /// + /// + /// If the value is or is not numeric, this returns zero. It will never + /// throw an exception. + /// + /// + /// This is equivalent to calling on + /// . + /// + /// + public double AsDouble => Type == LdValueType.Number ? _doubleValue : 0; + + /// + /// Returns an immutable list of values if this value is an array; otherwise an empty list. + /// + public ImmutableList List => _arrayValue ?? ImmutableList.Empty; + + /// + /// Returns an immutable dictionary of values if this value is an object; otherwise an empty dictionary. + /// + public ImmutableDictionary Dictionary => _objectValue ?? + ImmutableDictionary.Empty; + + /// + /// The number of values if this is an array or object; otherwise zero. + /// + public int Count + { + get + { + switch (_type) + { + case LdValueType.Array: + return _arrayValue.Count; + case LdValueType.Object: + return _objectValue.Count; + default: + return 0; + } + } + } + + #endregion + + #region Public methods + + /// + /// Retrieves an array item or object key by index. Never throws an exception. + /// + /// the item index + /// the item value if this is an array; the key if this is an object; otherwise + public LdValue Get(int index) + { + switch (_type) + { + case LdValueType.Array: + return index >= 0 && index < _arrayValue.Count ? _arrayValue[index] : LdValue.Null; + case LdValueType.Object: + return index >= 0 && index < _objectValue.Count ? LdValue.Of(_objectValue.Keys.ElementAt(index)) : LdValue.Null; + default: + return LdValue.Null; + } + } + + /// + /// Retrieves a object value by key. Never throws an exception. + /// + /// the key to retrieve + /// the value for the key, if this is an object; if not found, or if this is not an object + public LdValue Get(string key) + { + return _type == LdValueType.Object && _objectValue.TryGetValue(key, out var value) + ? value + : LdValue.Null; + } + + /// + /// Converts the value to a read-only list of elements of some type. + /// + /// + /// + /// The first parameter is one of the type converters from , or your own + /// implementation of for some type. + /// + /// + /// If the value is not a JSON array at all, an empty list is returned. This method will + /// never throw an exception. + /// + /// + /// This is an efficient method because it does not copy values to a new list, but returns + /// a read-only view into the existing array. + /// + /// + /// the element type + /// an array of elements of the specified type + public IReadOnlyList AsList(Converter desiredType) + { + if (_type == LdValueType.Array) + { + return new LdValueListConverter(_arrayValue, desiredType.ToType); + } + return new LdValueListConverter(null, null); + } + + /// + /// Converts the value to a read-only dictionary. + /// + /// + /// + /// The first parameter is one of the type converters from , or your own + /// implementation of for some type. + /// + /// + /// This is an efficient method because it does not copy values to a new dictionary, but returns + /// a read-only view into the existing object. + /// + /// + /// a read-only dictionary + public IReadOnlyDictionary AsDictionary(Converter desiredType) + { + if (_type == LdValueType.Object) + { + return new LdValueDictionaryConverter(_objectValue, desiredType.ToType); + } + return new LdValueDictionaryConverter(null, null); + } + + /// + /// Converts the value to its JSON encoding. + /// + /// + /// For instance, LdValue.Of(1).ToJsonString() returns "1"; + /// LdValue.Of("x").ToJsonString() returns "\"x\""; and + /// LdValue.Null.ToJsonString() returns "null". + /// + /// the JSON encoding of the value + /// + public string ToJsonString() + { + switch (_type) + { + case LdValueType.Null: + return "null"; + case LdValueType.Bool: + return _boolValue ? "true" : "false"; + default: + return JsonSerializer.Serialize(this); + } + } + + /// + /// Performs a deep-equality comparison. + /// + public override bool Equals(object o) => (o is LdValue v) && Equals(v); + + /// + /// Performs a deep-equality comparison. + /// + public bool Equals(LdValue o) + { + if (Type != o.Type) + { + return false; + } + switch (Type) + { + case LdValueType.Null: + return true; + case LdValueType.Bool: + return AsBool == o.AsBool; + case LdValueType.Number: + return AsDouble == o.AsDouble; // don't worry about ints because you can't lose precision going from int to double + case LdValueType.String: + return AsString.Equals(o.AsString); + case LdValueType.Array: + return AsList(Convert.Json).SequenceEqual(o.AsList(Convert.Json)); + case LdValueType.Object: + { + var d0 = AsDictionary(Convert.Json); + var d1 = o.AsDictionary(Convert.Json); + return d0.Count == d1.Count && d0.All(kv => + d1.TryGetValue(kv.Key, out var v) && kv.Value.Equals(v)); + } + default: + return false; + } + } + + /// + public override int GetHashCode() + { + switch (Type) + { + case LdValueType.Null: + return 0; + case LdValueType.Bool: + return AsBool.GetHashCode(); + case LdValueType.Number: + return AsFloat.GetHashCode(); + case LdValueType.String: + return AsString.GetHashCode(); + case LdValueType.Array: + { + var h = new HashCodeBuilder(); + foreach (var item in AsList(Convert.Json)) + { + h = h.With(item); + } + return h.Value; + } + case LdValueType.Object: + { + var h = new HashCodeBuilder(); + var d = AsDictionary(Convert.Json); + var keys = d.Keys.ToArray(); + Array.Sort(keys); // inefficient, but ensures determinacy + foreach (var key in keys) + { + h = h.With(key).With(d[key]); + } + return h.Value; + } + default: + return 0; + } + } + + /// + /// Converts the value to its JSON encoding (same as ). + /// + /// the JSON encoding of the value + public override string ToString() => ToJsonString(); + +#pragma warning disable CS1591 // don't need XML comments for these standard methods + public static bool operator ==(LdValue a, LdValue b) => a.Equals(b); + + public static bool operator !=(LdValue a, LdValue b) => !a.Equals(b); +#pragma warning restore CS1591 + + #endregion + + #region Inner types + + /// + /// An object returned by for building an array of values. + /// + public sealed class ArrayBuilder + { + private ImmutableList.Builder _builder = ImmutableList.CreateBuilder(); + + internal ArrayBuilder() { } + + /// + /// Adds a value to the array being built. + /// + /// the value to add + /// the same builder + public ArrayBuilder Add(LdValue value) + { + _builder.Add(value); + return this; + } + + /// + /// Adds a value to the array being built. + /// + /// the value to add + /// the same builder + public ArrayBuilder Add(bool value) + { + _builder.Add(LdValue.Of(value)); + return this; + } + + /// + /// Adds a value to the array being built. + /// + /// + /// Numeric values in LaunchDarkly have some precision limitations. For more details, see our + /// documentation on flag + /// value types. + /// + /// the value to add + /// the same builder + public ArrayBuilder Add(long value) + { + _builder.Add(LdValue.Of(value)); + return this; + } + + /// + /// Adds a value to the array being built. + /// + /// + /// Numeric values in LaunchDarkly have some precision limitations. For more details, see our + /// documentation on flag + /// value types. + /// + /// the value to add + /// the same builder + public ArrayBuilder Add(double value) + { + _builder.Add(LdValue.Of(value)); + return this; + } + + /// + /// Adds a value to the array being built. + /// + /// the value to add + /// the same builder + public ArrayBuilder Add(string value) + { + _builder.Add(LdValue.Of(value)); + return this; + } + + /// + /// Returns an array value containing the items provided so far. + /// + /// an immutable array + public LdValue Build() + { + return new LdValue(_builder.ToImmutable()); + } + } + + /// + /// An object returned by for building an object from keys and values. + /// + public sealed class ObjectBuilder + { + private ImmutableDictionary.Builder _builder = ImmutableDictionary.CreateBuilder(); + + internal ObjectBuilder() { } + + /// + /// Adds a key-value pair to the object being built. + /// + /// the key to add + /// the value to add + /// the same builder + public ObjectBuilder Add(string key, LdValue value) + { + _builder.Add(key, value); + return this; + } + + /// + /// Adds a key-value pair to the object being built. + /// + /// the key to add + /// the value to add + /// the same builder + public ObjectBuilder Add(string key, bool value) => + Add(key, LdValue.Of(value)); + + /// + /// Adds a key-value pair to the object being built. + /// + /// + /// Numeric values in LaunchDarkly have some precision limitations. For more details, see our + /// documentation on flag + /// value types. + /// + /// the key to add + /// the value to add + /// the same builder + public ObjectBuilder Add(string key, long value) => + Add(key, LdValue.Of(value)); + + /// + /// Adds a key-value pair to the object being built. + /// + /// + /// Numeric values in LaunchDarkly have some precision limitations. For more details, see our + /// documentation on flag + /// value types. + /// + /// the key to add + /// the value to add + /// the same builder + public ObjectBuilder Add(string key, double value) => + Add(key, LdValue.Of(value)); + + /// + /// Removes a key from the object, or does nothing if no such key exists. + /// + /// the key + /// the same builder + public ObjectBuilder Remove(string key) + { + _builder.Remove(key); + return this; + } + + /// + /// Adds a key-value pair to the object being built or replaces an existing key. + /// + /// the key + /// the value to add or replace + /// the same builder + public ObjectBuilder Set(string key, LdValue value) => + Remove(key).Add(key, value); + + /// + /// Adds a key-value pair to the object being built or replaces an existing key. + /// + /// the key + /// the value to add or replace + /// the same builder + public ObjectBuilder Set(string key, bool value) => + Set(key, LdValue.Of(value)); + + /// + /// Adds a key-value pair to the object being built or replaces an existing key. + /// + /// + /// Numeric values in LaunchDarkly have some precision limitations. For more details, see our + /// documentation on flag + /// value types. + /// + /// the key + /// the value to add or replace + /// the same builder + public ObjectBuilder Set(string key, long value) => + Set(key, LdValue.Of(value)); + + /// + /// Adds a key-value pair to the object being built or replaces an existing key. + /// + /// + /// Numeric values in LaunchDarkly have some precision limitations. For more details, see our + /// documentation on flag + /// value types. + /// + /// the key + /// the value to add or replace + /// the same builder + public ObjectBuilder Set(string key, double value) => + Set(key, LdValue.Of(value)); + + /// + /// Adds a key-value pair to the object being built or replaces an existing key. + /// + /// the key + /// the value to add or replace + /// the same builder + public ObjectBuilder Set(string key, string value) => + Set(key, LdValue.Of(value)); + + /// + /// Copies existing property keys and values from an existing JSON object; does + /// nothing if the value is not an object. + /// + /// a JSON value + /// the same builder + public ObjectBuilder Copy(LdValue fromObject) + { + foreach (var kv in fromObject.AsDictionary(Convert.Json)) + { + Set(kv.Key, kv.Value); + } + return this; + } + + /// + /// Adds a key-value pair to the object being built. + /// + /// the key to add + /// the value to add + /// the same builder + public ObjectBuilder Add(string key, string value) + { + _builder.Add(key, LdValue.Of(value)); + return this; + } + + /// + /// Returns an object value containing the keys and values provided so far. + /// + /// an immutable object + public LdValue Build() + { + return new LdValue(_builder.ToImmutable()); + } + } + + /// + /// Defines a conversion between and some other type. + /// + /// + /// + /// Besides converting individual values, provides factory methods + /// like which transform a collection of the specified type to the + /// corresponding complex type. + /// + /// + /// There are type-specific instances of this class for commonly used types in + /// , but you can also implement your own. + /// + /// + /// the type to convert from/to + public abstract class Converter + { + /// + /// Converts a value of the specified type to an . + /// + /// + /// This method should never throw an exception; if for some reason the value is invalid, + /// it should return . + /// + /// a value of this type + /// an + abstract public LdValue FromType(T valueOfType); + + /// + /// Converts an to a value of the specified type. + /// + /// + /// This method should never throw an exception; if the conversion cannot be done, it + /// should return default(T). + /// + /// an + /// a value of this type + abstract public T ToType(LdValue jsonValue); + + /// + /// Initializes an as an array, from a sequence of this type. + /// + /// + /// Values are copied, so subsequent changes to the source values do not affect the array. + /// + /// + /// + /// var listOfInts = new List<int> { 1, 2, 3 }; + /// var arrayValue = LdValue.Convert.Int.ArrayFrom(arrayOfInts); + /// + /// + /// a sequence of elements of the specified type + /// a struct representing a JSON array, or if the + /// parameter was null + public LdValue ArrayFrom(IEnumerable values) + { + if (values is null) + { + return Null; + } + return new LdValue(ImmutableList.CreateRange(values.Select(FromType))); + } + + /// + /// Initializes an as an array, from a sequence of this type. + /// + /// + /// Values are copied, so subsequent changes to the source values do not affect the array. + /// + /// + /// + /// var arrayValue = LdValue.Convert.Int.ArrayOf(1, 2, 3); + /// + /// + /// any number of elements of the specified type + /// a struct representing a JSON array + public LdValue ArrayOf(params T[] values) + { + return ArrayFrom(values); + } + + /// + /// Initializes an as a JSON object, from a dictionary containing + /// values of this type. + /// + /// + /// Values are copied, so subsequent changes to the source values do not affect the array. + /// + /// + /// + /// var dictionaryOfInts = new Dictionary<string, int> { { "a", 1 }, { "b", 2 } }; + /// var objectValue = LdValue.Convert.Int.ObjectFrom(dictionaryOfInts); + /// + /// + /// a dictionary with string keys and values of the specified type + /// a struct representing a JSON object, or if the + /// parameter was null + public LdValue ObjectFrom(IReadOnlyDictionary dictionary) + { + if (dictionary is null) + { + return Null; + } + var d = ImmutableDictionary.CreateRange(dictionary.Select(kv => + new KeyValuePair(kv.Key, FromType(kv.Value)))); + return new LdValue(d); + } + } + + /// + /// Predefined instances of for commonly used types. + /// + /// + /// These are mostly useful for methods that convert to or from a + /// collection of some type, such as and + /// . + /// + public static class Convert + { + /// + /// A for the type. + /// + /// + /// Its behavior is consistent with and + /// . + /// + public static readonly Converter Bool = new ConverterImpl( + v => LdValue.Of(v), + j => j.AsBool + ); + + /// + /// A for the type. + /// + /// + /// Its behavior is consistent with and + /// . + /// + public static readonly Converter Int = new ConverterImpl( + v => LdValue.Of(v), + j => j.AsInt + ); + + /// + /// A for the type. + /// + /// + /// + /// Its behavior is consistent with and + /// . + /// + /// + /// Note that the LaunchDarkly service, and most of the SDKs, represent numeric values internally + /// in 64-bit floating-point, which has slightly less precision than a signed 64-bit + /// ; therefore, the full range of values cannot be + /// accurately represented. If you need to set a user attribute to a numeric value with more + /// significant digits than will fit in a , it is best to encode it as a string. + /// + /// + public static readonly Converter Long = new ConverterImpl( + v => LdValue.Of(v), + j => j.AsLong + ); + + /// + /// A for the type. + /// + /// + /// Its behavior is consistent with and + /// . + /// + public static readonly Converter Float = new ConverterImpl( + v => LdValue.Of(v), + j => j.AsFloat + ); + + /// + /// A for the type. + /// + /// + /// Its behavior is consistent with and + /// . + /// + public static readonly Converter Double = new ConverterImpl( + v => LdValue.Of(v), + j => j.AsDouble + ); + + /// + /// A for the type. + /// + /// + /// Its behavior is consistent with and + /// . + /// + public static readonly Converter String = new ConverterImpl( + v => LdValue.Of(v), + j => j.AsString + ); + + /// + /// A that indicates the value is an + /// and does not need to be converted. + /// + public static readonly Converter Json = new ConverterImpl( + v => v, + j => j + ); + } + + private sealed class ConverterImpl : Converter + { + private readonly Func _fromTypeFn; + private readonly Func _toTypeFn; + + internal ConverterImpl(Func fromTypeFn, + Func toTypeFn) + { + _fromTypeFn = fromTypeFn; + _toTypeFn = toTypeFn; + } + + public override LdValue FromType(T valueOfType) => _fromTypeFn(valueOfType); + public override T ToType(LdValue jsonValue) => _toTypeFn(jsonValue); + } + + #endregion + } +} diff --git a/pkgs/shared/common/src/UnixMillisecondTime.cs b/pkgs/shared/common/src/UnixMillisecondTime.cs new file mode 100644 index 00000000..bea08374 --- /dev/null +++ b/pkgs/shared/common/src/UnixMillisecondTime.cs @@ -0,0 +1,105 @@ +using System; +using System.Text.Json.Serialization; +using LaunchDarkly.Sdk.Json; + +namespace LaunchDarkly.Sdk +{ + /// + /// An instant measured in milliseconds since the Unix epoch. + /// + /// + /// + /// LaunchDarkly services internally use this method of representing a date/timestamp as an + /// integer. For instance, it is used for the creation time property of an analytics event. + /// You do not need to refer to this type during normal usage of LaunchDarkly SDKs, but it + /// is public and supported for convenience. + /// + /// + /// When converting to or from JSON, it is encoded as an integer. + /// + /// + [JsonConverter(typeof(LdJsonConverters.UnixMillisecondTimeConverter))] + public struct UnixMillisecondTime : IEquatable, IComparable, + IJsonSerializable + { + /// + /// The instant that defines the beginning of Unix time. + /// + public static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// The millisecond time value. + /// + public long Value { get; } + + /// + /// Converts this value to a DateTime. + /// + public DateTime AsDateTime => Epoch.AddMilliseconds(Value); + + private UnixMillisecondTime(long value) + { + Value = value; + } + + /// + /// Gets the current date/time as a UnixMillisecondTime. + /// + public static UnixMillisecondTime Now => FromDateTime(DateTime.UtcNow); + + /// + /// Creates a UnixMillisecondTime value. + /// + /// the millisecond time value + /// a UnixMillisecondTime + public static UnixMillisecondTime OfMillis(long millis) => + new UnixMillisecondTime(millis); + + /// + /// Converts a DateTime to UnixMillisecondTime. + /// + /// a DateTime + /// a UnixMillisecondTime + public static UnixMillisecondTime FromDateTime(DateTime dateTime) => + new UnixMillisecondTime( + (long)(dateTime - Epoch).TotalMilliseconds + ); + + /// + /// Computes a new time based on a offset in milliseconds from this one. + /// + /// a positive or negative number of milliseconds + /// a new UnixMillisecondTime + public UnixMillisecondTime PlusMillis(long millis) => + new UnixMillisecondTime(Value + millis); + +#pragma warning disable CS1591 // don't need XML comments for these standard methods + public bool Equals(UnixMillisecondTime other) => Value == other.Value; + + public int CompareTo(UnixMillisecondTime other) => Value.CompareTo(other.Value); + + public override bool Equals(object other) => other is UnixMillisecondTime && + Equals((UnixMillisecondTime)other); + + public override int GetHashCode() => Value.GetHashCode(); + + public static bool operator ==(UnixMillisecondTime a, UnixMillisecondTime b) => + a.Value == b.Value; + + public static bool operator !=(UnixMillisecondTime a, UnixMillisecondTime b) => + a.Value != b.Value; + + public static bool operator <(UnixMillisecondTime a, UnixMillisecondTime b) => + a.Value < b.Value; + + public static bool operator <=(UnixMillisecondTime a, UnixMillisecondTime b) => + a.Value <= b.Value; + + public static bool operator >(UnixMillisecondTime a, UnixMillisecondTime b) => + a.Value > b.Value; + + public static bool operator >=(UnixMillisecondTime a, UnixMillisecondTime b) => + a.Value >= b.Value; +#pragma warning restore CS1591 + } +} diff --git a/pkgs/shared/common/src/User.cs b/pkgs/shared/common/src/User.cs new file mode 100644 index 00000000..5fd07d79 --- /dev/null +++ b/pkgs/shared/common/src/User.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using LaunchDarkly.Sdk.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Xml.Linq; + +namespace LaunchDarkly.Sdk +{ + /// + /// Attributes of a user for whom you are evaluating feature flags. + /// + /// + /// + /// contains any user-specific properties that may be used in feature flag + /// configurations to produce different flag variations for different users. You may define + /// these properties however you wish. + /// + /// + /// User supports only a subset of the behaviors that are available with the newer + /// type. A User is equivalent to an individual Context that has a + /// of ("user"); it also has + /// more constraints on attribute values than a Context does (for instance, built-in attributes + /// such as can only have string values). Older LaunchDarkly SDKs only + /// had the User model, and the User type has been retained for backward compatibility, but it + /// may be removed in a future SDK version; also, the SDK will always convert a User to a + /// Context internally, which has some overhead. Therefore, developers are recommended to + /// migrate toward using Context. + /// + /// + /// The only mandatory property of User is the , which must uniquely identify + /// each user. For authenticated users, this may be a username or e-mail address. For anonymous + /// users, this could be an IP address or session ID. + /// + /// + /// Besides the mandatory key, supports two kinds of optional attributes: + /// built-in attributes (e.g. and ) and custom + /// attributes. The built-in attributes have specific allowed value types; also, two of them + /// ( and ) have special meanings in LaunchDarkly. + /// Custom attributes have flexible value types, and can have any names that do not conflict + /// with built-in attributes. + /// + /// + /// Both built-in attributes and custom attributes can be referenced in targeting rules, and + /// are included in analytics data. + /// + /// + /// Instances of User are immutable once created. They can be created with the factory method + /// , or using a builder pattern with + /// or . + /// + /// + /// For converting this type to or from JSON, see . + /// + /// + [JsonConverter(typeof(LdJsonConverters.UserConverter))] + public class User : IEquatable, IJsonSerializable + { + private readonly string _key; + private readonly string _ip; + private readonly string _country; + private readonly string _firstName; + private readonly string _lastName; + private readonly string _name; + private readonly string _avatar; + private readonly string _email; + private readonly bool _anonymous; + internal readonly ImmutableDictionary _custom; + internal readonly ImmutableHashSet _privateAttributeNames; + + /// + /// The unique key for the user. + /// + public string Key => _key; + + /// + /// The IP address of the user. + /// + public string IPAddress => _ip; + + /// + /// The country code for the user. + /// + public string Country => _country; + + /// + /// The user's first name. + /// + public string FirstName => _firstName; + + /// + /// The user's last name. + /// + public string LastName => _lastName; + + /// + /// The user's full name. + /// + public string Name => _name; + + /// + /// The user's avatar. + /// + public string Avatar => _avatar; + + /// + /// The user's email address. + /// + public string Email => _email; + + /// + /// Whether or not the user is anonymous. + /// + public bool Anonymous => _anonymous; + + /// + /// Custom attributes for the user. + /// + public IImmutableDictionary Custom => _custom; + + /// + /// Used internally to track which attributes are private. + /// + public IImmutableSet PrivateAttributeNames => _privateAttributeNames; + + /// + /// Creates an for constructing a user object using a fluent syntax. + /// + /// + /// This is the only method for building a if you are setting properties + /// besides the . The has methods for setting + /// any number of properties, after which you call to get the + /// resulting instance. + /// + /// + /// + /// var user = User.Builder("my-key").Name("Bob").Email("test@example.com").Build(); + /// + /// + /// a that uniquely identifies a user + /// a builder object + public static IUserBuilder Builder(string key) + { + return new UserBuilder(key); + } + + /// + /// Creates an for constructing a user object, with its initial + /// properties copied from an existeing user. + /// + /// + /// This is the same as calling User.Builder(fromUser.Key) and then calling the + /// methods to set each of the individual properties from their current + /// values in fromUser. Modifying the builder does not affect the original . + /// + /// + /// + /// var user1 = User.Builder("my-key").FirstName("Joe").LastName("Schmoe").Build(); + /// var user2 = User.Builder(user1).FirstName("Jane").Build(); + /// // this is equvalent to: user2 = User.Builder("my-key").FirstName("Jane").LastName("Schmoe").Build(); + /// + /// + /// the user to copy + /// a builder object + public static IUserBuilder Builder(User fromUser) + { + return new UserBuilder(fromUser); + } + + private User(string key) + { + _key = key; + _custom = ImmutableDictionary.Create(); + _privateAttributeNames = ImmutableHashSet.Create(); + } + + /// + /// Creates a user by specifying all properties. + /// + public User(string key, string secondary, string ip, string country, string firstName, + string lastName, string name, string avatar, string email, bool? anonymous, + ImmutableDictionary custom, ImmutableHashSet privateAttributeNames) + { + _key = key; + // _secondary = secondary; + // secondary no longer exists; retained in constructor to minimize breakage if applications + // were calling the User constructor directly + _ip = ip; + _country = country; + _firstName = firstName; + _lastName = lastName; + _name = name; + _avatar = avatar; + _email = email; + _anonymous = anonymous ?? false; + // anonymous is now just a simple bool; kept bool? type in constructor for same reason as above + _custom = custom ?? ImmutableDictionary.Create(); + _privateAttributeNames = privateAttributeNames ?? ImmutableHashSet.Create(); + } + + /// + /// Creates a user with the given key. + /// + /// a that uniquely identifies a user + /// a instance + public static User WithKey(string key) + { + return new User(key); + } + + /// + /// Gets the value of a user attribute, if present. + /// + /// + /// This can be either a built-in attribute or a custom one. It returns the value using the + /// type, which can have any type that is supported in JSON. If the + /// attribute does not exist, it returns . + /// + /// the attribute to get + /// the attribute value or + public LdValue GetAttribute(UserAttribute attribute) + { + if (attribute.BuiltIn) + { + return attribute.BuiltInGetter(this); + } + if (_custom != null && _custom.TryGetValue(attribute.AttributeName, out var value)) + { + return value; + } + return LdValue.Null; + } + + /// + public override bool Equals(object obj) + { + if (obj is User u) + { + return ((IEquatable)this).Equals(u); + } + return false; + } + + /// + public bool Equals(User u) + { + if (u == null) + { + return false; + } + if (ReferenceEquals(this, u)) + { + return true; + } + return Object.Equals(Key, u.Key) && + Object.Equals(IPAddress, u.IPAddress) && + Object.Equals(Country, u.Country) && + Object.Equals(FirstName, u.FirstName) && + Object.Equals(LastName, u.LastName) && + Object.Equals(Name, u.Name) && + Object.Equals(Avatar, u.Avatar) && + Object.Equals(Email, u.Email) && + Anonymous == u.Anonymous && + Custom.Count == u.Custom.Count && + Custom.Keys.All(k => u.Custom.ContainsKey(k) && Object.Equals(Custom[k], u.Custom[k])) && + PrivateAttributeNames.SetEquals(u.PrivateAttributeNames); + } + + /// + public override int GetHashCode() + { + var hashBuilder = new HashCodeBuilder() + .With(Key) + .With(IPAddress) + .With(Country) + .With(FirstName) + .With(LastName) + .With(Name) + .With(Avatar) + .With(Email) + .With(Anonymous); + foreach (var c in Custom) + { + hashBuilder = hashBuilder.With(c.Key).With(c.Value); + } + foreach (var p in PrivateAttributeNames) + { + hashBuilder = hashBuilder.With(p); + } + return hashBuilder.Value; + } + } +} diff --git a/pkgs/shared/common/src/UserAttribute.cs b/pkgs/shared/common/src/UserAttribute.cs new file mode 100644 index 00000000..9dcdc32d --- /dev/null +++ b/pkgs/shared/common/src/UserAttribute.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LaunchDarkly.Sdk +{ + /// + /// Represents a built-in or custom attribute name supported by . + /// + /// + /// + /// Application code rarely needs to use this type; it is used internally by the SDK for + /// efficiency in flag evaluations. It can also be used as a reference for the constant + /// names of built-in attributes such as . However, in the newer + /// model, there are very few reserved attribute names, so the + /// equivalent of would simply be a custom attribute called "email". + /// + /// + /// For a fuller description of user attributes and how they can be referenced in feature + /// flag rules, read the reference guides on + /// Setting user attributes + /// and Targeting users. + /// + /// + /// + public struct UserAttribute : IEquatable + { + /// + /// The case-sensitive attribute name. + /// + public string AttributeName { get; } + + internal Func BuiltInGetter { get; } + + /// + /// True for a built-in attribute or false for a custom attribute. + /// + public bool BuiltIn => BuiltInGetter != null; + + private UserAttribute(string name, Func builtInGetter) + { + AttributeName = name; + BuiltInGetter = builtInGetter; + } + + /// + /// Represents the user key attribute. + /// + public static readonly UserAttribute Key = + new UserAttribute("key", u => LdValue.Of(u.Key)); + + /// + /// Represents the IP address attribute. + /// + public static readonly UserAttribute IPAddress = + new UserAttribute("ip", u => LdValue.Of(u.IPAddress)); + + /// + /// Represents the user email attribute. + /// + public static readonly UserAttribute Email = + new UserAttribute("email", u => LdValue.Of(u.Email)); + + /// + /// Represents the full name attribute. + /// + public static readonly UserAttribute Name = + new UserAttribute("name", u => LdValue.Of(u.Name)); + + /// + /// Represents the avatar URL attribute. + /// + public static readonly UserAttribute Avatar = + new UserAttribute("avatar", u => LdValue.Of(u.Avatar)); + + /// + /// Represents the first name attribute. + /// + public static readonly UserAttribute FirstName = + new UserAttribute("firstName", u => LdValue.Of(u.FirstName)); + + /// + /// Represents the last name attribute. + /// + public static readonly UserAttribute LastName = + new UserAttribute("lastName", u => LdValue.Of(u.LastName)); + + /// + /// Represents the country attribute. + /// + public static readonly UserAttribute Country = + new UserAttribute("country", u => LdValue.Of(u.Country)); + + /// + /// Represents the anonymous attribute. + /// + public static readonly UserAttribute Anonymous = + new UserAttribute("anonymous", u => LdValue.Of(u.Anonymous)); + + private static readonly Dictionary _builtins = + new UserAttribute[] + { + Key, IPAddress, Email, Name, Avatar, FirstName, LastName, Country, Anonymous + }.ToDictionary(a => a.AttributeName); + + internal static readonly UserAttribute[] OptionalStringAttrs = + new UserAttribute[] + { + IPAddress, Email, Name, Avatar, FirstName, LastName, Country + }; + + /// + /// Returns a UserAttribute instance for the specified attribute name. + /// + /// the attribute name + /// a + public static UserAttribute ForName(string name) + { + if (_builtins.TryGetValue(name, out var a)) + { + return a; + } + return new UserAttribute(name, null); + } + +#pragma warning disable CS1591 // don't need XML comments for these standard methods + public override bool Equals(object obj) => + obj is UserAttribute a && Equals(a); + + public bool Equals(UserAttribute a) => AttributeName == a.AttributeName; + + public static bool operator ==(UserAttribute a, UserAttribute b) => + a.AttributeName == b.AttributeName; + + public static bool operator !=(UserAttribute a, UserAttribute b) => + a.AttributeName != b.AttributeName; + + public override int GetHashCode() => AttributeName.GetHashCode(); + + public override string ToString() => AttributeName; +#pragma warning restore CS1591 + } +} diff --git a/pkgs/shared/common/src/UserBuilder.cs b/pkgs/shared/common/src/UserBuilder.cs new file mode 100644 index 00000000..3a13a682 --- /dev/null +++ b/pkgs/shared/common/src/UserBuilder.cs @@ -0,0 +1,520 @@ +using System; +using System.Collections.Immutable; + +namespace LaunchDarkly.Sdk +{ + /// + /// A mutable object that uses the Builder pattern to specify properties for a object. + /// + /// + /// + /// Obtain an instance of this class by calling . + /// + /// + /// All of the builder methods for setting a user attribute return a reference to the same builder, so they can be + /// chained together (see example). Some of them have the return type + /// rather than ; those are the user attributes that can be designated as private. + /// + /// + /// + /// + /// var user = User.Builder("my-key") + /// .Name("Bob") + /// .Email("test@example.com") + /// .Build(); + /// + /// + public interface IUserBuilder + { + /// + /// Creates a based on the properties that have been set on the builder. + /// Modifying the builder after this point does not affect the returned . + /// + /// the configured object + User Build(); + + /// + /// Sets the unique key for a user. + /// + /// the key + /// the same builder + IUserBuilder Key(string key); + + /// + /// Sets the IP address for a user. + /// + /// the IP address for the user + /// the same builder + IUserBuilderCanMakeAttributePrivate IPAddress(string ipAddress); + + /// + /// Sets the country identifier for a user. + /// + /// + /// This is commonly either a 2- or 3-character standard country code, but LaunchDarkly does not validate + /// this property or restrict its possible values. + /// + /// the country for the user + /// the same builder + IUserBuilderCanMakeAttributePrivate Country(string country); + + /// + /// Sets the first name for a user. + /// + /// the first name for the user + /// the same builder + IUserBuilderCanMakeAttributePrivate FirstName(string firstName); + + /// + /// Sets the last name for a user. + /// + /// the last name for the user + /// the same builder + IUserBuilderCanMakeAttributePrivate LastName(string lastName); + + /// + /// Sets the full name for a user. + /// + /// the name for the user + /// the same builder + IUserBuilderCanMakeAttributePrivate Name(string name); + + /// + /// Sets the avatar URL for a user. + /// + /// the avatar URL for the user + /// the same builder + IUserBuilderCanMakeAttributePrivate Avatar(string avatar); + + /// + /// Sets the email address for a user. + /// + /// the email address for the user + /// the same builder + IUserBuilderCanMakeAttributePrivate Email(string email); + + /// + /// Sets whether this user is anonymous, meaning that the user key will not appear on your LaunchDarkly dashboard. + /// + /// true if the user is anonymous + /// the same builder + IUserBuilder Anonymous(bool anonymous); + + /// + /// Adds a custom attribute whose value is a JSON value of any kind. + /// + /// + /// + /// The rules for allowable data types in custom attributes are the same as for flag + /// variation values. For more details, see our documentation on + /// flag value types. + /// + /// + /// When set to one of the built-in + /// user attribute keys, this custom attribute will be ignored. + /// + /// + /// + /// + /// var arrayOfIntsValue = LdValue.FromValues(new int[] { 1, 2, 3 }); + /// var user = User.Builder("key").Custom("numbers", arrayOfIntsValue).Build(); + /// + /// + /// the key for the custom attribute + /// the value for the custom attribute + /// the same builder + IUserBuilderCanMakeAttributePrivate Custom(string name, LdValue value); + + /// + /// Adds a custom attribute with a boolean value. + /// + /// + /// When set to one of the built-in + /// user attribute keys, this custom attribute will be ignored. + /// + /// the key for the custom attribute + /// the value for the custom attribute + /// the same builder + IUserBuilderCanMakeAttributePrivate Custom(string name, bool value); + + /// + /// Adds a custom attribute with a string value. + /// + /// + /// When set to one of the built-in + /// user attribute keys, this custom attribute will be ignored. + /// + /// the key for the custom attribute + /// the value for the custom attribute + /// the same builder + IUserBuilderCanMakeAttributePrivate Custom(string name, string value); + + /// + /// Adds a custom attribute with an integer value. + /// + /// + /// When set to one of the built-in + /// user attribute keys, this custom attribute will be ignored. + /// + /// the key for the custom attribute + /// the value for the custom attribute + /// the same builder + IUserBuilderCanMakeAttributePrivate Custom(string name, int value); + + /// + /// Adds a custom attribute with a value. + /// + /// + /// + /// Numeric values in custom attributes have some precision limitations, the same as for + /// numeric values in flag variations. For more details, see our documentation on + /// flag value types. + /// + /// + /// When set to one of the built-in + /// user attribute keys, this custom attribute will be ignored. + /// + /// + /// the key for the custom attribute + /// the value for the custom attribute + /// the same builder + IUserBuilderCanMakeAttributePrivate Custom(string name, long value); + + /// + /// Adds a custom attribute with a floating-point value. + /// + /// + /// + /// Numeric values in custom attributes have some precision limitations, the same as for + /// numeric values in flag variations. For more details, see our documentation on + /// flag value types. + /// + /// + /// When set to one of the built-in + /// user attribute keys, this custom attribute will be ignored. + /// + /// + /// the key for the custom attribute + /// the value for the custom attribute + /// the same builder + IUserBuilderCanMakeAttributePrivate Custom(string name, float value); + + /// + /// Adds a custom attribute with a value. + /// + /// + /// + /// Numeric values in custom attributes have some precision limitations, the same as for + /// numeric values in flag variations. For more details, see our documentation on + /// flag value types. + /// + /// + /// When set to one of the built-in + /// user attribute keys, this custom attribute will be ignored. + /// + /// + /// the key for the custom attribute + /// the value for the custom attribute + /// the same builder + IUserBuilderCanMakeAttributePrivate Custom(string name, double value); + } + + /// + /// An extension of that allows attributes to be made private via + /// the method. + /// + /// + /// setter methods for attribute that can be made private always + /// return this interface, rather than returning . See + /// for more details. + /// + public interface IUserBuilderCanMakeAttributePrivate : IUserBuilder + { + /// + /// Marks the last attribute that was set on this builder as being a private attribute: that is, its value will not be + /// sent to LaunchDarkly. + /// + /// + /// + /// This action only affects analytics events that are generated by this particular user object. To mark some (or all) + /// user attributes as private for all users, use the configuration properties PrivateAttributeName + /// and AllAttributesPrivate. + /// + /// + /// Not all attributes can be made private: and + /// cannot be private. This is enforced by the compiler, since the builder methods for attributes that can be made private are + /// the only ones that return ; therefore, you cannot write an expression + /// like User.Builder("user-key").AsPrivateAttribute(). + /// + /// + /// + /// + /// In this example, FirstName and LastName are marked as private, but Country is not. + /// + /// + /// var user = User.Builder("user-key") + /// .FirstName("Pierre").AsPrivateAttribute() + /// .LastName("Menard").AsPrivateAttribute() + /// .Country("ES") + /// .Build(); + /// + /// + /// the same builder + IUserBuilder AsPrivateAttribute(); + } + + internal class UserBuilder : IUserBuilder + { + private string _key; + private string _ipAddress; + private string _country; + private string _firstName; + private string _lastName; + private string _name; + private string _avatar; + private string _email; + private bool _anonymous; + private ImmutableDictionary.Builder _custom; + private ImmutableHashSet.Builder _privateAttributeNames; + + internal UserBuilder(string key) + { + _key = key; + } + + internal UserBuilder(User fromUser) + { + _key = fromUser.Key; + _ipAddress = fromUser.IPAddress; + _country = fromUser.Country; + _firstName = fromUser.FirstName; + _lastName = fromUser.LastName; + _name = fromUser.Name; + _avatar = fromUser.Avatar; + _email = fromUser.Email; + _anonymous = fromUser.Anonymous; + _privateAttributeNames = fromUser._privateAttributeNames.Count == 0 ? null : + fromUser._privateAttributeNames.ToBuilder(); + _custom = fromUser._custom.Count == 0 ? null : fromUser._custom.ToBuilder(); + } + + public User Build() + { + return new User(_key, null, _ipAddress, _country, _firstName, _lastName, _name, _avatar, _email, + _anonymous, + _custom is null ? ImmutableDictionary.Create() : _custom.ToImmutableDictionary(), + _privateAttributeNames is null ? ImmutableHashSet.Create() : _privateAttributeNames.ToImmutableHashSet()); + } + + public IUserBuilder Key(string key) + { + _key = key; + return this; + } + + public IUserBuilderCanMakeAttributePrivate IPAddress(string ipAddress) + { + _ipAddress = ipAddress; + return CanMakeAttributePrivate("ip"); + } + + public IUserBuilderCanMakeAttributePrivate Country(string country) + { + _country = country; + return CanMakeAttributePrivate("country"); + } + + public IUserBuilderCanMakeAttributePrivate FirstName(string firstName) + { + _firstName = firstName; + return CanMakeAttributePrivate("firstName"); + } + + public IUserBuilderCanMakeAttributePrivate LastName(string lastName) + { + _lastName = lastName; + return CanMakeAttributePrivate("lastName"); + } + + public IUserBuilderCanMakeAttributePrivate Name(string name) + { + _name = name; + return CanMakeAttributePrivate("name"); + } + + public IUserBuilderCanMakeAttributePrivate Avatar(string avatar) + { + _avatar = avatar; + return CanMakeAttributePrivate("avatar"); + } + + public IUserBuilderCanMakeAttributePrivate Email(string email) + { + _email = email; + return CanMakeAttributePrivate("email"); + } + + public IUserBuilder Anonymous(bool anonymous) + { + _anonymous = anonymous; + return this; + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, LdValue value) + { + if (_custom is null) + { + _custom = ImmutableDictionary.CreateBuilder(); + } + _custom[name] = value; + return CanMakeAttributePrivate(name); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, bool value) + { + return Custom(name, LdValue.Of(value)); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, string value) + { + return Custom(name, LdValue.Of(value)); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, int value) + { + return Custom(name, LdValue.Of(value)); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, long value) + { + return Custom(name, LdValue.Of(value)); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, float value) + { + return Custom(name, LdValue.Of(value)); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, double value) + { + return Custom(name, LdValue.Of(value)); + } + + private IUserBuilderCanMakeAttributePrivate CanMakeAttributePrivate(string attrName) + { + return new UserBuilderCanMakeAttributePrivate(this, attrName); + } + + internal IUserBuilder AddPrivateAttribute(string attrName) + { + if (_privateAttributeNames is null) + { + _privateAttributeNames = ImmutableHashSet.CreateBuilder(); + } + _privateAttributeNames.Add(attrName); + return this; + } + } + + internal class UserBuilderCanMakeAttributePrivate : IUserBuilderCanMakeAttributePrivate + { + private readonly UserBuilder _builder; + private readonly string _attrName; + + internal UserBuilderCanMakeAttributePrivate(UserBuilder builder, string attrName) + { + _builder = builder; + _attrName = attrName; + } + + public User Build() + { + return _builder.Build(); + } + + public IUserBuilder Key(string key) + { + return _builder.Key(key); + } + + public IUserBuilderCanMakeAttributePrivate IPAddress(string ipAddress) + { + return _builder.IPAddress(ipAddress); + } + + public IUserBuilderCanMakeAttributePrivate Country(string country) + { + return _builder.Country(country); + } + + public IUserBuilderCanMakeAttributePrivate FirstName(string firstName) + { + return _builder.FirstName(firstName); + } + + public IUserBuilderCanMakeAttributePrivate LastName(string lastName) + { + return _builder.LastName(lastName); + } + + public IUserBuilderCanMakeAttributePrivate Name(string name) + { + return _builder.Name(name); + } + + public IUserBuilderCanMakeAttributePrivate Avatar(string avatar) + { + return _builder.Avatar(avatar); + } + + public IUserBuilderCanMakeAttributePrivate Email(string email) + { + return _builder.Email(email); + } + + public IUserBuilder Anonymous(bool anonymous) + { + return _builder.Anonymous(anonymous); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, LdValue value) + { + return _builder.Custom(name, value); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, bool value) + { + return _builder.Custom(name, value); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, string value) + { + return _builder.Custom(name, value); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, int value) + { + return _builder.Custom(name, value); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, long value) + { + return _builder.Custom(name, value); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, float value) + { + return _builder.Custom(name, value); + } + + public IUserBuilderCanMakeAttributePrivate Custom(string name, double value) + { + return _builder.Custom(name, value); + } + + public IUserBuilder AsPrivateAttribute() + { + return _builder.AddPrivateAttribute(_attrName); + } + } +} diff --git a/pkgs/shared/common/test/ApplicationInfoBuilderTest.cs b/pkgs/shared/common/test/ApplicationInfoBuilderTest.cs new file mode 100644 index 00000000..7d9a41d2 --- /dev/null +++ b/pkgs/shared/common/test/ApplicationInfoBuilderTest.cs @@ -0,0 +1,47 @@ +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class ApplicationInfoBuilderTest + { + [Fact] + public void IgnoresInvalidValues() { + ApplicationInfoBuilder b = new ApplicationInfoBuilder(); + b.ApplicationId("im#invalid"); + b.ApplicationName("im#invalid"); + b.ApplicationVersion("im#invalid"); + b.ApplicationVersionName("im#invalid"); + ApplicationInfo info = b.Build(); + Assert.Null(info.ApplicationId); + Assert.Null(info.ApplicationName); + Assert.Null(info.ApplicationVersion); + Assert.Null(info.ApplicationVersionName); + } + + [Fact] + public void SanitizesValues() { + ApplicationInfoBuilder b = new ApplicationInfoBuilder(); + b.ApplicationId("id has spaces"); + b.ApplicationName("name has spaces"); + b.ApplicationVersion("version has spaces"); + b.ApplicationVersionName("version name has spaces"); + ApplicationInfo info = b.Build(); + Assert.Equal("id-has-spaces", info.ApplicationId); + Assert.Equal("name-has-spaces", info.ApplicationName); + Assert.Equal("version-has-spaces", info.ApplicationVersion); + Assert.Equal("version-name-has-spaces", info.ApplicationVersionName); + } + + [Fact] + public void NullValueIsValid() { + ApplicationInfoBuilder b = new ApplicationInfoBuilder(); + b.ApplicationId("myID"); // first non-null + ApplicationInfo info = b.Build(); + Assert.Equal("myID", info.ApplicationId); + + b.ApplicationId(null); // now back to null + ApplicationInfo info2 = b.Build(); + Assert.Null(info2.ApplicationId); + } + } +} diff --git a/pkgs/shared/common/test/AttributeRefTest.cs b/pkgs/shared/common/test/AttributeRefTest.cs new file mode 100644 index 00000000..55a1b94f --- /dev/null +++ b/pkgs/shared/common/test/AttributeRefTest.cs @@ -0,0 +1,165 @@ +using System; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class AttributeRefTest + { + [Fact] + public void UninitializedRef() + { + var a = new AttributeRef(); + Assert.False(a.Defined); + Assert.False(a.Valid); + Assert.Equal(Errors.AttrEmpty, a.Error); + Assert.Equal("", a.ToString()); + Assert.Equal(0, a.Depth); + } + + [Theory] + [InlineData("", Errors.AttrEmpty)] + [InlineData("/", Errors.AttrEmpty)] + [InlineData("//", Errors.AttrExtraSlash)] + [InlineData("/a//b", Errors.AttrExtraSlash)] + [InlineData("/a/b/", Errors.AttrExtraSlash)] + [InlineData("/a~x", Errors.AttrInvalidEscape)] + [InlineData("/a~", Errors.AttrInvalidEscape)] + [InlineData("/a/b~x", Errors.AttrInvalidEscape)] + [InlineData("/a/b~", Errors.AttrInvalidEscape)] + public void InvalidRef(string s, string expectedError) + { + var a = AttributeRef.FromPath(s); + Assert.True(a.Defined); + Assert.False(a.Valid); + Assert.Equal(expectedError, a.Error); + Assert.Equal(s, a.ToString()); + Assert.Equal(0, a.Depth); + } + + [Theory] + [InlineData("name")] + [InlineData("name/with/slashes")] + [InlineData("name~0~1with-what-looks-like-escape-sequences")] + public void RefWithNoLeadingSlash(string s) + { + var a = AttributeRef.FromPath(s); + Assert.True(a.Defined); + Assert.True(a.Valid); + Assert.Null(a.Error); + Assert.Equal(s, a.ToString()); + Assert.Equal(1, a.Depth); + Assert.Equal(s, a.GetComponent(0)); + } + + [Theory] + [InlineData("/name", "name")] + [InlineData("/0", "0")] + [InlineData("/name~1with~1slashes~0and~0tildes", "name/with/slashes~and~tildes")] + public void RefSimpleWithLeadingSlash(string s, string unescaped) + { + var a = AttributeRef.FromPath(s); + Assert.True(a.Defined); + Assert.True(a.Valid); + Assert.Null(a.Error); + Assert.Equal(s, a.ToString()); + Assert.Equal(1, a.Depth); + Assert.Equal(unescaped, a.GetComponent(0)); + } + + [Fact] + public void Literal() + { + var a0 = AttributeRef.FromLiteral("name"); + Assert.Equal(AttributeRef.FromPath("name"), a0); + + var a1 = AttributeRef.FromLiteral("a/b"); + Assert.Equal(AttributeRef.FromPath("a/b"), a1); + + var a2 = AttributeRef.FromLiteral("/a/b~c"); + Assert.Equal(AttributeRef.FromPath("/~1a~1b~0c"), a2); + Assert.Equal(1, a2.Depth); + + var a3 = AttributeRef.FromLiteral("/"); + Assert.Equal(AttributeRef.FromPath("/~1"), a3); + + var a4 = AttributeRef.FromLiteral(""); + Assert.Equal(Errors.AttrEmpty, a4.Error); + } + + [Theory] + [InlineData("", 0, 0, null)] + [InlineData("key", 1, 0, "key")] + [InlineData("/key", 1, 0, "key")] + [InlineData("/a/b", 2, 0, "a")] + [InlineData("/a/b", 2, 1, "b")] + [InlineData("/a~1b/c", 2, 0, "a/b")] + [InlineData("/a~0b/c", 2, 0, "a~b")] + [InlineData("/a/10/20/30x", 4, 1, "10")] + [InlineData("/a/10/20/30x", 4, 2, "20")] + [InlineData("/a/10/20/30x", 4, 3, "30x")] + [InlineData("", 0, -1, null)] + [InlineData("key", 1, -1, null)] + [InlineData("key", 1, 1, null)] + [InlineData("/key", 1, -1, null)] + [InlineData("/key", 1, 1, null)] + [InlineData("/a/b", 2, -1, null)] + [InlineData("/a/b", 2, 2, null)] + public void TryGetComponent(string input, int depth, int index, string expectedName) + { + var a = AttributeRef.FromPath(input); + Assert.Equal(depth, a.Depth); + Assert.Equal(expectedName, a.GetComponent(index)); + } + + [Fact] + public void Equality() + { + TypeBehavior.CheckEqualsAndHashCode( + () => new AttributeRef(), + () => AttributeRef.FromPath(""), + () => AttributeRef.FromPath("a"), + () => AttributeRef.FromPath("b"), + () => AttributeRef.FromPath("/a/b"), + () => AttributeRef.FromPath("/a/c"), + () => AttributeRef.FromPath("///") + ); + } + + [Theory] + [InlineData(null, "null")] + [InlineData("a", @"""a""")] + [InlineData("/a/b", @"""/a/b""")] + [InlineData("///invalid", @"""///invalid""")] + public void SerializeJson(string attrPath, string expected) + { + var a = attrPath is null ? new AttributeRef() : AttributeRef.FromPath(attrPath); + Assert.Equal(expected, LdJsonSerialization.SerializeObject(a)); + } + + [Theory] + [InlineData("null", null, true)] + [InlineData(@"""a""", "a", true)] + [InlineData(@"""/a/b""", "/a/b", true)] + [InlineData(@"""///invalid""", "///invalid", true)] + [InlineData("true", null, false)] + [InlineData("2", null, false)] + [InlineData("[]", null, false)] + [InlineData("{}", null, false)] + [InlineData(".", null, false)] + [InlineData("", null, false)] + public void DeserializeJson(string json, string attrPath, bool success) + { + if (success) + { + var a = LdJsonSerialization.DeserializeObject(json); + Assert.Equal(AttributeRef.FromPath(attrPath), a); + } + else + { + Assert.ThrowsAny(() => LdJsonSerialization.DeserializeObject(json)); + } + } + } +} diff --git a/pkgs/shared/common/test/ContextJsonTest.cs b/pkgs/shared/common/test/ContextJsonTest.cs new file mode 100644 index 00000000..4ed30b84 --- /dev/null +++ b/pkgs/shared/common/test/ContextJsonTest.cs @@ -0,0 +1,166 @@ +using System; +using System.Text.Json; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class ContextJsonTest + { + [Fact] + public void ValidDataSerializationAndDeserializationTests() + { + // Can't use a parameterized test for this because Xunit can't handle Context as a parameter type: + // https://stackoverflow.com/questions/30574322/memberdata-tests-show-up-as-one-test-instead-of-many + + TestBoth(Context.New(ContextKind.Of("org"), "key1"), @"{""kind"": ""org"", ""key"": ""key1""}"); + TestBoth(Context.New("key1b"), @"{""kind"": ""user"", ""key"": ""key1b""}"); + TestBoth(Context.Builder("key1c").Kind("org").Build(), + @"{""kind"": ""org"", ""key"": ""key1c""}"); + TestBoth(Context.Builder("key2").Name("my-name").Build(), + @"{""kind"": ""user"", ""key"": ""key2"", ""name"": ""my-name""}"); + TestBoth(Context.Builder("key4").Anonymous(true).Build(), + @"{""kind"": ""user"", ""key"": ""key4"", ""anonymous"": true}"); + TestBoth(Context.Builder("key5").Anonymous(false).Build(), + @"{""kind"": ""user"", ""key"": ""key5""}"); + TestBoth(Context.Builder("key6").Set("attr1", true).Build(), + @"{""kind"": ""user"", ""key"": ""key6"", ""attr1"": true}"); + TestBoth(Context.Builder("key6").Set("attr1", false).Build(), + @"{""kind"": ""user"", ""key"": ""key6"", ""attr1"": false}"); + TestBoth(Context.Builder("key6").Set("attr1", 123).Build(), + @"{""kind"": ""user"", ""key"": ""key6"", ""attr1"": 123}"); + TestBoth(Context.Builder("key6").Set("attr1", 1.5).Build(), + @"{""kind"": ""user"", ""key"": ""key6"", ""attr1"": 1.5}"); + TestBoth(Context.Builder("key6").Set("attr1", "xyz").Build(), + @"{""kind"": ""user"", ""key"": ""key6"", ""attr1"": ""xyz""}"); + TestBoth(Context.Builder("key6").Set("attr1", LdValue.ArrayOf(LdValue.Of(10), LdValue.Of(20))).Build(), + @"{""kind"": ""user"", ""key"": ""key6"", ""attr1"": [10, 20]}"); + TestBoth(Context.Builder("key6").Set("attr1", LdValue.BuildObject().Set("a", 1).Build()).Build(), + @"{""kind"": ""user"", ""key"": ""key6"", ""attr1"": {""a"": 1}}"); + TestBoth(Context.Builder("key7").Private("a").Private(AttributeRef.FromPath("/b/c")).Build(), + @"{""kind"": ""user"", ""key"": ""key7"", ""_meta"": {""privateAttributes"": [""a"", ""/b/c""]}}"); + TestBoth(Context.NewMulti(Context.New(ContextKind.Of("org"), "my-org-key"), Context.New("my-user-key")), + @"{""kind"": ""multi"", ""org"": {""key"": ""my-org-key""}, ""user"": {""key"": ""my-user-key""}}"); + } + + [Fact] + public void SerializeInvalidContext() + { + Assert.ThrowsAny(() => LdJsonSerialization.SerializeObject(new Context())); + + Assert.ThrowsAny(() => LdJsonSerialization.SerializeObject(Context.New(""))); + } + + [Theory] + [InlineData("null")] + [InlineData("false")] + [InlineData("1")] + [InlineData(@"""x""")] + [InlineData("[]")] + [InlineData("{}")] + // wrong type for top-level property: + [InlineData(@"{""kind"": null, ""key"": ""a""}")] + [InlineData(@"{""kind"": true, ""key"": ""a""}")] + [InlineData(@"{""kind"": ""org"", ""key"": null}")] + [InlineData(@"{""kind"": ""org"", ""key"": true}")] + [InlineData(@"{""kind"": ""multi"", ""org"": null}")] + [InlineData(@"{""kind"": ""multi"", ""org"": true}")] + [InlineData(@"{""kind"": ""org"", ""key"": ""a"", ""name"": true}")] + [InlineData(@"{""kind"": ""org"", ""key"": ""a"", ""anonymous"": ""yes""}")] + [InlineData(@"{""kind"": ""org"", ""key"": ""a"", ""anonymous"": null}")] + // invalid kind/key + [InlineData(@"{""kind"": ""org""}")] + [InlineData(@"{""kind"": ""user"", ""key"": """"}")] + [InlineData(@"{""kind"": """", ""key"": ""x""}")] + [InlineData(@"{""kind"": ""ørg"", ""key"": ""x""}")] + // wrong type within _meta + [InlineData(@"{""kind"": ""org"", ""key"": ""my-key"", ""_meta"": true}")] + [InlineData(@"{""kind"": ""org"", ""key"": ""my-key"", ""_meta"": {""privateAttributes"": true}}")] + // multi-kind problems + [InlineData(@"{""kind"": ""multi""}")] + [InlineData(@"{""kind"": ""multi"", ""user"": {""key"": """"}}")] + [InlineData(@"{""kind"": ""multi"", ""user"": {""key"": true}}")] + // wrong types in old user schema + [InlineData(@"{""key"": null}")] + [InlineData(@"{""key"": true}")] + [InlineData(@"{""key"": ""my-key"", ""anonymous"": ""x""}")] + [InlineData(@"{""key"": ""my-key"", ""name"": true}")] + [InlineData(@"{""key"": ""my-key"", ""firstName"": true}")] + [InlineData(@"{""key"": ""my-key"", ""lastName"": true}")] + [InlineData(@"{""key"": ""my-key"", ""email"": true}")] + [InlineData(@"{""key"": ""my-key"", ""country"": true}")] + [InlineData(@"{""key"": ""my-key"", ""avatar"": true}")] + [InlineData(@"{""key"": ""my-key"", ""ip"": true}")] + [InlineData(@"{""key"": ""my-key"", ""custom"": true}")] + [InlineData(@"{""key"": ""my-key"", ""privateAttributeNames"": true}")] + + //// missing key in old user schema + //`{ "name": "x"}`, + public void DeserializeInvalidContext(string input) + { + LdJsonSerialization.DeserializeObject(input); // just to be sure it's valid JSON + Assert.ThrowsAny(() => LdJsonSerialization.DeserializeObject(input)); + } + + [Fact] + public void DeserializeOldUserSchema() + { + TestDeserializeOnly(Context.New("key1"), @"{""key"": ""key1""}"); + TestDeserializeOnly(Context.Builder("key2").Name("my-name").Build(), + @"{""key"": ""key2"", ""name"": ""my-name""}"); + TestDeserializeOnly(Context.Builder("key2").Set("firstName", "a").Build(), + @"{""key"": ""key2"", ""firstName"": ""a""}"); + TestDeserializeOnly(Context.Builder("key2").Set("lastName", "a").Build(), + @"{""key"": ""key2"", ""lastName"": ""a""}"); + TestDeserializeOnly(Context.Builder("key2").Set("email", "a").Build(), + @"{""key"": ""key2"", ""email"": ""a""}"); + TestDeserializeOnly(Context.Builder("key2").Set("country", "a").Build(), + @"{""key"": ""key2"", ""country"": ""a""}"); + TestDeserializeOnly(Context.Builder("key2").Set("ip", "a").Build(), + @"{""key"": ""key2"", ""ip"": ""a""}"); + TestDeserializeOnly(Context.Builder("key2").Set("avatar", "a").Build(), + @"{""key"": ""key2"", ""avatar"": ""a""}"); + TestDeserializeOnly(Context.Builder("key4").Anonymous(true).Build(), + @"{""key"": ""key4"", ""anonymous"": true}"); + TestDeserializeOnly(Context.Builder("key5").Anonymous(false).Build(), + @"{""key"": ""key5"", ""anonymous"": false}"); + TestDeserializeOnly(Context.Builder("key6").Set("attr1", true).Build(), + @"{""key"": ""key6"", ""custom"": {""attr1"": true}}"); + TestDeserializeOnly(Context.Builder("key6").Set("attr1", false).Build(), + @"{""key"": ""key6"", ""custom"": {""attr1"": false}}"); + TestDeserializeOnly(Context.Builder("key6").Set("attr1", 123).Build(), + @"{""key"": ""key6"", ""custom"": {""attr1"": 123}}"); + TestDeserializeOnly(Context.Builder("key6").Set("attr1", 1.5).Build(), + @"{""key"": ""key6"", ""custom"": {""attr1"": 1.5}}"); + TestDeserializeOnly(Context.Builder("key6").Set("attr1", "xyz").Build(), + @"{""key"": ""key6"", ""custom"": {""attr1"": ""xyz""}}"); + TestDeserializeOnly(Context.Builder("key6").Set("attr1", LdValue.ArrayOf(LdValue.Of(10), LdValue.Of(20))).Build(), + @"{""key"": ""key6"", ""custom"": {""attr1"": [10, 20]}}"); + TestDeserializeOnly(Context.Builder("key6").Set("attr1", LdValue.BuildObject().Set("a", 1).Build()).Build(), + @"{""key"": ""key6"", ""custom"": {""attr1"": {""a"": 1}}}"); + TestDeserializeOnly(Context.Builder("key7").Private("a").Build(), + @"{""key"": ""key7"", ""privateAttributeNames"": [""a""]}"); + } + + [Fact] + public void EmptyKeyIsAllowedInOldUserSchema() + { + var c = LdJsonSerialization.DeserializeObject(@"{""key"": """"}"); + Assert.Equal(ContextKind.Default, c.Kind); + Assert.Equal("", c.Key); + } + + private static void TestBoth(Context c, string expectedJson) + { + TestSerializeOnly(c, expectedJson); + TestDeserializeOnly(c, expectedJson); + } + + private static void TestSerializeOnly(Context c, string expectedJson) => + JsonAssertions.AssertJsonEqual(expectedJson, LdJsonSerialization.SerializeObject(c)); + + private static void TestDeserializeOnly(Context c, string expectedJson) => + Assert.Equal(c, LdJsonSerialization.DeserializeObject(expectedJson)); + } +} diff --git a/pkgs/shared/common/test/ContextKindTest.cs b/pkgs/shared/common/test/ContextKindTest.cs new file mode 100644 index 00000000..c22e6df6 --- /dev/null +++ b/pkgs/shared/common/test/ContextKindTest.cs @@ -0,0 +1,47 @@ +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class ContextKindTest + { + [Fact] + public void UninitializedValueIsNotNull() + { + Assert.Equal("", new ContextKind().Value); + } + + [Fact] + public void NonEmptyValue() + { + Assert.Equal("abc", ContextKind.Of("abc").Value); + } + + [Fact] + public void NullOrEmptyBecomesDefault() + { + Assert.Equal("user", ContextKind.Of(null).Value); + Assert.Equal("user", ContextKind.Of("").Value); + } + + [Fact] + public void IsDefault() + { + Assert.False(ContextKind.Of("abc").IsDefault); + Assert.True(ContextKind.Of("user").IsDefault); + Assert.True(ContextKind.Default.IsDefault); + } + + [Fact] + public void EqualsAndHashCode() + { + TypeBehavior.CheckEqualsAndHashCode( + () => new ContextKind(), + () => ContextKind.Default, + () => ContextKind.Of("A"), + () => ContextKind.Of("a"), + () => ContextKind.Of("b") + ); + } + } +} diff --git a/pkgs/shared/common/test/ContextTest.cs b/pkgs/shared/common/test/ContextTest.cs new file mode 100644 index 00000000..cdecec58 --- /dev/null +++ b/pkgs/shared/common/test/ContextTest.cs @@ -0,0 +1,440 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class ContextTest + { + static readonly ContextKind kind1 = ContextKind.Of("kind1"), + kind2 = ContextKind.Of("kind2"), kind3 = ContextKind.Of("kind3"); + static readonly ContextKind invalidKindThatIsLiterallyKind = ContextKind.Of("kind"), + invalidKindWithDisallowedChar = ContextKind.Of("ørg"); + + [Fact] + public void SingleKindConstructors() + { + var c1 = Context.New("x"); + Assert.Equal(ContextKind.Default, c1.Kind); + Assert.Equal("x", c1.Key); + Assert.Null(c1.Name); + Assert.False(c1.Anonymous); + Assert.Empty(c1.PrivateAttributes); + + var c2 = Context.New(kind1, "x"); + Assert.Equal(kind1, c2.Kind); + Assert.Equal("x", c2.Key); + Assert.Null(c2.Name); + Assert.False(c2.Anonymous); + Assert.Empty(c2.PrivateAttributes); + } + + [Fact] + public void SingleKindBuilderProperties() + { + Assert.Equal(kind1, Context.Builder(".").Kind(kind1).Build().Kind); + Assert.Equal("x", Context.Builder(".").Key("x").Build().Key); + Assert.Equal("x", Context.Builder(".").Name("x").Build().Name); + Assert.True(Context.Builder(".").Anonymous(true).Build().Anonymous); + Assert.False(Context.Builder(".").Anonymous(true).Anonymous(false).Build().Anonymous); + Assert.Equal(LdValue.Of("x"), Context.Builder(".").Set("a", "x").Build().GetValue("a")); + } + + [Fact] + public void NoEmptyOrNullAttrName() + { + var c = Context.Builder("a").Set("", "b").Set("", "c").Build(); + Assert.Equal(Context.New("a"), c); + Assert.Empty(c.OptionalAttributeNames); + } + + [Fact] + public void InvalidContexts() + { + var c = new Context(); + Assert.False(c.Defined); + Assert.False(c.Valid); + Assert.Equal(Errors.ContextUninitialized, c.Error); + + ShouldBeInvalid(Context.New(null), Errors.ContextNoKey); + ShouldBeInvalid(Context.New(""), Errors.ContextNoKey); + ShouldBeInvalid(Context.New(invalidKindThatIsLiterallyKind, "key"), Errors.ContextKindCannotBeKind); + ShouldBeInvalid(Context.New(invalidKindWithDisallowedChar, "key"), Errors.ContextKindInvalidChars); + ShouldBeInvalid(Context.New(ContextKind.Multi, "key"), Errors.ContextKindMultiForSingle); + ShouldBeInvalid(Context.NewMulti(), Errors.ContextKindMultiWithNoKinds); + ShouldBeInvalid(Context.NewMulti(Context.New(kind1, "key1"), Context.New(kind1, "key2")), + Errors.ContextKindMultiDuplicates); + } + + private static void ShouldBeInvalid(Context c, string error) + { + Assert.True(c.Defined); + Assert.False(c.Valid); + Assert.Equal(error, c.Error); + + // we guarantee that Kind and Key are never null even for invalid contexts + Assert.Equal("", c.Kind.Value); + Assert.Equal("", c.Key); + } + + [Fact] + public void Multiple() + { + var sc = Context.New("my-key"); + Assert.False(sc.Multiple); + + var mc = Context.NewMulti(Context.New("my-key"), Context.New(kind1, "my-key")); + Assert.True(mc.Multiple); + Assert.True(mc.Defined); + Assert.True(mc.Valid); + } + + [Fact] + public void FullyQualifiedKey() + { + Assert.Equal("abc", Context.New("abc").FullyQualifiedKey); + Assert.Equal("abc:d", Context.New("abc:d").FullyQualifiedKey); + Assert.Equal("kind1:key1", Context.New(kind1, "key1").FullyQualifiedKey); + Assert.Equal("kind1:my%3Akey%25x/y", Context.New(kind1, "my:key%x/y").FullyQualifiedKey); + + Assert.Equal("kind1:key1:kind2:key%3A2", Context.NewMulti( + Context.New(kind1, "key1"), Context.New(kind2, "key:2") + ).FullyQualifiedKey); + + // Key should be the same regardless of context order. + Assert.Equal("kind1:key1:kind2:key%3A2", Context.NewMulti( + Context.New(kind2, "key:2"), Context.New(kind1, "key1") + ).FullyQualifiedKey); + } + + [Fact] + public void OptionalAttributeNames() + { + Assert.Equal(ImmutableHashSet.Create(), + Context.New("my-key").OptionalAttributeNames.ToImmutableHashSet()); + + Assert.Equal(ImmutableHashSet.Create("name"), + Context.Builder("my-key").Name("x").Build(). + OptionalAttributeNames.ToImmutableList()); + + Assert.Equal(ImmutableHashSet.Create("email", "happy"), + Context.Builder("my-key").Set("email", "x").Set("happy", true).Build(). + OptionalAttributeNames.ToImmutableHashSet()); + + // meta-attributes and required attributes are not included + Assert.Equal(ImmutableHashSet.Create(), + Context.Builder("my-key").Private("x").Anonymous(true).Build(). + OptionalAttributeNames.ToImmutableHashSet()); + + // none for multi-kind context + Assert.Equal(ImmutableHashSet.Create(), + Context.NewMulti( + Context.New(kind1, "key1"), + Context.Builder("key2").Name("x").Build() + ). + OptionalAttributeNames.ToImmutableHashSet()); + } + + [Fact] + public void PrivateAttributes() + { + Assert.Equal(ImmutableList.Create(), + Context.New("my-key").PrivateAttributes); + + Assert.Equal(ImmutableList.Create(AttributeRef.FromLiteral("a"), AttributeRef.FromLiteral("b")), + Context.Builder("my-key").Private("a", "b").Build().PrivateAttributes); + + Assert.Equal(ImmutableList.Create(AttributeRef.FromPath("/a"), AttributeRef.FromPath("/a/b")), + Context.Builder("my-key").Private(AttributeRef.FromPath("/a"), AttributeRef.FromPath("/a/b")). + Build().PrivateAttributes); + } + + [Fact] + public void GetValue() + { + // equivalent to GetValue(AttributeRef) for simple attribute name + var c = Context.Builder("my-key").Kind("org").Name("x").Set("my-attr", "y").Set("/starts-with-slash", "z").Build(); + + ExpectAttributeFoundForName(LdValue.Of("org"), c, "kind"); + ExpectAttributeFoundForName(LdValue.Of("my-key"), c, "key"); + ExpectAttributeFoundForName(LdValue.Of("x"), c, "name"); + ExpectAttributeFoundForName(LdValue.Of("y"), c, "my-attr"); + ExpectAttributeFoundForName(LdValue.Of("z"), c, "/starts-with-slash"); + + ExpectAttributeNotFoundForName(c, "/kind"); + ExpectAttributeNotFoundForName(c, "/key"); + ExpectAttributeNotFoundForName(c, "/name"); + ExpectAttributeNotFoundForName(c, "/my-attr"); + ExpectAttributeNotFoundForName(c, "other"); + ExpectAttributeNotFoundForName(c, ""); + ExpectAttributeNotFoundForName(c, "/"); + + var mc = Context.NewMulti(c, Context.New(ContextKind.Of("otherkind"), "otherkey")); + + ExpectAttributeFoundForName(LdValue.Of("multi"), mc, "kind"); + + ExpectAttributeNotFoundForName(mc, "/kind"); + ExpectAttributeNotFoundForName(mc, "key"); + + // does not allow querying of subpath/element + var objValue = LdValue.BuildObject().Set("a", 1).Build(); + var arrayValue = LdValue.ArrayOf(LdValue.Of(1)); + var c1 = Context.Builder("key").Set("obj-attr", objValue).Set("array-attr", arrayValue).Build(); + ExpectAttributeFoundForName(objValue, c1, "obj-attr"); + ExpectAttributeFoundForName(arrayValue, c1, "array-attr"); + ExpectAttributeNotFoundForName(c1, "/obj-attr/a"); + ExpectAttributeNotFoundForName(c1, "/array-attr/0"); + } + + private static void ExpectAttributeFoundForName(LdValue expectedValue, Context c, string name) + { + var value = c.GetValue(name); + Assert.False(value.IsNull, string.Format(@"attribute ""{0}"" should have been found, but was not", name)); + Assert.Equal(expectedValue, value); + } + + private static void ExpectAttributeNotFoundForName(Context c, string name) + { + var value = c.GetValue(name); + Assert.True(value.IsNull, string.Format(@"attribute ""{0}"" should not have been found, but was", name)); + } + + [Fact] + public void GetValueForRefSpecialTopLevelAttributes() + { + var multi = Context.NewMulti(Context.New("my-key"), Context.New(ContextKind.Of("otherkind"), "otherkey")); + + ExpectAttributeFoundForRef(LdValue.Of("org"), Context.New(ContextKind.Of("org"), "my-key"), "kind"); + ExpectAttributeFoundForRef(LdValue.Of("multi"), multi, "kind"); + + ExpectAttributeFoundForRef(LdValue.Of("my-key"), Context.New("my-key"), "key"); + ExpectAttributeNotFoundForRef(multi, "key"); + + ExpectAttributeFoundForRef(LdValue.Of("my-name"), Context.Builder("key").Name("my-name").Build(), "name"); + ExpectAttributeNotFoundForRef(Context.New("key"), "name"); + ExpectAttributeNotFoundForRef(multi, "name"); + + ExpectAttributeFoundForRef(LdValue.Of(false), Context.New("key"), "anonymous"); + ExpectAttributeFoundForRef(LdValue.Of(true), Context.Builder("key").Anonymous(true).Build(), "anonymous"); + ExpectAttributeNotFoundForRef(multi, "anonymous"); + } + + [Fact] + public void GetValueForRefCannotGetMetaProperties() + { + ExpectAttributeNotFoundForRef(Context.Builder("key").Private("attr").Build(), "private"); + ExpectAttributeNotFoundForRef(Context.Builder("key").Private("attr").Build(), "privateAttributes"); + } + + [Fact] + public void GetValueForRefCustomAttributeSingleKind() + { + // simple attribute name + ExpectAttributeFoundForRef(LdValue.Of("abc"), + Context.Builder("key").Set("my-attr", "abc").Build(), "my-attr"); + + // simple attribute name not found + ExpectAttributeNotFoundForRef(Context.New("key"), "my-attr"); + ExpectAttributeNotFoundForRef(Context.Builder("key").Set("other-attr", "abc").Build(), "my-attr"); + + // property in object + ExpectAttributeFoundForRef(LdValue.Of("abc"), + Context.Builder("key").Set("my-attr", LdValue.Parse(@"{""my-prop"":""abc""}")).Build(), + "/my-attr/my-prop"); + + // property in object not found + ExpectAttributeNotFoundForRef( + Context.Builder("key").Set("my-attr", LdValue.Parse(@"{""my-prop"":""abc""}")).Build(), + "/my-attr/other-prop"); + + // property in nested object + ExpectAttributeFoundForRef(LdValue.Of("abc"), + Context.Builder("key").Set("my-attr", LdValue.Parse(@"{""my-prop"":{""sub-prop"":""abc""}}")).Build(), + "/my-attr/my-prop/sub-prop"); + + // property in value that is not an object + ExpectAttributeNotFoundForRef( + Context.Builder("key").Set("my-attr", "xyz").Build(), + "/my-attr/my-prop"); + } + + private static void ExpectAttributeFoundForRef(LdValue expectedValue, Context c, string attrRef) + { + var value = c.GetValue(AttributeRef.FromPath(attrRef)); + Assert.False(value.IsNull, string.Format(@"attribute ""{0}"" should have been found, but was not", attrRef)); + Assert.Equal(expectedValue, value); + } + + private static void ExpectAttributeNotFoundForRef(Context c, string attrRef) + { + var value = c.GetValue(AttributeRef.FromPath(attrRef)); + Assert.True(value.IsNull, string.Format(@"attribute ""{0}"" should not have been found, but was", attrRef)); + } + + [Fact] + public void GetValueForInvalidRef() + { + ExpectAttributeNotFoundForRef(Context.New("key"), "/"); + } + + [Fact] + public void SetValueByNameCannotSetMetaProperties() + { + var c1 = Context.Builder("key").Set("private", "x").Build(); + Assert.Empty(c1.PrivateAttributes); + Assert.Equal(LdValue.Of("x"), c1.GetValue("private")); + + var c2 = Context.Builder("key").Set("privateAttributes", "x").Build(); + Assert.Empty(c2.PrivateAttributes); + Assert.Equal(LdValue.Of("x"), c2.GetValue("privateAttributes")); + } + + [Fact] + public void ContextToString() + { + var c = Context.Builder("key").Name("x").Build(); + JsonAssertions.AssertJsonEqual(LdJsonSerialization.SerializeObject(c), c.ToString()); + + Assert.Equal("(uninitialized Context)", new Context().ToString()); + + Assert.Equal(@"(invalid Context: ""kind"" is not a valid context kind)", + Context.New(invalidKindThatIsLiterallyKind, "key").ToString()); + } + + [Fact] + public void MultiKindContexts() + { + var c1 = Context.New(kind1, "key1"); + var c2 = Context.New(kind2, "key2"); + + var multi = Context.NewMulti(c1, c2); + + Assert.Equal(ImmutableList.Create(), c1.MultiKindContexts); + Assert.True(c1.TryGetContextByKind(kind1, out var c1a)); + Assert.Equal(c1a, c1); + Assert.False(c1.TryGetContextByKind(kind2, out var _)); + + Assert.Equal(ImmutableList.Create(c1, c2), multi.MultiKindContexts); + Assert.True(multi.TryGetContextByKind(kind1, out var m1)); + Assert.Equal(c1, m1); + Assert.True(multi.TryGetContextByKind(kind2, out var m2)); + Assert.Equal(c2, m2); + Assert.False(multi.TryGetContextByKind(kind3, out var _)); + + Assert.Equal(multi, Context.MultiBuilder().Add(c1).Add(c2).Build()); + Assert.Equal(c1, Context.NewMulti(c1)); + Assert.Equal(c1, Context.MultiBuilder().Add(c1).Build()); + + var uc1 = Context.New(ContextKind.Default, "key1"); + var multi2 = Context.NewMulti(uc1, c2); + Assert.True(multi2.TryGetContextByKind(ContextKind.Default, out var uc1a)); + Assert.Equal(uc1, uc1a); + Assert.True(multi2.TryGetContextByKind(new ContextKind(""), out var uc1b)); + Assert.Equal(uc1, uc1b); + + // nested multi-kind contexts are flattened + var c3 = Context.New(kind3, "key3"); + var multi123a = Context.NewMulti(multi, c3); + Assert.True(multi123a.Valid); + Assert.Equal(ImmutableList.Create(c1, c2, c3), multi123a.MultiKindContexts); + var multi123b = Context.MultiBuilder().Add(multi).Add(c3).Build(); + Assert.Equal(multi123a, multi123b); + } + + [Fact] + public void Equality() + { + TypeBehavior.CheckEqualsAndHashCode(MakeContextFactories()); + } + + [Fact] + public void BuilderFromContext() + { + foreach (var cf in MakeContextFactories()) + { + var c = cf(); + if (!c.Defined || c.Multiple) + { + continue; + } + var c1 = Context.BuilderFromContext(c).Build(); + Assert.Equal(c, c1); + } + } + + [Fact] + public void ContextFromUser() + { + var u = UserTest.UserToCopy; + var c = Context.FromUser(u); + Assert.Equal(ContextKind.Default, c.Kind); + Assert.Equal(u.Key, c.Key); + Assert.Equal(LdValue.Of(u.IPAddress), c.GetValue(UserAttribute.IPAddress.AttributeName)); + Assert.Equal(LdValue.Of(u.Country), c.GetValue(UserAttribute.Country.AttributeName)); + Assert.Equal(LdValue.Of(u.FirstName), c.GetValue(UserAttribute.FirstName.AttributeName)); + Assert.Equal(LdValue.Of(u.LastName), c.GetValue(UserAttribute.LastName.AttributeName)); + Assert.Equal(LdValue.Of(u.Name), c.GetValue(UserAttribute.Name.AttributeName)); + Assert.Equal(LdValue.Of(u.Avatar), c.GetValue(UserAttribute.Avatar.AttributeName)); + Assert.Equal(LdValue.Of(u.Email), c.GetValue(UserAttribute.Email.AttributeName)); + Assert.Equal(u.GetAttribute(UserAttribute.ForName("c1")), c.GetValue("c1")); + Assert.Equal(u.GetAttribute(UserAttribute.ForName("c2")), c.GetValue("c2")); + Assert.Equal(u.PrivateAttributeNames, new HashSet(c.PrivateAttributes.Select(a => a.ToString()))); + Assert.Equal(ImmutableHashSet.Create("ip", "country", "firstName", "lastName", "avatar", "email", "c1", "c2"), + c._attributes.Keys.ToImmutableHashSet()); + } + + [Fact] + public void ContextFromUserErrors() + { + var c1 = Context.FromUser(null); + Assert.False(c1.Valid); + Assert.Equal(Errors.ContextFromNullUser, c1.Error); + + var c2 = Context.FromUser(User.WithKey(null)); + Assert.False(c2.Valid); + Assert.Equal(Errors.ContextNoKey, c2.Error); + } + + private static Func[] MakeContextFactories() => + new Func[] + { + () => new Context(), + () => Context.New("a"), + () => Context.New("b"), + () => Context.New(kind1, "a"), + () => Context.New(kind1, "b"), + () => Context.Builder("a").Name("b").Build(), + () => Context.Builder("a").Name("c").Build(), + () => Context.Builder("a").Anonymous(true).Build(), + () => Context.Builder("a").Set("b", true).Build(), + () => Context.Builder("a").Set("b", false).Build(), + () => Context.Builder("a").Set("b", 0).Build(), + () => Context.Builder("a").Set("b", 1).Build(), + () => Context.Builder("a").Set("b", "").Build(), + () => Context.Builder("a").Set("b", "c").Build(), + TypeBehavior.ValueFactoryFromInstances( + Context.Builder("a").Set("b", true).Set("c", false).Build(), + Context.Builder("a").Set("c", false).Set("b", true).Build() + ), + () => Context.Builder("a").Name("b").Private("name").Build(), + () => Context.Builder("a").Name("b").Set("c", true).Private("name").Build(), + TypeBehavior.ValueFactoryFromInstances( + Context.Builder("a").Name("b").Set("c", true).Private("name", "c").Build(), + Context.Builder("a").Name("b").Set("c", true).Private("c", "name").Build() + ), + () => Context.Builder("a").Name("b").Set("c", true).Private("name", "d").Build(), + TypeBehavior.ValueFactoryFromInstances( + Context.NewMulti(Context.New(kind1, "a"), Context.New(kind2, "b")), + Context.NewMulti(Context.New(kind2, "b"), Context.New(kind1, "a")) + ), + () => Context.NewMulti(Context.New(kind1, "a"), Context.New(kind2, "c")), + () => Context.NewMulti(Context.New(kind1, "a"), Context.New(kind3, "b")), + () => Context.NewMulti(Context.New(kind1, "a"), Context.New(kind2, "b"), + Context.New(kind3, "c")) + }; + } +} diff --git a/pkgs/shared/common/test/EnvReporting/EnvironmentReporterBuilderTest.cs b/pkgs/shared/common/test/EnvReporting/EnvironmentReporterBuilderTest.cs new file mode 100644 index 00000000..89363254 --- /dev/null +++ b/pkgs/shared/common/test/EnvReporting/EnvironmentReporterBuilderTest.cs @@ -0,0 +1,58 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.EnvReporting +{ + public class EnvironmentReporterBuilderTest + { + + [Fact] + public void BuildWithNoParamsGivesNullProperty() + { + var builder = new EnvironmentReporterBuilder(); + var reporter = builder.Build(); + var actualAppInfo = reporter.ApplicationInfo; + Assert.Null(actualAppInfo); + } + + [Fact] + public void TestPriorityOfLayersConfigLayerApplicationIdExists() + { + var configLayer = new ConfigLayerBuilder() + .SetAppInfo(new ApplicationInfo("configId", "configName", "configVersion", "configVersionName")) + .Build(); + + var platformLayer = new Layer(new ApplicationInfo("platformId", "platformName", + "platformVersion", "platformVersionName"), null, null, null); + + var builder = new EnvironmentReporterBuilder(); + builder.SetConfigLayer(configLayer); + builder.SetPlatformLayer(platformLayer); + var reporter = builder.Build(); + + var expectedAppInfo = new ApplicationInfo("configId", "configName", "configVersion", "configVersionName"); + var actualAppInfo = reporter.ApplicationInfo; + Assert.Equal(expectedAppInfo, actualAppInfo); + } + + [Fact] + public void TestPriorityOfLayersConfigLayerApplicationIdNotExists() + { + var configLayer = new ConfigLayerBuilder() + .SetAppInfo(new ApplicationInfo(null, "these", "dont", "matter")) + .Build(); + + var platformLayer = new Layer(new ApplicationInfo("platformId", "platformName", + "platformVersion", "platformVersionName"), null, null, null); + + var builder = new EnvironmentReporterBuilder(); + builder.SetConfigLayer(configLayer); + builder.SetPlatformLayer(platformLayer); + var reporter = builder.Build(); + + var expectedAppInfo = new ApplicationInfo("platformId", "platformName", + "platformVersion", "platformVersionName"); + var actualAppInfo = reporter.ApplicationInfo; + Assert.Equal(expectedAppInfo, actualAppInfo); + } + } +} diff --git a/pkgs/shared/common/test/EvaluationDetailTest.cs b/pkgs/shared/common/test/EvaluationDetailTest.cs new file mode 100644 index 00000000..beaf8a3a --- /dev/null +++ b/pkgs/shared/common/test/EvaluationDetailTest.cs @@ -0,0 +1,148 @@ +using System.Collections.Generic; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class EvaluationDetailTest + { + [Fact] + public void TestIsDefaultValueTrue() + { + var detail = new EvaluationDetail("default", null, EvaluationReason.OffReason); + Assert.True(detail.IsDefaultValue); + } + + [Fact] + public void TestIsDefaultValueFalse() + { + var detail = new EvaluationDetail("default", 0, EvaluationReason.OffReason); + Assert.False(detail.IsDefaultValue); + } + + public struct ReasonTestCase + { + public EvaluationReason Reason { get; set; } + public string JsonString { get; set; } + public string ExpectedShortString { get; set; } + } + + [Fact] + public void TestReasonSerializationDeserialization() + { + foreach (var test in new ReasonTestCase[] + { + new ReasonTestCase { Reason = EvaluationReason.OffReason, + JsonString = @"{""kind"":""OFF""}", ExpectedShortString = "OFF" }, + new ReasonTestCase { Reason = EvaluationReason.FallthroughReason, + JsonString = @"{""kind"":""FALLTHROUGH""}", ExpectedShortString = "FALLTHROUGH" }, + new ReasonTestCase { + Reason = EvaluationReason.FallthroughReason.WithInExperiment(true), + JsonString = @"{""kind"":""FALLTHROUGH"",""inExperiment"":true}", + ExpectedShortString = "FALLTHROUGH" }, + new ReasonTestCase { Reason = EvaluationReason.FallthroughReason.WithBigSegmentsStatus(BigSegmentsStatus.Healthy), + JsonString = @"{""kind"":""FALLTHROUGH"",""bigSegmentsStatus"":""HEALTHY""}", ExpectedShortString = "FALLTHROUGH" }, + new ReasonTestCase { + Reason = EvaluationReason.FallthroughReason.WithInExperiment(true).WithBigSegmentsStatus(BigSegmentsStatus.Healthy), + JsonString = @"{""kind"":""FALLTHROUGH"",""inExperiment"":true,""bigSegmentsStatus"":""HEALTHY""}", + ExpectedShortString = "FALLTHROUGH" }, + new ReasonTestCase { Reason = EvaluationReason.TargetMatchReason, + JsonString = @"{""kind"":""TARGET_MATCH""}", ExpectedShortString = "TARGET_MATCH" }, + new ReasonTestCase { Reason = EvaluationReason.RuleMatchReason(1, "id"), + JsonString = @"{""kind"":""RULE_MATCH"",""ruleIndex"":1,""ruleId"":""id""}", + ExpectedShortString = "RULE_MATCH(1,id)" + }, + new ReasonTestCase { Reason = EvaluationReason.RuleMatchReason(1, "id").WithInExperiment(true), + JsonString = @"{""kind"":""RULE_MATCH"",""ruleIndex"":1,""ruleId"":""id"",""inExperiment"":true}", + ExpectedShortString = "RULE_MATCH(1,id)" + }, + new ReasonTestCase { Reason = EvaluationReason.RuleMatchReason(1, "id").WithBigSegmentsStatus(BigSegmentsStatus.Healthy), + JsonString = @"{""kind"":""RULE_MATCH"",""ruleIndex"":1,""ruleId"":""id"",""bigSegmentsStatus"":""HEALTHY""}", + ExpectedShortString = "RULE_MATCH(1,id)" + }, + new ReasonTestCase { Reason = EvaluationReason.RuleMatchReason(1, "id").WithInExperiment(true).WithBigSegmentsStatus(BigSegmentsStatus.Healthy), + JsonString = @"{""kind"":""RULE_MATCH"",""ruleIndex"":1,""ruleId"":""id"",""inExperiment"":true,""bigSegmentsStatus"":""HEALTHY""}", + ExpectedShortString = "RULE_MATCH(1,id)" + }, + new ReasonTestCase { Reason = EvaluationReason.PrerequisiteFailedReason("key"), + JsonString = @"{""kind"":""PREREQUISITE_FAILED"",""prerequisiteKey"":""key""}", + ExpectedShortString = "PREREQUISITE_FAILED(key)" + }, + new ReasonTestCase { Reason = EvaluationReason.ErrorReason(EvaluationErrorKind.Exception), + JsonString = @"{""kind"":""ERROR"",""errorKind"":""EXCEPTION""}", + ExpectedShortString = "ERROR(EXCEPTION)" + } + }) + { + AssertJsonEqual(test.JsonString, LdJsonSerialization.SerializeObject(test.Reason)); + Assert.Equal(test.Reason, LdJsonSerialization.DeserializeObject(test.JsonString)); + Assert.Equal(test.ExpectedShortString, test.Reason.ToString()); + } + } + + [Fact] + public void TestBigSegmentsStatusSerializationDeserialization() + { + foreach (var test in new KeyValuePair[] + { + new KeyValuePair(BigSegmentsStatus.Healthy, "HEALTHY"), + new KeyValuePair(BigSegmentsStatus.Stale, "STALE"), + new KeyValuePair(BigSegmentsStatus.NotConfigured, "NOT_CONFIGURED"), + new KeyValuePair(BigSegmentsStatus.StoreError, "STORE_ERROR"), + }) + { + var reason = EvaluationReason.FallthroughReason.WithBigSegmentsStatus(test.Key); + var reasonJson = LdJsonSerialization.SerializeObject(reason); + Assert.Equal(LdValue.Parse(reasonJson).Get("bigSegmentsStatus"), LdValue.Of(test.Value)); + var reason1 = LdJsonSerialization.DeserializeObject(reasonJson); + Assert.Equal(test.Key, reason1.BigSegmentsStatus); + } + } + + [Fact] + public void TestErrorKindSerializationDeserialization() + { + foreach (var test in new KeyValuePair[] + { + new KeyValuePair(EvaluationErrorKind.ClientNotReady, "CLIENT_NOT_READY"), + new KeyValuePair(EvaluationErrorKind.Exception, "EXCEPTION"), + new KeyValuePair(EvaluationErrorKind.FlagNotFound, "FLAG_NOT_FOUND"), + new KeyValuePair(EvaluationErrorKind.MalformedFlag, "MALFORMED_FLAG"), + new KeyValuePair(EvaluationErrorKind.UserNotSpecified, "USER_NOT_SPECIFIED"), + new KeyValuePair(EvaluationErrorKind.WrongType, "WRONG_TYPE"), + }) + { + var reason = EvaluationReason.ErrorReason(test.Key); + var reasonJson = LdJsonSerialization.SerializeObject(reason); + Assert.Equal(LdValue.Parse(reasonJson).Get("errorKind"), LdValue.Of(test.Value)); + var reason1 = LdJsonSerialization.DeserializeObject(reasonJson); + Assert.Equal(test.Key, reason1.ErrorKind); + } + } + + [Fact] + public void TestEqualityAndHashCode() + { + TypeBehavior.CheckEqualsAndHashCode( + // each value in this list should be unequal to all the other values and equal to itself + () => EvaluationReason.OffReason, + () => EvaluationReason.FallthroughReason, + () => EvaluationReason.FallthroughReason.WithInExperiment(true), + () => EvaluationReason.RuleMatchReason(0, "rule1"), + () => EvaluationReason.RuleMatchReason(0, "rule1").WithInExperiment(true), + () => EvaluationReason.RuleMatchReason(0, "rule1").WithBigSegmentsStatus(BigSegmentsStatus.Stale), + () => EvaluationReason.RuleMatchReason(1, "rule2"), + () => EvaluationReason.PrerequisiteFailedReason("a"), + () => EvaluationReason.PrerequisiteFailedReason("b"), + () => EvaluationReason.ErrorReason(EvaluationErrorKind.FlagNotFound), + () => EvaluationReason.ErrorReason(EvaluationErrorKind.Exception) + ); + } + + private void AssertJsonEqual(string expectedString, string actualString) + { + Assert.Equal(LdValue.Parse(expectedString), LdValue.Parse(actualString)); + } + } +} diff --git a/pkgs/shared/common/test/Helpers/ValidationUtilsTest.cs b/pkgs/shared/common/test/Helpers/ValidationUtilsTest.cs new file mode 100644 index 00000000..26f9c342 --- /dev/null +++ b/pkgs/shared/common/test/Helpers/ValidationUtilsTest.cs @@ -0,0 +1,29 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Helpers +{ + public class ValidationUtilsTest + { + [Fact] + public void ValidateStringValue() + { + Assert.NotNull(ValidationUtils.ValidateStringValue("bad-\n")); + Assert.NotNull(ValidationUtils.ValidateStringValue("bad-\t")); + Assert.NotNull(ValidationUtils.ValidateStringValue("###invalid")); + Assert.NotNull(ValidationUtils.ValidateStringValue("")); + Assert.NotNull( + ValidationUtils.ValidateStringValue( + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEFwhoops")); + Assert.NotNull(ValidationUtils.ValidateStringValue("#@$%^&")); + Assert.Null(ValidationUtils.ValidateStringValue("a-Az-Z0-9._-")); + } + + [Fact] + public void SanitizeSpaces() + { + Assert.Equal("NoSpaces", ValidationUtils.SanitizeSpaces("NoSpaces")); + Assert.Equal("Look-at-all-this-space", ValidationUtils.SanitizeSpaces("Look at all this space")); + Assert.Equal("", ValidationUtils.SanitizeSpaces("")); + } + } +} diff --git a/pkgs/shared/common/test/LaunchDarkly.CommonSdk.Tests.csproj b/pkgs/shared/common/test/LaunchDarkly.CommonSdk.Tests.csproj new file mode 100644 index 00000000..6c3b94c9 --- /dev/null +++ b/pkgs/shared/common/test/LaunchDarkly.CommonSdk.Tests.csproj @@ -0,0 +1,22 @@ + + + net462;net8.0 + $(TESTFRAMEWORK) + LaunchDarkly.CommonSdk.Tests + LaunchDarkly.Sdk + + + + + + + + + + + + + + + + diff --git a/pkgs/shared/common/test/LdValueTest.cs b/pkgs/shared/common/test/LdValueTest.cs new file mode 100644 index 00000000..72a9f351 --- /dev/null +++ b/pkgs/shared/common/test/LdValueTest.cs @@ -0,0 +1,548 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class LdValueTest + { + const int someInt = 3; + const long someLong = 3; + const float someFloat = 3.25f; + const double someDouble = 3.25d; + const string someString = "hi"; + + static readonly LdValue aTrueBoolValue = LdValue.Of(true); + static readonly LdValue anIntValue = LdValue.Of(someInt); + static readonly LdValue aLongValue = LdValue.Of(someLong); + static readonly LdValue aFloatValue = LdValue.Of(someFloat); + static readonly LdValue aDoubleValue = LdValue.Of(someDouble); + static readonly LdValue aStringValue = LdValue.Of(someString); + static readonly LdValue aNumericLookingStringValue = LdValue.Of("3"); + static readonly LdValue anArrayValue = LdValue.Convert.Int.ArrayOf(3); + static readonly LdValue anObjectValue = LdValue.Convert.String.ObjectFrom(MakeDictionary("x")); + + [Fact] + public void CanGetValueAsBool() + { + Assert.Equal(LdValueType.Bool, aTrueBoolValue.Type); + Assert.True(aTrueBoolValue.AsBool); + Assert.True(LdValue.Convert.Bool.ToType(aTrueBoolValue)); + } + + [Fact] + public void NonBooleanValueAsBoolIsFalse() + { + var values = new LdValue[] + { + LdValue.Null, + aStringValue, + anIntValue, + aLongValue, + aFloatValue, + aDoubleValue, + anArrayValue, + anObjectValue + }; + foreach (var value in values) + { + Assert.False(value.AsBool); + Assert.False(LdValue.Convert.Bool.ToType(value)); + } + } + + [Fact] + public void CanGetValueAsString() + { + Assert.Equal(LdValueType.String, aStringValue.Type); + Assert.Equal(someString, aStringValue.AsString); + Assert.Equal(someString, LdValue.Convert.String.ToType(aStringValue)); + } + + [Fact] + public void NonStringValueAsStringIsNull() + { + var values = new LdValue[] + { + LdValue.Null, + aTrueBoolValue, + anIntValue, + aFloatValue, + anArrayValue, + anObjectValue + }; + foreach (var value in values) + { + Assert.NotEqual(LdValueType.String, value.Type); + Assert.Null(value.AsString); + Assert.Null(LdValue.Convert.String.ToType(value)); + } + } + + [Fact] + public void CanGetIntegerValueOfAnyNumericType() + { + TestConvertIntegerToNumericType(LdValue.Convert.Int, v => v.AsInt); + TestConvertIntegerToNumericType(LdValue.Convert.Long, v => v.AsLong); + TestConvertIntegerToNumericType(LdValue.Convert.Float, v => v.AsFloat); + TestConvertIntegerToNumericType(LdValue.Convert.Double, v => v.AsDouble); + Assert.Equal(LdValueType.Number, anIntValue.Type); + Assert.Equal(LdValueType.Number, aLongValue.Type); + } + + private void TestConvertIntegerToNumericType(LdValue.Converter converter, Func getter) + { + var t_2 = (T)Convert.ChangeType(2, typeof(T)); + TestTypeConversion((int)2, t_2, n => LdValue.Of(n), converter, getter); + TestTypeConversion((long)2, t_2, n => LdValue.Of(n), converter, getter); + TestTypeConversion((float)2, t_2, n => LdValue.Of(n), converter, getter); + TestTypeConversion((double)2, t_2, n => LdValue.Of(n), converter, getter); + } + + [Fact] + public void NonIntegerValueAsIntegerRoundsToNearest() + { + TestConvertNonIntegerToIntegerType(LdValue.Convert.Int, v => v.AsInt); + TestConvertNonIntegerToIntegerType(LdValue.Convert.Long, v => v.AsLong); + } + + private void TestConvertNonIntegerToIntegerType(LdValue.Converter converter, Func getter) + { + var t_2 = (T)Convert.ChangeType(2, typeof(T)); + var t_3 = (T)Convert.ChangeType(3, typeof(T)); + var t_minus_2 = (T)Convert.ChangeType(-2, typeof(T)); + var t_minus_3 = (T)Convert.ChangeType(-3, typeof(T)); + TestTypeConversion((float)2.25, t_2, n => LdValue.Of(n), converter, getter); + TestTypeConversion((double)2.25, t_2, n => LdValue.Of(n), converter, getter); + TestTypeConversion((float)2.75, t_3, n => LdValue.Of(n), converter, getter); + TestTypeConversion((double)2.75, t_3, n => LdValue.Of(n), converter, getter); + TestTypeConversion((float)-2.25, t_minus_2, n => LdValue.Of(n), converter, getter); + TestTypeConversion((double)-2.25, t_minus_2, n => LdValue.Of(n), converter, getter); + TestTypeConversion((float)-2.75, t_minus_3, n => LdValue.Of(n), converter, getter); + TestTypeConversion((double)-2.75, t_minus_3, n => LdValue.Of(n), converter, getter); + } + + [Fact] + public void CanGetNonIntegerValueAsFloatingPoint() + { + TestTypeConversion(2.5f, 2.5f, n => LdValue.Of(n), LdValue.Convert.Float, v => v.AsFloat); + TestTypeConversion(2.5d, 2.5f, n => LdValue.Of(n), LdValue.Convert.Float, v => v.AsFloat); + TestTypeConversion(2.5d, 2.5d, n => LdValue.Of(n), LdValue.Convert.Double, v => v.AsDouble); + TestTypeConversion(2.5d, 2.5d, n => LdValue.Of(n), LdValue.Convert.Double, v => v.AsDouble); + Assert.Equal(LdValueType.Number, aFloatValue.Type); + Assert.Equal(LdValueType.Number, aDoubleValue.Type); + } + + [Fact] + public void NonNumericValueAsNumberIsZero() + { + TestNonNumericValueAsNumericTypeIsZero(LdValue.Convert.Int, v => v.AsInt, 0); + TestNonNumericValueAsNumericTypeIsZero(LdValue.Convert.Long, v => v.AsLong, 0); + TestNonNumericValueAsNumericTypeIsZero(LdValue.Convert.Float, v => v.AsFloat, 0); + TestNonNumericValueAsNumericTypeIsZero(LdValue.Convert.Double, v => v.AsDouble, 0); + } + + private void TestNonNumericValueAsNumericTypeIsZero(LdValue.Converter converter, + Func getter, T zero) + { + var values = new LdValue[] + { + LdValue.Null, + aTrueBoolValue, + aStringValue, + aNumericLookingStringValue, + anArrayValue, + anObjectValue + }; + foreach (var value in values) + { + Assert.Equal(zero, getter(value)); + Assert.Equal(zero, converter.ToType(value)); + } + } + + private void TestTypeConversion(T fromValue, U toValue, Func constructor, + LdValue.Converter converter, Func getter) + { + var value = constructor(fromValue); + Assert.Equal(toValue, getter(value)); + Assert.Equal(toValue, converter.ToType(value)); + } + + [Fact] + public void CanGetValuesAsList() + { + Assert.Equal(new LdValue[] { LdValue.Of("x"), LdValue.Of(true) }, + LdValue.BuildArray().Add("x").Add(true).Build().List); + Assert.Equal(new bool[] { true, false }, LdValue.Convert.Bool.ArrayFrom(new bool[] { true, false }).AsList(LdValue.Convert.Bool)); + Assert.Equal(new bool[] { true, false }, LdValue.Convert.Bool.ArrayOf(true, false).AsList(LdValue.Convert.Bool)); + Assert.Equal(new bool[] { true, false }, LdValue.BuildArray().Add(true).Add(false).Build().AsList(LdValue.Convert.Bool)); + Assert.Equal(new int[] { 1, 2 }, LdValue.Convert.Int.ArrayFrom(new int[] { 1, 2 }).AsList(LdValue.Convert.Int)); + Assert.Equal(new int[] { 1, 2 }, LdValue.Convert.Int.ArrayOf(1, 2).AsList(LdValue.Convert.Int)); + Assert.Equal(new int[] { 1, 2 }, LdValue.BuildArray().Add(1).Add(2).Build().AsList(LdValue.Convert.Int)); + Assert.Equal(new long[] { 1, 2 }, LdValue.Convert.Long.ArrayFrom(new long[] { 1, 2 }).AsList(LdValue.Convert.Long)); + Assert.Equal(new long[] { 1, 2 }, LdValue.Convert.Long.ArrayOf(1, 2).AsList(LdValue.Convert.Long)); + Assert.Equal(new long[] { 1, 2 }, LdValue.BuildArray().Add(1).Add(2).Build().AsList(LdValue.Convert.Long)); + Assert.Equal(new float[] { 1.0f, 2.0f }, LdValue.Convert.Float.ArrayFrom(new float[] { 1.0f, 2.0f }).AsList(LdValue.Convert.Float)); + Assert.Equal(new float[] { 1.0f, 2.0f }, LdValue.Convert.Float.ArrayOf(1.0f, 2.0f).AsList(LdValue.Convert.Float)); + Assert.Equal(new float[] { 1.0f, 2.0f }, LdValue.BuildArray().Add(1.0f).Add(2.0f).Build().AsList(LdValue.Convert.Float)); + Assert.Equal(new double[] { 1.0d, 2.0d }, LdValue.Convert.Double.ArrayFrom(new double[] { 1.0d, 2.0d }).AsList(LdValue.Convert.Double)); + Assert.Equal(new double[] { 1.0d, 2.0d }, LdValue.Convert.Double.ArrayOf(1.0d, 2.0d).AsList(LdValue.Convert.Double)); + Assert.Equal(new double[] { 1.0f, 2.0f }, LdValue.BuildArray().Add(1.0d).Add(2.0d).Build().AsList(LdValue.Convert.Double)); + Assert.Equal(new string[] { "a", "b" }, LdValue.Convert.String.ArrayFrom(new string[] { "a", "b" }).AsList(LdValue.Convert.String)); + Assert.Equal(new string[] { "a", "b" }, LdValue.Convert.String.ArrayOf("a", "b").AsList(LdValue.Convert.String)); + Assert.Equal(new string[] { "a", "b" }, LdValue.BuildArray().Add("a").Add("b").Build().AsList(LdValue.Convert.String)); + Assert.Equal(new LdValue[] { anIntValue, aStringValue }, + LdValue.ArrayFrom(new LdValue[] { anIntValue, aStringValue }).AsList(LdValue.Convert.Json)); + Assert.Equal(new LdValue[] { anIntValue, aStringValue }, + LdValue.ArrayOf(anIntValue, aStringValue).AsList(LdValue.Convert.Json)); + Assert.Equal(new LdValue[] { anIntValue, aStringValue }, + LdValue.BuildArray().Add(anIntValue).Add(aStringValue).Build().AsList(LdValue.Convert.Json)); + Assert.Equal(LdValue.Null, LdValue.Convert.Int.ArrayFrom((IEnumerable)null)); + } + + [Fact] + public void ListCanGetItemByIndex() + { + var v = LdValue.Convert.Int.ArrayOf(1, 2, 3); + + Assert.Equal(3, v.Count); + Assert.Equal(LdValue.Of(2), v.Get(1)); + Assert.Equal(LdValue.Null, v.Get(-1)); + Assert.Equal(LdValue.Null, v.Get(3)); + + var list = v.AsList(LdValue.Convert.Int); + Assert.Equal(2, list[1]); + Assert.Throws(() => list[-1]); + Assert.Throws(() => list[3]); + } + + [Fact] + public void ListCanBeEnumerated() + { + var v = LdValue.Convert.Int.ArrayOf(1, 2, 3); + var list = v.AsList(LdValue.Convert.Int); + var listOut = new List(); + Assert.Equal(3, list.Count); + foreach (var n in list) + { + listOut.Add(n); + } + Assert.Equal(listOut, list); + } + + [Fact] + public void NonArrayValuesReturnEmptyOrList() + { + var values = new LdValue[] + { + LdValue.Null, + aTrueBoolValue, + anIntValue, + aFloatValue, + aStringValue, + anObjectValue + }; + foreach (var value in values) + { + Assert.Empty(value.List); + + // use list conversion + var emptyList = value.AsList(LdValue.Convert.Bool); + Assert.Equal(0, emptyList.Count); + Assert.Throws(() => emptyList[0]); + foreach (var n in emptyList) + { + Assert.True(false, "should not have enumerated an element"); + } + } + } + + [Fact] + public void PrimitiveTypesCannotBeEnumerated() + { + var values = new LdValue[] + { + LdValue.Null, + aTrueBoolValue, + anIntValue, + aFloatValue, + aStringValue + }; + foreach (var value in values) + { + Assert.Equal(0, value.Count); + Assert.Equal(LdValue.Null, value.Get(0)); + Assert.Equal(LdValue.Null, value.Get(-1)); + } + } + + [Fact] + public void CanGetValueAsDictionary() + { + AssertDictsEqual(MakeDictionary(LdValue.Of("x"), LdValue.Of(true)), + LdValue.BuildObject().Add("1", "x").Add("2", true).Build().Dictionary); + AssertDictsEqual(MakeDictionary(true, false), + LdValue.Convert.Bool.ObjectFrom(MakeDictionary(true, false)).AsDictionary(LdValue.Convert.Bool)); + AssertDictsEqual(MakeDictionary(true, false), + LdValue.BuildObject().Add("1", true).Add("2", false).Build().AsDictionary(LdValue.Convert.Bool)); + AssertDictsEqual(MakeDictionary(1, 2), + LdValue.Convert.Int.ObjectFrom(MakeDictionary(1, 2)).AsDictionary(LdValue.Convert.Int)); + AssertDictsEqual(MakeDictionary(1, 2), + LdValue.BuildObject().Add("1", 1).Add("2", 2).Build().AsDictionary(LdValue.Convert.Int)); + AssertDictsEqual(MakeDictionary(1L, 2L), + LdValue.Convert.Long.ObjectFrom(MakeDictionary(1L, 2L)).AsDictionary(LdValue.Convert.Long)); + AssertDictsEqual(MakeDictionary(1L, 2L), + LdValue.BuildObject().Add("1", 1L).Add("2", 2L).Build().AsDictionary(LdValue.Convert.Long)); + AssertDictsEqual(MakeDictionary(1.0f, 2.0f), + LdValue.Convert.Float.ObjectFrom(MakeDictionary(1.0f, 2.0f)).AsDictionary(LdValue.Convert.Float)); + AssertDictsEqual(MakeDictionary(1.0f, 2.0f), + LdValue.BuildObject().Add("1", 1.0f).Add("2", 2.0f).Build().AsDictionary(LdValue.Convert.Float)); + AssertDictsEqual(MakeDictionary(1.0d, 2.0d), + LdValue.Convert.Double.ObjectFrom(MakeDictionary(1.0d, 2.0d)).AsDictionary(LdValue.Convert.Double)); + AssertDictsEqual(MakeDictionary(1.0d, 2.0d), + LdValue.BuildObject().Add("1", 1.0d).Add("2", 2.0d).Build().AsDictionary(LdValue.Convert.Double)); + AssertDictsEqual(MakeDictionary("a", "b"), + LdValue.Convert.String.ObjectFrom(MakeDictionary("a", "b")).AsDictionary(LdValue.Convert.String)); + AssertDictsEqual(MakeDictionary("a", "b"), + LdValue.BuildObject().Add("1", "a").Add("2", "b").Build().AsDictionary(LdValue.Convert.String)); + AssertDictsEqual(MakeDictionary(anIntValue, aStringValue), + LdValue.Convert.Json.ObjectFrom(MakeDictionary(anIntValue, aStringValue)).AsDictionary(LdValue.Convert.Json)); + AssertDictsEqual(MakeDictionary(anIntValue, aStringValue), + LdValue.BuildObject().Add("1", anIntValue).Add("2", aStringValue).Build().AsDictionary(LdValue.Convert.Json)); + Assert.Equal(LdValue.Null, LdValue.Convert.String.ObjectFrom((IReadOnlyDictionary)null)); + } + + [Fact] + public void DictionaryCanGetValueByKey() + { + var v = LdValue.BuildObject().Add("a", 100).Add("b", 200).Add("c", 300).Build(); + + Assert.Equal(3, v.Count); + Assert.Equal(LdValue.Of(200), v.Get("b")); + Assert.NotEqual(LdValue.Null, v.Get(1)); + Assert.Equal(LdValue.Null, v.Get("x")); + Assert.Equal(LdValue.Null, v.Get(-1)); + Assert.Equal(LdValue.Null, v.Get(3)); + + var d = v.AsDictionary(LdValue.Convert.Int); + Assert.True(d.ContainsKey("b")); + Assert.Equal(200, d["b"]); + Assert.True(d.TryGetValue("a", out var n)); + Assert.Equal(100, n); + Assert.False(d.ContainsKey("x")); + Assert.Throws(() => d["x"]); + Assert.False(d.TryGetValue("x", out n)); + } + + [Fact] + public void DictionaryCanBeEnumerated() + { + var v = LdValue.BuildObject().Add("a", 100).Add("b", 200).Add("c", 300).Build(); + var d = v.AsDictionary(LdValue.Convert.Int); + Assert.Equal(3, d.Count); + Assert.Equal(new string[] { "a", "b", "c" }, new List(d.Keys).OrderBy(s => s)); + Assert.Equal(new int[] { 100, 200, 300 }, new List(d.Values).OrderBy(n => n)); + Assert.Equal(new KeyValuePair[] + { + new KeyValuePair("a", 100), + new KeyValuePair("b", 200), + new KeyValuePair("c", 300), + }, d.OrderBy(e => e.Key)); + } + + [Fact] + public void NonObjectValuesReturnEmptyDictionary() + { + var values = new LdValue[] + { + LdValue.Null, + aTrueBoolValue, + anIntValue, + aFloatValue, + aStringValue, + anArrayValue + }; + foreach (var value in values) + { + Assert.Equal(LdValue.Null, value.Get("1")); + + Assert.Empty(value.Dictionary); + + var emptyDict = value.AsDictionary(LdValue.Convert.Bool); + Assert.Equal(0, emptyDict.Count); + Assert.False(emptyDict.ContainsKey("1")); + Assert.Throws(() => emptyDict["1"]); + Assert.False(emptyDict.TryGetValue("1", out var b)); + foreach (var e in emptyDict) + { + Assert.True(false, "should not have enumerated an element"); + } + Assert.Equal(new string[0], emptyDict.Keys); + Assert.Equal(new bool[0], emptyDict.Values); + } + } + + [Fact] + public void TestEqualsAndHashCodeForPrimitives() + { + TypeBehavior.CheckEqualsAndHashCode( + // each value in this list should be unequal to all the other values and equal to itself + () => LdValue.Null, + () => LdValue.Of(true), + () => LdValue.Of(false), + () => LdValue.Of(1), + () => LdValue.Of(2), + () => LdValue.Of("a"), + () => LdValue.Of("b") + ); + } + + [Fact] + public void EqualsUsesDeepEqualityForArrays() + { + TypeBehavior.CheckEqualsAndHashCode( + () => LdValue.BuildArray().Add("a") + .Add(LdValue.BuildArray().Add("b").Add("c").Build()) + .Build(), + () => LdValue.BuildArray().Add("a").Build(), + () => LdValue.BuildArray().Add("a").Add("b").Add("c").Build(), + () => LdValue.BuildArray().Add("a") + .Add(LdValue.BuildArray().Add("b").Add("x").Build()) + .Build() + ); + } + + [Fact] + public void EqualsUsesDeepEqualityForObjects() + { + TypeBehavior.CheckEqualsAndHashCode( + () => LdValue.BuildObject() + .Add("a", "b") + .Add("c", LdValue.BuildObject().Add("d", "e").Build()) + .Build(), + () => LdValue.BuildObject() + .Add("a", "b") + .Build(), + () => LdValue.BuildObject() + .Add("a", "b") + .Add("c", LdValue.BuildObject().Add("d", "e").Build()) + .Add("f", "g") + .Build(), + () => LdValue.BuildObject() + .Add("a", "b") + .Add("c", LdValue.BuildObject().Add("d", "f").Build()) + .Build() + ); + } + + [Fact] + public void ObjectBuilderAddDoesNotAllowDuplicates() + { + var builder = LdValue.BuildObject().Add("a", 1).Add("b", 2); + Assert.ThrowsAny(() => builder.Add("a", 3)); + } + + [Fact] + public void ObjectBuilderSetAllowsDuplicates() + { + var builder = LdValue.BuildObject().Add("a", 1).Add("b", 2); + builder.Set("a", 3); + Assert.Equal(LdValue.BuildObject().Add("a", 3).Add("b", 2).Build(), builder.Build()); + } + + [Fact] + public void ObjectBuilderRemove() + { + var builder = LdValue.BuildObject().Add("a", 1).Add("b", 2); + builder.Remove("a"); + builder.Remove("c"); // nonexistent key is no-op + Assert.Equal(LdValue.BuildObject().Add("b", 2).Build(), builder.Build()); + } + + [Fact] + public void ObjectBuilderCanCopyProperties() + { + var builder = LdValue.BuildObject().Add("a", 0) + .Copy(LdValue.BuildObject().Add("a", 1).Add("b", 2).Build()) + .Copy(LdValue.Of("ignore this")); + Assert.Equal(LdValue.BuildObject().Add("a", 1).Add("b", 2).Build(), builder.Build()); + } + + [Fact] + public void CanUseLongTypeForNumberGreaterThanMaxInt() + { + long n = (long)int.MaxValue + 1; + Assert.Equal(n, LdValue.Of(n).AsLong); + Assert.Equal(n, LdValue.Convert.Long.ToType(LdValue.Of(n))); + Assert.Equal(n, LdValue.Convert.Long.FromType(n).AsLong); + } + + [Fact] + public void CanUseDoubleTypeForNumberGreaterThanMaxFloat() + { + double n = (double)float.MaxValue + 1; + Assert.Equal(n, LdValue.Of(n).AsDouble); + Assert.Equal(n, LdValue.Convert.Double.ToType(LdValue.Of(n))); + Assert.Equal(n, LdValue.Convert.Double.FromType(n).AsDouble); + } + + [Fact] + public void TestJsonSerialization() + { + VerifySerializeAndParse(LdValue.Null, "null"); + VerifySerializeAndParse(aTrueBoolValue, "true"); + VerifySerializeAndParse(LdValue.Of(false), "false"); + VerifySerializeAndParse(anIntValue, someInt.ToString()); + VerifySerializeAndParse(aFloatValue, someFloat.ToString()); + VerifySerializeAndParse(anArrayValue, "[3]"); + VerifySerializeAndParse(anObjectValue, "{\"1\":\"x\"}"); + Assert.Throws(() => LdJsonSerialization.DeserializeObject("nono")); + Assert.Throws(() => LdValue.Parse("nono")); + } + + private void VerifySerializeAndParse(LdValue value, string expectedJson) + { + var json1 = LdJsonSerialization.SerializeObject(value); + var json2 = value.ToJsonString(); + Assert.Equal(expectedJson, json1); + Assert.Equal(json1, json2); + var parsed1 = LdJsonSerialization.DeserializeObject(expectedJson); + var parsed2 = LdValue.Parse(expectedJson); + Assert.Equal(value, parsed1); + Assert.Equal(value, parsed2); + } + + [Fact] + public void TestNullStringConstructorIsEquivalentToNullInstance() + { + Assert.Equal(LdValue.Null, LdValue.Of(null)); + } + + private static Dictionary MakeDictionary(params T[] values) + { + var ret = new Dictionary(); + var i = 0; + foreach (var v in values) + { + ret[(++i).ToString()] = v; + } + return ret; + } + + private void AssertDictsEqual(IReadOnlyDictionary expected, IReadOnlyDictionary actual) + { + if (expected.Count != actual.Count || expected.Except(actual).Any()) + { + Assert.False(true, string.Format("expected: {0}, actual: {0}", + string.Join(", ", expected.Select(e => e.Key + "=" + e.Value)), + string.Join(", ", actual.Select(e => e.Key + "=" + e.Value)))); + } + } + + } +} diff --git a/pkgs/shared/common/test/SystemTextJsonInteroperabilityTest.cs b/pkgs/shared/common/test/SystemTextJsonInteroperabilityTest.cs new file mode 100644 index 00000000..dffe314e --- /dev/null +++ b/pkgs/shared/common/test/SystemTextJsonInteroperabilityTest.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class SystemTextJsonInteroperabilityTest + { + // These tests verify that all of our LaunchDarkly.Sdk types that have a custom JSON conversion + // behave correctly with System.Text.Json. + + // Keep these tests in sync with LdJsonNetTest.cs in LaunchDarkly.CommonSdk.JsonNet.Tests. + + private static readonly AttributeRef ExpectedAttributeRef = AttributeRef.FromLiteral("a"); + private const string ExpectedAttributeRefJson = @"""a"""; + private static readonly Context ExpectedContext = Context.New("user-key"); + private const string ExpectedContextJson = @"{""kind"":""user"",""key"":""user-key""}"; + private static readonly EvaluationReason ExpectedEvaluationReason = EvaluationReason.OffReason; + private const string ExpectedEvaluationReasonJson = @"{""kind"":""OFF""}"; + private static readonly UnixMillisecondTime ExpectedUnixTime = UnixMillisecondTime.OfMillis(123456789); + private const string ExpectedUnixTimeJson = "123456789"; + private static readonly User ExpectedUser = User.WithKey("user-key"); + private const string ExpectedUserJson = @"{""key"":""user-key""}"; + private static readonly LdValue ExpectedValue = LdValue.Of(true); + private const string ExpectedValueJson = "true"; + + // The reason for the "ObjectWithNullable..." classes is to test the serialization of nullable variants + // of value types. For any value type T, if we pass a "T?" value to SerializeObject, the type of the + // parameter in that method is actually "object" and so what is really passed is either a T or a plain + // old null-- it doesn't really see a "T?". But if it's in a property like this, it really will detect + // the type. + + private sealed class ObjectWithNullableAttributeRef + { + public AttributeRef? attr { get; set; } + } + + private sealed class ObjectWithNullableContext + { + public Context? context { get; set; } + } + + private sealed class ObjectWithNullableReason + { + public EvaluationReason? reason { get; set; } + } + + private sealed class ObjectWithNullableTime + { + public UnixMillisecondTime? time { get; set; } // see above + } + + private sealed class ObjectWithNullableValue + { + public LdValue? value { get; set; } // see above + // "LdValue?" is a bit pointless, since an LdValue can already encode null, but it is a struct so this should work + } + + [Fact] + public void AttributeRefConversion() => + VerifySerializationAndDeserialization(ExpectedAttributeRef, ExpectedAttributeRefJson); + + [Fact] + public void ContextConversion() => + VerifySerializationAndDeserialization(ExpectedContext, ExpectedContextJson); + + [Fact] + public void EvaluationReasonConversion() => + VerifySerializationAndDeserialization(ExpectedEvaluationReason, ExpectedEvaluationReasonJson); + + [Fact] + public void LdValueConversion() => + VerifySerializationAndDeserialization(ExpectedValue, ExpectedValueJson); + + [Fact] + public void UnixMillisecondTimeConversion() => + VerifySerializationAndDeserialization(ExpectedUnixTime, ExpectedUnixTimeJson); + + [Fact] + public void UserConversion() => + VerifySerializationAndDeserialization(ExpectedUser, ExpectedUserJson); + + [Fact] + public void NullableValueTypes() + { + Assert.Equal(@"{""attr"":" + ExpectedAttributeRefJson + "}", + JsonSerializer.Serialize(new ObjectWithNullableAttributeRef { attr = ExpectedAttributeRef })); + Assert.Equal(@"{""attr"":null}", + JsonSerializer.Serialize(new ObjectWithNullableAttributeRef { attr = null })); + Assert.Equal(@"{""context"":" + ExpectedContextJson + "}", + JsonSerializer.Serialize(new ObjectWithNullableContext { context = ExpectedContext })); + Assert.Equal(@"{""context"":null}", + JsonSerializer.Serialize(new ObjectWithNullableContext { context = null })); + Assert.Equal(@"{""reason"":" + ExpectedEvaluationReasonJson + "}", + JsonSerializer.Serialize(new ObjectWithNullableReason { reason = ExpectedEvaluationReason })); + Assert.Equal(@"{""reason"":null}", + JsonSerializer.Serialize(new ObjectWithNullableReason { reason = null })); + Assert.Equal(@"{""time"":" + ExpectedUnixTimeJson + "}", + JsonSerializer.Serialize(new ObjectWithNullableTime { time = ExpectedUnixTime })); + Assert.Equal(@"{""time"":null}", + JsonSerializer.Serialize(new ObjectWithNullableTime { time = null })); + Assert.Equal(@"{""value"":" + ExpectedValueJson + "}", + JsonSerializer.Serialize(new ObjectWithNullableValue { value = ExpectedValue })); + Assert.Equal(@"{""value"":null}", + JsonSerializer.Serialize(new ObjectWithNullableValue { value = null })); + } + + private void VerifySerializationAndDeserialization(T value, string expectedJson) + { + Assert.Equal(expectedJson, JsonSerializer.Serialize(value)); + Assert.Equal(value, JsonSerializer.Deserialize(expectedJson)); + } + } +} diff --git a/pkgs/shared/common/test/TestUtil.cs b/pkgs/shared/common/test/TestUtil.cs new file mode 100644 index 00000000..2ef298e7 --- /dev/null +++ b/pkgs/shared/common/test/TestUtil.cs @@ -0,0 +1,13 @@ +using System; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class TestUtil + { + public static void AssertJsonEquals(string expected, string actual) + { + Assert.Equal(LdValue.Parse(expected), LdValue.Parse(actual)); + } + } +} diff --git a/pkgs/shared/common/test/UnixMillisecondTimeTest.cs b/pkgs/shared/common/test/UnixMillisecondTimeTest.cs new file mode 100644 index 00000000..b01b1a0b --- /dev/null +++ b/pkgs/shared/common/test/UnixMillisecondTimeTest.cs @@ -0,0 +1,64 @@ +using System; +using LaunchDarkly.Sdk.Json; +using Xunit; + +using static LaunchDarkly.Sdk.UnixMillisecondTime; + +namespace LaunchDarkly.Sdk +{ + public class UnixMillisecondTimeTest + { + private const long someTime = 1605311688609; + + [Fact] + public void MillisValue() + { + var t = OfMillis(someTime); + Assert.Equal(someTime, t.Value); + } + + [Fact] + public void PlusMillis() + { + var t = OfMillis(someTime); + Assert.Equal(someTime + 444, t.PlusMillis(444).Value); + } + + [Fact] + public void FromAndToDateTime() + { + var dt = new DateTime(1989, 11, 9, 17, 53, 00); + var t = FromDateTime(dt); + Assert.Equal(626637180000, t.Value); + Assert.Equal(dt, t.AsDateTime); + } + + [Fact] + public void Comparisons() + { + for (var a = 1; a < 3; a++) + { + for (var b = 1; b < 3; b++) + { + Assert.Equal(a == b, OfMillis(a).Equals(OfMillis(b))); + Assert.Equal(a == b, OfMillis(a) == OfMillis(b)); + Assert.Equal(a != b, OfMillis(a) != OfMillis(b)); + Assert.Equal(a < b, OfMillis(a) < OfMillis(b)); + Assert.Equal(a <= b, OfMillis(a) <= OfMillis(b)); + Assert.Equal(a > b, OfMillis(a) > OfMillis(b)); + Assert.Equal(a >= b, OfMillis(a) >= OfMillis(b)); + Assert.Equal(a.CompareTo(b), OfMillis(a).CompareTo(OfMillis(b))); + } + Assert.Equal(a.GetHashCode(), OfMillis(a).GetHashCode()); + } + } + + [Fact] + public void JsonConversion() + { + Assert.Equal("12345", LdJsonSerialization.SerializeObject(UnixMillisecondTime.OfMillis(12345))); + Assert.Equal(UnixMillisecondTime.OfMillis(12345), + LdJsonSerialization.DeserializeObject("12345")); + } + } +} diff --git a/pkgs/shared/common/test/UserBuilderTest.cs b/pkgs/shared/common/test/UserBuilderTest.cs new file mode 100644 index 00000000..90aaab75 --- /dev/null +++ b/pkgs/shared/common/test/UserBuilderTest.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Sdk.Json; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class UserBuilderTestBase + { + public struct StringPropertyDesc + { + public string Name; + public Func> Setter; + public Func Getter; + + public StringPropertyDesc(string name, Func> setter, Func getter) + { + Name = name; + Setter = setter; + Getter = getter; + } + + public override string ToString() => Name; + } + + public struct StringPropertyCanBePrivateDesc + { + public string Name; + public Func> Setter; + public Func Getter; + + public StringPropertyCanBePrivateDesc(string name, Func> setter, Func getter) + { + Name = name; + Setter = setter; + Getter = getter; + } + + public override string ToString() => Name; + } + + private static IEnumerable MakeParams(params object[] ps) + { + return ps.Select(p => new object[] { p }); + } + + public static IEnumerable AllStringProperties => MakeParams( + new StringPropertyDesc("key", b => b.Key, u => u.Key), + new StringPropertyDesc("ip", b => b.IPAddress, u => u.IPAddress), + new StringPropertyDesc("country", b => b.Country, u => u.Country), + new StringPropertyDesc("firstName", b => b.FirstName, u => u.FirstName), + new StringPropertyDesc("lastName", b => b.LastName, u => u.LastName), + new StringPropertyDesc("name", b => b.Name, u => u.Name), + new StringPropertyDesc("avatar", b => b.Avatar, u => u.Avatar), + new StringPropertyDesc("email", b => b.Email, u => u.Email) + ); + + public static IEnumerable PrivateStringProperties = MakeParams( + new StringPropertyCanBePrivateDesc("ip", b => b.IPAddress, u => u.IPAddress), + new StringPropertyCanBePrivateDesc("country", b => b.Country, u => u.Country), + new StringPropertyCanBePrivateDesc("firstName", b => b.FirstName, u => u.FirstName), + new StringPropertyCanBePrivateDesc("lastName", b => b.LastName, u => u.LastName), + new StringPropertyCanBePrivateDesc("name", b => b.Name, u => u.Name), + new StringPropertyCanBePrivateDesc("avatar", b => b.Avatar, u => u.Avatar), + new StringPropertyCanBePrivateDesc("email", b => b.Email, u => u.Email) + ); + } + + public class UserBuilderTest : UserBuilderTestBase + { + private const string key = "UserKey"; + + [Fact] + public void UserWithKeySetsKey() + { + var user = User.WithKey(key); + Assert.Equal(key, user.Key); + } + + [Fact] + public void BuilderCanSetKey() + { + var user = User.Builder(key).Build(); + Assert.Equal(key, user.Key); + } + + [Theory] + [MemberData(nameof(AllStringProperties))] + public void StringPropertyIsNullByDefault(StringPropertyDesc p) + { + var user = User.Builder(key).Build(); + var value = p.Getter(user); + if (p.Name == "key") + { + Assert.Equal(key, value); + } + else + { + Assert.Null(value); + } + } + + [Theory] + [MemberData(nameof(AllStringProperties))] + public void BuilderCanSetStringProperty(StringPropertyDesc p) + { + var expectedValue = "x"; + var user = p.Setter(User.Builder(key))(expectedValue).Build(); + Assert.Equal(expectedValue, p.Getter(user)); + Assert.Empty(user.PrivateAttributeNames); + } + + [Theory] + [MemberData(nameof(PrivateStringProperties))] + public void BuilderCanSetPrivateStringProperty(StringPropertyCanBePrivateDesc p) + { + var expectedValue = p.Name + " value"; + var user = p.Setter(User.Builder(key))(expectedValue).AsPrivateAttribute().Build(); + Assert.Equal(expectedValue, p.Getter(user)); + Assert.Equal(new HashSet { p.Name }, user.PrivateAttributeNames); + } + + [Fact] + public void AnonymousDefaultsToFalse() + { + var user = User.Builder(key).Build(); + Assert.False(user.Anonymous); + } + + [Fact] + public void BuilderCanSetAnonymousTrue() + { + var user = User.Builder(key).Anonymous(true).Build(); + Assert.True(user.Anonymous); + } + + [Fact] + public void BuilderCanSetAnonymousFalse() + { + var user = User.Builder(key).Anonymous(true).Anonymous(false).Build(); + Assert.False(user.Anonymous); + } + + [Fact] + public void CustomDefaultsToEmptyDictionary() + { + var user = User.Builder(key).Build(); + Assert.NotNull(user.Custom); + Assert.Equal(0, user.Custom.Count); + } + + private void TestCustomAttribute(T value, + Func setter, LdValue.Converter converter) + { + var user0 = setter(User.Builder(key), "foo", value).Build(); + Assert.Equal(value, converter.ToType(user0.Custom["foo"])); + Assert.Empty(user0.PrivateAttributeNames); + + var user1 = setter(User.Builder(key), "bar", value).AsPrivateAttribute().Build(); + Assert.Equal(value, converter.ToType(user1.Custom["bar"])); + Assert.Equal(new HashSet { "bar" }, user1.PrivateAttributeNames); + } + + [Fact] + public void BuilderCanSetJsonCustomAttribute() + { + var value = LdValue.Convert.Int.ArrayOf(1, 2); + var user0 = User.Builder(key).Custom("foo", value).Build(); + Assert.Equal(value, user0.Custom["foo"]); + Assert.Equal(0, user0.PrivateAttributeNames.Count); + + var user1 = User.Builder(key).Custom("bar", value).AsPrivateAttribute().Build(); + Assert.Equal(value, user1.Custom["bar"]); + Assert.Equal(new HashSet { "bar" }, user1.PrivateAttributeNames); + } + + [Fact] + public void BuilderCanSetBoolCustomAttribute() + { + TestCustomAttribute(true, (b, n, v) => b.Custom(n, v), LdValue.Convert.Bool); + } + + [Fact] + public void BuilderCanSetStringCustomAttribute() + { + TestCustomAttribute("x", (b, n, v) => b.Custom(n, v), LdValue.Convert.String); + } + + [Fact] + public void BuilderCanSetIntCustomAttribute() + { + TestCustomAttribute(3, (b, n, v) => b.Custom(n, v), LdValue.Convert.Int); + } + + [Fact] + public void BuilderCanSetLongCustomAttribute() + { + TestCustomAttribute(1634661422123L, (b, n, v) => b.Custom(n, v), LdValue.Convert.Long); + } + + [Fact] + public void BuilderCanSetFloatCustomAttribute() + { + TestCustomAttribute(1.5f, (b, n, v) => b.Custom(n, v), LdValue.Convert.Float); + } + + [Fact] + public void BuilderCanSetDoubleCustomAttribute() + { + TestCustomAttribute(double.MaxValue, (b, n, v) => b.Custom(n, v), LdValue.Convert.Double); + } + + [Fact] + public void TestUserEqualityWithBuilderFromUser() + { + User copy = User.Builder(UserTest.UserToCopy).Build(); + Assert.NotSame(UserTest.UserToCopy, copy); + Assert.True(copy.Equals(UserTest.UserToCopy)); + Assert.True(UserTest.UserToCopy.Equals(copy)); + Assert.Equal(UserTest.UserToCopy.GetHashCode(), copy.GetHashCode()); + } + + [Fact] + public void TestUserInequalityWithModifiedBuilder() + { + Func[] mods = { + b => b.IPAddress("x"), + b => b.IPAddress(null), + b => b.Country("FR"), + b => b.Country(null), + b => b.FirstName("x"), + b => b.FirstName(null), + b => b.LastName("x"), + b => b.LastName(null), + b => b.Name("x"), + b => b.Name(null), + b => b.Avatar("x"), + b => b.Avatar(null), + b => b.Email("x"), + b => b.Email(null), + b => b.Anonymous(true), + b => b.Custom("c3", "v3"), + b => b.Name("n").AsPrivateAttribute(), + b => b.Name("o").AsPrivateAttribute() + }; + foreach (var mod in mods) + { + User modUser = mod(User.Builder(UserTest.UserToCopy)).Build(); + Assert.False(UserTest.UserToCopy.Equals(modUser), + LdJsonSerialization.SerializeObject(modUser) + " should not equal " + + LdJsonSerialization.SerializeObject(UserTest.UserToCopy)); + Assert.False(UserTest.UserToCopy.GetHashCode() == modUser.GetHashCode(), + LdJsonSerialization.SerializeObject(modUser) + " should not have same hashCode as " + + LdJsonSerialization.SerializeObject(UserTest.UserToCopy)); + } + } + + [Fact] + public void TestEmptyImmutableCollectionsAreReused() + { + var user0 = User.Builder("a").Build(); + var user1 = User.Builder("b").Build(); + Assert.Same(user0.Custom, user1.Custom); + Assert.Same(user0.PrivateAttributeNames, user1.PrivateAttributeNames); + } + } +} diff --git a/pkgs/shared/common/test/UserSerializationTests.cs b/pkgs/shared/common/test/UserSerializationTests.cs new file mode 100644 index 00000000..870d7bb6 --- /dev/null +++ b/pkgs/shared/common/test/UserSerializationTests.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using LaunchDarkly.Sdk.Json; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class UserSerializationTests : UserBuilderTestBase + { + private const string key = "UserKey"; + + // We test user deserialization just because a developer who sees Newtonsoft.Json attributes used in + // the User class could reasonably expect both serialization and deserialization to work correctly. + // We never deserialize users in the .NET or Xamarin SDK. + + [Fact] + public void DeserializeBasicUserAsJson() + { + var json = $"{{\"key\":\"{key}\"}}"; + var user = LdJsonSerialization.DeserializeObject(json); + Assert.Equal(key, user.Key); + Assert.Equal(0, user.Custom.Count); + Assert.Equal(0, user.PrivateAttributeNames.Count); + } + + [Theory] + [MemberData(nameof(AllStringProperties))] + public void CanDeserializeStringProperty(StringPropertyDesc p) + { + var value = "x"; + var jsonObject = LdValue.BuildObject().Add("key", "x").Add(p.Name, value).Build(); + var user = LdJsonSerialization.DeserializeObject(jsonObject.ToJsonString()); + Assert.Equal(value, p.Getter(user)); + } + + [Fact] + public void CanDeserializeCustomAttribute() + { + var json = $"{{\"key\":\"{key}\",\"custom\": {{\"a\":\"b\"}}}}"; + var user = LdJsonSerialization.DeserializeObject(json); + Assert.Equal("b", user.Custom["a"].AsString); + } + + [Fact] + public void CanDeserializePrivateAttribute() + { + var json = $"{{\"key\":\"{key}\",\"name\":\"Lucy\",\"privateAttributeNames\":[\"name\"]}}"; + var user = LdJsonSerialization.DeserializeObject(json); + Assert.Equal(new List { "name" }, user.PrivateAttributeNames); + } + + [Fact] + public void CustomAttributesAndPrivateAttributesOmittedIfEmpty() + { + var user = User.WithKey(key); + var expected = $"{{\"key\":\"{key}\"}}"; + TestUtil.AssertJsonEquals(expected, LdJsonSerialization.SerializeObject(user)); + } + + [Fact] + public void AnonymousTrueIsSerializedAsTrue() + { + var user = User.Builder(key).Anonymous(true).Build(); + var expected = $"{{\"key\":\"{key}\",\"anonymous\":true}}"; + TestUtil.AssertJsonEquals(expected, LdJsonSerialization.SerializeObject(user)); + } + + [Fact] + public void AnonymousFalseIsOmitted() + { + var user = User.Builder(key).Anonymous(false).Build(); + var expected = $"{{\"key\":\"{key}\"}}"; + TestUtil.AssertJsonEquals(expected, LdJsonSerialization.SerializeObject(user)); + } + + [Theory] + [MemberData(nameof(AllStringProperties))] + public void CanSerializeStringProperty(StringPropertyDesc p) + { + var value = "x"; + var user = p.Setter(User.Builder(key))(value).Build(); + var json = LdValue.Parse(LdJsonSerialization.SerializeObject(user)); + Assert.Equal(LdValue.Of(value), json.Get(p.Name)); + } + + [Fact] + public void CustomAttributesAreSerialized() + { + LdValue value1 = LdValue.Of("hi"); + LdValue value2 = LdValue.Of(2); + var user = User.Builder(key).Custom("name1", value1).Custom("name2", value2).Build(); + var json = LdValue.Parse(LdJsonSerialization.SerializeObject(user)); + Assert.Equal(LdValue.Of("hi"), json.Get("custom").Get("name1")); + Assert.Equal(LdValue.Of(2), json.Get("custom").Get("name2")); + } + + [Fact] + public void PrivateAttributeNamesAreSerialized() + { + var user = User.Builder(key) + .Name("user-name").AsPrivateAttribute() + .Email("test@example.com").AsPrivateAttribute() + .Build(); + var json = LdValue.Parse(LdJsonSerialization.SerializeObject(user)); + var names = new List(json.Get("privateAttributeNames").AsList(LdValue.Convert.String)); + names.Sort(); + Assert.Equal(new List { "email", "name" }, names); + } + } +} diff --git a/pkgs/shared/common/test/UserTest.cs b/pkgs/shared/common/test/UserTest.cs new file mode 100644 index 00000000..4bd8ae6f --- /dev/null +++ b/pkgs/shared/common/test/UserTest.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class UserTest + { + private const string key = "UserKey"; + + public static readonly User UserToCopy = User.Builder("userkey") + .IPAddress("1") + .Country("US") + .FirstName("f") + .LastName("l") + .Name("n") + .Avatar("a") + .Email("e") + .Custom("c1", "v1") + .Custom("c2", "v2").AsPrivateAttribute() + .Build(); + + [Fact] + public void UserWithKeySetsKey() + { + var user = User.WithKey(key); + Assert.Equal(key, user.Key); + } + + [Fact] + public void TestPropertyDefaults() + { + var user = User.WithKey(key); + Assert.Null(user.IPAddress); + Assert.Null(user.Country); + Assert.Null(user.FirstName); + Assert.Null(user.LastName); + Assert.Null(user.Name); + Assert.Null(user.Avatar); + Assert.Null(user.Email); + Assert.NotNull(user.Custom); + Assert.Equal(0, user.Custom.Count); + Assert.NotNull(user.PrivateAttributeNames); + Assert.Equal(0, user.PrivateAttributeNames.Count); + } + + [Fact] + public void TestEmptyImmutableCollectionsAreReused() + { + var user0 = User.WithKey("a"); + var user1 = User.WithKey("b"); + Assert.Same(user0.Custom, user1.Custom); + Assert.Same(user0.PrivateAttributeNames, user1.PrivateAttributeNames); + } + + [Fact] + public void TestUserSelfEquality() + { + Assert.True(UserToCopy.Equals(UserToCopy)); + } + } +} diff --git a/release-please-config.json b/release-please-config.json index 9318b0fe..d70fac7f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -48,6 +48,18 @@ "extra-files": [ "src/LaunchDarkly.ServerSdk.Telemetry.csproj" ] + }, + "pkgs/shared/common": { + "package-name": "LaunchDarkly.ServerSdk.Common", + "extra-files": [ + "src/LaunchDarkly.ServerSdk.Common.csproj" + ] + }, + "pkgs/shared/common-json-net": { + "package-name": "LaunchDarkly.ServerSdk.Common.JsonNet", + "extra-files": [ + "src/LaunchDarkly.ServerSdk.Common.JsonNet.csproj" + ] } } }